M3.80: graphic style libraries in the library panel, with .vcstyles save and load

Window > Graphic Style Libraries and the Graphic Styles panel's library button open
read-only libraries of original styles generated in code (Shadows and Glows, Outlines
and Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency) in
the shared library panel. Clicking a style adds it and applies it to the selection as
one undo step (Alt: on top). Save Graphic Style Library writes .vcstyles JSON (styles
unlinked from swatches, with the patterns they paint with) to the user library folder
(User Defined) or a file; Other Library loads one, or another document's styles.

Commands: graphicStyle.libraries, graphicStyle.library, graphicStyle.addFromLibrary,
graphicStyle.saveLibrary, graphicStyle.loadLibrary; UI: window.graphicStyleLibrary
(.other), ui.saveGraphicStyleLibrary, window.userGraphicStyleLibrary1-10. The user and
loaded library bookkeeping is shared with swatch libraries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 03:00:35 +07:00
parent 2b07aa2ac9
commit 874a287958
23 changed files with 1847 additions and 208 deletions

View File

@ -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 {

View File

@ -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,

View File

@ -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,

View File

@ -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<GraphicStyle>,
/// The pattern swatches the styles' fills and strokes paint with.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub patterns: Vec<PatternDef>,
}
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<Self, String> {
if let Some(n) = names.iter().find(|n| d.graphic_style(n).is_none()) {
return Err(format!("no graphic style `{n}`"));
}
let styles: Vec<GraphicStyle> =
d.graphic_styles.iter().filter(|g| names.is_empty() || names.contains(&g.name)).map(GraphicStyle::standalone).collect();
let mut patterns: Vec<PatternDef> = 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<Item = &str> {
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<StyleLibrary, String> {
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<GraphicStyle>,
}
/// 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<Arc<StyleLibrary>> {
static LIBS: OnceLock<Vec<Arc<StyleLibrary>>> = 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<AppearanceItem>, effects: Vec<Effect>) -> GraphicStyle {
GraphicStyle::new(name, Appearance { items, effects, contents_index: None })
}
use AppearanceItem::{Fill as F, Stroke as S};
fn shadows_glows() -> Vec<GraphicStyle> {
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<GraphicStyle> {
let dashed =
|h: &str, width: f64, pattern: Vec<f64>| 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<GraphicStyle> {
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<GraphicStyle> {
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<GraphicStyle> {
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<GraphicStyle> {
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::<Vec<_>>(), ["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));
}
}

View File

@ -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
})
}

View File

@ -405,29 +405,35 @@ fn style_list(s: &mut Session, _: &Value) -> Result<Value> {
.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::<Vec<_>>(),
"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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>(),
"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<Value> {
let cmd = "graphicStyle.redefine";
let (src, n) = source(s, p, cmd)?;

View File

@ -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<CommandSpec> {
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<Self, String> {
style_libs::read(text, stem)
}
fn name(&self) -> &str {
&self.name
}
}
/// User Defined and loaded graphic style libraries.
pub type Libraries = super::swatchlib::Libraries<StyleLibrary>;
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<LibraryInfo> {
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<StyleLibrary>)> {
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<StyleLibrary>)> {
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<Value> {
s.style_libraries.rescan();
let libs: Vec<Value> = 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<Value> {
let (info, lib) = library_param(s, p, "graphicStyle.library")?;
let styles: Vec<Value> = 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<String, String>) -> 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<Value> {
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::<Vec<_>>(),
"existing": existing.iter().map(|g| &g.name).collect::<Vec<_>>(),
});
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", &params)?;
// 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<Value> {
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<Value> {
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()}))
}

View File

@ -76,26 +76,46 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// 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<L = SwatchLibrary> {
/// The user library folder. The desktop app sets it; without one (the web, headless sessions)
/// there are no User Defined libraries.
user_dir: Option<String>,
extra: Vec<Extra>,
extra: Vec<Extra<L>>,
}
#[derive(Clone, Debug)]
struct Extra {
struct Extra<L> {
info: LibraryInfo,
path: Option<String>,
lib: Arc<SwatchLibrary>,
lib: Arc<L>,
}
/// 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<Self, String>;
fn name(&self) -> &str;
}
impl LibraryFile for SwatchLibrary {
const EXTS: &'static [&'static str] = LIBRARY_EXTS;
fn read(text: &str, stem: &str) -> std::result::Result<Self, String> {
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<L: LibraryFile> Libraries<L> {
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<Item = &LibraryInfo> {
self.extra.iter().map(|e| &e.info)
}
/// The User Defined or loaded library `id`.
pub fn get(&self, id: &str) -> Option<(LibraryInfo, Arc<L>)> {
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<L>) -> Result<(LibraryInfo, Arc<L>)> {
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<String> {
fn library_files(dir: &str, exts: &[&str]) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
let mut files: Vec<String> = 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<String> {
}
#[cfg(target_arch = "wasm32")]
fn library_files(_: &str) -> Vec<String> {
fn library_files(_: &str, _: &[&str]) -> Vec<String> {
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<LibraryInfo> {
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<SwatchLibrary>)> {
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<Value> {
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<Value> = 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<Value> {
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<Value> {
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()}))
}

View File

@ -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;

View File

@ -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);
}
}
}

View File

@ -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).

View File

@ -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<String>) -> Result<Value, String> {
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<Value, String> {
save(app, d, "graphicStyle.saveLibrary", STYLES_EXT, params(d))
}

View File

@ -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<String>) -> Result<Value, String> {
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<String>) -> Result<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
let format = format_of(d).id();
let mut p = params(d);
p["format"] = json!(format);
save(app, d, "swatch.library.save", format, p)
}

