diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 30e29492..ae8fbcc0 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -166,9 +166,11 @@ fn main() -> eframe::Result { Box::new(move |cc| { let mut app = VectorcraftApp::new(Session::new(), services()); load_prefs(&mut app); - // User Defined swatch libraries live next to the preferences. + // User Defined swatch and graphic style libraries live next to the preferences. let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string())); app.session.swatch_libraries.set_user_dir(swatches); + let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string())); + app.session.style_libraries.set_user_dir(styles); app.integrated_titlebar = cfg!(target_os = "macos"); app.custom_titlebar = CUSTOM_TITLEBAR; if let Some(port) = control_port { diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index ec556018..6851205a 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -486,6 +486,12 @@ impl AppearanceItem { AppearanceItem::Stroke(s) => &s.paint, } } + pub fn paint_mut(&mut self) -> &mut Paint { + match self { + AppearanceItem::Fill(f) => &mut f.paint, + AppearanceItem::Stroke(s) => &mut s.paint, + } + } pub fn visible(&self) -> bool { match self { AppearanceItem::Fill(f) => f.visible, diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6059ad79..0c676e3b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -15,6 +15,7 @@ pub mod pattern; mod pixels; mod reach; pub mod selection; +pub mod style_libs; pub mod swatches; pub mod text; @@ -45,6 +46,7 @@ pub use node::Knockout; pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use selection::{AnchorRef, Selection}; +pub use style_libs::StyleLibrary; pub use text::{ AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape, diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs new file mode 100644 index 00000000..88a3eb06 --- /dev/null +++ b/crates/doc/src/style_libs.rs @@ -0,0 +1,511 @@ +//! Graphic style libraries: named, read-only sets of graphic styles (Window → Graphic Style +//! Libraries). The built-in ones are generated here in code ([`STYLE_LIBRARIES`]); user libraries +//! are `.vcstyles` files ([`write`], [`read`]): JSON holding the styles and the pattern +//! definitions they paint with. +//! +//! Library styles stand alone: no id, and no links to the swatches of the document they came from +//! ([`StyleLibrary::from_document`]). + +use std::sync::{Arc, OnceLock}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_geom::Point; + +use crate::{ + Appearance, AppearanceItem, Arrowhead, Dash, Document, Effect, FillLayer, GraphicStyle, Knockout, LineCap, LineJoin, PatternDef, StrokeAlign, + StrokeLayer, WidthProfile, +}; + +/// A graphic style library: its name, its styles and the patterns they paint with. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct StyleLibrary { + pub name: String, + #[serde(default)] + pub styles: Vec, + /// The pattern swatches the styles' fills and strokes paint with. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub patterns: Vec, +} + +impl StyleLibrary { + pub fn len(&self) -> usize { + self.styles.len() + } + pub fn is_empty(&self) -> bool { + self.styles.is_empty() + } + pub fn style(&self, name: &str) -> Option<&GraphicStyle> { + self.styles.iter().find(|g| g.name == name) + } + + /// The document's styles `names` (empty: all of them, in panel order) as a library named + /// `name`, standing alone ([`GraphicStyle::standalone`]) with the patterns they paint with. + pub fn from_document(d: &Document, names: &[String], name: String) -> Result { + if let Some(n) = names.iter().find(|n| d.graphic_style(n).is_none()) { + return Err(format!("no graphic style `{n}`")); + } + let styles: Vec = + d.graphic_styles.iter().filter(|g| names.is_empty() || names.contains(&g.name)).map(GraphicStyle::standalone).collect(); + let mut patterns: Vec = vec![]; + for p in styles.iter().flat_map(GraphicStyle::patterns) { + if !patterns.iter().any(|x| x.name == p) + && let Some(def) = d.pattern(p) + { + patterns.push(def.clone()); + } + } + Ok(Self { name, styles, patterns }) + } +} + +impl GraphicStyle { + /// Do both styles look the same (appearance and transparency; names and ids aside)? + pub fn same_look(&self, other: &GraphicStyle) -> bool { + (&self.appearance, self.opacity, self.blend, self.isolate, self.knockout, self.unit_box) + == (&other.appearance, other.opacity, other.blend, other.isolate, other.knockout, other.unit_box) + } + + /// The style as a library holds it: no id, and its paints unlinked from swatches. + pub fn standalone(&self) -> GraphicStyle { + let mut g = GraphicStyle { id: 0, ..self.clone() }; + for it in &mut g.appearance.items { + match it.paint_mut() { + // The colour shown stays, unlinked. + Paint::Solid { swatch, tint, .. } => (*swatch, *tint) = (None, 1.0), + Paint::Gradient(gp) => { + gp.swatch = None; + gp.gradient.stops.iter_mut().for_each(|st| st.set_color(st.color, None)); + } + Paint::None | Paint::Pattern { .. } => {} + } + } + g + } + + /// The names of the patterns the style's fills and strokes paint with. + pub fn patterns(&self) -> impl Iterator { + self.appearance.items.iter().filter_map(|it| match it.paint() { + Paint::Pattern { pattern, .. } => Some(pattern.as_str()), + _ => None, + }) + } +} + +// ---------- .vcstyles files ---------- + +/// The extension (and `format` header) of graphic style library files. +pub const STYLES_EXT: &str = "vcstyles"; + +/// The file: a header around the library. +#[derive(Serialize, Deserialize)] +struct NativeFile { + format: String, + version: u32, + #[serde(flatten)] + library: StyleLibrary, +} + +/// `lib` as a `.vcstyles` file. +pub fn write(lib: &StyleLibrary) -> String { + let file = NativeFile { format: STYLES_EXT.into(), version: 1, library: lib.clone() }; + serde_json::to_string_pretty(&file).unwrap_or_default() +} + +/// Read a `.vcstyles` file; an unnamed library is called `name`. +pub fn read(text: &str, name: &str) -> Result { + let f: NativeFile = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|e| format!("not a graphic style library: {e}"))?; + if f.format != STYLES_EXT { + return Err(format!("not a graphic style library (format `{}`)", f.format)); + } + let mut lib = f.library; + if lib.name.trim().is_empty() { + lib.name = name.into(); + } + Ok(lib) +} + +/// Does `text` look like a `.vcstyles` file (its header names the format)? +pub fn sniff(text: &str) -> bool { + let t = text.trim_start_matches('\u{feff}').trim_start(); + let head = t.char_indices().nth(256).map_or(t, |(i, _)| &t[..i]); + t.starts_with('{') && head.contains(&format!("\"{STYLES_EXT}\"")) +} + +// ---------- built-in libraries ---------- + +/// A built-in library: a stable id, its menu name and the function that makes its styles. +pub struct BuiltinStyleLibrary { + pub id: &'static str, + pub name: &'static str, + make: fn() -> Vec, +} + +/// The built-in graphic style libraries, in menu order. +pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ + BuiltinStyleLibrary { id: "shadows-glows", name: "Shadows and Glows", make: shadows_glows }, + BuiltinStyleLibrary { id: "outlines-rules", name: "Outlines and Rules", make: outlines_rules }, + BuiltinStyleLibrary { id: "hand-drawn", name: "Hand-Drawn", make: hand_drawn }, + BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes }, + BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects }, + BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency }, +]; + +/// Built-in library `id`, made once. +pub fn builtin_style_library(id: &str) -> Option> { + static LIBS: OnceLock>> = OnceLock::new(); + let libs = LIBS.get_or_init(|| { + STYLE_LIBRARIES.iter().map(|b| Arc::new(StyleLibrary { name: b.name.into(), styles: (b.make)(), patterns: vec![] })).collect() + }); + STYLE_LIBRARIES.iter().position(|b| b.id == id).map(|i| libs[i].clone()) +} + +fn hex(h: &str) -> Color { + Color::from_hex(h).unwrap_or(Color::BLACK) +} + +fn solid(h: &str) -> Paint { + Paint::solid(hex(h)) +} + +fn fill(h: &str) -> FillLayer { + FillLayer::new(solid(h)) +} + +fn stroke(h: &str, width: f64) -> StrokeLayer { + StrokeLayer::new(solid(h), width) +} + +/// A stroke with round caps and joins. +fn round(h: &str, width: f64) -> StrokeLayer { + StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..stroke(h, width) } +} + +/// Live effect `id` with `params` (the others keep the effect's defaults). +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +/// A gradient of `(offset, colour, opacity)` stops. +fn gradient(kind: GradientKind, stops: &[(f32, &str, f32)]) -> Gradient { + Gradient { kind, stops: stops.iter().map(|&(offset, c, opacity)| GradientStop { opacity, ..GradientStop::new(offset, hex(c)) }).collect() } +} + +/// A linear gradient fitted to each object's box at `angle` degrees (90: upwards). +fn linear(angle: f64, stops: &[(f32, &str, f32)]) -> Paint { + Paint::Gradient(Box::new(GradientPaint { angle, ..GradientPaint::new(gradient(GradientKind::Linear, stops)) })) +} + +/// A radial gradient placed in the unit box (each object gets it at the same place relative to its +/// bounds): centred at `(x, y)` with `radius`. +fn radial_at(x: f64, y: f64, radius: f64, stops: &[(f32, &str, f32)]) -> Paint { + let geom = GradientGeom { start: Point::new(x, y), end: Point::new(x + radius, y), aspect: 1.0 }; + Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(gradient(GradientKind::Radial, stops)) })) +} + +/// A style of fills and strokes `items` (painted first to last) and object `effects`. +fn style(name: &str, items: Vec, effects: Vec) -> GraphicStyle { + GraphicStyle::new(name, Appearance { items, effects, contents_index: None }) +} + +use AppearanceItem::{Fill as F, Stroke as S}; + +fn shadows_glows() -> Vec { + let shadow = |opacity: f64, x: f64, y: f64, blur: f64, color: &str| { + fx("stylize.dropShadow", json!({"mode": "multiply", "opacity": opacity, "x": x, "y": y, "blur": blur, "color": color})) + }; + vec![ + style("Soft Shadow", vec![F(fill("#f4f1ea"))], vec![shadow(45.0, 4.0, 4.0, 6.0, "#1d1b2a")]), + style( + "Hard Shadow", + vec![F(fill("#ffd166")), S(stroke("#1d1b2a", 2.0))], + vec![fx("stylize.dropShadow", json!({"mode": "normal", "opacity": 100.0, "x": 5.0, "y": 5.0, "blur": 0.0, "color": "#1d1b2a"}))], + ), + style("Lifted Card", vec![F(fill("#ffffff")), S(stroke("#d9d9e3", 0.5))], vec![shadow(30.0, 0.0, 8.0, 12.0, "#2b2d42")]), + style( + "Halo", + vec![F(fill("#2b2d42"))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 90.0, "blur": 8.0, "color": "#8ecae6"}))], + ), + style( + "Neon Tube", + vec![S(round("#ff4fd8", 4.0)), S(round("#ffe8fb", 1.5))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 85.0, "blur": 7.0, "color": "#ff4fd8"}))], + ), + style( + "Inner Light", + vec![F(fill("#3a86ff"))], + vec![fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 70.0, "blur": 9.0, "color": "#ffffff", "source": "center"}))], + ), + style( + "Ember", + vec![F(fill("#e85d04"))], + vec![ + fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 80.0, "blur": 6.0, "color": "#ffba08", "source": "edge"})), + fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 70.0, "blur": 5.0, "color": "#dc2f02"})), + ], + ), + style("Feathered", vec![F(fill("#8338ec"))], vec![fx("stylize.feather", json!({"radius": 6.0}))]), + style("Haze", vec![F(FillLayer { opacity: 0.8, ..fill("#06d6a0") })], vec![fx("blur.gaussian", json!({"radius": 3.0}))]), + ] +} + +fn outlines_rules() -> Vec { + let dashed = + |h: &str, width: f64, pattern: Vec| StrokeLayer { dash: Some(Dash { pattern, offset: 0.0, align_corners: true }), ..round(h, width) }; + let keyline = |h: &str, width: f64, offset: f64| StrokeLayer { + effects: vec![fx("path.offsetPath", json!({"offset": offset, "joins": "round"}))], + ..stroke(h, width) + }; + vec![ + style("Double Rule", vec![F(fill("#ffffff")), S(stroke("#14213d", 6.0)), S(stroke("#ffffff", 3.0))], vec![]), + style("Triple Rule", vec![F(fill("#fefae0")), S(stroke("#283618", 10.0)), S(stroke("#fefae0", 6.0)), S(stroke("#283618", 2.0))], vec![]), + style("Dashed Border", vec![F(fill("#e9f5f2")), S(dashed("#264653", 2.0, vec![6.0, 4.0]))], vec![]), + style("Dotted Border", vec![F(fill("#fff4e6")), S(dashed("#e76f51", 3.0, vec![0.0, 6.0]))], vec![]), + style("Offset Keyline", vec![F(fill("#e9c46a")), S(keyline("#264653", 1.5, 4.0))], vec![]), + style("Inset Keyline", vec![F(fill("#2a9d8f")), S(keyline("#ffffff", 1.5, -4.0))], vec![]), + style("Outside Stroke", vec![F(fill("#f4a261")), S(StrokeLayer { align: StrokeAlign::Outside, ..stroke("#264653", 4.0) })], vec![]), + style("Tapered Ink", vec![S(StrokeLayer { profile: Some(WidthProfile::lens()), ..round("#1b1b1b", 5.0) })], vec![]), + style("Pointer", vec![S(StrokeLayer { end_arrow: Some(Arrowhead::Triangle), ..round("#e76f51", 2.0) })], vec![]), + style("Highlighter", vec![S(StrokeLayer { opacity: 0.6, blend: BlendMode::Multiply, ..round("#ffd60a", 10.0) })], vec![]), + ] +} + +fn hand_drawn() -> Vec { + let rough = |h: &str, width: f64, seed: u32| StrokeLayer { + effects: vec![fx("distort.roughen", json!({"size": 1.5, "relative": true, "detail": 8.0, "points": "smooth", "seed": seed}))], + ..round(h, width) + }; + let scribble = |h: &str, angle: f64, opacity: f32| FillLayer { + opacity, + effects: vec![fx("stylize.scribble", json!({"angle": angle, "strokeWidth": 1.5, "spacing": 4.0, "curviness": 5.0, "variation": 0.5}))], + ..fill(h) + }; + vec![ + style("Sketchy Line", vec![F(fill("#fefae0")), S(rough("#1d3557", 1.25, 1)), S(rough("#1d3557", 0.75, 7))], vec![]), + style("Scribble Fill", vec![F(scribble("#e63946", 30.0, 1.0)), S(rough("#1d3557", 1.25, 3))], vec![]), + style("Crosshatch", vec![F(scribble("#457b9d", 45.0, 0.8)), F(scribble("#457b9d", -45.0, 0.8)), S(rough("#1d3557", 1.0, 5))], vec![]), + style( + "Wobble", + vec![F(fill("#a8dadc")), S(round("#1d3557", 2.0))], + vec![fx("distort.tweak", json!({"h": 3.0, "v": 3.0, "relative": true, "seed": 5}))], + ), + style( + "Zig Zag Edge", + vec![ + F(fill("#f1faee")), + S(StrokeLayer { + effects: vec![fx("distort.zigZag", json!({"size": 3.0, "relative": false, "ridges": 6, "points": "corner"}))], + ..stroke("#e63946", 1.5) + }), + ], + vec![], + ), + style( + "Wavy Edge", + vec![F(fill("#bde0fe")), S(round("#3a5a9b", 1.5))], + vec![fx("distort.zigZag", json!({"size": 3.0, "relative": false, "ridges": 5, "points": "smooth"}))], + ), + style("Puffy", vec![F(fill("#ffc8dd")), S(round("#c9184a", 1.5))], vec![fx("distort.puckerBloat", json!({"amount": 25.0}))]), + style("Starburst", vec![F(fill("#ffbe0b")), S(stroke("#fb5607", 1.0))], vec![fx("distort.puckerBloat", json!({"amount": -40.0}))]), + ] +} + +fn gradient_finishes() -> Vec { + let rim = |h: &str| S(stroke(h, 1.0)); + vec![ + style( + "Glossy", + vec![ + F(FillLayer::new(linear(-90.0, &[(0.0, "#7ab8ff", 1.0), (1.0, "#1d4ed8", 1.0)]))), + F(FillLayer::new(linear(-90.0, &[(0.0, "#ffffff", 0.7), (0.5, "#ffffff", 0.0)]))), + rim("#1e3a8a"), + ], + vec![], + ), + style( + "Brushed Metal", + vec![ + F(FillLayer::new(linear( + 0.0, + &[(0.0, "#bfbfbf", 1.0), (0.3, "#f5f5f5", 1.0), (0.55, "#a6a6a6", 1.0), (0.8, "#e6e6e6", 1.0), (1.0, "#8c8c8c", 1.0)], + ))), + rim("#595959"), + ], + vec![], + ), + style( + "Spotlight", + vec![F(FillLayer::new(radial_at(0.35, 0.3, 0.9, &[(0.0, "#ffffff", 1.0), (0.35, "#ffbe0b", 1.0), (1.0, "#fb5607", 1.0)])))], + vec![], + ), + style( + "Sunset Band", + vec![F(FillLayer::new(linear(-90.0, &[(0.0, "#ffbe0b", 1.0), (0.35, "#fb5607", 1.0), (0.7, "#ff006e", 1.0), (1.0, "#8338ec", 1.0)])))], + vec![], + ), + style( + "Sea Glass", + vec![ + F(FillLayer { + opacity: 0.85, + ..FillLayer::new(linear(-45.0, &[(0.0, "#caf0f8", 1.0), (0.5, "#48cae4", 1.0), (1.0, "#0077b6", 1.0)])) + }), + rim("#03045e"), + ], + vec![], + ), + style( + "Pearl", + vec![ + F(FillLayer::new(radial_at(0.4, 0.35, 0.8, &[(0.0, "#ffffff", 1.0), (0.5, "#e8e1f0", 1.0), (1.0, "#b8b0c8", 1.0)]))), + S(stroke("#8a7fa0", 0.75)), + ], + vec![], + ), + style( + "Chrome Rim", + vec![F(fill("#22223b")), S(StrokeLayer::new(linear(-90.0, &[(0.0, "#f8f9fa", 1.0), (0.5, "#6c757d", 1.0), (1.0, "#dee2e6", 1.0)]), 4.0))], + vec![], + ), + style( + "Glow Orb", + vec![F(FillLayer::new(radial_at(0.5, 0.5, 0.5, &[(0.0, "#ffffff", 1.0), (0.4, "#80ffdb", 1.0), (1.0, "#5390d9", 1.0)])))], + vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 60.0, "blur": 6.0, "color": "#80ffdb"}))], + ), + ] +} + +fn shape_effects() -> Vec { + let basic = |f: &str, s: &str, w: f64| vec![F(fill(f)), S(round(s, w))]; + vec![ + style("Rounded", basic("#90be6d", "#43aa8b", 2.0), vec![fx("stylize.roundCorners", json!({"radius": 10.0}))]), + style( + "Pill Badge", + vec![F(fill("#f94144"))], + vec![fx("convertToShape.roundedRectangle", json!({"relative": true, "extraW": 12.0, "extraH": 6.0, "radius": 40.0}))], + ), + style( + "Round Badge", + vec![F(fill("#577590")), S(stroke("#ffffff", 2.0))], + vec![fx("convertToShape.ellipse", json!({"relative": true, "extraW": 10.0, "extraH": 10.0}))], + ), + style("Arc", basic("#f8961e", "#f3722c", 1.5), vec![fx("warp.arc", json!({"bend": 30.0}))]), + style("Flag Wave", basic("#277da1", "#1d4e89", 1.5), vec![fx("warp.flag", json!({"bend": 25.0}))]), + style("Bulge", basic("#f9c74f", "#f8961e", 1.5), vec![fx("warp.bulge", json!({"bend": 30.0}))]), + style("Twisted", basic("#4d908e", "#264653", 1.5), vec![fx("distort.twist", json!({"angle": 30.0}))]), + style( + "Echo", + vec![F(FillLayer { opacity: 0.55, ..fill("#43aa8b") }), S(stroke("#264653", 1.0))], + vec![fx("distort.transform", json!({"copies": 3, "moveH": 4.0, "moveV": 4.0}))], + ), + style( + "Spiral Copies", + vec![F(FillLayer { opacity: 0.35, ..fill("#f94144") }), S(stroke("#9d0208", 0.75))], + vec![fx("distort.transform", json!({"copies": 5, "rotate": 12.0, "scaleH": 90.0, "scaleV": 90.0}))], + ), + ] +} + +fn blends_transparency() -> Vec { + let with = + |g: GraphicStyle, opacity: f32, blend: BlendMode, isolate: bool, knockout: Knockout| GraphicStyle { opacity, blend, isolate, knockout, ..g }; + let one = |name: &str, h: &str| style(name, vec![F(fill(h))], vec![]); + // A fill blending with the fills under it, so the style shows on any backdrop. + let over = |paint: Paint, blend: BlendMode| F(FillLayer { blend, ..FillLayer::new(paint) }); + vec![ + with(one("Multiply Tint", "#ffbe0b"), 1.0, BlendMode::Multiply, false, Knockout::Neutral), + with(one("Darken Veil", "#4cc9f0"), 0.85, BlendMode::Darken, false, Knockout::Neutral), + with(style("Ghosted", vec![F(fill("#7209b7")), S(stroke("#3a0ca3", 1.0))], vec![]), 0.4, BlendMode::Normal, false, Knockout::Neutral), + with(one("Invert", "#ffffff"), 1.0, BlendMode::Difference, false, Knockout::Neutral), + style("Two Tone", vec![F(fill("#4361ee")), F(FillLayer { opacity: 0.6, blend: BlendMode::Multiply, ..fill("#f72585") })], vec![]), + style( + "Overlay Glaze", + vec![F(fill("#f72585")), over(linear(-90.0, &[(0.0, "#ffffff", 1.0), (1.0, "#000000", 1.0)]), BlendMode::Overlay)], + vec![], + ), + style( + "Screen Light", + vec![F(fill("#3a0ca3")), over(radial_at(0.5, 0.4, 0.6, &[(0.0, "#4cc9f0", 1.0), (1.0, "#000000", 1.0)]), BlendMode::Screen)], + vec![], + ), + with( + style( + "Soft Light Wash", + vec![F(fill("#0096c7")), over(linear(0.0, &[(0.0, "#ffffff", 1.0), (1.0, "#03045e", 1.0)]), BlendMode::SoftLight)], + vec![], + ), + 1.0, + BlendMode::Normal, + true, + Knockout::Neutral, + ), + with(style("Knockout Group", vec![F(fill("#4895ef")), S(stroke("#3f37c9", 1.0))], vec![]), 0.7, BlendMode::Normal, true, Knockout::On), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_libraries_are_non_empty_with_unique_names() { + let mut ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), STYLE_LIBRARIES.len(), "library ids are unique"); + for b in STYLE_LIBRARIES { + let lib = builtin_style_library(b.id).unwrap(); + assert_eq!(lib.name, b.name); + assert!(lib.len() >= 6, "{}", b.id); + let mut names: Vec<&str> = lib.styles.iter().map(|g| g.name.as_str()).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), lib.len(), "style names in {} are unique", b.id); + assert!(lib.styles.iter().all(|g| g.id == 0 && g.unit_box && !g.appearance.items.is_empty())); + } + assert!(Arc::ptr_eq(&builtin_style_library("hand-drawn").unwrap(), &builtin_style_library("hand-drawn").unwrap()), "made once"); + assert!(builtin_style_library("nope").is_none()); + } + + #[test] + fn vcstyles_round_trip() { + let mut lib = (*builtin_style_library("gradient-finishes").unwrap()).clone(); + lib.styles.extend(builtin_style_library("blends-transparency").unwrap().styles.iter().cloned()); + let text = write(&lib); + assert!(sniff(&text) && !sniff("{\"format\": \"vectorcraft\"}") && !sniff("GIMP Palette")); + assert_eq!(read(&text, "Fallback").unwrap(), lib); + // An unnamed library takes the file's name; other JSON isn't a library. + let unnamed = write(&StyleLibrary { name: " ".into(), ..lib.clone() }); + assert_eq!(read(&unnamed, "Mine").unwrap().name, "Mine"); + assert!(read("{\"format\": \"vcswatches\", \"version\": 1, \"name\": \"x\"}", "x").is_err()); + } + + #[test] + fn a_library_from_a_document_stands_alone_and_keeps_its_patterns() { + let mut d = Document::new(100.0, 100.0); + let red = Paint::Solid { color: Color::rgb(1.0, 0.0, 0.0), swatch: Some("Red".into()), tint: 0.5 }; + let dots = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }; + d.patterns.push(PatternDef::new("Dots", vec![])); + let mut g = GraphicStyle::new("Linked", Appearance::basic(red, dots, 2.0)); + g.id = 9; + d.graphic_styles.push(g); + let lib = StyleLibrary::from_document(&d, &["Linked".into()], "Mine".into()).unwrap(); + assert_eq!(lib.len(), 1); + let s = &lib.styles[0]; + assert_eq!((s.id, s.appearance.fill_paint()), (0, Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "unlinked, no id"); + assert_eq!(lib.patterns.iter().map(|p| p.name.as_str()).collect::>(), ["Dots"]); + assert!(s.same_look(&GraphicStyle { name: "Other".into(), id: 3, ..s.clone() })); + assert!(!s.same_look(&GraphicStyle { opacity: 0.5, ..s.clone() })); + assert_eq!(StyleLibrary::from_document(&d, &[], "All".into()).unwrap().len(), d.graphic_styles.len()); + assert!(StyleLibrary::from_document(&d, &["nope".into()], "x".into()).is_err()); + // Gradients lose their swatch and their stops' links too. + let mut gp = GradientPaint::new(gradient(GradientKind::Linear, &[(0.0, "#ff0000", 1.0), (1.0, "#0000ff", 1.0)])); + gp.swatch = Some("Sunset".into()); + gp.gradient.stops[0].set_color(hex("#ff0000"), Some(("Red".into(), 0.5))); + let g = GraphicStyle::new("G", Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 1.0)).standalone(); + let Paint::Gradient(gp) = g.appearance.fill_paint() else { panic!("a gradient") }; + assert!(gp.swatch.is_none() && gp.gradient.stops.iter().all(|s| s.swatch.is_none() && s.tint == 1.0)); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 301a2518..971206a1 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -38,6 +38,7 @@ mod recolor; mod select; mod stroke; mod style; +pub mod stylelib; mod swatch; pub mod swatchlib; pub(crate) mod tabs; @@ -200,6 +201,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(swatchlib::specs()); v.extend(freeform::specs()); v.extend(flatten::specs()); + v.extend(stylelib::specs()); v }) } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index a7f826e5..0f9bc0be 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -405,29 +405,35 @@ fn style_list(s: &mut Session, _: &Value) -> Result { .graphic_styles .iter() .map(|g| { - let ap = &g.appearance; - let count = |fill: bool| ap.items.iter().filter(|i| i.is_fill() == fill).count(); - json!({ - "id": g.id, - "name": g.name, - "fill": ap.fill_paint().label(), - "stroke": ap.stroke_paint().label(), - "strokeWidth": ap.stroke_width(), - "fills": count(true), - "strokes": count(false), - "effects": ap.effects.iter().map(|e| e.id.as_str()).collect::>(), - "opacity": (g.opacity * 100.0).round(), - "blend": g.blend.label(), - "isolate": g.isolate, - "knockout": g.knockout.label(), - "linked": links.get(&g.id).map(|v| v.iter().map(|i| i.0).collect::>()).unwrap_or_default(), - }) + let mut v = style_json(g); + v["id"] = json!(g.id); + v["linked"] = json!(links.get(&g.id).map(|v| v.iter().map(|i| i.0).collect::>()).unwrap_or_default()); + v }) .collect(); let selected = s.selection_graphic_style().filter(|(_, linked)| *linked).map(|(g, _)| g.name.clone()); Ok(json!({ "styles": styles, "selected": selected, "overrideCharColor": s.prefs.override_char_color })) } +/// Style `g` described as `graphicStyle.list` lists it (without `id` and `linked`). +pub(crate) fn style_json(g: &GraphicStyle) -> Value { + let ap = &g.appearance; + let count = |fill: bool| ap.items.iter().filter(|i| i.is_fill() == fill).count(); + json!({ + "name": g.name, + "fill": ap.fill_paint().label(), + "stroke": ap.stroke_paint().label(), + "strokeWidth": ap.stroke_width(), + "fills": count(true), + "strokes": count(false), + "effects": ap.effects.iter().map(|e| e.id.as_str()).collect::>(), + "opacity": (g.opacity * 100.0).round(), + "blend": g.blend.label(), + "isolate": g.isolate, + "knockout": g.knockout.label(), + }) +} + fn style_redefine(s: &mut Session, p: &Value) -> Result { let cmd = "graphicStyle.redefine"; let (src, n) = source(s, p, cmd)?; diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs new file mode 100644 index 00000000..3b8c34e0 --- /dev/null +++ b/crates/engine/src/cmd/stylelib.rs @@ -0,0 +1,231 @@ +//! Graphic style libraries (Window → Graphic Style Libraries, the Graphic Styles panel's library +//! button): read-only sets of graphic styles the library panel shows. The built-in libraries are +//! generated in [`vectorcraft_doc::style_libs`]; User Defined ones are the `.vcstyles` files in the +//! user library folder ([`Libraries`]), and Other Library… loads more from files. +//! `graphicStyle.addFromLibrary` copies styles into the document; `graphicStyle.saveLibrary` +//! writes the document's. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::style_libs::{self, STYLE_LIBRARIES, STYLES_EXT, builtin_style_library}; +use vectorcraft_doc::{Document, GraphicStyle, PatternDef, StyleLibrary}; + +use super::menucmds::squash; +use super::swatch::str_list; +use super::swatchlib::{LibraryFile, LibraryInfo, stem}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "graphicStyle.libraries", + "Graphic Style Libraries", + [], + None, + "{} → {libraries: [{id, name, category: \"builtIn\"|\"user\" (User Defined: the .vcstyles files in the user library folder, rescanned now)|\"loaded\" (graphicStyle.loadLibrary), count, path?}], userFolder} every graphic style library the library panel opens, in menu order", + always, + list + ), + cmd!( + query "graphicStyle.library", + "Graphic Style Library", + [], + None, + "{library: id or name} → {id, name, category, styles: [{name, fill, stroke, strokeWidth, fills, strokes, effects: [effect id…], opacity, blend, isolate, knockout}] (as graphicStyle.list)}", + always, + get + ), + cmd!( + "graphicStyle.addFromLibrary", + "Add to Graphic Styles", + ["Window", "Graphic Style Libraries"], + None, + "{library: id or name, name? | names?: [style names in the library] (default: all of it), apply?: false (also apply the first one to `ids` or the selection, if any, as graphicStyle.apply), add?: false (with apply: add its appearance on top, as Alt-click), ids?} copy library styles into the document's Graphic Styles as one undo step (with the apply). A style the document already has (same name and look) isn't added again; a name taken by another style gets a number (\"Halo 2\"). The patterns they paint with come along (a name taken by another pattern or swatch gets a number) → {library, added: [names in the document], existing: [names already there], applied?: name}", + has_doc, + add + ), + cmd!( + "graphicStyle.saveLibrary", + "Save Graphic Style Library…", + ["Window", "Graphic Styles"], + None, + "{path?, names?: [style names] (default: all), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's graphic styles as a .vcstyles library (JSON: the styles, unlinked from swatches, and the patterns they paint with) → {path, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, count}", + has_doc, + save + ), + cmd!( + "graphicStyle.loadLibrary", + "Other Library…", + ["Window", "Graphic Style Libraries"], + None, + "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits) → {library: id, name, count}", + always, + load + ), + ] +} + +impl LibraryFile for StyleLibrary { + const EXTS: &'static [&'static str] = &[STYLES_EXT]; + fn read(text: &str, stem: &str) -> std::result::Result { + style_libs::read(text, stem) + } + fn name(&self) -> &str { + &self.name + } +} + +/// User Defined and loaded graphic style libraries. +pub type Libraries = super::swatchlib::Libraries; + +fn builtin_info(b: &style_libs::BuiltinStyleLibrary) -> LibraryInfo { + LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" } +} + +/// Every graphic style library, in menu order: built-in, User Defined, loaded. +pub fn libraries(s: &Session) -> Vec { + STYLE_LIBRARIES.iter().map(builtin_info).chain(s.style_libraries.infos().cloned()).collect() +} + +/// Library `key` (an id, or a name in any case) with its info. +pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc)> { + if let Some(found) = s.style_libraries.get(key) { + return Some(found); + } + if let Some(b) = STYLE_LIBRARIES.iter().find(|b| b.id == key) { + return Some((builtin_info(b), builtin_style_library(key)?)); + } + let id = libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key))?.id; + library(s, &id) +} + +fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc)> { + let key = str_param(p, "library").ok_or_else(|| bad(cmd, "missing `library` (see graphicStyle.libraries)"))?; + library(s, key).ok_or_else(|| bad(cmd, format!("no graphic style library `{key}` (see graphicStyle.libraries)"))) +} + +fn list(s: &mut Session, _: &Value) -> Result { + s.style_libraries.rescan(); + let libs: Vec = libraries(s) + .into_iter() + .map(|l| { + let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); + json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.style_libraries.path(&l.id)}) + }) + .collect(); + Ok(json!({ "libraries": libs, "userFolder": s.style_libraries.user_dir() })) +} + +fn get(s: &mut Session, p: &Value) -> Result { + let (info, lib) = library_param(s, p, "graphicStyle.library")?; + let styles: Vec = lib.styles.iter().map(super::style::style_json).collect(); + Ok(json!({"id": info.id, "name": info.name, "category": info.category, "styles": styles})) +} + +/// Pattern `name` of `lib` in `d`: the document's own when it has the same pattern (under any +/// name), else the library's added (under a free name). Returns the name it has in `d`. +fn bring_pattern(d: &mut Document, lib: &StyleLibrary, name: &str) -> String { + let Some(def) = lib.patterns.iter().find(|p| p.name == name) else { return name.to_string() }; + if let Some(same) = d.patterns.iter().find(|p| PatternDef { name: def.name.clone(), ..(*p).clone() } == *def) { + return same.name.clone(); + } + let new = unique_name(name, |n| d.pattern(n).is_some() || d.swatch_name_taken(n)); + super::patterncmds::add_pattern(d, PatternDef { name: new.clone(), ..def.clone() }); + new +} + +/// Library style `g` as a document style: its patterns brought along (`renamed`: library pattern +/// name → its name in `d`, filled in as they come) and a free name and id. +fn adopt(d: &mut Document, lib: &StyleLibrary, g: &GraphicStyle, renamed: &mut HashMap) -> GraphicStyle { + let mut g = g.clone(); + for it in &mut g.appearance.items { + if let Paint::Pattern { pattern, .. } = it.paint_mut() { + *pattern = renamed.entry(pattern.clone()).or_insert_with_key(|n| bring_pattern(d, lib, n)).clone(); + } + } + g.name = unique_name(&g.name, |n| d.graphic_style(n).is_some()); + g.id = d.next_graphic_style_id(); + g +} + +fn add(s: &mut Session, p: &Value) -> Result { + const C: &str = "graphicStyle.addFromLibrary"; + let (info, lib) = library_param(s, p, C)?; + let mut names = str_list(p, "names"); + names.extend(str_param(p, "name").map(str::to_string)); + if let Some(n) = names.iter().find(|n| lib.style(n).is_none()) { + return Err(bad(C, format!("no graphic style `{n}` in `{}`", lib.name))); + } + let mut picked: Vec<&GraphicStyle> = + if names.is_empty() { lib.styles.iter().collect() } else { names.iter().filter_map(|n| lib.style(n)).collect() }; + // Each once, in the order asked for. + let mut seen = HashSet::new(); + picked.retain(|g| seen.insert(g.name.as_str())); + // Styles the document already has (same name and look) are reused, not added again. + let d = &s.doc()?.doc; + let (existing, fresh): (Vec<&GraphicStyle>, Vec<&GraphicStyle>) = + picked.iter().partition(|g| d.graphic_style(&g.name).is_some_and(|x| x.same_look(g))); + let undo_before = s.doc()?.history.undo.len(); + // Library name → the name it got in the document. + let added: Vec<(String, String)> = if fresh.is_empty() { + vec![] + } else { + s.edit("Add to Graphic Styles", |d, _| { + let mut renamed = HashMap::new(); + let mut out = vec![]; + for g in fresh { + let new = adopt(d, &lib, g, &mut renamed); + out.push((g.name.clone(), new.name.clone())); + d.graphic_styles.push(new); + } + Ok(out) + })? + }; + let doc_name = |lib_name: &str| added.iter().find(|(l, _)| l == lib_name).map_or_else(|| lib_name.to_string(), |(_, n)| n.clone()); + let mut out = json!({ + "library": info.id, + "added": added.iter().map(|(_, n)| n).collect::>(), + "existing": existing.iter().map(|g| &g.name).collect::>(), + }); + let first = picked.first().map(|g| doc_name(&g.name)); + let targets = p.get("ids").is_some() || !super::appearance::appearance_targets(s, &json!({}))?.is_empty(); + if let Some(name) = first.filter(|_| bool_or(p, "apply", false) && targets) { + let mut params = json!({"name": name, "add": bool_or(p, "add", false)}); + if let Some(ids) = p.get("ids") { + params["ids"] = ids.clone(); + } + s.execute("graphicStyle.apply", ¶ms)?; + // Adding and applying undo together. + squash(s, undo_before, "Add to Graphic Styles"); + out["applied"] = json!(name); + } + Ok(out) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "graphicStyle.saveLibrary"; + let st = s.doc()?; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string); + let lib = StyleLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?; + let mut out = json!({ "count": lib.len() }); + s.style_libraries.write(p, &lib.name, STYLES_EXT, style_libs::write(&lib), &mut out, C)?; + Ok(out) +} + +fn load(s: &mut Session, p: &Value) -> Result { + const C: &str = "graphicStyle.loadLibrary"; + let (info, lib) = s.style_libraries.load(p, C, |bytes, file| { + // A library file, or a document whose graphic styles become the library. + match std::str::from_utf8(bytes).ok().filter(|t| style_libs::sniff(t)) { + Some(t) => style_libs::read(t, stem(file)).map_err(|e| bad(C, e)), + None => { + let doc = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?.doc; + StyleLibrary::from_document(&doc, &[], stem(file).to_string()).map_err(|e| bad(C, e)) + } + } + })?; + Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) +} diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index f7fa9193..942ad5d8 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -76,26 +76,46 @@ pub fn specs() -> Vec { } /// The libraries beyond the built-in ones: User Defined (the library files in the user library -/// folder, as last scanned) and loaded ones (`swatch.library.load`). +/// folder, as last scanned) and loaded ones (`swatch.library.load`). Graphic style libraries use +/// the same with [`vectorcraft_doc::StyleLibrary`] ([`super::stylelib`]). #[derive(Clone, Debug, Default)] -pub struct Libraries { +pub struct Libraries { /// The user library folder. The desktop app sets it; without one (the web, headless sessions) /// there are no User Defined libraries. user_dir: Option, - extra: Vec, + extra: Vec>, } #[derive(Clone, Debug)] -struct Extra { +struct Extra { info: LibraryInfo, path: Option, - lib: Arc, + lib: Arc, +} + +/// A kind of library that lives in files ([`Libraries`]). +pub trait LibraryFile: Sized { + /// The extensions of its files (lower case). + const EXTS: &'static [&'static str]; + /// Read a library file whose name without the extension is `stem` (an unnamed library's name). + fn read(text: &str, stem: &str) -> std::result::Result; + fn name(&self) -> &str; +} + +impl LibraryFile for SwatchLibrary { + const EXTS: &'static [&'static str] = LIBRARY_EXTS; + fn read(text: &str, stem: &str) -> std::result::Result { + palette_io::read(text, stem) + } + fn name(&self) -> &str { + &self.name + } } /// The extensions of library files [`palette_io::read`] reads. pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl"]; -impl Libraries { +impl Libraries { pub fn user_dir(&self) -> Option<&str> { self.user_dir.as_deref() } @@ -110,28 +130,88 @@ impl Libraries { pub fn rescan(&mut self) { self.extra.retain(|e| e.info.category != "user"); let Some(dir) = self.user_dir.clone() else { return }; - for path in library_files(&dir) { + for path in library_files(&dir, L::EXTS) { let file = file_name(&path); - let Some(lib) = read_file(&path).ok().and_then(|b| palette_io::read(&String::from_utf8_lossy(&b), stem(&file)).ok()) else { continue }; - let info = LibraryInfo { id: format!("user/{file}"), name: lib.name.clone(), category: "user" }; + let Some(lib) = read_file(&path).ok().and_then(|b| L::read(&String::from_utf8_lossy(&b), stem(&file)).ok()) else { continue }; + let info = LibraryInfo { id: format!("user/{file}"), name: lib.name().to_string(), category: "user" }; self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) }); } } - /// Add (or replace, by id) a loaded library. - fn put(&mut self, e: Extra) { - self.extra.retain(|x| x.info.id != e.info.id); - self.extra.push(e); + /// The User Defined and loaded libraries, in menu order. + pub fn infos(&self) -> impl Iterator { + self.extra.iter().map(|e| &e.info) + } + + /// The User Defined or loaded library `id`. + pub fn get(&self, id: &str) -> Option<(LibraryInfo, Arc)> { + self.extra.iter().find(|e| e.info.id == id).map(|e| (e.info.clone(), e.lib.clone())) + } + + /// The file library `id` was read from. + pub fn path(&self, id: &str) -> Option<&str> { + self.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.as_deref()) + } + + /// Write library file `text` as the save commands do: into the user library folder as + /// `name`.`ext` with `user: true` (→ `path`, and `library`: its id), to `path`, else back as + /// `data`; the results go into `out`. + pub(crate) fn write(&mut self, p: &Value, name: &str, ext: &str, text: String, out: &mut Value, cmd: &str) -> Result<()> { + if bool_or(p, "user", false) { + let dir = self + .user_dir() + .ok_or_else(|| bad(cmd, "no user library folder here (save with a path, or without one to get the data)"))? + .to_string(); + // A file name from the library's name, without characters file systems reject. + let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect(); + let file = format!("{}.{ext}", base.trim_matches(['.', ' '])); + let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string(); + create_dir(&dir)?; + write_file(&path, text.as_bytes())?; + self.rescan(); + out["path"] = json!(path); + out["library"] = json!(format!("user/{file}")); + } else if let Some(path) = str_param(p, "path") { + write_file(path, text.as_bytes())?; + out["path"] = json!(path); + } else { + out["data"] = json!(text); + } + Ok(()) + } + + /// Load a library from `p` (`path`, `data` or `dataBase64`; `name`: the file's name) as the + /// load commands do, `parse(bytes, file name)` reading it. A file of the user library folder is + /// its User Defined library; others are listed as loaded until the app quits. + pub(crate) fn load(&mut self, p: &Value, cmd: &str, parse: impl FnOnce(&[u8], &str) -> Result) -> Result<(LibraryInfo, Arc)> { + let path = str_param(p, "path"); + let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) { + (Some(path), ..) => read_file(path)?, + (None, Some(text), _) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?, + _ => return Err(bad(cmd, "give `path`, `data` or `dataBase64`")), + }; + let file = str_param(p, "name").map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into()); + let lib = parse(&bytes, &file)?; + self.rescan(); + if let Some(e) = path.and_then(|p| self.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p))) { + return Ok((e.info.clone(), e.lib.clone())); + } + let info = LibraryInfo { id: format!("loaded/{}", path.unwrap_or(&file)), name: lib.name().to_string(), category: "loaded" }; + let lib = Arc::new(lib); + self.extra.retain(|x| x.info.id != info.id); + self.extra.push(Extra { info: info.clone(), path: path.map(str::to_string), lib: lib.clone() }); + Ok((info, lib)) } } -/// The library files in folder `dir`, sorted by name. +/// The library files with extensions `exts` in folder `dir`, sorted by name. #[cfg(not(target_arch = "wasm32"))] -fn library_files(dir: &str) -> Vec { +fn library_files(dir: &str, exts: &[&str]) -> Vec { let Ok(rd) = std::fs::read_dir(dir) else { return vec![] }; let mut files: Vec = rd .filter_map(|e| e.ok().map(|e| e.path())) - .filter(|p| p.extension().is_some_and(|x| LIBRARY_EXTS.contains(&x.to_string_lossy().to_ascii_lowercase().as_str()))) + .filter(|p| p.extension().is_some_and(|x| exts.contains(&x.to_string_lossy().to_ascii_lowercase().as_str()))) .map(|p| p.to_string_lossy().to_string()) .collect(); files.sort_by_key(|f| f.to_lowercase()); @@ -139,7 +219,7 @@ fn library_files(dir: &str) -> Vec { } #[cfg(target_arch = "wasm32")] -fn library_files(_: &str) -> Vec { +fn library_files(_: &str, _: &[&str]) -> Vec { vec![] } @@ -147,7 +227,8 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()) } -fn stem(name: &str) -> &str { +/// `name` without its extension. +pub(crate) fn stem(name: &str) -> &str { name.rsplit_once('.').map_or(name, |(s, _)| s) } @@ -171,13 +252,13 @@ fn builtin_info((category, b): (&'static str, &BuiltinLibrary)) -> LibraryInfo { /// Every library, in menu order: built-in, gradients, User Defined, loaded. pub fn libraries(s: &Session) -> Vec { - builtins().map(builtin_info).chain(s.swatch_libraries.extra.iter().map(|e| e.info.clone())).collect() + builtins().map(builtin_info).chain(s.swatch_libraries.infos().cloned()).collect() } /// Library `key` (an id, or a name in any case) with its info. pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc)> { - if let Some(e) = s.swatch_libraries.extra.iter().find(|e| e.info.id == key) { - return Some((e.info.clone(), e.lib.clone())); + if let Some(found) = s.swatch_libraries.get(key) { + return Some(found); } if let Some(b) = builtins().find(|(_, b)| b.id == key) { return Some((builtin_info(b), builtin_library(key)?)); @@ -193,12 +274,11 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc< fn list(s: &mut Session, _: &Value) -> Result { s.swatch_libraries.rescan(); - let path = |id: &str| s.swatch_libraries.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.clone()); let libs: Vec = libraries(s) .into_iter() .map(|l| { let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); - json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": path(&l.id)}) + json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.swatch_libraries.path(&l.id)}) }) .collect(); Ok(json!({ "libraries": libs, "userFolder": s.swatch_libraries.user_dir() })) @@ -237,61 +317,22 @@ fn save(s: &mut Session, p: &Value) -> Result { let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?; let (count, text) = (lib.len(), palette_io::write(&lib, format)); let mut out = json!({"format": format.id(), "count": count}); - if bool_or(p, "user", false) { - let dir = s - .swatch_libraries - .user_dir() - .ok_or_else(|| bad(C, "no user library folder here (save with a path, or without one to get the data)"))? - .to_string(); - // A file name from the library's name, without characters file systems reject. - let base: String = lib.name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect(); - let file = format!("{}.{}", base.trim_matches(['.', ' ']), format.id()); - let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string(); - create_dir(&dir)?; - write_file(&path, text.as_bytes())?; - s.swatch_libraries.rescan(); - out["path"] = json!(path); - out["library"] = json!(format!("user/{file}")); - } else if let Some(path) = path { - write_file(path, text.as_bytes())?; - out["path"] = json!(path); - } else { - out["data"] = json!(text); - } + s.swatch_libraries.write(p, &lib.name, format.id(), text, &mut out, C)?; Ok(out) } fn load(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.library.load"; - let path = str_param(p, "path"); - let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) { - (Some(path), ..) => read_file(path)?, - (None, Some(text), _) => text.as_bytes().to_vec(), - (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?, - _ => return Err(bad(C, "give `path`, `data` or `dataBase64`")), - }; - let file = str_param(p, "name").map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into()); - // A library file, or a document whose swatches become the library. - let text = std::str::from_utf8(&bytes).ok().filter(|t| palette_io::sniff(t)); - let lib = match text { - Some(t) => palette_io::read(t, stem(&file)).map_err(|e| bad(C, e))?, - None => { - let doc = super::fileio::load(&file, &bytes).map_err(|e| bad(C, e.to_string()))?.doc; - document_library(&doc, &[], stem(&file).to_string(), C)? + let (info, lib) = s.swatch_libraries.load(p, C, |bytes, file| { + // A library file, or a document whose swatches become the library. + match std::str::from_utf8(bytes).ok().filter(|t| palette_io::sniff(t)) { + Some(t) => palette_io::read(t, stem(file)).map_err(|e| bad(C, e)), + None => { + let doc = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?.doc; + document_library(&doc, &[], stem(file).to_string(), C) + } } - }; - // A file of the user library folder is its User Defined library. - s.swatch_libraries.rescan(); - let user = path.and_then(|p| s.swatch_libraries.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p))); - let info = match user { - Some(e) => e.info.clone(), - None => { - let id = format!("loaded/{}", path.unwrap_or(&file)); - let info = LibraryInfo { id, name: lib.name.clone(), category: "loaded" }; - s.swatch_libraries.put(Extra { info: info.clone(), path: path.map(str::to_string), lib: Arc::new(lib.clone()) }); - info - } - }; + })?; Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f5ad1530..b5f60c15 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -493,6 +493,8 @@ pub struct Session { /// The selected freeform gradient point (`paint.freeform.selectPoint`) and whose gradient it /// was selected on: read it with [`Session::selected_freeform_point`]. pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>, + /// User Defined and loaded graphic style libraries (Window → Graphic Style Libraries); not saved. + pub style_libraries: cmd::stylelib::Libraries, } impl Default for Session { @@ -526,6 +528,7 @@ impl Session { pending_paint: None, swatch_libraries: Default::default(), freeform_point: None, + style_libraries: Default::default(), } } @@ -850,6 +853,8 @@ mod tests_strokereach; #[cfg(test)] mod tests_strokeux; #[cfg(test)] +mod tests_stylelib; +#[cfg(test)] mod tests_stylepanel; #[cfg(test)] mod tests_styles; diff --git a/crates/engine/src/tests_stylelib.rs b/crates/engine/src/tests_stylelib.rs new file mode 100644 index 00000000..10a9baf9 --- /dev/null +++ b/crates/engine/src/tests_stylelib.rs @@ -0,0 +1,226 @@ +//! Graphic style libraries: the built-in libraries, adding to the document (deduped, one undo step, +//! applied, patterns brought along), saving and loading `.vcstyles` and user libraries. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Paint, Swatch}; +use vectorcraft_doc::{GraphicStyle, Node, NodeId, PatternDef}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn doc(s: &Session) -> &vectorcraft_doc::Document { + &s.doc().unwrap().doc +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn names(v: &Value) -> Vec<&str> { + v.as_array().unwrap().iter().map(|n| n.as_str().unwrap()).collect() +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + NodeId(run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap()) +} + +#[test] +fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() { + let mut s = Session::new(); + let list = run(&mut s, "graphicStyle.libraries", json!({})); + let libs = list["libraries"].as_array().unwrap(); + assert!(libs.len() >= 6 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}"); + for l in libs { + let id = l["id"].as_str().unwrap(); + let g = run(&mut s, "graphicStyle.library", json!({"library": id})); + let styles = g["styles"].as_array().unwrap(); + assert!(!styles.is_empty() && l["count"].as_u64() == Some(styles.len() as u64), "{id}"); + let (_, lib) = cmd::stylelib::library(&s, id).unwrap(); + for st in &lib.styles { + let ap = &st.appearance; + let item_fx = ap.items.iter().flat_map(|i| i.effects()); + for e in ap.effects.iter().chain(item_fx) { + assert!(vectorcraft_render::effects::effect_info(&e.id).is_some(), "{id} / {}: unknown effect {}", st.name, e.id); + } + } + } + // By name in any case. + assert_eq!(run(&mut s, "graphicStyle.library", json!({"library": "shadows and glows"}))["id"], "shadows-glows"); + assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err()); +} + +#[test] +fn add_dedupes_names_as_one_undo_step() { + let mut s = session(); + let before = (undo_len(&s), doc(&s).graphic_styles.len()); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "names": ["Halo", "Ember", "Halo"]})); + assert_eq!((names(&r["added"]), names(&r["existing"])), (vec!["Halo", "Ember"], vec![])); + assert_eq!(undo_len(&s), before.0 + 1, "one undo step"); + let d = doc(&s); + assert_eq!(d.graphic_styles.len(), before.1 + 2); + let halo = d.graphic_style("Halo").unwrap(); + assert!(halo.id != 0 && d.graphic_styles.iter().filter(|g| g.id == halo.id).count() == 1, "a fresh id"); + // Adding again finds them and changes nothing (no undo step). + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "name": "Halo"})); + assert_eq!((names(&r["added"]), names(&r["existing"])), (vec![], vec!["Halo"])); + assert_eq!(undo_len(&s), before.0 + 1); + // A name taken by another look gets a number. + let id = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + run(&mut s, "graphicStyle.new", json!({"name": "Feathered", "id": id.0})); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "names": ["Feathered"]})); + assert_eq!(names(&r["added"]), ["Feathered 2"]); + assert_eq!(doc(&s).graphic_style("Feathered 2").unwrap().appearance.effects[0].id, "stylize.feather"); + // The whole library; undo removes a whole add. + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "hand-drawn"})); + let all = cmd::stylelib::library(&s, "hand-drawn").unwrap().1.len(); + assert_eq!(r["added"].as_array().unwrap().len(), all); + run(&mut s, "edit.undo", json!({})); + assert!(doc(&s).graphic_style("Sketchy Line").is_none() && doc(&s).graphic_style("Feathered 2").is_some()); + assert!(s.execute("graphicStyle.addFromLibrary", &json!({"library": "hand-drawn", "name": "Nope"})).is_err()); +} + +#[test] +fn add_with_apply_styles_the_selection_in_the_same_undo_step() { + let mut s = session(); + let id = rect(&mut s, 10.0, 20.0, 100.0, 50.0); + let before = undo_len(&s); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "gradient-finishes", "name": "Spotlight", "apply": true})); + assert_eq!(r["applied"], "Spotlight"); + assert_eq!(undo_len(&s), before + 1, "added and applied as one step"); + let (g, linked) = s.selection_graphic_style().unwrap(); + assert_eq!((g.name.as_str(), linked), ("Spotlight", true)); + // The unit-box gradient lands at the same place relative to the object's bounds. + let Paint::Gradient(gp) = doc(&s).node(id).unwrap().appearance.fill_paint() else { panic!("a gradient") }; + let geom = gp.geom.unwrap(); + assert!((geom.start - Point::new(10.0 + 0.35 * 100.0, 20.0 + 0.3 * 50.0)).hypot() < 1e-6, "{geom:?}"); + run(&mut s, "edit.undo", json!({})); + assert!(doc(&s).graphic_style("Spotlight").is_none()); + assert_eq!(doc(&s).node(id).unwrap().graphic_style, None); + // Without a selection it only adds; `ids` targets objects; an existing style is applied as is. + run(&mut s, "select.none", json!({})); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "blends-transparency", "name": "Ghosted", "apply": true})); + assert!(r.get("applied").is_none() && doc(&s).graphic_style("Ghosted").is_some()); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "blends-transparency", "name": "Ghosted", "apply": true, "ids": [id.0]})); + assert_eq!((names(&r["existing"]), r["applied"].as_str()), (vec!["Ghosted"], Some("Ghosted"))); + assert_eq!(doc(&s).node(id).unwrap().opacity, 0.4); +} + +/// A document with pattern `name` (a dot) and its swatch. +fn add_pattern(s: &mut Session, name: &str, radius: f64) { + s.edit("Pattern", |d, _| { + let dot = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(0.0, 0.0, radius, radius)), vectorcraft_doc::Appearance::default_art()); + d.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 10.0), ..PatternDef::new(name, vec![Arc::new(dot)]) }); + d.swatches.push(Swatch { name: name.into(), paint: pattern(name), global: false, spot: false }); + Ok(()) + }) + .unwrap(); +} + +fn pattern(name: &str) -> Paint { + Paint::Pattern { pattern: name.into(), xf: Default::default() } +} + +#[test] +fn save_and_load_round_trip_with_patterns_and_unlinked_swatches() { + let mut s = session(); + add_pattern(&mut s, "Dots", 4.0); + run(&mut s, "swatch.edit", json!({"name": "Red", "global": true})); + let id = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + run(&mut s, "paint.setFill", json!({"swatch": "Dots"})); + run(&mut s, "paint.setStroke", json!({"swatch": "Red"})); + run(&mut s, "transparency.set", json!({"opacity": 50, "blend": "Multiply"})); + run(&mut s, "graphicStyle.new", json!({"name": "Dotted", "id": id.0})); + let r = run(&mut s, "graphicStyle.saveLibrary", json!({"names": ["Dotted", "Sunshine"], "name": "Kit"})); + assert_eq!(r["count"], 2); + let data = r["data"].as_str().unwrap().to_string(); + assert!(data.contains("\"vcstyles\"")); + let r = run(&mut s, "graphicStyle.loadLibrary", json!({"data": data, "name": "kit.vcstyles"})); + assert_eq!((r["name"].as_str(), r["count"].as_u64()), (Some("Kit"), Some(2))); + let lib_id = r["library"].as_str().unwrap().to_string(); + let (_, lib) = cmd::stylelib::library(&s, &lib_id).unwrap(); + let dotted = lib.style("Dotted").unwrap(); + let mine = doc(&s).graphic_style("Dotted").unwrap(); + assert!(dotted.same_look(&mine.standalone()) && (dotted.opacity, dotted.id) == (0.5, 0)); + let red = doc(&s).swatch("Red").and_then(|w| w.paint.color()).unwrap(); + assert_eq!(mine.appearance.stroke_paint(), Paint::Solid { color: red, swatch: Some("Red".into()), tint: 1.0 }); + assert_eq!(dotted.appearance.stroke_paint(), Paint::solid(red), "unlinked from the swatch"); + assert_eq!(lib.patterns.len(), 1); + let list = run(&mut s, "graphicStyle.libraries", json!({})); + assert!(list["libraries"].as_array().unwrap().iter().any(|l| l["id"] == lib_id.as_str() && l["category"] == "loaded")); + + // Into another document: the pattern comes along (with its swatch), once. + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + run(&mut s, "graphicStyle.addFromLibrary", json!({"library": lib_id, "name": "Dotted"})); + let d = doc(&s); + assert_eq!(d.pattern("Dots"), lib.patterns.first()); + assert!(d.swatch("Dots").is_some()); + assert_eq!(d.graphic_style("Dotted").unwrap().appearance.fill_paint(), pattern("Dots")); + // A document whose "Dots" is another pattern gets the library's under a free name. + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + add_pattern(&mut s, "Dots", 8.0); + run(&mut s, "graphicStyle.addFromLibrary", json!({"library": lib_id})); + let d = doc(&s); + assert_eq!(d.graphic_style("Dotted").unwrap().appearance.fill_paint(), pattern("Dots 2")); + assert_eq!(d.patterns.len(), 2); + assert!(s.execute("graphicStyle.saveLibrary", &json!({"names": ["Nope"]})).is_err()); + assert!(s.execute("graphicStyle.saveLibrary", &json!({"user": true})).is_err(), "no user folder in a headless session"); +} + +#[test] +fn user_libraries_and_other_documents_styles() { + let dir = std::env::temp_dir().join(format!("vc-stylelib-user-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = session(); + s.style_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let r = run(&mut s, "graphicStyle.saveLibrary", json!({"user": true, "name": "My: Looks"})); + assert_eq!(r["library"], "user/My- Looks.vcstyles"); + let path = r["path"].as_str().unwrap().to_string(); + let list = run(&mut s, "graphicStyle.libraries", json!({})); + let user: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "user").collect(); + assert_eq!(user.len(), 1); + assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("My: Looks"), Some(doc(&s).graphic_styles.len() as u64))); + assert_eq!(list["userFolder"].as_str(), Some(dir.to_string_lossy().as_ref())); + // Opening a file of the user folder gives its User Defined library. + assert_eq!(run(&mut s, "graphicStyle.loadLibrary", json!({ "path": path }))["library"], "user/My- Looks.vcstyles"); + let _ = std::fs::remove_dir_all(dir); + + // Another document's graphic styles load as a library. + let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + run(&mut s, "graphicStyle.new", json!({"name": "Poster Look", "id": id.0})); + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "graphicStyle.loadLibrary", json!({"dataBase64": b64, "name": "Poster.vectorcraft"})); + assert_eq!(r["name"], "Poster"); + let (_, lib) = cmd::stylelib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert!(lib.style("Poster Look").is_some_and(|g: &GraphicStyle| g.id == 0)); + assert!(s.execute("graphicStyle.loadLibrary", &json!({"data": "hello", "name": "x.txt"})).is_err()); +} + +#[test] +fn every_builtin_style_paints_visibly_on_a_rectangle() { + let mut s = session(); + let mut renderer = vectorcraft_render::Renderer::new(); + for b in vectorcraft_doc::style_libs::STYLE_LIBRARIES { + let (_, lib) = cmd::stylelib::library(&s, b.id).unwrap(); + for g in &lib.styles { + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + let id = rect(&mut s, 25.0, 30.0, 50.0, 40.0); + run(&mut s, "graphicStyle.addFromLibrary", json!({"library": b.id, "name": g.name, "apply": true})); + assert_eq!(doc(&s).node(id).unwrap().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name); + let img = renderer.render_region(doc(&s), Rect::new(0.0, 0.0, 100.0, 100.0), 0.5, true); + let painted = img.pixels.chunks_exact(4).filter(|p| p[..3] != [255, 255, 255]).count(); + assert!(painted > 40, "{} / {} paints {painted} pixels", b.id, g.name); + } + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index c2b81d31..a928798d 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -30,6 +30,7 @@ mod path_ops; mod recolor; pub mod saturate; mod save_changes; +pub mod save_style_library; pub mod save_swatch_library; mod shapes; pub mod swatch_options; @@ -160,6 +161,7 @@ registry! { EyedropperOptions: [eyedropper::KIND] => eyedropper::SPEC, FlattenTransparency: [flatten::KIND] => flatten::SPEC, FlattenerPresets: [flattener_presets::KIND] => flattener_presets::SPEC, + SaveStyleLibrary: [save_style_library::KIND] => save_style_library::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/save_style_library.rs b/crates/ui-egui/src/dialogs/save_style_library.rs new file mode 100644 index 00000000..17a94866 --- /dev/null +++ b/crates/ui-egui/src/dialogs/save_style_library.rs @@ -0,0 +1,41 @@ +//! Save Graphic Style Library: a name and where it goes: the user library folder (listed under +//! Window → Graphic Style Libraries → User Defined) or a `.vcstyles` file picked in a save dialog +//! (a download on the web). OK runs `graphicStyle.saveLibrary`. +//! +//! Fields: `name`, `user` (save to the user library folder), `selectedOnly` and `names` (the styles +//! selected in the Graphic Styles panel), `__user` (there is a user library folder). + +use serde_json::Value; +use vectorcraft_doc::style_libs::STYLES_EXT; + +use super::DialogSpec; +use super::save_swatch_library::{destination, fields, name_row, params, save}; +use super::swatch_options::grid; +use crate::VectorcraftApp; +use crate::state::Dialog; + +/// The dialog kind of Save Graphic Style Library. +pub const KIND: &str = "saveGraphicStyleLibrary"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Save Graphic Style Library".into(), body, confirm, min_width: 360.0, ..DialogSpec::FORM }; + +/// Open Save Graphic Style Library for the document's styles (`names`: the ones selected in the +/// panel). +pub fn open(app: &mut VectorcraftApp, names: Vec) -> Result { + let fields = fields(app, app.session.style_libraries.user_dir().is_some(), names)?; + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + grid(ui, |ui| { + name_row(ui, d); + destination(ui, d, "Selected Styles Only"); + }); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + save(app, d, "graphicStyle.saveLibrary", STYLES_EXT, params(d)) +} diff --git a/crates/ui-egui/src/dialogs/save_swatch_library.rs b/crates/ui-egui/src/dialogs/save_swatch_library.rs index 62a0ec21..5d599e78 100644 --- a/crates/ui-egui/src/dialogs/save_swatch_library.rs +++ b/crates/ui-egui/src/dialogs/save_swatch_library.rs @@ -5,6 +5,9 @@ //! Fields: `name`, `format` (`vcswatches`, `gpl` or `css`), `user` (save to the user library //! folder), `selectedOnly` and `names` (the swatches selected in the Swatches panel), `__user` //! (there is a user library folder). +//! +//! The name and Save To rows and the saving are shared with Save Graphic Style Library +//! ([`super::save_style_library`]). use serde_json::{Value, json}; use vectorcraft_color::palette_io::PaletteFormat; @@ -21,54 +24,39 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Save Swatch Libra /// Open Save Swatch Library for the document's swatches (`names`: the ones selected in the panel). pub fn open(app: &mut VectorcraftApp, names: Vec) -> Result { + let mut fields = fields(app, app.session.swatch_libraries.user_dir().is_some(), names)?; + fields["format"] = json!(PaletteFormat::Native.id()); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +/// The fields a Save … Library dialog opens with: the document's name, Save To the user library +/// folder when there is one (`user`), and the panel's selected `names`. +pub(super) fn fields(app: &VectorcraftApp, user: bool, names: Vec) -> Result { let st = app.session.active().ok_or("no document open")?; let title = st.title(); let name = title.rsplit_once('.').map_or(title.as_str(), |(s, _)| s).to_string(); - let user = app.session.swatch_libraries.user_dir().is_some(); - let fields = json!({ - "name": name, "format": PaletteFormat::Native.id(), "user": user, "__user": user, - "selectedOnly": false, "names": names, - }); - app.ui.dialog = Some(Dialog::new(KIND, fields)); - Ok(Value::Null) + Ok(json!({"name": name, "user": user, "__user": user, "selectedOnly": false, "names": names})) } fn format_of(d: &Dialog) -> PaletteFormat { PaletteFormat::parse(&d.str("format")).unwrap_or(PaletteFormat::Native) } +fn set(d: &mut Dialog, key: &str, v: Value) { + d.fields.insert(key.into(), v); +} + fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let labels = PaletteFormat::ALL.map(PaletteFormat::label); - let names = d.fields.get("names").and_then(Value::as_array).map_or(0, Vec::len); - let set = |d: &mut Dialog, key: &str, v: Value| { - d.fields.insert(key.into(), v); - }; grid(ui, |ui| { - label(ui, "Name:"); - form::text(ui, d, "name", 220.0); - ui.end_row(); + name_row(ui, d); label(ui, "Format:"); if let Some(i) = widgets::dropdown(ui, "library-format", format_of(d).label(), &labels, 230.0) { set(d, "format", json!(PaletteFormat::ALL[i].id())); } ui.end_row(); - label(ui, "Save To:"); - let (user, has_user) = (d.bool("user"), d.bool("__user")); - if widgets::radio(ui, "User Defined Libraries", user, has_user) { - set(d, "user", json!(true)); - } - ui.end_row(); - ui.label(""); - if widgets::radio(ui, "A File…", !user, true) { - set(d, "user", json!(false)); - } - ui.end_row(); - ui.label(""); - let only = d.bool("selectedOnly"); - if widgets::check(ui, &format!("Selected Swatches Only ({names})"), only, names > 0) { - set(d, "selectedOnly", json!(!only)); - } - ui.end_row(); + destination(ui, d, "Selected Swatches Only"); }); if format_of(d) == PaletteFormat::Gpl { widgets::dim_label(ui, "GPL palettes keep solid colours only, as RGB."); @@ -76,22 +64,60 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } -/// `swatch.library.save` parameters from the fields. -fn params(d: &Dialog) -> Value { - let mut p = json!({"name": d.str("name"), "format": format_of(d).id(), "user": d.bool("user") && d.bool("__user")}); +/// The Name row of a Save … Library dialog's [`grid`]. +pub(super) fn name_row(ui: &mut egui::Ui, d: &mut Dialog) { + label(ui, "Name:"); + form::text(ui, d, "name", 220.0); + ui.end_row(); +} + +/// The Save To rows of a Save … Library dialog's [`grid`]: the user library folder (when there is +/// one) or a file, and `only` (Selected Swatches Only) with the number of selected items. +pub(super) fn destination(ui: &mut egui::Ui, d: &mut Dialog, only: &str) { + let names = d.fields.get("names").and_then(Value::as_array).map_or(0, Vec::len); + label(ui, "Save To:"); + let (user, has_user) = (d.bool("user"), d.bool("__user")); + if widgets::radio(ui, "User Defined Libraries", user, has_user) { + set(d, "user", json!(true)); + } + ui.end_row(); + ui.label(""); + if widgets::radio(ui, "A File…", !user, true) { + set(d, "user", json!(false)); + } + ui.end_row(); + ui.label(""); + let selected = d.bool("selectedOnly"); + if widgets::check(ui, &format!("{only} ({names})"), selected, names > 0) { + set(d, "selectedOnly", json!(!selected)); + } + ui.end_row(); +} + +/// The save command's parameters from the shared fields: `name`, `user` and the selected `names`. +pub(super) fn params(d: &Dialog) -> Value { + let mut p = json!({"name": d.str("name"), "user": d.bool("user") && d.bool("__user")}); if d.bool("selectedOnly") { p["names"] = d.fields.get("names").cloned().unwrap_or_else(|| json!([])); } p } -fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let p = params(d); +/// Run save command `cmd` with `p`: into the user library folder, else to a file the user picks +/// (extension `ext`). +pub(super) fn save(app: &mut VectorcraftApp, d: &Dialog, cmd: &str, ext: &str, p: Value) -> Result { if p["user"] == json!(true) { - let r = run_and_close(app, "swatch.library.save", p)?; + let r = run_and_close(app, cmd, p)?; app.status(format!("Saved to User Defined: {}", d.str("name"))); return Ok(r); } app.ui.dialog = None; - crate::io::save_command_output(app, "swatch.library.save", format_of(d).id(), p).map(|path| json!({ "path": path })) + crate::io::save_command_output(app, cmd, ext, p).map(|path| json!({ "path": path })) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let format = format_of(d).id(); + let mut p = params(d); + p["format"] = json!(format); + save(app, d, "swatch.library.save", format, p) } diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index add52de4..673bbe36 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -8,21 +8,24 @@ use vectorcraft_geom::Affine; use crate::VectorcraftApp; -/// Open bytes of any readable format as a new document (templates open untitled); swatch library -/// files open in the library panel and flattener presets files are imported. +/// Open bytes of any readable format as a new document (templates open untitled); swatch and +/// graphic style library files open in the library panel and flattener presets files are imported. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result<(), String> { - if vectorcraft_engine::cmd::flatten::PRESET_EXTS.contains(&fileio::extension(name).as_str()) { + let ext = fileio::extension(name); + if vectorcraft_engine::cmd::flatten::PRESET_EXTS.contains(&ext.as_str()) { let r = app.run("flattener.presets.import", serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?; let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); app.status(format!("Imported flattener presets: {}", names.join(", "))); return Ok(()); } - if vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&fileio::extension(name).as_str()) { + let swatches = vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str()); + if swatches || ext == vectorcraft_doc::style_libs::STYLES_EXT { let p = match path { Some(path) => serde_json::json!({ "path": path }), None => serde_json::json!({"name": name, "data": String::from_utf8_lossy(bytes)}), }; - return crate::panels::swatches::load_library(app, p).map(|_| ()); + let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library }; + return load(app, p).map(|_| ()); } let r = fileio::open_bytes(&mut app.session, name, bytes, path).map_err(|e| e.to_string())?; app.sync_views(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 66c35a03..3cea890b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -277,6 +277,39 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{highlight?: none|rasterizedRegions|transparentObjects|allAffected|expandedPatterns|outlinedStrokes|outlinedText|allRasterized, overprints?: preserve|simulate|discard, preset?: name (loads its options), options?: {option keys, over the preset's}, showOptions?} set the Flattener Preview panel (ui.inspect: ui.flattener_preview), show it and refresh its snapshot → what flattener.preview answers for it", ), + ( + "window.graphicStyleLibrary", + "Graphic Style Library", + "", + "{library: id or name (see graphicStyle.libraries) | null (close)} open the read-only library panel on a graphic style library (UI state `library_panel`, kind graphicStyles); clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add) → {open, name, count}", + ), + ( + "window.graphicStyleLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in the library panel", + ), + ( + "ui.saveGraphicStyleLibrary", + "Save Graphic Style Library…", + "", + "{names?: [the styles selected in the Graphic Styles panel]} open Save Graphic Style Library (dialog `saveGraphicStyleLibrary`: name, user: save to the user library folder, selectedOnly); OK runs graphicStyle.saveLibrary", + ), + ( + "window.userGraphicStyleLibrary1", + "User Graphic Style Library 1", + "", + "{} open the 1. User Defined graphic style library (graphicStyle.libraries, category user)", + ), + ("window.userGraphicStyleLibrary2", "User Graphic Style Library 2", "", "{} open the 2. User Defined graphic style library"), + ("window.userGraphicStyleLibrary3", "User Graphic Style Library 3", "", "{} open the 3. User Defined graphic style library"), + ("window.userGraphicStyleLibrary4", "User Graphic Style Library 4", "", "{} open the 4. User Defined graphic style library"), + ("window.userGraphicStyleLibrary5", "User Graphic Style Library 5", "", "{} open the 5. User Defined graphic style library"), + ("window.userGraphicStyleLibrary6", "User Graphic Style Library 6", "", "{} open the 6. User Defined graphic style library"), + ("window.userGraphicStyleLibrary7", "User Graphic Style Library 7", "", "{} open the 7. User Defined graphic style library"), + ("window.userGraphicStyleLibrary8", "User Graphic Style Library 8", "", "{} open the 8. User Defined graphic style library"), + ("window.userGraphicStyleLibrary9", "User Graphic Style Library 9", "", "{} open the 9. User Defined graphic style library"), + ("window.userGraphicStyleLibrary10", "User Graphic Style Library 10", "", "{} open the 10. User Defined graphic style library"), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -634,6 +667,16 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::flattener_preview::command(app, p), + "window.graphicStyleLibrary" => crate::panels::graphic_styles::open_library(app, p), + "window.graphicStyleLibrary.other" => crate::panels::graphic_styles::other_library(app, s("path")), + "ui.saveGraphicStyleLibrary" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::save_style_library::open(app, names.unwrap_or_default()) + } + id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => match crate::panels::graphic_styles::user_library(app, id) { + Some(l) => crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })), + None => Err("no such user library".into()), + }, _ => return None, }; Some(r) @@ -729,14 +772,17 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { id if id.starts_with(crate::panels::swatches::USER_SLOT) => { crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) } + id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => { + crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } _ => label.into(), } } /// Menu slots that are left out while they have nothing to show (unused saved views, User -/// Defined swatch libraries). +/// Defined swatch and graphic style libraries). fn hidden_when_disabled(id: &str) -> bool { - id.starts_with("view.goto") || id.starts_with(crate::panels::swatches::USER_SLOT) + id.starts_with("view.goto") || id.starts_with(crate::panels::swatches::USER_SLOT) || id.starts_with(crate::panels::graphic_styles::USER_SLOT) } /// File → Open Recent Files slots. @@ -811,6 +857,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.saveSwatchLibrary" => app.session.active().is_some(), id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(), "ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.saveGraphicStyleLibrary" => app.session.active().is_some(), + id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => crate::panels::graphic_styles::user_library(app, id).is_some(), _ => true, } } @@ -1402,7 +1450,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { todo("Variables"), Sep, sub("Brush Libraries", library_placeholders()), - sub("Graphic Style Libraries", library_placeholders()), + sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()), sub("Swatch Libraries", crate::panels::swatches::window_menu()), sub("Symbol Libraries", library_placeholders()), ], diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index 0e6fbbb7..05ca7a39 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -5,7 +5,8 @@ //! dropped on (`canvas::panel_drop`); art dragged off the canvas or the Appearance panel's //! thumbnail dropped on the panel becomes a new style. Holding the right button on a style shows it //! large, on the selected object when there is one. The Control bar's Style picker lists the same -//! tiles ([`picker`]). +//! tiles ([`picker`]). Graphic style libraries open in the library panel +//! ([`GraphicStyleLibraries`]). use std::cell::RefCell; use std::collections::HashMap; @@ -13,13 +14,18 @@ use std::sync::Arc; use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, TextureHandle, Ui, pos2, vec2}; use serde_json::{Value, json}; -use vectorcraft_color::Paint; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::style_libs::STYLE_LIBRARIES; use vectorcraft_doc::text::{CharStyle, TextObject}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeId, NodeKind, PatternDef, StyleLibrary}; +use vectorcraft_engine::cmd::stylelib; +use vectorcraft_engine::cmd::swatchlib::LibraryInfo; use vectorcraft_geom::{Affine, Point, shapes}; +use super::library_panel::{self, LibraryKind, LibraryRef, Row}; use super::{alt_held, pstate, selection_len, set_pstate}; use crate::VectorcraftApp; +use crate::menus::Item; use crate::theme::Tokens; use crate::widgets::{self, PanelDrag, menu_item}; @@ -72,22 +78,36 @@ fn uses_brush(g: &GraphicStyle) -> bool { g.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some())) } +/// What a style's thumbnail paints with besides the style: the patterns and brushes of a document, +/// or the patterns of a library. +#[derive(Clone, Copy)] +struct Paints<'a> { + patterns: &'a [PatternDef], + brushes: Option<&'a Value>, +} + +impl<'a> Paints<'a> { + fn of(d: &'a Document) -> Self { + Self { patterns: &d.patterns, brushes: d.unknown.get("brushes") } + } +} + /// The look of `g` hashed: its appearance and transparency, and the patterns and brushes it paints /// with. Thumbnails are cached by it, so a style's thumbnail is re-rendered only when its look /// changes. -fn look_hash(d: &Document, g: &GraphicStyle) -> u64 { +fn look_hash(paints: Paints, g: &GraphicStyle) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); serde_json::to_vec(&(&g.appearance, g.opacity, g.blend, g.isolate, g.knockout)).unwrap_or_default().hash(&mut h); for it in &g.appearance.items { if let Paint::Pattern { pattern, .. } = it.paint() - && let Some(p) = d.pattern(pattern) + && let Some(p) = paints.patterns.iter().find(|p| p.name == *pattern) { serde_json::to_vec(p).unwrap_or_default().hash(&mut h); } } if uses_brush(g) { - d.unknown.get("brushes").map(Value::to_string).hash(&mut h); + paints.brushes.map(Value::to_string).hash(&mut h); } h.finish() } @@ -102,7 +122,11 @@ struct Shown<'a> { impl Shown<'_> { /// The look hash of style `i`. fn look(&self, i: usize) -> u64 { - self.looks.get(i).copied().unwrap_or_else(|| look_hash(self.d, &self.d.graphic_styles[i])) + self.looks.get(i).copied().unwrap_or_else(|| look_hash(Paints::of(self.d), &self.d.graphic_styles[i])) + } + /// Draw the thumbnail of style `i` into `r`. + fn paint(&self, app: &VectorcraftApp, ui: &Ui, r: Rect, i: usize) { + paint_thumb(app, ui, r, Paints::of(self.d), &self.d.graphic_styles[i], self.look(i), self.preview); } } @@ -113,7 +137,7 @@ fn look_hashes(ctx: &egui::Context, app: &VectorcraftApp) -> Arc> { match pstate::>)>>(ctx, "gs-looks") { Some((k, looks)) if k == key => looks, _ => { - let looks: Arc> = Arc::new(st.doc.graphic_styles.iter().map(|g| look_hash(&st.doc, g)).collect()); + let looks: Arc> = Arc::new(st.doc.graphic_styles.iter().map(|g| look_hash(Paints::of(&st.doc), g)).collect()); set_pstate(ctx, "gs-looks", Some((key, looks.clone()))); looks } @@ -121,13 +145,13 @@ fn look_hashes(ctx: &egui::Context, app: &VectorcraftApp) -> Arc> { } /// Style `g` applied (as `graphicStyle.apply` applies it) to the preview shape, rendered `px` -/// pixels square on white, with the patterns and brushes of `d`. -fn render(app: &VectorcraftApp, d: &Document, g: &GraphicStyle, preview: Preview, px: u32) -> Option { +/// pixels square on white, with `paints`. +fn render(app: &VectorcraftApp, paints: Paints, g: &GraphicStyle, preview: Preview, px: u32) -> Option { const SIDE: f64 = 100.0; let mut doc = Document::new(SIDE, SIDE); - doc.patterns = d.patterns.clone(); + doc.patterns = paints.patterns.to_vec(); if uses_brush(g) - && let Some(b) = d.unknown.get("brushes") + && let Some(b) = paints.brushes { doc.unknown.insert("brushes".into(), b.clone()); } @@ -150,14 +174,22 @@ fn render(app: &VectorcraftApp, d: &Document, g: &GraphicStyle, preview: Preview } /// The thumbnail of style `g` (`look`: its [`look_hash`]) at `size` points. -fn thumb(app: &VectorcraftApp, ctx: &egui::Context, d: &Document, g: &GraphicStyle, look: u64, preview: Preview, size: f32) -> Option { +fn thumb( + app: &VectorcraftApp, + ctx: &egui::Context, + paints: Paints, + g: &GraphicStyle, + look: u64, + preview: Preview, + size: f32, +) -> Option { let px = (size * ctx.pixels_per_point()).round().max(8.0) as u32; // Override Character Color changes only how type takes a style. let key = (look, px, preview, preview == Preview::Text && app.session.prefs.override_char_color); if let Some(t) = THUMBS.with(|c| c.borrow().get(&key).cloned()) { return Some(t); } - let tex = ctx.load_texture(format!("gs-{look:x}-{px}-{preview:?}"), render(app, d, g, preview, px)?, egui::TextureOptions::LINEAR); + let tex = ctx.load_texture(format!("gs-{look:x}-{px}-{preview:?}"), render(app, paints, g, preview, px)?, egui::TextureOptions::LINEAR); THUMBS.with(|c| { let mut c = c.borrow_mut(); if c.len() > 512 { @@ -168,17 +200,30 @@ fn thumb(app: &VectorcraftApp, ctx: &egui::Context, d: &Document, g: &GraphicSty Some(tex) } -/// Draw the thumbnail of style `i` into `r`. -fn paint_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, s: &Shown, i: usize) { +/// Draw the thumbnail of style `g` (`look`: its [`look_hash`]) into `r`. +fn paint_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, paints: Paints, g: &GraphicStyle, look: u64, preview: Preview) { if !ui.is_rect_visible(r) { return; } - match thumb(app, ui.ctx(), s.d, &s.d.graphic_styles[i], s.look(i), s.preview, r.width()) { + match thumb(app, ui.ctx(), paints, g, look, preview, r.width()) { Some(t) => ui.painter().image(t.id(), r, UV, Color32::WHITE), None => ui.painter().rect_filled(r, 0.0, Color32::WHITE), }; } +/// Outline thumbnail `r`: in the accent colour when selected (`on`), brighter when hovered. +fn outline(ui: &Ui, r: Rect, on: bool, hovered: bool) { + let t = Tokens::get(ui.ctx()); + let color = if on { + t.accent + } else if hovered { + t.text + } else { + t.border + }; + ui.painter().rect_stroke(r, 0.0, Stroke::new(if on { 2.0 } else { 1.0 }, color), StrokeKind::Inside); +} + /// Style `g` on a copy of the first selected object, `px` pixels square on white. One texture, /// re-rendered only when the document, the style's look or the size changes. fn object_preview(app: &VectorcraftApp, ctx: &egui::Context, g: &GraphicStyle, look: u64, px: u32) -> Option { @@ -205,7 +250,7 @@ fn large_preview(app: &VectorcraftApp, ctx: &egui::Context, s: &Shown, i: usize) let Some(at) = ctx.pointer_hover_pos() else { return }; let (g, look) = (&s.d.graphic_styles[i], s.look(i)); let px = (LARGE * ctx.pixels_per_point()).round() as u32; - let tex = object_preview(app, ctx, g, look, px).or_else(|| thumb(app, ctx, s.d, g, look, s.preview, LARGE)); + let tex = object_preview(app, ctx, g, look, px).or_else(|| thumb(app, ctx, Paints::of(s.d), g, look, s.preview, LARGE)); let t = Tokens::get(ctx); let area = egui::Area::new(egui::Id::new("gs-large")).order(egui::Order::Tooltip).fixed_pos(at + vec2(14.0, 14.0)); area.interactable(false).show(ctx, |ui| { @@ -324,21 +369,12 @@ fn zone_input(ui: &Ui, zone: &Response, d: &Document, ev: &mut Events) { /// The thumbnail grid (Thumbnail View and the Control bar's picker), `sel` outlined. fn tiles(app: &VectorcraftApp, ui: &mut Ui, s: &Shown, sel: &[String], ev: &mut Events) { - let t = Tokens::get(ui.ctx()); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(3.0, 3.0); for (i, g) in s.d.graphic_styles.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(TILE, TILE), Sense::click_and_drag()); - paint_thumb(app, ui, r, s, i); - let on = sel.contains(&g.name); - let color = if on { - t.accent - } else if resp.hovered() { - t.text - } else { - t.border - }; - ui.painter().rect_stroke(r, 0.0, Stroke::new(if on { 2.0 } else { 1.0 }, color), StrokeKind::Inside); + s.paint(app, ui, r, i); + outline(ui, r, sel.contains(&g.name), resp.hovered()); cell_input(ui, &resp.on_hover_text(&g.name), s.d, i, false, ev); } }); @@ -356,7 +392,7 @@ fn rows(app: &VectorcraftApp, ui: &mut Ui, s: &Shown, sel: &[String], row: (f32, ui.painter().rect_filled(r, 0.0, t.hover); } let th = Rect::from_min_size(r.left_center() + vec2(4.0, -side / 2.0), vec2(side, side)); - paint_thumb(app, ui, th, s, i); + s.paint(app, ui, th, i); ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); ui.painter().text(pos2(th.right() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.0), t.text); cell_input(ui, &resp, s.d, i, true, ev); @@ -449,7 +485,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let has_sel = selection_len(app) > 0; let linked = app.session.selection_graphic_style().is_some(); widgets::bottom_bar(ui, |ui| { - widgets::icon_button_enabled(ui, "library", "Graphic Style Libraries (on the roadmap)", false, false, 24.0); + let open = app.ui.library_panel.as_ref().is_some_and(|o| o.kind == GraphicStyleLibraries::KIND); + let lr = widgets::icon_button(ui, "library", GraphicStyleLibraries::MENU, open, 24.0); + egui::Popup::menu(&lr).show(|ui| { + ui.set_min_width(200.0); + library_panel::library_menu::(app, ui); + }); if widgets::icon_button_enabled(ui, "link-2-off", "Break Link to Graphic Style", false, linked, 24.0).clicked() { app.run("graphicStyle.breakLink", json!({})).ok(); } @@ -467,7 +508,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { /// chip and the Control bar's Style chip. pub(crate) fn paint_style(app: &VectorcraftApp, ui: &Ui, r: Rect, name: &str) { match app.session.active().and_then(|st| Some((&st.doc, st.doc.graphic_style_index(name)?))) { - Some((d, i)) => paint_thumb(app, ui, r, &Shown { d, looks: &look_hashes(ui.ctx(), app), preview: pstate(ui.ctx(), "gs-preview") }, i), + Some((d, i)) => Shown { d, looks: &look_hashes(ui.ctx(), app), preview: pstate(ui.ctx(), "gs-preview") }.paint(app, ui, r, i), None => { ui.painter().rect_filled(r, 0.0, Color32::WHITE); } @@ -575,7 +616,187 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { { app.run("ui.graphicStyleOptions", json!({ "name": n })).ok(); } - menu_item(ui, "Open Graphic Style Library", false, false); + ui.menu_button("Open Graphic Style Library", |ui| library_panel::library_menu::(app, ui)); + if menu_item(ui, "Save Graphic Style Library…", has_doc, false) { + run(app, "ui.saveGraphicStyleLibrary", json!({ "names": sel })); + } +} + +// ---------- libraries ---------- + +/// A style of a library as the library panel shows it. +pub(crate) struct LibStyle { + lib: Arc, + index: usize, + /// Its [`look_hash`]. + look: u64, +} + +impl LibStyle { + fn style(&self) -> &GraphicStyle { + &self.lib.styles[self.index] + } +} + +/// A library's styles as the library panel shows them. +type LibStyles = Arc>; + +thread_local! { + /// The libraries the library panel showed, with their styles' looks (computed once per library). + static SHOWN_LIBS: RefCell, LibStyles)>> = const { RefCell::new(Vec::new()) }; +} + +/// `lib`'s styles with their looks. +fn shown_library(lib: Arc) -> LibStyles { + SHOWN_LIBS.with(|c| { + let mut c = c.borrow_mut(); + if let Some((_, shown)) = c.iter().find(|(l, _)| Arc::ptr_eq(l, &lib)) { + return shown.clone(); + } + let paints = Paints { patterns: &lib.patterns, brushes: None }; + let shown: LibStyles = + Arc::new(lib.styles.iter().enumerate().map(|(index, g)| LibStyle { lib: lib.clone(), index, look: look_hash(paints, g) }).collect()); + if c.len() >= 16 { + c.remove(0); + } + c.push((lib, shown.clone())); + shown + }) +} + +/// Style `g` in words (the library panel's list tooltips): its fills, strokes and effects, and its +/// transparency. +fn describe(g: &GraphicStyle) -> String { + let ap = &g.appearance; + let count = |fill: bool, one: &str| match ap.items.iter().filter(|i| i.is_fill() == fill).count() { + 0 => None, + 1 => Some(format!("1 {one}")), + n => Some(format!("{n} {one}s")), + }; + let mut parts: Vec = [count(true, "fill"), count(false, "stroke")].into_iter().flatten().collect(); + let effects = ap.effects.iter().filter_map(|e| vectorcraft_render::effects::effect_info(&e.id)); + parts.extend(effects.map(|e| e.label.trim_end_matches('…').to_string())); + if g.opacity < 1.0 { + parts.push(format!("{}% opacity", (g.opacity * 100.0).round())); + } + if g.blend != BlendMode::Normal { + parts.push(g.blend.label().to_string()); + } + parts.join(", ") +} + +/// Graphic style libraries in the library panel (engine: `graphicStyle.libraries`, +/// `graphicStyle.addFromLibrary`…). +pub(crate) struct GraphicStyleLibraries; + +impl LibraryKind for GraphicStyleLibraries { + const KIND: &'static str = "graphicStyles"; + const OPEN: &'static str = "window.graphicStyleLibrary"; + const MENU: &'static str = "Graphic Style Libraries Menu"; + const ADD: &'static str = "Add to Graphic Styles"; + /// Styles need room to show their effects. + const VIEW: super::swatches::View = super::swatches::View::LargeThumb; + type Lib = LibStyles; + type Item = LibStyle; + + fn list(app: &VectorcraftApp) -> Vec { + let libs = stylelib::libraries(&app.session).into_iter(); + libs.map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect() + } + fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> { + stylelib::library(&app.session, id).map(|(info, lib)| (info.name, shown_library(lib))) + } + fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); + lib.iter().filter(|s| found(&s.style().name)).map(|s| Row { name: &s.style().name, item: Some(s) }).collect() + } + /// The style on the Graphic Styles panel's preview shape. + fn draw(app: &VectorcraftApp, ui: &Ui, r: Rect, s: &LibStyle, selected: bool, hovered: bool) { + let paints = Paints { patterns: &s.lib.patterns, brushes: None }; + paint_thumb(app, ui, r, paints, s.style(), s.look, pstate(ui.ctx(), "gs-preview")); + outline(ui, r, selected, hovered); + } + fn row_icons(_: &Ui, _: Rect, _: &LibStyle) {} + fn describe(s: &LibStyle) -> String { + describe(s.style()) + } + /// Adds the style and applies it to the selection (Alt: on top of its appearance), as one step. + fn click(app: &mut VectorcraftApp, ui: &Ui, id: &str, name: &str) { + run(app, "graphicStyle.addFromLibrary", json!({"library": id, "name": name, "apply": true, "add": alt_held(ui)})); + } + fn add(app: &mut VectorcraftApp, id: &str, names: Vec) { + run(app, "graphicStyle.addFromLibrary", json!({"library": id, "names": names})); + } + fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.separator(); + if menu_item(ui, "Other Library…", true, false) + && let Err(e) = other_library(app, None) + { + app.status(e); + } + let doc = app.session.active().map(|st| st.doc.clone()); + if menu_item(ui, "Save Graphic Style Library…", doc.is_some(), false) { + let names = doc.map(|d| selected(ui.ctx(), &d)).unwrap_or_default(); + run(app, "ui.saveGraphicStyleLibrary", json!({ "names": names })); + } + } +} + +/// `window.graphicStyleLibrary {library}`: open a graphic style library in the library panel +/// (`library` null closes it). +pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result { + library_panel::open_command::(app, p, "graphicStyle.libraries", |app, key| { + stylelib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len())) + }) +} + +/// `graphicStyle.loadLibrary` params, then open the library in the panel. +pub(crate) fn load_library(app: &mut VectorcraftApp, params: Value) -> Result { + let r = app.run("graphicStyle.loadLibrary", params)?; + open_library(app, &json!({ "library": r["library"] })) +} + +/// Other Library…: load the library (or document) at `path`, else one picked in an open dialog. +pub(crate) fn other_library(app: &mut VectorcraftApp, path: Option) -> Result { + match library_panel::pick_library_file(app, path)? { + Some(path) => load_library(app, json!({ "path": path })), + None => Ok(Value::Null), + } +} + +/// The id prefix of the Window → Graphic Style Libraries → User Defined slots. +pub(crate) const USER_SLOT: &str = "window.userGraphicStyleLibrary"; + +/// The User Defined library slot `id` (`window.userGraphicStyleLibrary3`) stands for. +pub(crate) fn user_library(app: &VectorcraftApp, id: &str) -> Option { + library_panel::user_slot(id, USER_SLOT, stylelib::libraries(&app.session)) +} + +/// Window → Graphic Style Libraries. +pub(crate) fn window_menu() -> Vec { + const SLOTS: [&str; 10] = [ + "window.userGraphicStyleLibrary1", + "window.userGraphicStyleLibrary2", + "window.userGraphicStyleLibrary3", + "window.userGraphicStyleLibrary4", + "window.userGraphicStyleLibrary5", + "window.userGraphicStyleLibrary6", + "window.userGraphicStyleLibrary7", + "window.userGraphicStyleLibrary8", + "window.userGraphicStyleLibrary9", + "window.userGraphicStyleLibrary10", + ]; + let open = + |b: &'static vectorcraft_doc::style_libs::BuiltinStyleLibrary| Item::Cmd(b.name, GraphicStyleLibraries::OPEN, json!({ "library": b.id })); + let mut items: Vec = STYLE_LIBRARIES.iter().map(open).collect(); + items.extend([ + Item::Sep, + Item::Sub("User Defined", SLOTS.iter().map(|id| Item::Cmd("User Library", id, Value::Null)).collect()), + Item::Sep, + Item::Cmd("Other Library…", "window.graphicStyleLibrary.other", Value::Null), + Item::Cmd("Save Graphic Style Library…", "ui.saveGraphicStyleLibrary", Value::Null), + ]); + items } #[cfg(test)] @@ -656,7 +877,7 @@ mod tests { let tex = |app: &VectorcraftApp, i: usize, preview: Preview| { let d = app.session.active().unwrap().doc.clone(); let looks = look_hashes(&ctx, app); - thumb(app, &ctx, &d, &d.graphic_styles[i], looks[i], preview, TILE).unwrap().id() + thumb(app, &ctx, Paints::of(&d), &d.graphic_styles[i], looks[i], preview, TILE).unwrap().id() }; app.session.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); app.session.execute("graphicStyle.new", &json!({"name": "Red"})).unwrap(); @@ -674,7 +895,7 @@ mod tests { assert_eq!(tex(&app, 0, Preview::Square), first); // The rendered thumbnail shows the style on the square, on white. let d = app.session.active().unwrap().doc.clone(); - let img = render(&app, &d, &d.graphic_styles[i], Preview::Square, 40).unwrap(); + let img = render(&app, Paints::of(&d), &d.graphic_styles[i], Preview::Square, 40).unwrap(); let px = |x: usize, y: usize| img.pixels[y * img.size[0] + x]; assert_eq!(px(20, 20), Color32::from_rgb(0, 0, 255)); assert_eq!(px(2, 2), Color32::WHITE); diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index f6db6510..5e68c3a6 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -1,14 +1,16 @@ -//! The library panel: a floating, read-only view of one library (swatch libraries; any kind of -//! library implementing [`LibraryKind`]) with thumbnail and list views, a find field, the -//! libraries menu and previous / next arrows. A plain click on an item adds it to the document and -//! applies it; Shift and Cmd/Ctrl-clicks select items (and folders) for Add to …. +//! The library panel: a floating, read-only view of one library (swatch and graphic style +//! libraries; any kind of library implementing [`LibraryKind`]) with thumbnail and list views, a +//! find field, the libraries menu and previous / next arrows. A plain click on an item adds it to +//! the document and applies it; Shift and Cmd/Ctrl-clicks select items (and folders) for Add to …. //! -//! Which library is open lives in `UiState::library_panel` (`window.swatchLibrary` opens one), so -//! agents can open and read it; the view, find text and selection are panel state. +//! Which library is open lives in `UiState::library_panel` (`window.swatchLibrary` and +//! `window.graphicStyleLibrary` open one), so agents can open and read it; the view, find text and +//! selection are panel state. use egui::{CornerRadius, Rect, Sense, Ui, vec2}; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::swatchlib::LibraryInfo; use super::swatches::{View, click_selection, folder_tile, list_row, list_row_name, tile_grid}; use super::{pstate, set_pstate}; @@ -49,6 +51,8 @@ pub(crate) trait LibraryKind { const MENU: &'static str; /// What adding the selection is called ("Add to Swatches"). const ADD: &'static str; + /// The view the panel shows until another is picked. + const VIEW: View = View::MediumThumb; /// A library's contents, cheap to clone. type Lib; type Item; @@ -79,6 +83,11 @@ fn key(key: &str) -> String { format!("{}-library-{key}", K::KIND) } +/// The view of `K`'s panel. +fn view(ctx: &egui::Context) -> View { + pstate::>(ctx, &key::("view")).unwrap_or(K::VIEW) +} + /// The interaction id of the tile or row of `name`. pub(crate) fn tile_id(name: &str) -> egui::Id { egui::Id::new(("library-tile", K::KIND, name)) @@ -89,10 +98,59 @@ pub fn show_window(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(open) = app.ui.library_panel.clone() else { return }; match open.kind.as_str() { super::swatches::SwatchLibraries::KIND => window::(app, ctx, &open.id), + super::graphic_styles::GraphicStyleLibraries::KIND => window::(app, ctx, &open.id), _ => app.ui.library_panel = None, } } +/// The submenu of the libraries menu that lists libraries of `category` (`None`: the top level). +pub(crate) fn submenu(category: &str) -> Option<&'static str> { + match category { + "gradients" => Some("Gradients"), + "user" => Some("User Defined"), + "loaded" => Some("Other Libraries"), + _ => None, + } +} + +/// The opener of library `K`'s libraries (`window.swatchLibrary {library}`): open library `library` +/// (an id or a name; `list`: the command listing them) in the panel, `null` closing it. `found` +/// looks a library up: its id, name and item count. → {open, name, count} +pub(crate) fn open_command( + app: &mut VectorcraftApp, + p: &Value, + list: &str, + found: impl FnOnce(&VectorcraftApp, &str) -> Option<(String, String, usize)>, +) -> Result { + let Some(key) = p.get("library").filter(|v| !v.is_null()) else { + app.ui.library_panel = None; + return Ok(json!({ "open": null })); + }; + let key = key.as_str().ok_or_else(|| format!("`library` is a library id or name (see {list})"))?; + let (id, name, count) = found(app, key).ok_or_else(|| format!("no library `{key}` (see {list})"))?; + app.ui.library_panel = Some(OpenLibrary { kind: K::KIND.into(), id: id.clone() }); + Ok(json!({"open": id, "name": name, "count": count})) +} + +/// Other Library…: the library file at `path`, else one picked in an open dialog; `None` when the +/// pick is asynchronous (on the web the picked file arrives later through [`crate::io::open_bytes`]). +pub(crate) fn pick_library_file(app: &mut VectorcraftApp, path: Option) -> Result, String> { + if path.is_none() + && let Some(f) = app.services.open_async.as_mut() + { + f(); + return Ok(None); + } + path.or_else(|| app.services.pick_open.as_mut().and_then(|f| f())).map(Some).ok_or_else(|| "cancelled".into()) +} + +/// The User Defined library a Window menu slot (`prefix` and a number from 1) stands for, of +/// `libraries`. +pub(crate) fn user_slot(id: &str, prefix: &str, libraries: Vec) -> Option { + let n: usize = id.strip_prefix(prefix)?.parse().ok()?; + libraries.into_iter().filter(|l| l.category == "user").nth(n.checked_sub(1)?) +} + /// The panel's window: a tab strip with the library's name, the panel menu and a close button, then /// the body. A library that no longer exists closes it. fn window(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str) { @@ -150,7 +208,7 @@ fn set_selection(ctx: &egui::Context, id: &str, sel: Vec /// Find field, thumbnails or list rows, and the bottom bar. fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K::Lib) { - let view: View = pstate(ui.ctx(), &key::("view")); + let view = view::(ui.ctx()); let query = if pstate::(ui.ctx(), &key::("hide-find")) { String::new() } else { @@ -256,10 +314,10 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { K::add(app, id, selected); } ui.separator(); - let view: View = pstate(ui.ctx(), &key::("view")); + let view = view::(ui.ctx()); for (v, label) in View::ALL { if menu_item(ui, label, true, v == view) { - set_pstate(ui.ctx(), &key::("view"), v); + set_pstate(ui.ctx(), &key::("view"), Some(v)); } } ui.separator(); @@ -408,7 +466,7 @@ mod tests { let mut app = app(); let ctx = context(); app.run("window.swatchLibrary", json!({"library": "web-safe-216"})).unwrap(); - set_pstate(&ctx, &key::("view"), View::SmallList); + set_pstate(&ctx, &key::("view"), Some(View::SmallList)); ctx.data_mut(|d| d.insert_temp(egui::Id::new(key::("find")), "#FF00".to_string())); frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.0); frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.5); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d0ea14cb..446efe5a 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -444,3 +444,5 @@ mod tests_stroke; mod tests_strokedepth; #[cfg(test)] mod tests_strokeux; +#[cfg(test)] +mod tests_style_libraries; diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 8deaab36..92eacec5 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -14,7 +14,7 @@ use vectorcraft_color::{Color, GradientKind, Paint, Swatch, SwatchLibrary}; use vectorcraft_doc::Document; use vectorcraft_engine::cmd::swatchlib; -use super::library_panel::{self, LibraryKind, LibraryRef, OpenLibrary, Row}; +use super::library_panel::{self, LibraryKind, LibraryRef, Row}; use super::{active_paint, pstate, set_pstate}; use crate::menus::Item; use crate::theme::Tokens; @@ -960,13 +960,8 @@ impl LibraryKind for SwatchLibraries { type Item = Swatch; fn list(app: &VectorcraftApp) -> Vec { - let submenu = |category: &str| match category { - "gradients" => Some("Gradients"), - "user" => Some("User Defined"), - "loaded" => Some("Other Libraries"), - _ => None, - }; - swatchlib::libraries(&app.session).into_iter().map(|l| LibraryRef { submenu: submenu(l.category), id: l.id, name: l.name }).collect() + let libs = swatchlib::libraries(&app.session).into_iter(); + libs.map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect() } fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> { swatchlib::library(&app.session, id).map(|(info, lib)| (info.name, lib)) @@ -1037,14 +1032,9 @@ fn add_from_library(app: &mut VectorcraftApp, params: Value) { /// `window.swatchLibrary {library}`: open a swatch library in the library panel (`library` null /// closes it). pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result { - let Some(key) = p.get("library").filter(|v| !v.is_null()) else { - app.ui.library_panel = None; - return Ok(json!({ "open": null })); - }; - let key = key.as_str().ok_or("`library` is a library id or name (see swatch.library.list)")?; - let (info, lib) = swatchlib::library(&app.session, key).ok_or_else(|| format!("no swatch library `{key}` (see swatch.library.list)"))?; - app.ui.library_panel = Some(OpenLibrary { kind: SwatchLibraries::KIND.into(), id: info.id.clone() }); - Ok(json!({"open": info.id, "name": info.name, "count": lib.len()})) + library_panel::open_command::(app, p, "swatch.library.list", |app, key| { + swatchlib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len())) + }) } /// `swatch.library.load` params, then open the library in the panel. @@ -1056,14 +1046,10 @@ pub(crate) fn load_library(app: &mut VectorcraftApp, params: Value) -> Result) -> Result { - if path.is_none() - && let Some(f) = app.services.open_async.as_mut() - { - f(); - return Ok(Value::Null); + match library_panel::pick_library_file(app, path)? { + Some(path) => load_library(app, json!({ "path": path })), + None => Ok(Value::Null), } - let path = path.or_else(|| app.services.pick_open.as_mut().and_then(|f| f())).ok_or("cancelled")?; - load_library(app, json!({ "path": path })) } /// The id prefix of the Window → Swatch Libraries → User Defined slots. @@ -1071,8 +1057,7 @@ pub(crate) const USER_SLOT: &str = "window.userSwatchLibrary"; /// The User Defined library slot `id` (`window.userSwatchLibrary3`) stands for. pub(crate) fn user_library(app: &VectorcraftApp, id: &str) -> Option { - let n: usize = id.strip_prefix(USER_SLOT)?.parse().ok()?; - swatchlib::libraries(&app.session).into_iter().filter(|l| l.category == "user").nth(n.checked_sub(1)?) + library_panel::user_slot(id, USER_SLOT, swatchlib::libraries(&app.session)) } /// Window → Swatch Libraries. diff --git a/crates/ui-egui/src/panels/tests_style_libraries.rs b/crates/ui-egui/src/panels/tests_style_libraries.rs new file mode 100644 index 00000000..dbf0037f --- /dev/null +++ b/crates/ui-egui/src/panels/tests_style_libraries.rs @@ -0,0 +1,188 @@ +//! Headless frames of graphic style libraries in the library panel: clicking a style adds and +//! applies it, the Window menu and the Graphic Styles panel open libraries, and Save Graphic Style +//! Library writes `.vcstyles` files that open as libraries. + +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, vec2}; +use serde_json::{Value, json}; +use vectorcraft_doc::style_libs::STYLE_LIBRARIES; +use vectorcraft_engine::Session; + +use super::graphic_styles::GraphicStyleLibraries; +use super::library_panel::{self, LibraryKind, OpenLibrary}; +use crate::VectorcraftApp; + +fn app_with(services: crate::Services) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app +} + +fn app() -> VectorcraftApp { + app_with(Default::default()) +} + +fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx +} + +/// One frame of `draw` with `events` and `modifiers` held; the texts it painted. +fn frame(ctx: &egui::Context, events: Vec, modifiers: Modifiers, time: f64, mut draw: impl FnMut(&mut egui::Ui)) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))); + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }; + let mut out = ctx.run_ui(input, &mut draw); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v +} + +/// Click the library panel's tile of style `name` with `modifiers` (a new window takes a sizing +/// frame first). +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) { + for t in [time, time + 0.25] { + frame(ctx, vec![], Modifiers::NONE, t, |ui| library_panel::show_window(app, ui.ctx())); + } + let id = library_panel::tile_id::(name); + let at = ctx.read_response(id).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; + frame(ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5, |ui| library_panel::show_window(app, ui.ctx())); +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().history.undo.len() +} + +#[test] +fn clicking_a_library_style_adds_and_applies_it_in_one_step() { + let mut app = app(); + let ctx = context(); + let rect = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap()["id"].as_u64().unwrap(); + let r = app.run("window.graphicStyleLibrary", json!({"library": "Shadows and Glows"})).unwrap(); + assert_eq!((r["open"].as_str(), r["name"].as_str()), (Some("shadows-glows"), Some("Shadows and Glows"))); + assert_eq!(app.ui.library_panel, Some(OpenLibrary { kind: "graphicStyles".into(), id: "shadows-glows".into() })); + let undo = undo_len(&app); + click(&mut app, &ctx, "Halo", Modifiers::NONE, 0.0); + let (g, linked) = app.session.selection_graphic_style().expect("applied to the selection"); + assert_eq!((g.name.as_str(), linked), ("Halo", true)); + assert_eq!(undo_len(&app), undo + 1, "added and applied as one undo step"); + let tile = ctx.read_response(library_panel::tile_id::("Halo")).unwrap().rect; + assert_eq!(tile.width(), 30.0, "styles open in large thumbnails"); + // Alt-click adds the style's appearance on top, unlinked. + let fills = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().appearance.items.len(); + let before = fills(&app); + click(&mut app, &ctx, "Ember", Modifiers::ALT, 2.0); + assert!(app.session.doc().unwrap().doc.graphic_style("Ember").is_some()); + assert_eq!(fills(&app), before + 1); + assert!(app.session.selection_graphic_style().is_none_or(|(_, linked)| !linked)); + // Modifier clicks only select; Add to Graphic Styles adds the selection. + click(&mut app, &ctx, "Feathered", Modifiers::COMMAND, 4.0); + assert!(app.session.doc().unwrap().doc.graphic_style("Feathered").is_none()); + GraphicStyleLibraries::add(&mut app, "shadows-glows", vec!["Feathered".into(), "Haze".into()]); + let d = &app.session.doc().unwrap().doc; + assert!(d.graphic_style("Feathered").is_some() && d.graphic_style("Haze").is_some()); + // Find filters by name; list rows describe the styles; looks are computed once per library. + let lib = GraphicStyleLibraries::get(&app, "shadows-glows").unwrap().1; + let rows = GraphicStyleLibraries::rows(&lib, "ember"); + assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Ember"]); + assert_eq!(GraphicStyleLibraries::describe(rows[0].item.unwrap()), "1 fill, Inner Glow, Outer Glow"); + assert!(std::sync::Arc::ptr_eq(&lib, &GraphicStyleLibraries::get(&app, "shadows-glows").unwrap().1)); + assert!(app.run("window.graphicStyleLibrary", json!({"library": "nope"})).is_err()); + app.run("window.graphicStyleLibrary", json!({"library": null})).unwrap(); + assert!(app.ui.library_panel.is_none()); +} + +#[test] +fn the_window_menu_and_the_panel_open_every_library() { + let mut app = app(); + let entries = crate::menus::menu_entries(&app); + let opened: Vec = entries + .iter() + .filter(|e| e.path == ["Window", "Graphic Style Libraries"] && e.command.as_deref() == Some("window.graphicStyleLibrary")) + .map(|e| e.params["library"].clone()) + .collect(); + assert_eq!(opened, STYLE_LIBRARIES.iter().map(|b| json!(b.id)).collect::>()); + let has = |cmd: &str| entries.iter().any(|e| e.path == ["Window", "Graphic Style Libraries"] && e.command.as_deref() == Some(cmd) && e.enabled); + assert!(has("window.graphicStyleLibrary.other") && has("ui.saveGraphicStyleLibrary")); + assert!(!entries.iter().any(|e| e.path == ["Window", "Graphic Style Libraries", "User Defined"]), "empty slots are hidden"); + // The Graphic Styles panel's library button and menu. + let ctx = context(); + let texts = frame(&ctx, vec![], Modifiers::NONE, 0.0, |ui| { + super::graphic_styles::show(&mut app, ui); + super::graphic_styles::menu(&mut app, ui); + }); + for t in ["Open Graphic Style Library", "Save Graphic Style Library…"] { + assert!(texts.iter().any(|x| x.ends_with(t)), "{t}: {texts:?}"); + } + let ids: Vec = GraphicStyleLibraries::list(&app).into_iter().map(|l| l.id).collect(); + assert_eq!(ids, STYLE_LIBRARIES.iter().map(|b| b.id).collect::>()); +} + +#[test] +fn save_dialog_writes_user_libraries_that_the_window_menu_lists() { + let dir = std::env::temp_dir().join(format!("vc-style-libraries-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut app = app(); + app.session.style_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let user_items = |app: &VectorcraftApp| -> Vec<(String, String)> { + crate::menus::menu_entries(app) + .into_iter() + .filter(|e| e.path == ["Window", "Graphic Style Libraries", "User Defined"]) + .map(|e| (e.label, e.command.unwrap_or_default())) + .collect() + }; + app.run("ui.saveGraphicStyleLibrary", json!({"names": ["Sunshine"]})).unwrap(); + let d = app.ui.dialog.as_mut().expect("Save Graphic Style Library opens"); + assert_eq!((d.kind.as_str(), d.bool("user")), (crate::dialogs::save_style_library::KIND, true)); + d.fields.insert("name".into(), json!("Warm")); + d.fields.insert("selectedOnly".into(), json!(true)); + let ctx = context(); + frame(&ctx, vec![], Modifiers::NONE, 0.0, |ui| crate::dialogs::show(&mut app, ui.ctx())); + let texts = frame(&ctx, vec![], Modifiers::NONE, 0.5, |ui| crate::dialogs::show(&mut app, ui.ctx())); + assert!(texts.iter().any(|t| t == "Selected Styles Only (1)"), "{texts:?}"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(user_items(&app), [("Warm".to_string(), "window.userGraphicStyleLibrary1".to_string())]); + app.run("window.userGraphicStyleLibrary1", json!({})).unwrap(); + assert_eq!(app.ui.library_panel.as_ref().map(|o| o.id.as_str()), Some("user/Warm.vcstyles")); + let (name, lib) = GraphicStyleLibraries::get(&app, "user/Warm.vcstyles").unwrap(); + assert_eq!((name.as_str(), lib.len()), ("Warm", 1), "only the selected style"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn saving_to_a_file_and_opening_a_vcstyles_file() { + let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); + let w = written.clone(); + let services = crate::Services { + pick_save: Some(Box::new(|name: &str| Some(format!("/tmp/{name}")))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = app_with(services); + app.run("ui.saveGraphicStyleLibrary", json!({})).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().bool("__user"), "no user folder: a file"); + crate::dialogs::confirm(&mut app).unwrap(); + let (path, bytes) = written.borrow()[0].clone(); + assert!(path.ends_with(".vcstyles"), "{path}"); + // Opening it opens it in the library panel, not as a document. + crate::io::open_bytes(&mut app, "Saved.vcstyles", &bytes, None).unwrap(); + let open = app.ui.library_panel.clone().unwrap(); + assert_eq!(open.kind, GraphicStyleLibraries::KIND); + assert_eq!(app.session.documents().len(), 1); + let other = GraphicStyleLibraries::list(&app).into_iter().find(|l| l.id == open.id).unwrap(); + assert_eq!(other.submenu, Some("Other Libraries")); + assert_eq!(GraphicStyleLibraries::get(&app, &open.id).unwrap().1.len(), app.session.doc().unwrap().doc.graphic_styles.len()); +} diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index 092299d4..0b745969 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -49,7 +49,7 @@ fn effect_menu_has_a_vector_effects_section() { fn library_submenus_are_disabled_placeholders() { let app = app(); let entries = menus::menu_entries(&app); - for lib in ["Brush Libraries", "Graphic Style Libraries", "Symbol Libraries"] { + for lib in ["Brush Libraries", "Symbol Libraries"] { let items: Vec<_> = entries.iter().filter(|e| e.path == ["Window", lib]).collect(); let labels: Vec<&str> = items.iter().map(|e| e.label.as_str()).collect(); assert_eq!(labels, ["Built-in Libraries", "User Defined", "Other Library…"], "{lib}"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0b96f112..431639a9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -92,6 +92,16 @@ opening a `.vcswatches` or `.gpl` file with `app.open` does the same. `ui.saveSw `saveSwatchLibrary` dialog (fields `name`, `format`: `vcswatches`/`gpl`/`css`, `user`: save to the user library folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.save` (to a file it asks for a path). +Graphic style libraries open in the same panel: `window.graphicStyleLibrary {library}` (`library_panel: {kind: +"graphicStyles", id}`; `library: null` closes it). Clicking a style there runs `graphicStyle.addFromLibrary {library, +name, apply: true, add}` (`add` with Alt), adding and applying it in one undo step; Shift/Cmd-clicks select styles +for Add to Graphic Styles. `window.graphicStyleLibrary.other {path?}` loads a `.vcstyles` file (or another +document's graphic styles) and opens it there; opening a `.vcstyles` file with `app.open` does the same. The User +Defined libraries are `window.userGraphicStyleLibrary1`…`10` in Window → Graphic Style Libraries. +`ui.saveGraphicStyleLibrary {names?}` opens the `saveGraphicStyleLibrary` dialog (fields `name`, `user`: save to the +user library folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `graphicStyle.saveLibrary` (to a file it +asks for a path). + Tile Edge Color: Object → Pattern → Tile Edge Color… (`ui.tileEdgeColor`) opens the `tileEdgeColor` dialog (field `color`: `#rrggbb` or a preset name such as "Light Blue"); `ui.dialog.confirm` sets the preference `patternTileEdgeColor` (`prefs.set`), the colour pattern editing mode draws the tile edge and the swatch bounds in. diff --git a/docs/mcp.md b/docs/mcp.md index ff037cce..ff7a13fd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -347,6 +347,29 @@ CSS writes custom properties); without `path` it returns `{data}`, and `user: tr folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? | dataBase64?, name?}` loads a `.vcswatches` or `.gpl` file, or another document's swatches, as a library to add from. +## Graphic style libraries + +Graphic style libraries are read-only sets of graphic styles generated in code (Shadows and Glows, Outlines and +Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency). `graphicStyle.libraries` lists them +(`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `graphicStyle.library {library}` returns one's +styles in the `graphicStyle.list` shape (`library` is an id or a name). `graphicStyle.addFromLibrary {library, name? | +names?}` copies styles into the document's Graphic Styles as one undo step (no names: the whole library); styles the +document already has (same name and look) are reported under `existing`, a name another look has gets a number, and +the patterns the styles paint with come along. `apply: true` also applies the first one to `ids` or the selection +(`add: true` adds its appearance on top instead), in the same undo step: what clicking a style in the library panel +does. Library styles keep placed gradients in unit-box space, so each object gets them at the same place relative to +its bounds. + +```json +{"name":"run_command","arguments":{"command":"graphicStyle.library","params":{"library":"hand-drawn"}}} +{"name":"run_command","arguments":{"command":"graphicStyle.addFromLibrary","params":{"library":"hand-drawn","name":"Crosshatch","apply":true}}} +``` +`graphicStyle.saveLibrary {path?, names?, name?, user?}` writes the document's styles as a `.vcstyles` library (JSON: +the styles unlinked from swatches, with their opacity, blend mode, isolate and knockout, and the patterns they paint +with); without `path` it returns `{data}`, and `user: true` saves into the user library folder of the desktop app +(category `user`, User Defined). `graphicStyle.loadLibrary {path? | data? | dataBase64?, name?}` loads a `.vcstyles` +file, or another document's graphic styles, as a library to add from. + ## Strokes on type `stroke.set` without an `item` gives type its characters' stroke: weight, cap, join, miter limit and the dash