From 321ab52aeabc497d4991429bba3eeec85c789d5d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:03:45 +0700 Subject: [PATCH] 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 570dc855..d8214a04 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 a84b6fa0..086bdfb0 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 fcfa609e..b1a9ba1c 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 00000000..33d0982d --- /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 5d207293..ad932134 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 9f09c495..ca5e7473 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 05ccd07d..066f5d37 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 fc21c292..ab959fd9 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 38ed6692..7226a414 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 4aacf46a..d8a38197 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 345bdde9..b1e3f21a 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":{}}} +```