From eb83efdb19a8deeda61d8a0fda80a395b641e81b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:01:10 +0700 Subject: [PATCH 1/2] 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 b242ae03e..d62a52dff 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 5e36727aa..4d6379439 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 6e8d80b0d..08adc46c3 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 6ea09da73..0a4a930a7 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 000000000..fcfa609eb --- /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 41c35d299..38ed66924 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"]); + } +} From 321ab52aeabc497d4991429bba3eeec85c789d5d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:03:45 +0700 Subject: [PATCH 2/2] M3.45: graphic style links: redefine, break link, options, unused, sort, Select > Same The Appearance panel names the style a linked object uses and its menu offers Redefine Graphic Style "name", which updates every object still linked (edited objects keep their look and are unlinked). The Graphic Styles panel breaks links (button and menu), selects all unused styles, sorts by name, deletes several styles, and opens Graphic Style Options (rename; links follow) on double-click; New Graphic Style from the menu or with Alt asks for a name. Select > Same > Graphic Style and Appearance Attribute work, and Document Info lists the style names. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/docinfo.rs | 18 ++-- crates/engine/src/cmd/select.rs | 80 +++++++++++++-- crates/engine/src/tests_styles.rs | 33 +++++++ .../src/dialogs/graphic_style_options.rs | 97 +++++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/menus.rs | 12 ++- crates/ui-egui/src/panels/appearance.rs | 13 ++- crates/ui-egui/src/panels/doc_info.rs | 5 +- crates/ui-egui/src/panels/graphic_styles.rs | 3 + docs/control-protocol.md | 4 + docs/mcp.md | 17 ++++ 11 files changed, 264 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/graphic_style_options.rs diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 570dc855e..d8214a04e 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, …}", + "{selectionOnly?} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …}", has_doc, info ), @@ -43,16 +43,15 @@ fn paint_kind(p: &Paint) -> Option<&'static str> { fn info(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let d = &st.doc; - let roots: Vec<&Node> = if bool_or(p, "selectionOnly", false) { - st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() - } else { - d.layers.iter().map(|l| &**l).collect() - }; + let selection_only = bool_or(p, "selectionOnly", false); + let roots: Vec<&Node> = + if selection_only { st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() } else { d.layers.iter().map(|l| &**l).collect() }; let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); let mut fonts = BTreeSet::new(); let mut images: BTreeMap = BTreeMap::new(); let mut symbols = BTreeSet::new(); let mut spot = BTreeSet::new(); + let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() }; for r in roots { r.walk(&mut |n| { let k = match &n.kind { @@ -104,6 +103,12 @@ fn info(s: &mut Session, p: &Value) -> Result { if !n.appearance.effects.is_empty() { *counts.entry("liveEffects").or_default() += 1; } + if selection_only + && let Some(g) = super::style::linked_style(d, n) + && !styles.contains(&g.name.as_str()) + { + styles.push(&g.name); + } }); } Ok(json!({ @@ -115,6 +120,7 @@ fn info(s: &mut Session, p: &Value) -> Result { "spotColors": spot, "swatches": d.swatches_iter().count(), "graphicStyles": d.graphic_styles.len(), + "graphicStyleNames": styles, "characterStyles": d.char_styles.len(), "paragraphStyles": d.para_styles.len(), "patterns": d.patterns.len(), diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index a84b6fa04..086bdfb04 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -75,6 +75,24 @@ pub fn specs() -> Vec { cmd!("select.object.openPaths", "Open Paths", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n .path_data() .is_some_and(|p| !p.is_closed()))), + cmd!( + "select.same.graphicStyle", + "Graphic Style", + ["Select", "Same"], + None, + "{} the objects linked to the graphic style the first selected object is linked to", + has_selection, + same_graphic_style + ), + cmd!( + "select.same.appearanceAttribute", + "Appearance Attribute", + ["Select", "Same"], + None, + "{item?: appearance item index} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill", + has_selection, + same_attribute + ), ] } @@ -224,20 +242,68 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result { fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result { let st = s.doc()?; let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?; + select_where(s, cmd, &json!({}), |_, n| !n.is_container() && eq(n, &refn)) +} + +/// Select the visible, unlocked objects in visible, unlocked layers that `f` accepts (not looking +/// inside accepted ones); Select > Reselect repeats `cmd` with `p`. +fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result { + fn visit(d: &Document, n: &Node, f: &impl Fn(&Document, &Node) -> bool, ids: &mut Vec) { + if n.visible && !n.locked && !n.is_layer() && f(d, n) { + return ids.push(n.id); + } + for c in n.children().into_iter().flatten() { + visit(d, c, f, ids); + } + } + let st = s.doc()?; let mut ids = vec![]; for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) { - l.walk(&mut |n| { - if !n.is_container() && n.visible && !n.locked && eq(n, &refn) { - ids.push(n.id); - } - }); + visit(&st.doc, l, &f, &mut ids); } - let c = cmd.to_string(); s.select(|_, sel| sel.set(ids.iter().copied()))?; - s.doc_mut()?.last_selection_cmd = Some((c, json!({}))); + s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone())); Ok(json!({ "count": ids.len() })) } +fn same_graphic_style(s: &mut Session, _: &Value) -> Result { + let cmd = "select.same.graphicStyle"; + let st = s.doc()?; + let id = st + .selection + .objects + .first() + .and_then(|id| super::style::linked_style(&st.doc, st.doc.node(*id)?)) + .map(|g| g.id) + .ok_or_else(|| bad(cmd, "the selection has no graphic style"))?; + select_where(s, cmd, &json!({}), |d, n| n.graphic_style == Some(id) && super::style::linked_style(d, n).is_some()) +} + +/// An appearance attribute Select > Same > Appearance Attribute matches. +enum Attribute { + Item(vectorcraft_doc::AppearanceItem), + Effect(vectorcraft_doc::Effect), +} + +fn same_attribute(s: &mut Session, p: &Value) -> Result { + let cmd = "select.same.appearanceAttribute"; + let st = s.doc()?; + let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?; + let ap = &refn.appearance; + let item = p.get("item").and_then(Value::as_u64).map(|i| i as usize).or(s.appearance_item()); + let attr = match (item.and_then(|i| ap.items.get(i)), ap.effects.first()) { + (Some(it), _) => Attribute::Item(it.clone()), + (None, Some(e)) => Attribute::Effect(e.clone()), + (None, None) => { + Attribute::Item(ap.items.iter().rev().find(|i| i.is_fill()).cloned().ok_or_else(|| bad(cmd, "the object has no fill, stroke or effect"))?) + } + }; + select_where(s, cmd, p, |_, n| match &attr { + Attribute::Item(it) => n.appearance.items.contains(it), + Attribute::Effect(e) => n.appearance.effects.iter().any(|x| x.id == e.id && x.params == e.params), + }) +} + fn same_layers(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let layers: BTreeSet = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect(); diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests_styles.rs index fcfa609eb..b1a9ba1cf 100644 --- a/crates/engine/src/tests_styles.rs +++ b/crates/engine/src/tests_styles.rs @@ -198,10 +198,14 @@ fn deleting_a_style_unlinks_and_renaming_keeps_links() { 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); + run(&mut s, "select.set", json!({"ids": [b.0]})); + assert_eq!(run(&mut s, "select.same.graphicStyle", json!({}))["count"], 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); + run(&mut s, "select.set", json!({"ids": [b.0]})); + assert!(s.execute("select.same.graphicStyle", &json!({})).is_err()); } #[test] @@ -218,3 +222,32 @@ fn unused_styles_and_sort_by_name() { 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"]); } + +#[test] +fn select_same_appearance_attribute() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); + let b = rect(&mut s, 100.0); + set_fill(&mut s, b, "#ff0000"); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); + let c = rect(&mut s, 200.0); + // The shared effect. + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "select.same.appearanceAttribute", json!({})); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); + // The shared stroke (item 1). + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "select.same.appearanceAttribute", json!({"item": 1})); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b, c]); +} + +#[test] +fn document_info_lists_style_names() { + let mut s = session(); + let (a, ..) = linked_three(&mut s); + let i = run(&mut s, "document.info", json!({})); + assert_eq!(i["graphicStyleNames"].as_array().unwrap().len(), 5); + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert_eq!(run(&mut s, "document.info", json!({"selectionOnly": true}))["graphicStyleNames"], json!(["S"])); +} diff --git a/crates/ui-egui/src/dialogs/graphic_style_options.rs b/crates/ui-egui/src/dialogs/graphic_style_options.rs new file mode 100644 index 000000000..33d0982dc --- /dev/null +++ b/crates/ui-egui/src/dialogs/graphic_style_options.rs @@ -0,0 +1,97 @@ +//! Graphic Style Options: the style's name. Opened on a style it renames it (`graphicStyle.rename`, +//! linked objects stay linked); opened for New Graphic Style it names the style made from the +//! selection (`graphicStyle.new`). +//! +//! Fields: `name` and `__style` (the style being renamed; empty for a new style). + +use serde_json::{Value, json}; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::state::Dialog; + +/// The dialog kind of Graphic Style Options. +pub const KIND: &str = "graphicStyleOptions"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Graphic Style Options".into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM }; + +/// Open Graphic Style Options for style `name`, or (`None`) to name a new style from the selection. +pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result { + let d = &app.session.active().ok_or("no document open")?.doc; + let name = match name { + Some(n) => d.graphic_style(n).ok_or_else(|| format!("no graphic style `{n}`"))?.name.clone(), + None => d.new_graphic_style_name(), + }; + let style = if d.graphic_style(&name).is_some() { name.clone() } else { String::new() }; + app.ui.dialog = Some(Dialog::new(KIND, json!({ "name": name, "__style": style }))); + Ok(Value::Null) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + grid(ui, |ui| { + label(ui, "Style Name:"); + form::text(ui, d, "name", 190.0); + ui.end_row(); + }); + false +} + +/// OK: name the new style or rename the style. A name another style has keeps the dialog open. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (name, style) = (d.str("name"), d.str("__style")); + let r = match style.as_str() { + "" => app.run("graphicStyle.new", json!({ "name": name })), + s if s == name => Ok(Value::Null), + s => app.run("graphicStyle.rename", json!({ "name": s, "to": name })), + }; + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app + } + + fn set(app: &mut VectorcraftApp, v: &str) { + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!(v)); + } + + fn names(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect() + } + + #[test] + fn names_a_new_style_then_renames_it() { + let mut app = app(); + app.run("ui.graphicStyleOptions", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Graphic Style 5"); + // Drawn headlessly in the shared frame. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + set(&mut app, "Glow"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && names(&app).last().unwrap() == "Glow"); + + app.run("ui.graphicStyleOptions", json!({"name": "Glow"})).unwrap(); + set(&mut app, "Sunshine"); + assert!(super::super::confirm(&mut app).is_err(), "names are unique"); + assert!(app.ui.dialog.is_some(), "a taken name keeps the dialog open"); + set(&mut app, "Halo"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(names(&app).last().unwrap(), "Halo"); + assert!(app.run("ui.graphicStyleOptions", json!({"name": "nope"})).is_err()); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 5d2072933..ad9321341 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -17,6 +17,7 @@ mod effect; mod export_for_screens; mod form; mod gradient_stop; +pub mod graphic_style_options; pub mod new_color_group; mod new_document; pub mod new_swatch; @@ -140,6 +141,7 @@ registry! { NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC, GradientStop: ["gradientStop"] => gradient_stop::SPEC, ColorPicker: [color_picker::KIND] => color_picker::SPEC, + GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9f09c4956..ca5e7473a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -189,6 +189,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke", ), + ( + "ui.graphicStyleOptions", + "Graphic Style Options…", + "", + "{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -524,6 +530,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_color_picker(app, p), + "ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()), _ => return None, }; Some(r) @@ -687,6 +694,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), "file.export.pdf" => app.session.active().is_some(), "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), + "ui.graphicStyleOptions" => app.session.active().is_some(), _ => true, } } @@ -1077,14 +1085,14 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "Same", vec![ c("Appearance", "select.same.appearance"), - todo("Appearance Attribute"), + c("Appearance Attribute", "select.same.appearanceAttribute"), c("Blending Mode", "select.same.blendingMode"), c("Fill & Stroke", "select.same.fillAndStroke"), c("Fill Color", "select.same.fillColor"), c("Opacity", "select.same.opacity"), c("Stroke Color", "select.same.strokeColor"), c("Stroke Weight", "select.same.strokeWeight"), - todo("Graphic Style"), + c("Graphic Style", "select.same.graphicStyle"), c("Shape", "select.same.shapeType"), c("Symbol Instance", "select.same.symbolInstance"), Sep, diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 05ccd07df..066f5d372 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -175,12 +175,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); let mixed = mixed_appearances(app); let label = if mixed { "Mixed Appearances" } else { object_label(app) }; + // A linked object names its graphic style ("Rectangle: Sunshine"). + let label = match app.session.selection_graphic_style() { + Some((g, true)) if !mixed => std::borrow::Cow::Owned(format!("{label}: {}", g.name)), + _ => std::borrow::Cow::Borrowed(label), + }; if !hide_thumb { let th = Rect::from_min_size(r.left_center() + vec2(6.0, -12.0), vec2(24.0, 24.0)); let fill = node.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or_else(|| app.session.paint.fill.clone()); chip(ui, th, &fill); } - text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), label, true); + text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), &label, true); match &node { // Objects that differ have no common stack to list. Some(_) if mixed => {} @@ -582,7 +587,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { set_pstate(ui.ctx(), "ap-hide-thumb", !hide); } ui.separator(); - menu_item(ui, "Redefine Graphic Style", false, false); + let style = app.session.selection_graphic_style().map(|(g, _)| g.name.clone()); + let redefine = style.as_ref().map_or_else(|| "Redefine Graphic Style".into(), |n| format!("Redefine Graphic Style \u{201c}{n}\u{201d}")); + if menu_item(ui, &redefine, style.is_some(), false) { + app.run("graphicStyle.redefine", json!({})).ok(); + } menu_item(ui, "Show All Hidden Attributes", false, false); } diff --git a/crates/ui-egui/src/panels/doc_info.rs b/crates/ui-egui/src/panels/doc_info.rs index fc21c2923..ab959fd93 100644 --- a/crates/ui-egui/src/panels/doc_info.rs +++ b/crates/ui-egui/src/panels/doc_info.rs @@ -1,5 +1,5 @@ //! Document Info panel: the document's setup and what it contains (objects by kind, fonts, -//! images, symbols, spot colours…), for the whole document or the selection only. +//! images, symbols, spot colours, graphic styles…), for the whole document or the selection only. use egui::Ui; use serde_json::{Value, json}; @@ -71,7 +71,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::dim_label(ui, "None"); } widgets::divider(ui); - for (key, title) in [("fonts", "Fonts"), ("symbols", "Symbols"), ("spotColors", "Spot Colors")] { + for (key, title) in [("fonts", "Fonts"), ("symbols", "Symbols"), ("spotColors", "Spot Colors"), ("graphicStyleNames", "Graphic Styles")] { let list: Vec<&str> = i[key].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); widgets::subheader(ui, &format!("{title} ({})", list.len())); for f in list { @@ -87,7 +87,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::divider(ui); for (key, label) in [ ("swatches", "Swatches"), - ("graphicStyles", "Graphic Styles"), ("characterStyles", "Character Styles"), ("paragraphStyles", "Paragraph Styles"), ("patterns", "Pattern Swatches"), diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index 38ed66924..7226a4148 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -241,6 +241,9 @@ mod tests { show(&mut app, ui); menu(&mut app, ui); }); + // The Appearance panel names the style the object is linked to. + let shown = frame(&ctx, |ui| super::super::appearance::show(&mut app, ui)); + assert!(shown.iter().any(|t| t == "Rectangle: Mine"), "{shown:?}"); 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(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4aacf46a4..d8a381970 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -36,3 +36,7 @@ fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern `swatches`); `ui.dialog.confirm` runs `swatch.new` / `swatch.newGroup`. The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`. + +Graphic styles: `ui.graphicStyleOptions {name}` opens the `graphicStyleOptions` dialog (field `name`) for a style and +`ui.dialog.confirm` renames it with `graphicStyle.rename` (a name another style has returns an error and keeps the +dialog open); without `name` it names a new style made from the selection (`graphicStyle.new`). diff --git a/docs/mcp.md b/docs/mcp.md index 345bdde95..b1e3f21ad 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -196,3 +196,20 @@ plain group and keeps the clipping path, unpainted. {"name":"run_command","arguments":{"command":"select.set","params":{"ids":[12,15]}}} {"name":"run_command","arguments":{"command":"object.clippingMask.make","params":{}}} ``` + +## Graphic styles + +A graphic style holds an appearance (fills, strokes, effects) plus opacity, blend mode, isolate and knockout. +`graphicStyle.new {name?, id?}` captures an object (a group without its own fills or strokes lends its topmost +object's, type its characters'). `graphicStyle.apply {name, ids?}` gives the objects the style and links them; +`add: true` adds the style on top of the existing appearance instead. Linked objects stay linked while they keep the +style's look: editing their appearance or transparency breaks the link, and `graphicStyle.redefine {name?, id?}` +updates only the objects still linked. `graphicStyle.list` returns each style with the ids linked to it and the +style of the first selected object; `select.same.graphicStyle` selects an object's fellow users. + +```json +{"name":"run_command","arguments":{"command":"graphicStyle.new","params":{"id":12,"name":"Glow"}}} +{"name":"run_command","arguments":{"command":"graphicStyle.apply","params":{"name":"Glow","ids":[15,20]}}} +{"name":"run_command","arguments":{"command":"graphicStyle.redefine","params":{"id":12}}} +{"name":"run_command","arguments":{"command":"graphicStyle.list","params":{}}} +```