View File

@ -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<String>) -> 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();

View File

@ -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<R
Ok(Value::Null)
}
"ui.flattenerPreview" => 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<Item>)> {
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()),
],

View File

@ -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<Vec<u64>> {
match pstate::<Option<((u64, u64), Arc<Vec<u64>>)>>(ctx, "gs-looks") {
Some((k, looks)) if k == key => looks,
_ => {
let looks: Arc<Vec<u64>> = Arc::new(st.doc.graphic_styles.iter().map(|g| look_hash(&st.doc, g)).collect());
let looks: Arc<Vec<u64>> = 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<Vec<u64>> {
}
/// 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<egui::ColorImage> {
/// pixels square on white, with `paints`.
fn render(app: &VectorcraftApp, paints: Paints, g: &GraphicStyle, preview: Preview, px: u32) -> Option<egui::ColorImage> {
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<TextureHandle> {
fn thumb(
app: &VectorcraftApp,
ctx: &egui::Context,
paints: Paints,
g: &GraphicStyle,
look: u64,
preview: Preview,
size: f32,
) -> Option<TextureHandle> {
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<TextureHandle> {
@ -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::<GraphicStyleLibraries>(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::<GraphicStyleLibraries>(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<StyleLibrary>,
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<Vec<LibStyle>>;
thread_local! {
/// The libraries the library panel showed, with their styles' looks (computed once per library).
static SHOWN_LIBS: RefCell<Vec<(Arc<StyleLibrary>, LibStyles)>> = const { RefCell::new(Vec::new()) };
}
/// `lib`'s styles with their looks.
fn shown_library(lib: Arc<StyleLibrary>) -> 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<String> = [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<LibraryRef> {
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<Row<'a, LibStyle>> {
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<String>) {
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<Value, String> {
library_panel::open_command::<GraphicStyleLibraries>(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<Value, String> {
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<String>) -> Result<Value, String> {
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<LibraryInfo> {
library_panel::user_slot(id, USER_SLOT, stylelib::libraries(&app.session))
}
/// Window → Graphic Style Libraries.
pub(crate) fn window_menu() -> Vec<Item> {
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<Item> = 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);

View File

@ -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<K: LibraryKind>(key: &str) -> String {
format!("{}-library-{key}", K::KIND)
}
/// The view of `K`'s panel.
fn view<K: LibraryKind>(ctx: &egui::Context) -> View {
pstate::<Option<View>>(ctx, &key::<K>("view")).unwrap_or(K::VIEW)
}
/// The interaction id of the tile or row of `name`.
pub(crate) fn tile_id<K: LibraryKind>(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::<super::swatches::SwatchLibraries>(app, ctx, &open.id),
super::graphic_styles::GraphicStyleLibraries::KIND => window::<super::graphic_styles::GraphicStyleLibraries>(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<K: LibraryKind>(
app: &mut VectorcraftApp,
p: &Value,
list: &str,
found: impl FnOnce(&VectorcraftApp, &str) -> Option<(String, String, usize)>,
) -> Result<Value, String> {
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<String>) -> Result<Option<String>, 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<LibraryInfo>) -> Option<LibraryInfo> {
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<K: LibraryKind>(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str) {
@ -150,7 +208,7 @@ fn set_selection<K: LibraryKind>(ctx: &egui::Context, id: &str, sel: Vec<String>
/// Find field, thumbnails or list rows, and the bottom bar.
fn body<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K::Lib) {
let view: View = pstate(ui.ctx(), &key::<K>("view"));
let view = view::<K>(ui.ctx());
let query = if pstate::<bool>(ui.ctx(), &key::<K>("hide-find")) {
String::new()
} else {
@ -256,10 +314,10 @@ fn panel_menu<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
K::add(app, id, selected);
}
ui.separator();
let view: View = pstate(ui.ctx(), &key::<K>("view"));
let view = view::<K>(ui.ctx());
for (v, label) in View::ALL {
if menu_item(ui, label, true, v == view) {
set_pstate(ui.ctx(), &key::<K>("view"), v);
set_pstate(ui.ctx(), &key::<K>("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::<SwatchLibraries>("view"), View::SmallList);
set_pstate(&ctx, &key::<SwatchLibraries>("view"), Some(View::SmallList));
ctx.data_mut(|d| d.insert_temp(egui::Id::new(key::<SwatchLibraries>("find")), "#FF00".to_string()));
frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.0);
frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.5);

View File

@ -444,3 +444,5 @@ mod tests_stroke;
mod tests_strokedepth;
#[cfg(test)]
mod tests_strokeux;
#[cfg(test)]
mod tests_style_libraries;

View File

@ -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<LibraryRef> {
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<Value, String> {
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::<SwatchLibraries>(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<Va
/// Other Library…: load the library (or document) at `path`, else one picked in an open dialog
/// (on the web the picked file arrives later and opens through [`crate::io::open_bytes`]).
pub(crate) fn other_library(app: &mut VectorcraftApp, path: Option<String>) -> Result<Value, String> {
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<swatchlib::LibraryInfo> {
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.

View File

@ -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<Event>, modifiers: Modifiers, time: f64, mut draw: impl FnMut(&mut egui::Ui)) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
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::<GraphicStyleLibraries>(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::<GraphicStyleLibraries>("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::<Vec<_>>(), ["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<Value> = 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::<Vec<_>>());
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<String> = GraphicStyleLibraries::list(&app).into_iter().map(|l| l.id).collect();
assert_eq!(ids, STYLE_LIBRARIES.iter().map(|b| b.id).collect::<Vec<_>>());
}
#[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());
}

View File

@ -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}");

View File

@ -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.

View File

@ -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