Merge pull request #48 from storytold/pr/m3-graphic-style-libraries
M3: graphic style libraries with .vcstyles save and load (M3.80)
This commit is contained in:
commit
cbdef4ea1f
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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", ¶ms)?;
|
||||
// Adding and applying undo together.
|
||||
squash(s, undo_before, "Add to Graphic Styles");
|
||||
out["applied"] = json!(name);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<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()}))
|
||||
}
|
||||
|
|
@ -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()}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -444,3 +444,5 @@ mod tests_stroke;
|
|||
mod tests_strokedepth;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeux;
|
||||
#[cfg(test)]
|
||||
mod tests_style_libraries;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
23
docs/mcp.md
23
docs/mcp.md
|
|
@ -347,6 +347,29 @@ CSS writes custom properties); without `path` it returns `{data}`, and `user: tr
|
|||
folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? |
|
||||
dataBase64?, name?}` loads a `.vcswatches` or `.gpl` file, or another document's swatches, as a library to add from.
|
||||
|
||||
## Graphic style libraries
|
||||
|
||||
Graphic style libraries are read-only sets of graphic styles generated in code (Shadows and Glows, Outlines and
|
||||
Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency). `graphicStyle.libraries` lists them
|
||||
(`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `graphicStyle.library {library}` returns one's
|
||||
styles in the `graphicStyle.list` shape (`library` is an id or a name). `graphicStyle.addFromLibrary {library, name? |
|
||||
names?}` copies styles into the document's Graphic Styles as one undo step (no names: the whole library); styles the
|
||||
document already has (same name and look) are reported under `existing`, a name another look has gets a number, and
|
||||
the patterns the styles paint with come along. `apply: true` also applies the first one to `ids` or the selection
|
||||
(`add: true` adds its appearance on top instead), in the same undo step: what clicking a style in the library panel
|
||||
does. Library styles keep placed gradients in unit-box space, so each object gets them at the same place relative to
|
||||
its bounds.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"graphicStyle.library","params":{"library":"hand-drawn"}}}
|
||||
{"name":"run_command","arguments":{"command":"graphicStyle.addFromLibrary","params":{"library":"hand-drawn","name":"Crosshatch","apply":true}}}
|
||||
```
|
||||
`graphicStyle.saveLibrary {path?, names?, name?, user?}` writes the document's styles as a `.vcstyles` library (JSON:
|
||||
the styles unlinked from swatches, with their opacity, blend mode, isolate and knockout, and the patterns they paint
|
||||
with); without `path` it returns `{data}`, and `user: true` saves into the user library folder of the desktop app
|
||||
(category `user`, User Defined). `graphicStyle.loadLibrary {path? | data? | dataBase64?, name?}` loads a `.vcstyles`
|
||||
file, or another document's graphic styles, as a library to add from.
|
||||
|
||||
## Strokes on type
|
||||
|
||||
`stroke.set` without an `item` gives type its characters' stroke: weight, cap, join, miter limit and the dash
|
||||
|
|
|
|||
Loading…
Reference in New Issue