From eb83efdb19a8deeda61d8a0fda80a395b641e81b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:01:10 +0700 Subject: [PATCH] M3.44: graphic styles keep opacity and blend, capture groups and type, add on Alt-click Styles now store the object's opacity, blend mode, isolate and knockout and apply them. New Graphic Style from a group without its own paint captures its topmost object's appearance, and from type its characters' fill and stroke. Names are made unique. graphicStyle.apply takes ids and add (Alt-click in the panel adds the style on top of the existing appearance). graphicStyle.list reports every style. Applying links objects to a stable style id (Node.graphic_style), which the management commands build on. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 93 ++++- crates/doc/src/node.rs | 5 + crates/engine/src/cmd/style.rs | 413 ++++++++++++++++++-- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_styles.rs | 220 +++++++++++ crates/ui-egui/src/panels/graphic_styles.rs | 237 ++++++++--- 6 files changed, 864 insertions(+), 106 deletions(-) create mode 100644 crates/engine/src/tests_styles.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b242ae03..d62a52df 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -254,11 +254,49 @@ impl Default for GridPrefs { } } -/// A named graphic style (Graphic Styles panel). +/// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GraphicStyle { pub name: String, pub appearance: Appearance, + /// Stable id linked objects refer to ([`Node::graphic_style`]); 0 until first needed (styles + /// from older files get one when an object is first linked to them). + #[serde(default, skip_serializing_if = "skip::is_default")] + pub id: u32, + #[serde(default = "one", skip_serializing_if = "skip::is_one")] + pub opacity: f32, + #[serde(default, skip_serializing_if = "skip::is_default")] + pub blend: vectorcraft_color::BlendMode, + #[serde(default, skip_serializing_if = "skip::is_default")] + pub isolate: bool, + #[serde(default, skip_serializing_if = "skip::is_default")] + pub knockout: bool, +} + +fn one() -> f32 { + 1.0 +} + +/// The name of the style new documents list first (Sort by Name keeps it first). +pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style"; + +impl GraphicStyle { + /// A style with `appearance` and default transparency (no id yet). + pub fn new(name: impl Into, appearance: Appearance) -> Self { + Self { name: name.into(), appearance, id: 0, opacity: 1.0, blend: Default::default(), isolate: false, knockout: false } + } + /// A style capturing `n`'s transparency and `appearance`. + pub fn of(name: impl Into, appearance: Appearance, n: &Node) -> Self { + Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) } + } + /// Give `n` this style's transparency. + pub fn apply_transparency(&self, n: &mut Node) { + (n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout); + } + /// Does `n` have this style's transparency? + pub fn transparency_matches(&self, n: &Node) -> bool { + (n.opacity, n.blend, n.isolate, n.knockout) == (self.opacity, self.blend, self.isolate, self.knockout) + } } /// A symbol definition. @@ -633,20 +671,51 @@ impl Document { } } +/// Graphic styles. +impl Document { + /// Index of the graphic style named `name`. + pub fn graphic_style_index(&self, name: &str) -> Option { + self.graphic_styles.iter().position(|g| g.name == name) + } + /// The graphic style named `name`. + pub fn graphic_style(&self, name: &str) -> Option<&GraphicStyle> { + self.graphic_styles.iter().find(|g| g.name == name) + } + /// The graphic style with id `id` (0 never matches). + pub fn graphic_style_by_id(&self, id: u32) -> Option<&GraphicStyle> { + self.graphic_styles.iter().find(|g| g.id != 0 && g.id == id) + } + /// An id no graphic style has. + pub fn next_graphic_style_id(&self) -> u32 { + self.graphic_styles.iter().map(|g| g.id).max().unwrap_or(0) + 1 + } + /// The id of graphic style `index`, assigning one if it has none yet. + pub fn graphic_style_id(&mut self, index: usize) -> u32 { + if self.graphic_styles[index].id == 0 { + self.graphic_styles[index].id = self.next_graphic_style_id(); + } + self.graphic_styles[index].id + } + /// The default name of a new graphic style: the first free "Graphic Style N", N counting on + /// from the number of styles. + pub fn new_graphic_style_name(&self) -> String { + (self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default() + } +} + fn default_graphic_styles() -> Vec { use vectorcraft_color::{Color, Paint}; - vec![ - GraphicStyle { name: "Default Graphic Style".into(), appearance: Appearance::default_art() }, - GraphicStyle { name: "Black Outline".into(), appearance: Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0) }, - GraphicStyle { - name: "Heavy Ink".into(), - appearance: Appearance::basic(Paint::solid(Color::from_hex("#1b1464").unwrap()), Paint::solid(Color::BLACK), 4.0), - }, - GraphicStyle { - name: "Sunshine".into(), - appearance: Appearance::basic(Paint::solid(Color::from_hex("#fbb03b").unwrap()), Paint::solid(Color::from_hex("#f15a24").unwrap()), 2.0), - }, + let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap()); + [ + GraphicStyle::new(DEFAULT_GRAPHIC_STYLE, Appearance::default_art()), + GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)), + GraphicStyle::new("Heavy Ink", Appearance::basic(solid("#1b1464"), Paint::solid(Color::BLACK), 4.0)), + GraphicStyle::new("Sunshine", Appearance::basic(solid("#fbb03b"), solid("#f15a24"), 2.0)), ] + .into_iter() + .zip(1..) + .map(|(g, id)| GraphicStyle { id, ..g }) + .collect() } #[cfg(test)] diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 5e36727a..4d637943 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -270,6 +270,10 @@ pub struct Node { #[serde(default, skip_serializing_if = "Option::is_none")] pub graph: Option>, pub kind: NodeKind, + /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps + /// that style's look: editing its appearance or transparency breaks the link. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graphic_style: Option, } /// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). @@ -314,6 +318,7 @@ impl Node { wrap: None, graph: None, kind, + graphic_style: None, } } pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 6e8d80b0..08adc46c 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1,34 +1,271 @@ -//! The Graphic Styles panel: apply, new, delete and duplicate. +//! The Graphic Styles panel: new, apply (replace or add), link, redefine, break link, rename, +//! delete, duplicate, sort and list. +//! +//! Applying a style links the targeted objects to it ([`Node::graphic_style`]). An object stays +//! linked while it keeps the style's look; editing its appearance or transparency breaks the link +//! ([`in_sync`]), and Redefine Graphic Style updates only the objects still linked. + +use std::collections::HashMap; use serde_json::{Value, json}; +use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind}; use super::*; use crate::EngineError; pub fn specs() -> Vec { vec![ - cmd!("graphicStyle.apply", "Apply Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_selection, style_apply), - cmd!("graphicStyle.new", "New Graphic Style", ["Window", "Graphic Styles"], None, "{name?} from the selection", has_selection, style_new), - cmd!("graphicStyle.delete", "Delete Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_delete), - cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_duplicate), + cmd!( + "graphicStyle.apply", + "Apply Graphic Style", + ["Window", "Graphic Styles"], + None, + "{name, add?: bool, ids?} give the objects (default: the selection; a group's contents) the style's appearance, and themselves its opacity, blend mode, isolate and knockout, and link them to it; add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks", + has_doc, + style_apply + ), + cmd!( + "graphicStyle.new", + "New Graphic Style", + ["Window", "Graphic Styles"], + None, + "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'); links the object. Names are made unique → {name}", + has_doc, + style_new + ), + cmd!( + "graphicStyle.delete", + "Delete Graphic Style", + ["Window", "Graphic Styles"], + None, + "{name} | {names: [name…]} delete styles; their objects keep their look but are unlinked", + has_doc, + style_delete + ), + cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name} → {name}", has_doc, style_duplicate), + cmd!( + query "graphicStyle.list", + "List Graphic Styles", + [], + None, + "{} → {styles: [{id, name, fill, stroke, strokeWidth, fills, strokes, effects: [effect id…], opacity: 0..100, blend, isolate, knockout, linked: [object ids]}], selected: the style the first selected object is linked to, or null}", + has_doc, + style_list + ), + cmd!( + "graphicStyle.redefine", + "Redefine Graphic Style", + ["Window", "Appearance"], + None, + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked → {name}", + has_doc, + style_redefine + ), + cmd!( + "graphicStyle.breakLink", + "Break Link to Graphic Style", + ["Window", "Graphic Styles"], + None, + "{ids?} unlink the objects (default: the selection) from their graphic style; they keep their look", + has_doc, + style_break_link + ), + cmd!( + "graphicStyle.rename", + "Rename Graphic Style", + ["Window", "Graphic Styles"], + None, + "{name, to} (Graphic Style Options; names are unique and linked objects stay linked) → {name}", + has_doc, + style_rename + ), + cmd!( + query "graphicStyle.unused", + "Select All Unused", + ["Window", "Graphic Styles"], + None, + "{} → {names: [the styles no object is linked to]} (the panel selects them)", + has_doc, + style_unused + ), + cmd!( + "graphicStyle.sortByName", + "Sort by Name", + ["Window", "Graphic Styles"], + None, + "{} sort the styles by name (the Default Graphic Style stays first)", + has_doc, + style_sort + ), ] } +// ---------- links ---------- + +/// Does a style applied to `n` (`top`: `n` is the target itself) descend into its children? Groups +/// and layers style their contents, and so do other containers (blends…) inside them, while a +/// compound path takes it as a whole, as with paint commands ([`leaf_targets`]). +fn descends(n: &Node, top: bool) -> bool { + match n.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } => true, + NodeKind::Compound { .. } => false, + _ => !top && n.is_container(), + } +} + +/// The objects whose appearance a style applied to `n` sets: `n`, or its painted contents. +fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { + match n.children() { + Some(ch) if descends(n, top) => ch.iter().for_each(|c| painted(c, false, out)), + _ => out.push(n), + } +} + +/// [`painted`], mutably (copying only the nodes on the way). +fn paint_mut(n: &mut Node, top: bool, f: &mut impl FnMut(&mut Node)) { + if !descends(n, top) { + return f(n); + } + for c in n.children_mut().into_iter().flatten() { + paint_mut(std::sync::Arc::make_mut(c), false, f); + } +} + +/// Does `n` still have style `g`'s look (its transparency, and its appearance on every painted +/// object)? Linked objects that don't are no longer linked. +pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool { + let mut leaves = vec![]; + painted(n, true, &mut leaves); + g.transparency_matches(n) && leaves.iter().all(|l| l.appearance == g.appearance) +} + +/// The style `n` is linked to (and still looks like). +pub(crate) fn linked_style<'a>(d: &'a Document, n: &Node) -> Option<&'a GraphicStyle> { + d.graphic_style_by_id(n.graphic_style?).filter(|g| in_sync(n, g)) +} + +/// Objects anywhere in the art that `f` accepts. +fn objects_where(d: &Document, mut f: impl FnMut(&Node) -> bool) -> Vec { + let mut ids = vec![]; + for l in &d.layers { + l.walk(&mut |n| { + if f(n) { + ids.push(n.id); + } + }); + } + ids +} + +/// The objects linked to each style id. +fn links(d: &Document) -> HashMap> { + let mut out: HashMap> = HashMap::new(); + for l in &d.layers { + l.walk(&mut |n| { + if let Some(g) = linked_style(d, n) { + out.entry(g.id).or_default().push(n.id); + } + }); + } + out +} + +/// The appearance a new or redefined style takes from `n`: its own. A group without fills or +/// strokes of its own lends those of its topmost painted object, and type its characters' fill +/// and stroke. +fn captured(n: &Node) -> Appearance { + let mut ap = n.appearance.clone(); + if !ap.items.is_empty() { + return ap; + } + match &n.kind { + NodeKind::Text(t) => { + if let Some(r) = t.runs.first() { + ap.items = Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width).items; + } + } + _ => { + let mut leaves = vec![]; + painted(n, true, &mut leaves); + if let Some(top) = leaves.last().filter(|l| l.id != n.id) { + // The topmost object's own effects apply before the group's. + let top = captured(top); + ap.items = top.items; + ap.effects.splice(0..0, top.effects); + } + } + } + ap +} + +/// Give `id` style `g` (its painted objects the appearance, itself the transparency) and link it. +fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle) { + if let Some(n) = d.node_mut(id) { + paint_mut(n, true, &mut |l| l.appearance = g.appearance.clone()); + g.apply_transparency(n); + n.graphic_style = Some(g.id); + } +} + +fn set_link(d: &mut Document, ids: &[NodeId], link: Option) { + for id in ids { + if let Some(n) = d.node_mut(*id) { + n.graphic_style = link; + } + } +} + +fn style_index(d: &Document, name: &str) -> Result { + d.graphic_style_index(name).ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`"))) +} + +/// The object a style is made from: `id`, else the first selected object. +fn source<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(NodeId, &'a Node)> { + let id = match id_param(p, "id") { + Some(id) => id, + None => super::edit::selected_roots(s)?.first().copied().ok_or_else(|| bad(cmd, "select an object (or pass `id`)"))?, + }; + Ok((id, s.doc()?.doc.node(id).ok_or_else(|| bad(cmd, format!("no object {}", id.0)))?)) +} + +impl Session { + /// The graphic style of the first selected object: the one it was last linked to, and whether + /// it is still linked (still has that style's look). + pub fn selection_graphic_style(&self) -> Option<(&GraphicStyle, bool)> { + let st = self.active()?; + let n = st.doc.node(*st.selection.objects.first()?)?; + let g = st.doc.graphic_style_by_id(n.graphic_style?)?; + Some((g, in_sync(n, g))) + } +} + +// ---------- commands ---------- + fn style_apply(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?; - let ap = s - .doc()? - .doc - .graphic_styles - .iter() - .find(|g| g.name == name) - .map(|g| g.appearance.clone()) - .ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?; - let ids = paint_targets(s, p)?; + let i = style_index(&s.doc()?.doc, name)?; + let add = bool_or(p, "add", false); + let ids = super::appearance::appearance_targets(s, p)?; + if ids.is_empty() { + return Err(bad("graphicStyle.apply", "nothing selected (or pass `ids`)")); + } s.edit("Apply Graphic Style", |d, _| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - n.appearance = ap.clone(); + if add { + let ap = d.graphic_styles[i].appearance.clone(); + for id in &ids { + if let Some(n) = d.node_mut(*id) { + paint_mut(n, true, &mut |l| { + l.appearance.items.extend(ap.items.iter().cloned()); + l.appearance.effects.extend(ap.effects.iter().cloned()); + }); + n.graphic_style = None; + } + } + } else { + d.graphic_style_id(i); + let g = d.graphic_styles[i].clone(); + for id in &ids { + apply_style(d, *id, &g); } } Ok(()) @@ -37,23 +274,37 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { } fn style_new(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let id = *st.selection.objects.first().unwrap(); - let ap = st.doc.node(id).map(|n| n.appearance.clone()).unwrap_or_default(); - let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Graphic Style {}", st.doc.graphic_styles.len() + 1)); + let (src, n) = source(s, p, "graphicStyle.new")?; + let d = &s.doc()?.doc; + let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + Some(n) => unique_name(n, |x| d.graphic_style(x).is_some()), + None => d.new_graphic_style_name(), + }; + let g = GraphicStyle { id: d.next_graphic_style_id(), ..GraphicStyle::of(name.clone(), captured(n), n) }; s.edit("New Graphic Style", |d, _| { - d.graphic_styles.push(vectorcraft_doc::GraphicStyle { name: name.clone(), appearance: ap }); + set_link(d, &[src], Some(g.id)); + d.graphic_styles.push(g); Ok(()) })?; Ok(json!({ "name": name })) } fn style_delete(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.delete", "missing name"))?.to_string(); - s.edit("Delete Graphic Style", |d, _| { - let before = d.graphic_styles.len(); - d.graphic_styles.retain(|g| g.name != name); - if d.graphic_styles.len() == before { Err(EngineError::Other(format!("no graphic style `{name}`"))) } else { Ok(()) } + let names: Vec = match (p.get("names").and_then(Value::as_array), str_param(p, "name")) { + (Some(a), _) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + (None, Some(n)) => vec![n.to_string()], + (None, None) => return Err(bad("graphicStyle.delete", "missing name")), + }; + s.edit(if names.len() > 1 { "Delete Graphic Styles" } else { "Delete Graphic Style" }, |d, _| { + for name in &names { + let i = style_index(d, name)?; + let id = d.graphic_styles.remove(i).id; + if id != 0 { + let ids = objects_where(d, |n| n.graphic_style == Some(id)); + set_link(d, &ids, None); + } + } + Ok(()) })?; ok() } @@ -61,12 +312,112 @@ fn style_delete(s: &mut Session, p: &Value) -> Result { fn style_duplicate(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.duplicate", "missing name"))?.to_string(); let new = s.edit("Duplicate Graphic Style", |d, _| { - let pos = d.graphic_styles.iter().position(|g| g.name == name).ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?; - let mut g = d.graphic_styles[pos].clone(); - let nm = unique_name(&format!("{name} copy"), |n| d.graphic_styles.iter().any(|x| x.name == n)); - g.name = nm.clone(); + let pos = style_index(d, &name)?; + let nm = unique_name(&format!("{name} copy"), |n| d.graphic_style(n).is_some()); + let g = GraphicStyle { name: nm.clone(), id: d.next_graphic_style_id(), ..d.graphic_styles[pos].clone() }; d.graphic_styles.insert(pos + 1, g); Ok(nm) })?; Ok(json!({"name": new})) } + +fn style_list(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let links = links(d); + let styles: Vec = d + .graphic_styles + .iter() + .map(|g| { + let ap = &g.appearance; + let count = |fill: bool| ap.items.iter().filter(|i| i.is_fill() == fill).count(); + json!({ + "id": g.id, + "name": g.name, + "fill": ap.fill_paint().label(), + "stroke": ap.stroke_paint().label(), + "strokeWidth": ap.stroke_width(), + "fills": count(true), + "strokes": count(false), + "effects": ap.effects.iter().map(|e| e.id.as_str()).collect::>(), + "opacity": (g.opacity * 100.0).round(), + "blend": g.blend.label(), + "isolate": g.isolate, + "knockout": g.knockout, + "linked": links.get(&g.id).map(|v| v.iter().map(|i| i.0).collect::>()).unwrap_or_default(), + }) + }) + .collect(); + let selected = s.selection_graphic_style().filter(|(_, linked)| *linked).map(|(g, _)| g.name.clone()); + Ok(json!({ "styles": styles, "selected": selected })) +} + +fn style_redefine(s: &mut Session, p: &Value) -> Result { + let cmd = "graphicStyle.redefine"; + let (src, n) = source(s, p, cmd)?; + let d = &s.doc()?.doc; + let i = match str_param(p, "name") { + Some(name) => style_index(d, name)?, + None => n + .graphic_style + .and_then(|id| d.graphic_style_by_id(id)) + .and_then(|g| d.graphic_style_index(&g.name)) + .ok_or_else(|| bad(cmd, "the object has no graphic style (pass `name`)"))?, + }; + let look = GraphicStyle::of(String::new(), captured(n), n); + let name = d.graphic_styles[i].name.clone(); + s.edit(&format!("Redefine Graphic Style \u{201c}{name}\u{201d}"), |d, _| { + let id = d.graphic_style_id(i); + let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look }); + // Objects still linked follow the new definition; edited ones are unlinked (they keep + // their look), and the source keeps its own. + let linked = objects_where(d, |n| n.graphic_style == Some(id) && n.id != src); + let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); + set_link(d, &edited, None); + let g = d.graphic_styles[i].clone(); + for nid in follow { + apply_style(d, nid, &g); + } + set_link(d, &[src], Some(id)); + Ok(()) + })?; + Ok(json!({ "name": name })) +} + +fn style_break_link(s: &mut Session, p: &Value) -> Result { + let ids = super::appearance::appearance_targets(s, p)?; + s.edit("Break Link to Graphic Style", |d, _| { + set_link(d, &ids, None); + Ok(()) + })?; + ok() +} + +fn style_rename(s: &mut Session, p: &Value) -> Result { + let cmd = "graphicStyle.rename"; + let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?.to_string(); + let to = str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad(cmd, "missing `to`"))?.to_string(); + s.edit("Graphic Style Options", |d, _| { + let i = style_index(d, &name)?; + if to != name && d.graphic_style(&to).is_some() { + return Err(EngineError::Other(format!("a graphic style named `{to}` already exists"))); + } + d.graphic_styles[i].name = to.clone(); + Ok(()) + })?; + Ok(json!({ "name": to })) +} + +fn style_unused(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let links = links(d); + let names: Vec<&str> = d.graphic_styles.iter().filter(|g| !links.contains_key(&g.id)).map(|g| g.name.as_str()).collect(); + Ok(json!({ "names": names })) +} + +fn style_sort(s: &mut Session, _: &Value) -> Result { + s.edit("Sort by Name", |d, _| { + d.graphic_styles.sort_by_cached_key(|g| (g.name != DEFAULT_GRAPHIC_STYLE, g.name.to_lowercase())); + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6ea09da7..0a4a930a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -772,6 +772,8 @@ mod tests_prefs; #[cfg(test)] mod tests_strokegeom; #[cfg(test)] +mod tests_styles; +#[cfg(test)] mod tests_swatches; #[cfg(test)] mod tests_textedit; diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests_styles.rs new file mode 100644 index 00000000..fcfa609e --- /dev/null +++ b/crates/engine/src/tests_styles.rs @@ -0,0 +1,220 @@ +//! Graphic styles: transparency, capture from groups and type, add-apply, unique names, links +//! (redefine, break link, delete, rename), Select All Unused, Sort by Name and Select > Same. + +use serde_json::{Value, json}; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{GraphicStyle, Node}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +/// A selected 50×50 rectangle at `x` with the default appearance. +fn rect(s: &mut Session, x: f64) -> NodeId { + let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap()); + run(s, "select.set", json!({"ids": [id.0]})); + id +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn style(s: &Session, name: &str) -> GraphicStyle { + s.doc().unwrap().doc.graphic_style(name).unwrap_or_else(|| panic!("no style {name}")).clone() +} + +fn fill(s: &Session, id: NodeId) -> Paint { + node(s, id).appearance.fill_paint() +} + +fn set_fill(s: &mut Session, id: NodeId, hex: &str) { + run(s, "paint.setFill", json!({"color": hex, "ids": [id.0]})); +} + +fn linked(s: &mut Session, name: &str) -> Vec { + let l = run(s, "graphicStyle.list", json!({})); + let st = l["styles"].as_array().unwrap().iter().find(|g| g["name"] == name).unwrap_or_else(|| panic!("{name} not listed")).clone(); + st["linked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() +} + +#[test] +fn styles_carry_opacity_and_blend_through_a_save() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "transparency.set", json!({"item": null, "opacity": 50, "blend": "multiply", "knockout": true})); + run(&mut s, "graphicStyle.new", json!({"name": "Half"})); + let g = style(&s, "Half"); + assert_eq!((g.opacity, g.blend, g.knockout), (0.5, BlendMode::Multiply, true)); + let b = rect(&mut s, 100.0); + run(&mut s, "graphicStyle.apply", json!({"name": "Half"})); + let nb = node(&s, b); + assert_eq!((nb.opacity, nb.blend, nb.knockout), (0.5, BlendMode::Multiply, true)); + let l = run(&mut s, "graphicStyle.list", json!({})); + let half = l["styles"].as_array().unwrap().iter().find(|g| g["name"] == "Half").unwrap(); + assert_eq!((half["opacity"].as_f64(), half["blend"].as_str()), (Some(50.0), Some("Multiply"))); + assert_eq!(linked(&mut s, "Half"), vec![a.0, b.0]); + assert_eq!(l["selected"], "Half"); + + // Native round trip: the style's transparency, its id and the objects' links survive. + let doc = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + assert_eq!(back.graphic_styles, doc.graphic_styles); + assert_eq!(back.node(b).unwrap().graphic_style, Some(g.id)); + // Styles and objects from files without links still load (and get an id when first linked). + let old: GraphicStyle = serde_json::from_value(json!({"name": "Old", "appearance": {"items": []}})).unwrap(); + assert_eq!((old.id, old.opacity, old.blend, old.isolate), (0, 1.0, BlendMode::Normal, false)); + let mut d = (*doc).clone(); + d.graphic_styles.push(old); + let i = d.graphic_styles.len() - 1; + assert_eq!(d.graphic_style_id(i), d.graphic_styles.iter().map(|g| g.id).max().unwrap()); + assert!(!serde_json::to_string(&GraphicStyle::new("Plain", Default::default())).unwrap().contains("opacity")); +} + +#[test] +fn a_style_from_a_group_or_type_is_not_empty() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let b = rect(&mut s, 100.0); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + assert!(node(&s, g).appearance.items.is_empty()); + run(&mut s, "graphicStyle.new", json!({"name": "Group Look"})); + let gs = style(&s, "Group Look"); + assert_eq!(gs.appearance.fill_paint(), Paint::solid(Color::from_hex("#ff0000").unwrap())); + // Both members share the look, so the group is linked. + assert_eq!(linked(&mut s, "Group Look"), vec![g.0]); + + let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 200, "text": "Hi"}))["id"].as_u64().unwrap()); + run(&mut s, "select.set", json!({"ids": [t.0]})); + run(&mut s, "graphicStyle.new", json!({"name": "Type Look"})); + assert!(!style(&s, "Type Look").appearance.fill_paint().is_none()); + + // Applying to a group styles its contents and links the group. + let c = rect(&mut s, 200.0); + run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0]})); + assert_eq!(node(&s, a).appearance, style(&s, "Black Outline").appearance); + assert_eq!(node(&s, g).graphic_style, Some(style(&s, "Black Outline").id)); + assert_ne!(node(&s, c).appearance, style(&s, "Black Outline").appearance); +} + +#[test] +fn alt_apply_adds_the_style_on_top() { + let mut s = session(); + rect(&mut s, 10.0); + run(&mut s, "appearance.clear", json!({})); + run(&mut s, "paint.setFill", json!({"color": "#00ff00"})); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); + run(&mut s, "graphicStyle.new", json!({"name": "Rough"})); + let rough = style(&s, "Rough"); + let b = rect(&mut s, 100.0); + let before = node(&s, b).appearance; + run(&mut s, "graphicStyle.apply", json!({"name": "Rough", "add": true})); + let after = node(&s, b).appearance; + assert_eq!(after.items.len(), before.items.len() + rough.appearance.items.len()); + assert_eq!(after.items[..before.items.len()], before.items[..]); + assert_eq!(after.items.last(), rough.appearance.items.last()); + assert_eq!(after.effects.len(), before.effects.len() + 1); + assert_eq!(node(&s, b).graphic_style, None); + // One undo step. + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, b).appearance, before); +} + +#[test] +fn style_names_are_unique() { + let mut s = session(); + rect(&mut s, 10.0); + assert_eq!(run(&mut s, "graphicStyle.new", json!({"name": "X"}))["name"], "X"); + assert_eq!(run(&mut s, "graphicStyle.new", json!({"name": "X"}))["name"], "X 2"); + let n = s.doc().unwrap().doc.graphic_styles.len(); + assert_eq!(run(&mut s, "graphicStyle.new", json!({}))["name"], format!("Graphic Style {}", n + 1)); + assert!(s.execute("graphicStyle.rename", &json!({"name": "X 2", "to": "X"})).is_err()); + assert!(s.execute("graphicStyle.rename", &json!({"name": "X 2", "to": " "})).is_err()); + assert_eq!(run(&mut s, "graphicStyle.rename", json!({"name": "X 2", "to": "Y"}))["name"], "Y"); + assert_eq!(run(&mut s, "graphicStyle.duplicate", json!({"name": "Y"}))["name"], "Y copy"); + assert_ne!(style(&s, "Y copy").id, style(&s, "Y").id); +} + +/// Three rectangles: `a` (the source of style "S"), `b` and `c`, all linked to "S". +fn linked_three(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let a = rect(s, 10.0); + run(s, "graphicStyle.new", json!({"name": "S"})); + let b = rect(s, 100.0); + let c = rect(s, 200.0); + run(s, "graphicStyle.apply", json!({"name": "S", "ids": [b.0, c.0]})); + assert_eq!(linked(s, "S"), vec![a.0, b.0, c.0]); + (a, b, c) +} + +#[test] +fn redefine_updates_every_linked_object() { + let mut s = session(); + let (a, b, c) = linked_three(&mut s); + set_fill(&mut s, a, "#0000ff"); + // Editing the source broke its link, but it remembers the style Redefine replaces. + assert_eq!(linked(&mut s, "S"), vec![b.0, c.0]); + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert_eq!(s.selection_graphic_style().map(|(g, linked)| (g.name.clone(), linked)), Some(("S".into(), false))); + run(&mut s, "graphicStyle.redefine", json!({})); + let blue = Paint::solid(Color::from_hex("#0000ff").unwrap()); + assert_eq!((fill(&s, b), fill(&s, c)), (blue.clone(), blue)); + assert_eq!(linked(&mut s, "S"), vec![a.0, b.0, c.0]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(fill(&s, b), Paint::solid(Color::WHITE)); +} + +#[test] +fn redefine_leaves_unlinked_and_edited_objects_alone() { + let mut s = session(); + let (a, b, c) = linked_three(&mut s); + run(&mut s, "graphicStyle.breakLink", json!({"ids": [c.0]})); + assert_eq!(node(&s, c).graphic_style, None); + let d = rect(&mut s, 300.0); + run(&mut s, "graphicStyle.apply", json!({"name": "S"})); + set_fill(&mut s, d, "#00ff00"); + set_fill(&mut s, a, "#0000ff"); + run(&mut s, "graphicStyle.redefine", json!({"name": "S", "id": a.0})); + assert_eq!(fill(&s, b), Paint::solid(Color::from_hex("#0000ff").unwrap())); + assert_eq!(fill(&s, c), Paint::solid(Color::WHITE)); + // The edited object keeps its look and is unlinked. + assert_eq!(fill(&s, d), Paint::solid(Color::from_hex("#00ff00").unwrap())); + assert_eq!(node(&s, d).graphic_style, None); + assert!(s.execute("graphicStyle.redefine", &json!({"id": c.0})).is_err()); +} + +#[test] +fn deleting_a_style_unlinks_and_renaming_keeps_links() { + let mut s = session(); + let (_, b, c) = linked_three(&mut s); + run(&mut s, "graphicStyle.rename", json!({"name": "S", "to": "T"})); + assert_eq!(linked(&mut s, "T").len(), 3); + let look = node(&s, c).appearance; + run(&mut s, "graphicStyle.delete", json!({"name": "T"})); + assert_eq!((node(&s, b).graphic_style, node(&s, c).graphic_style), (None, None)); + assert_eq!(node(&s, c).appearance, look); +} + +#[test] +fn unused_styles_and_sort_by_name() { + let mut s = session(); + linked_three(&mut s); + let unused = run(&mut s, "graphicStyle.unused", json!({})); + let names: Vec<&str> = unused["names"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!(names.contains(&"Sunshine") && !names.contains(&"S")); + run(&mut s, "graphicStyle.delete", json!({"names": names})); + assert_eq!(s.doc().unwrap().doc.graphic_styles.len(), 1); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "graphicStyle.sortByName", json!({})); + let names: Vec = s.doc().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect(); + assert_eq!(names, ["Default Graphic Style", "Black Outline", "Heavy Ink", "S", "Sunshine"]); +} diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index 41c35d29..38ed6692 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -1,14 +1,23 @@ -//! Graphic Styles panel: thumbnail grid or list of the document's styles. +//! Graphic Styles panel: thumbnail grid or list of the document's styles. Clicking a style applies +//! it to the selection (Alt-click adds it on top), Shift/Cmd-click selects several, double-click +//! opens Graphic Style Options. -use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2}; +use egui::{Rect, Response, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; -use vectorcraft_doc::Appearance; +use vectorcraft_doc::{Appearance, GraphicStyle}; -use super::{pstate, selection_len, set_pstate}; +use super::{alt_held, pstate, selection_len, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; +/// What a click on a style does (run after drawing, once the document is no longer borrowed). +enum Click { + Apply(String), + Toggle(String), + Options(String), +} + fn preview(ui: &Ui, r: Rect, ap: &Appearance) { ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); let inner = r.shrink(r.width() * 0.18); @@ -29,13 +38,31 @@ fn preview(ui: &Ui, r: Rect, ap: &Appearance) { } } +/// The click on a style's row or tile. +fn clicked(ui: &Ui, resp: &Response, name: &str) -> Option { + if resp.double_clicked() { + Some(Click::Options(name.to_string())) + } else if resp.clicked() { + let m = ui.input(|i| i.modifiers); + Some(if m.shift || m.command { Click::Toggle(name.to_string()) } else { Click::Apply(name.to_string()) }) + } else { + None + } +} + +/// The styles selected in the panel. +fn selected(ctx: &egui::Context) -> Vec { + pstate(ctx, "gs-sel") +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let styles: Vec<(String, Appearance)> = - app.session.active().map(|d| d.doc.graphic_styles.iter().map(|g| (g.name.clone(), g.appearance.clone())).collect()).unwrap_or_default(); - let sel: Option = pstate(ui.ctx(), "gs-sel"); + // The document is shared (an `Arc`): holding it while drawing copies nothing. + let doc = app.session.active().map(|d| d.doc.clone()); + let styles: &[GraphicStyle] = doc.as_ref().map_or(&[], |d| &d.graphic_styles); + let sel = selected(ui.ctx()); let list: bool = pstate(ui.ctx(), "gs-list"); - let mut clicked = None; + let mut click = None; widgets::list_box(ui, |ui| { ui.set_min_height(120.0); ui.set_width(ui.available_width()); @@ -44,93 +71,127 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { return; } if list { - for (name, ap) in &styles { + for g in styles { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - if sel.as_deref() == Some(name) { + if sel.contains(&g.name) { ui.painter().rect_filled(r, 0.0, t.row_selected); } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } - preview(ui, Rect::from_min_size(r.left_center() + vec2(4.0, -9.0), vec2(18.0, 18.0)), ap); - ui.painter().text(r.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); - if resp.clicked() { - clicked = Some(name.clone()); + preview(ui, Rect::from_min_size(r.left_center() + vec2(4.0, -9.0), vec2(18.0, 18.0)), &g.appearance); + ui.painter().text(r.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.0), t.text); + if let Some(c) = clicked(ui, &resp, &g.name) { + click = Some(c); } } } else { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(3.0, 3.0); - for (name, ap) in &styles { + for g in styles { let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click()); - preview(ui, r, ap); - let on = sel.as_deref() == Some(name.as_str()); - ui.painter().rect_stroke( - r, - 0.0, - Stroke::new( - if on { 2.0 } else { 1.0 }, - if on { - t.accent - } else if resp.hovered() { - t.text - } else { - t.border - }, - ), - StrokeKind::Inside, - ); - if resp.on_hover_text(name).clicked() { - clicked = Some(name.clone()); + preview(ui, r, &g.appearance); + 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); + let resp = resp.on_hover_text(&g.name); + if let Some(c) = clicked(ui, &resp, &g.name) { + click = Some(c); } } }); } }); - if let Some(name) = clicked { - set_pstate(ui.ctx(), "gs-sel", Some(name.clone())); - if selection_len(app) > 0 { - app.run("graphicStyle.apply", json!({"name": name})).ok(); + match click { + Some(Click::Apply(name)) => { + if selection_len(app) > 0 { + app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok(); + } + set_pstate(ui.ctx(), "gs-sel", vec![name]); } + Some(Click::Toggle(name)) => { + let mut sel = sel; + match sel.iter().position(|n| *n == name) { + Some(i) => { + sel.remove(i); + } + None => sel.push(name), + } + set_pstate(ui.ctx(), "gs-sel", sel); + } + Some(Click::Options(name)) => { + app.run("ui.graphicStyleOptions", json!({ "name": name })).ok(); + } + None => {} } - let sel: Option = pstate(ui.ctx(), "gs-sel"); + let sel = selected(ui.ctx()); 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); - widgets::icon_button_enabled(ui, "link-2-off", "Break Link to Graphic Style (on the roadmap)", false, false, 24.0); - ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); - if widgets::icon_button_enabled(ui, "dc-new-item", "New Graphic Style", false, has_sel, 24.0).clicked() { - app.run("graphicStyle.new", json!({})).ok(); + 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(); } - if widgets::icon_button_enabled(ui, "trash-2", "Delete Graphic Style", false, sel.is_some(), 24.0).clicked() - && let Some(n) = &sel - && app.run("graphicStyle.delete", json!({"name": n})).is_ok() - { - set_pstate::>(ui.ctx(), "gs-sel", None); + ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); + if widgets::icon_button_enabled(ui, "dc-new-item", "New Graphic Style (Alt-click to name it)", false, has_sel, 24.0).clicked() { + new_style(app, alt_held(ui)); + } + if widgets::icon_button_enabled(ui, "trash-2", "Delete Graphic Style", false, !sel.is_empty(), 24.0).clicked() { + delete(app, ui.ctx(), &sel); } }); } -pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - let sel: Option = pstate(ui.ctx(), "gs-sel"); - let list: bool = pstate(ui.ctx(), "gs-list"); - if menu_item(ui, "New Graphic Style…", selection_len(app) > 0, false) { +/// A new style from the selection, first asking for its name (`ask`) in Graphic Style Options. +fn new_style(app: &mut VectorcraftApp, ask: bool) { + if ask { + app.run("ui.graphicStyleOptions", json!({})).ok(); + } else { app.run("graphicStyle.new", json!({})).ok(); } - if menu_item(ui, "Duplicate Graphic Style", sel.is_some(), false) - && let Some(n) = &sel +} + +fn delete(app: &mut VectorcraftApp, ctx: &egui::Context, sel: &[String]) { + if app.run("graphicStyle.delete", json!({ "names": sel })).is_ok() { + set_pstate(ctx, "gs-sel", Vec::::new()); + } +} + +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let sel = selected(ui.ctx()); + let one = (sel.len() == 1).then(|| sel[0].clone()); + let has_doc = app.session.active().is_some(); + let list: bool = pstate(ui.ctx(), "gs-list"); + if menu_item(ui, "New Graphic Style…", selection_len(app) > 0, false) { + new_style(app, true); + } + if menu_item(ui, "Duplicate Graphic Style", one.is_some(), false) + && let Some(n) = &one { - app.run("graphicStyle.duplicate", json!({"name": n})).ok(); + app.run("graphicStyle.duplicate", json!({ "name": n })).ok(); } menu_item(ui, "Merge Graphic Styles", false, false); - if menu_item(ui, "Delete Graphic Style", sel.is_some(), false) - && let Some(n) = &sel - { - app.run("graphicStyle.delete", json!({"name": n})).ok(); + if menu_item(ui, "Delete Graphic Style", !sel.is_empty(), false) { + delete(app, ui.ctx(), &sel); + } + if menu_item(ui, "Break Link to Graphic Style", app.session.selection_graphic_style().is_some(), false) { + app.run("graphicStyle.breakLink", json!({})).ok(); } - menu_item(ui, "Break Link to Graphic Style", false, false); ui.separator(); - menu_item(ui, "Select All Unused", false, false); - menu_item(ui, "Sort by Name", false, false); + if menu_item(ui, "Select All Unused", has_doc, false) + && let Ok(r) = app.run("graphicStyle.unused", json!({})) + { + let names: Vec = serde_json::from_value(r["names"].clone()).unwrap_or_default(); + set_pstate(ui.ctx(), "gs-sel", names); + } + if menu_item(ui, "Sort by Name", has_doc, false) { + app.run("graphicStyle.sortByName", json!({})).ok(); + } ui.separator(); if menu_item(ui, "Thumbnail View", true, !list) { set_pstate(ui.ctx(), "gs-list", false); @@ -139,6 +200,56 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { set_pstate(ui.ctx(), "gs-list", true); } ui.separator(); - menu_item(ui, "Graphic Style Options…", false, false); + if menu_item(ui, "Graphic Style Options…", one.is_some(), false) + && let Some(n) = &one + { + app.run("ui.graphicStyleOptions", json!({ "name": n })).ok(); + } menu_item(ui, "Open Graphic Style Library", false, false); } + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + /// The texts `draw` paints in one headless frame. + fn frame(ctx: &egui::Context, draw: impl FnMut(&mut Ui)) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut out = ctx.run_ui(egui::RawInput::default(), draw); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v + } + + #[test] + fn list_view_names_the_styles_and_deletes_the_selected_ones() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app.session.execute("graphicStyle.new", &json!({"name": "Mine"})).unwrap(); + let ctx = egui::Context::default(); + set_pstate(&ctx, "gs-list", true); + let texts = frame(&ctx, |ui| { + show(&mut app, ui); + menu(&mut app, ui); + }); + assert!(texts.iter().any(|t| t == "Mine") && texts.iter().any(|t| t == "Sunshine"), "{texts:?}"); + // Select All Unused picks every style but the one the rectangle is linked to. + let unused: Vec = serde_json::from_value(app.session.execute("graphicStyle.unused", &json!({})).unwrap()["names"].clone()).unwrap(); + assert_eq!(unused.len(), 4); + set_pstate(&ctx, "gs-sel", unused.clone()); + frame(&ctx, |ui| show(&mut app, ui)); + delete(&mut app, &ctx, &unused); + assert!(selected(&ctx).is_empty()); + let names: Vec<&str> = app.session.active().unwrap().doc.graphic_styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Mine"]); + } +}