From 018bed29dbd48da6250e68038bd146ade2611449 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 12:28:05 +0700 Subject: [PATCH 1/6] M3.21: the Appearance panel's active item drives paint, stroke, gradient and transparency edits Clicking a fill or stroke row runs appearance.setActiveItem; the toolbar, Control bar and Properties proxies and the Color, Swatches, Gradient, Stroke and Transparency panels show and edit that item, and the gradient tool's annotator follows it. paint.setFill/setStroke, stroke.set/ setAdvanced, paint.editGradient/setGradientGeom and transparency.set take item (omitted: the active item, null: the top one). Item edits and the appearance.* commands address the selected objects' own stacks, so a group's own fills no longer leak into its contents. Any selection change clears the active item. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 152 +++++++++++ crates/engine/src/cmd/appearance.rs | 254 ++++++++++++++++-- crates/engine/src/cmd/gradient.rs | 107 +++----- crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/cmd/paint.rs | 83 +++--- crates/engine/src/cmd/stroke.rs | 162 ++++++----- crates/engine/src/inspect.rs | 2 +- crates/engine/src/lib.rs | 7 + crates/engine/src/tests_appearance.rs | 196 ++++++++++++++ crates/engine/src/tooling.rs | 1 + crates/tools/src/lib.rs | 4 + crates/tools/src/xform/gradient.rs | 30 ++- crates/ui-egui/src/chrome.rs | 11 +- crates/ui-egui/src/panels/appearance.rs | 48 +++- crates/ui-egui/src/panels/mod.rs | 56 +++- crates/ui-egui/src/panels/properties.rs | 9 +- crates/ui-egui/src/panels/stroke.rs | 12 +- crates/ui-egui/src/panels/tests_appearance.rs | 80 ++++++ crates/ui-egui/src/panels/transparency.rs | 11 +- crates/ui-egui/src/toolbar.rs | 5 +- docs/mcp.md | 7 + 21 files changed, 975 insertions(+), 264 deletions(-) create mode 100644 crates/engine/src/tests_appearance.rs create mode 100644 crates/ui-egui/src/panels/tests_appearance.rs diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 91ef1b84..4ed6f749 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -223,6 +223,47 @@ pub enum AppearanceItem { Stroke(StrokeLayer), } +impl AppearanceItem { + pub fn is_fill(&self) -> bool { + matches!(self, AppearanceItem::Fill(_)) + } + /// `"fill"` or `"stroke"` (the serialized `kind`). + pub fn kind_name(&self) -> &'static str { + if self.is_fill() { "fill" } else { "stroke" } + } + pub fn visible(&self) -> bool { + match self { + AppearanceItem::Fill(f) => f.visible, + AppearanceItem::Stroke(s) => s.visible, + } + } + pub fn opacity(&self) -> f32 { + match self { + AppearanceItem::Fill(f) => f.opacity, + AppearanceItem::Stroke(s) => s.opacity, + } + } + pub fn blend(&self) -> BlendMode { + match self { + AppearanceItem::Fill(f) => f.blend, + AppearanceItem::Stroke(s) => s.blend, + } + } + /// The item's own live effects (applied to this fill or stroke only). + pub fn effects(&self) -> &Vec { + match self { + AppearanceItem::Fill(f) => &f.effects, + AppearanceItem::Stroke(s) => &s.effects, + } + } + pub fn effects_mut(&mut self) -> &mut Vec { + match self { + AppearanceItem::Fill(f) => &mut f.effects, + AppearanceItem::Stroke(s) => &mut s.effects, + } + } +} + /// Appearance attributes. `items` is in paint order: `items[0]` is painted first (the bottom of the /// Appearance panel list). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -289,6 +330,97 @@ impl Appearance { AppearanceItem::Stroke(s) => s.effects.is_empty() && s.opacity == 1.0 && s.blend == BlendMode::Normal, }) } + /// Fill item `index`, or the topmost fill for `None`. `None` when that item is not a fill. + pub fn fill_at(&self, index: Option) -> Option<&FillLayer> { + match index { + None => self.fill(), + Some(i) => match self.items.get(i)? { + AppearanceItem::Fill(f) => Some(f), + AppearanceItem::Stroke(_) => None, + }, + } + } + pub fn fill_at_mut(&mut self, index: Option) -> Option<&mut FillLayer> { + match index { + None => self.fill_mut(), + Some(i) => match self.items.get_mut(i)? { + AppearanceItem::Fill(f) => Some(f), + AppearanceItem::Stroke(_) => None, + }, + } + } + /// Stroke item `index`, or the topmost stroke for `None`. `None` when that item is not a stroke. + pub fn stroke_at(&self, index: Option) -> Option<&StrokeLayer> { + match index { + None => self.stroke(), + Some(i) => match self.items.get(i)? { + AppearanceItem::Stroke(s) => Some(s), + AppearanceItem::Fill(_) => None, + }, + } + } + pub fn stroke_at_mut(&mut self, index: Option) -> Option<&mut StrokeLayer> { + match index { + None => self.stroke_mut(), + Some(i) => match self.items.get_mut(i)? { + AppearanceItem::Stroke(s) => Some(s), + AppearanceItem::Fill(_) => None, + }, + } + } + /// `index` when it names a fill (`fill`) or stroke item, else `None` (the topmost one): the + /// item a fill or stroke edit changes while item `index` is the Appearance panel's target. + pub fn item_of_kind(&self, index: Option, fill: bool) -> Option { + index.filter(|i| self.items.get(*i).is_some_and(|it| it.is_fill() == fill)) + } + /// The fill the Fill proxy shows while item `index` is targeted: that item when it is a fill, + /// else the topmost fill. + pub fn fill_for(&self, index: Option) -> Option<&FillLayer> { + self.fill_at(self.item_of_kind(index, true)) + } + /// The stroke the Stroke proxy and panel show while item `index` is targeted. + pub fn stroke_for(&self, index: Option) -> Option<&StrokeLayer> { + self.stroke_at(self.item_of_kind(index, false)) + } + /// Set the paint of fill item `index` (`None`: the top fill, created if missing). False when + /// `index` is not a fill. + pub fn set_fill_at(&mut self, index: Option, p: Paint) -> bool { + if index.is_none() { + self.set_fill(p); + return true; + } + self.fill_at_mut(index).map(|f| f.paint = p).is_some() + } + /// Set the paint of stroke item `index` (`None`: the top stroke, created if missing). False + /// when `index` is not a stroke. + pub fn set_stroke_at(&mut self, index: Option, p: Paint) -> bool { + if index.is_none() { + self.set_stroke(p); + return true; + } + self.stroke_at_mut(index).map(|s| s.paint = p).is_some() + } + /// The paint of fill (`fill`) or stroke item `index` (`None`: the topmost one). + pub fn paint_at(&self, index: Option, fill: bool) -> Option<&Paint> { + if fill { self.fill_at(index).map(|f| &f.paint) } else { self.stroke_at(index).map(|s| &s.paint) } + } + /// [`Self::set_fill_at`] or [`Self::set_stroke_at`]. + pub fn set_paint_at(&mut self, index: Option, fill: bool, p: Paint) -> bool { + if fill { self.set_fill_at(index, p) } else { self.set_stroke_at(index, p) } + } + /// The effects of item `index`, or the object-level effects for `None`. + pub fn effects_at(&self, index: Option) -> Option<&Vec> { + match index { + None => Some(&self.effects), + Some(i) => self.items.get(i).map(AppearanceItem::effects), + } + } + pub fn effects_mut(&mut self, index: Option) -> Option<&mut Vec> { + match index { + None => Some(&mut self.effects), + Some(i) => self.items.get_mut(i).map(AppearanceItem::effects_mut), + } + } /// Largest distance the painted area extends beyond the geometry (for visual bounds). pub fn outset(&self) -> f64 { self.items @@ -330,6 +462,26 @@ mod tests { assert_eq!(a.stroke_width(), 1.0); } + #[test] + fn items_by_index() { + let mut a = Appearance::default_art(); + a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); + // [Fill white, Stroke black, Fill none] + assert_eq!(a.fill_at(None).unwrap().paint, Paint::None); + assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::WHITE)); + assert!(a.fill_at(Some(1)).is_none() && a.stroke_at(Some(0)).is_none() && a.fill_at(Some(9)).is_none()); + assert_eq!(a.item_of_kind(Some(1), false), Some(1)); + assert_eq!(a.item_of_kind(Some(1), true), None); + assert_eq!(a.fill_for(Some(1)).unwrap().paint, Paint::None); + assert!(a.set_fill_at(Some(0), Paint::solid(Color::BLACK))); + assert!(!a.set_stroke_at(Some(0), Paint::None)); + assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::BLACK)); + a.effects_mut(Some(1)).unwrap().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true }); + assert_eq!(a.effects_at(Some(1)).unwrap().len(), 1); + assert!(a.effects_at(None).unwrap().is_empty() && a.effects_mut(Some(3)).is_none()); + assert_eq!((a.items[1].kind_name(), a.items[2].kind_name()), ("stroke", "fill")); + } + #[test] fn set_creates_missing() { let mut a = Appearance::default(); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 10f24bad..7c19ef8b 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -1,9 +1,17 @@ -//! The Appearance panel: fill and stroke items, live effects, Clear / Reduce to Basic, and the +//! The Appearance panel: fill and stroke items, the active item that paint, stroke, gradient, +//! transparency and effect edits target, live effects, Clear / Reduce to Basic, and the //! Eyedropper's appearance copy. +//! +//! **Item targeting.** Commands that edit a fill or stroke take `item?`: a paint-order index into +//! the appearance stack (`items[0]` is painted first, the bottom row of the panel). Omitted, they +//! edit the Appearance panel's active item (`appearance.setActiveItem`) when one is set for the +//! current selection and no `ids` are given, else the topmost fill or stroke; `null` always means +//! the topmost one. A targeted item lives in the selected objects' own stacks (a group's own fill, +//! not its contents'), so item edits apply to the selected objects themselves. use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer}; use super::edit::selected_roots; use super::paint::paint_from; @@ -12,23 +20,39 @@ use crate::EngineError; pub fn specs() -> Vec { vec![ - cmd!("appearance.addFill", "Add New Fill", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, true)), - cmd!("appearance.addStroke", "Add New Stroke", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, false)), - cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{}", has_selection, clear_appearance), - cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{}", has_selection, reduce_basic), + cmd!( + "appearance.addFill", + "Add New Fill", + ["Window", "Appearance"], + None, + "{ids?} add a fill on top of each selected object's own stack", + has_selection, + |s, p| add_item(s, p, true) + ), + cmd!( + "appearance.addStroke", + "Add New Stroke", + ["Window", "Appearance"], + None, + "{ids?} add a stroke on top of each selected object's own stack", + has_selection, + |s, p| add_item(s, p, false) + ), + cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{ids?}", has_selection, clear_appearance), + cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{ids?}", has_selection, reduce_basic), cmd!( "appearance.setItem", "Appearance Item", [], None, - "{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack", + "{index: paint-order item index, opacity?: 0..1 (values above 1 are percent), blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), ids?} edit one fill/stroke of each selected object's own appearance stack", has_selection, set_item ), - cmd!("appearance.removeItem", "Remove Item", [], None, "{index}", has_selection, remove_item), + cmd!("appearance.removeItem", "Remove Item", [], None, "{index, ids?}", has_selection, remove_item), cmd!("appearance.addEffect", "Add Effect", ["Effect"], None, "{effect: id, params?: {…}} append a live effect", has_selection, add_effect), - cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index}", has_selection, duplicate_item), - cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to} (paint-order indices)", has_selection, move_item), + cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index, ids?}", has_selection, duplicate_item), + cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to, ids?} (paint-order indices)", has_selection, move_item), cmd!( "appearance.copyFrom", "Eyedropper", @@ -38,11 +62,166 @@ pub fn specs() -> Vec { has_doc, copy_from ), + cmd!( + "appearance.setActiveItem", + "Select Appearance Item", + [], + None, + "{index: paint-order item index in the first selected object's stack | null} make that fill/stroke row the target of the paint.setFill/setStroke, stroke.set/setAdvanced, paint.editGradient/setGradientGeom, transparency.set and effect.* calls that omit `item` (a fill row brings the Fill proxy forward, a stroke row the Stroke proxy); null or any selection change clears it → {index}", + has_selection, + set_active_item + ), ] } -fn add_item(s: &mut Session, fill: bool) -> Result { - let ids = paint_targets(s, &json!({}))?; +/// The Appearance panel's active row, remembered for the selection it was chosen in. +#[derive(Clone, Debug)] +pub(crate) struct ActiveItem { + doc: u64, + objects: Vec, + index: usize, +} + +impl Session { + /// The Appearance panel's active fill/stroke: a paint-order index into the first selected + /// object's stack, while the selection it was chosen for is unchanged. + pub fn appearance_item(&self) -> Option { + let a = self.active_appearance_item.as_ref()?; + let st = self.active()?; + if st.uid != a.doc || st.selection.objects != a.objects { + return None; + } + let n = st.doc.node(*a.objects.first()?)?; + (a.index < n.appearance.items.len()).then_some(a.index) + } + + /// Re-point the active item after its stack changed (`None` drops it). + fn remap_appearance_item(&mut self, f: impl FnOnce(usize) -> Option) { + if let Some(a) = &mut self.active_appearance_item { + match f(a.index) { + Some(i) => a.index = i, + None => self.active_appearance_item = None, + } + } + } +} + +/// Which fill or stroke of an appearance stack an edit changes (the `item` param). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ItemTarget { + /// The topmost fill or stroke (for effects: the object-level effects). + Top, + /// Item `index`. `explicit`: named by the `item` param, so it must exist and be of the edited + /// kind; otherwise it is the panel's active item, which applies only where it fits. + Item { index: usize, explicit: bool }, +} + +/// Parse the `item` param of `cmd` (see the module docs). +pub(crate) fn item_target(s: &Session, p: &Value, cmd: &str) -> Result { + match p.get("item") { + Some(Value::Null) => Ok(ItemTarget::Top), + Some(v) => v + .as_u64() + .map(|i| ItemTarget::Item { index: i as usize, explicit: true }) + .ok_or_else(|| bad(cmd, "`item` must be an appearance item index (paint order) or null")), + None if p.get("ids").is_some() || p.get("id").is_some() => Ok(ItemTarget::Top), + None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false })), + } +} + +impl ItemTarget { + /// Objects a fill/stroke edit changes: the selected objects' own stacks when an item is + /// targeted, else the painted leaves ([`paint_targets`]). + pub(crate) fn targets(self, s: &Session, p: &Value) -> Result> { + match self { + ItemTarget::Top => paint_targets(s, p), + ItemTarget::Item { .. } => appearance_targets(s, p), + } + } + + /// The item of `ap` a fill (`fill`) or stroke edit changes; `None` = the topmost one. + pub(crate) fn resolve(self, ap: &Appearance, fill: bool, cmd: &str) -> Result> { + match self { + ItemTarget::Top => Ok(None), + ItemTarget::Item { index, explicit } => match ap.item_of_kind(Some(index), fill) { + None if explicit => Err(bad(cmd, format!("appearance item {index} is not a {}", if fill { "fill" } else { "stroke" }))), + found => Ok(found), + }, + } + } +} + +/// Objects whose own appearance stack a command edits: `ids`/`id`, else the selected objects +/// (a selected compound-path member stands for its compound). Layers are skipped. +pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result> { + if p.get("ids").is_none() && p.get("id").is_none() { + return selected_roots(s); + } + let d = &s.doc()?.doc; + Ok(targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer())).collect()) +} + +/// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke +/// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters). +pub(crate) fn edit_items( + s: &mut Session, + ids: &[NodeId], + item: ItemTarget, + cmd: &str, + label: &str, + fill: bool, + mut f: impl FnMut(&mut Node, Option) -> Result<()>, +) -> Result<()> { + if ids.is_empty() { + return Ok(()); + } + s.edit(label, |d, _| { + for id in ids { + let Some(n) = d.node_mut(*id) else { continue }; + let index = item.resolve(&n.appearance, fill, cmd)?; + f(n, index)?; + } + Ok(()) + }) +} + +/// Whether an edit aimed at `item` on `ids` changes a stroke: the `stroke` param, else the kind of +/// the targeted item (on the first target), else `default`. +pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, ids: &[NodeId], default: bool) -> Result { + if let Some(b) = p.get("stroke").and_then(Value::as_bool) { + return Ok(b); + } + let ItemTarget::Item { index, .. } = item else { return Ok(default) }; + let d = &s.doc()?.doc; + Ok(ids.first().and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) +} + +pub(crate) fn index_param(p: &Value, key: &str, cmd: &str) -> Result { + p.get(key).and_then(Value::as_u64).map(|i| i as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) +} + +fn set_active_item(s: &mut Session, p: &Value) -> Result { + const C: &str = "appearance.setActiveItem"; + let index = match p.get("index") { + Some(Value::Null) => { + s.active_appearance_item = None; + return Ok(json!({ "index": Value::Null })); + } + Some(_) => index_param(p, "index", C)?, + None => return Err(bad(C, "missing `index` (an item index or null)")), + }; + let st = s.doc()?; + let objects = st.selection.objects.clone(); + let first = objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| EngineError::Other("nothing selected".into()))?; + let fill = first.appearance.items.get(index).ok_or_else(|| bad(C, format!("no appearance item {index}")))?.is_fill(); + let doc = st.uid; + s.fill_active = fill; + s.active_appearance_item = Some(ActiveItem { doc, objects, index }); + Ok(json!({ "index": index })) +} + +fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { + let ids = appearance_targets(s, p)?; s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -59,8 +238,8 @@ fn add_item(s: &mut Session, fill: bool) -> Result { ok() } -fn clear_appearance(s: &mut Session, _: &Value) -> Result { - let ids = paint_targets(s, &json!({}))?; +fn clear_appearance(s: &mut Session, p: &Value) -> Result { + let ids = appearance_targets(s, p)?; s.edit("Clear Appearance", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -69,11 +248,12 @@ fn clear_appearance(s: &mut Session, _: &Value) -> Result { } Ok(()) })?; + s.active_appearance_item = None; ok() } -fn reduce_basic(s: &mut Session, _: &Value) -> Result { - let ids = paint_targets(s, &json!({}))?; +fn reduce_basic(s: &mut Session, p: &Value) -> Result { + let ids = appearance_targets(s, p)?; s.edit("Reduce to Basic Appearance", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -90,13 +270,14 @@ fn reduce_basic(s: &mut Session, _: &Value) -> Result { } Ok(()) })?; + s.active_appearance_item = None; ok() } fn set_item(s: &mut Session, p: &Value) -> Result { - let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.setItem", "missing index"))? as usize; + let idx = index_param(p, "index", "appearance.setItem")?; let paint = paint_from(s, p)?; - let ids = paint_targets(s, p)?; + let ids = appearance_targets(s, p)?; let blend = str_param(p, "blend").and_then(BlendMode::parse); s.edit("Appearance", |d, _| { for id in &ids { @@ -130,8 +311,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result { } fn remove_item(s: &mut Session, p: &Value) -> Result { - let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.removeItem", "missing index"))? as usize; - let ids = paint_targets(s, p)?; + let idx = index_param(p, "index", "appearance.removeItem")?; + let ids = appearance_targets(s, p)?; s.edit("Remove Item", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) @@ -142,6 +323,11 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; + s.remap_appearance_item(|a| match a.cmp(&idx) { + std::cmp::Ordering::Less => Some(a), + std::cmp::Ordering::Equal => None, + std::cmp::Ordering::Greater => Some(a - 1), + }); ok() } @@ -161,8 +347,8 @@ fn add_effect(s: &mut Session, p: &Value) -> Result { } fn duplicate_item(s: &mut Session, p: &Value) -> Result { - let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.duplicateItem", "missing index"))? as usize; - let ids = paint_targets(s, p)?; + let idx = index_param(p, "index", "appearance.duplicateItem")?; + let ids = appearance_targets(s, p)?; s.edit("Duplicate Item", |d, _| { let mut any = false; for id in &ids { @@ -174,14 +360,16 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { } if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) } })?; + s.remap_appearance_item(|a| Some(if a > idx { a + 1 } else { a })); ok() } fn move_item(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.moveItem"; - let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing from"))? as usize; - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize; - let ids = paint_targets(s, p)?; + let from = index_param(p, "from", C)?; + let to = index_param(p, "to", C)?; + let ids = appearance_targets(s, p)?; + let mut landed = to; s.edit("Reorder Appearance", |d, _| { for id in &ids { let Some(n) = d.node_mut(*id) else { continue }; @@ -190,11 +378,23 @@ fn move_item(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("no item at index {from}"))); } let it = items.remove(from); - let to = to.min(items.len()); - items.insert(to, it); + landed = to.min(items.len()); + items.insert(landed, it); } Ok(()) })?; + // The moved row stays the active one; the rows it passed shift by one. + s.remap_appearance_item(|a| { + Some(if a == from { + landed + } else if from < a && a <= landed { + a - 1 + } else if landed <= a && a < from { + a + 1 + } else { + a + }) + }); ok() } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 01b0d0e7..5cace287 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -4,6 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::NodeKind; +use super::appearance::{ItemTarget, edit_items, edits_stroke, item_target}; use super::edit::selected_roots; use super::*; use crate::EngineError; @@ -15,7 +16,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), @@ -24,7 +25,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start: [x,y], end: [x,y], ids?, stroke?: bool} set the gradient vector (solid paints become the default gradient)", + "{start: [x,y], end: [x,y], ids?, stroke?: bool (default: the targeted item's kind, else the fill), item?: fill/stroke item index|null} set the gradient vector (solid paints become the default gradient)", has_doc, set_gradient_geom ), @@ -112,51 +113,26 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option Result { const C: &str = "paint.editGradient"; - let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); - let ids = paint_targets(s, p)?; + let item = item_target(s, p, C)?; + let ids = item.targets(s, p)?; + let stroke = edits_stroke(s, p, item, &ids, !s.fill_active)?; // Validate against the defaults first so bad params fail without touching the document. let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }; let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?; - if ids.is_empty() { - if stroke { - s.paint.stroke = new_default; - } else { - s.paint.fill = new_default; - } - return Ok(json!({"ids": []})); - } - let mut err = None; - s.edit("Gradient", |d, _| { - for id in &ids { - let Some(n) = d.node_mut(*id) else { continue }; - if let NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - let cur = if stroke { &mut r.style.stroke } else { &mut r.style.fill }; - match apply_gradient_edit(cur, p, None) { - Ok(np) => *cur = np, - Err(e) => err = Some(e), - } - } - continue; - } - let b = n.geometric_bounds(); - let ap = &mut n.appearance; - let cur = if stroke { ap.stroke_paint() } else { ap.fill_paint() }; - match apply_gradient_edit(&cur, p, b) { - Ok(np) => { - if stroke { - ap.set_stroke(np) - } else { - ap.set_fill(np) - } - } - Err(e) => err = Some(e), + edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| { + if index.is_none() + && let NodeKind::Text(t) = &mut n.kind + { + for r in &mut t.runs { + let cur = if stroke { &mut r.style.stroke } else { &mut r.style.fill }; + *cur = apply_gradient_edit(cur, p, None).map_err(|e| bad(C, e))?; } + return Ok(()); } - match err.take() { - Some(e) => Err(bad(C, e)), - None => Ok(()), - } + let b = n.geometric_bounds(); + let np = apply_gradient_edit(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b).map_err(|e| bad(C, e))?; + n.appearance.set_paint_at(index, !stroke, np); + Ok(()) })?; if stroke { s.paint.stroke = new_default; @@ -167,38 +143,31 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { } fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { - let start = point_param(p, "start").ok_or_else(|| bad("paint.setGradientGeom", "missing start [x,y]"))?; - let end = point_param(p, "end").ok_or_else(|| bad("paint.setGradientGeom", "missing end [x,y]"))?; - let stroke = bool_or(p, "stroke", false); - let ids = match ids_param(p, "ids") { - Some(v) => v, - None => selected_roots(s)?, + const C: &str = "paint.setGradientGeom"; + let start = point_param(p, "start").ok_or_else(|| bad(C, "missing start [x,y]"))?; + let end = point_param(p, "end").ok_or_else(|| bad(C, "missing end [x,y]"))?; + let item = item_target(s, p, C)?; + let targets = match item { + ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), Ok)?)?, + _ => item.targets(s, p)?, }; - let targets = leaf_targets(s, &ids)?; if targets.is_empty() { return Err(EngineError::Other("nothing selected".into())); } - s.edit("Gradient", |d, _| { - for id in &targets { - let Some(n) = d.node_mut(*id) else { continue }; - if matches!(n.kind, NodeKind::Text(_)) { - continue; - } - let cur = if stroke { n.appearance.stroke_paint() } else { n.appearance.fill_paint() }; - let mut gp = match cur { - Paint::Gradient(g) => *g, - _ => GradientPaint::new(Gradient::default()), - }; - let aspect = gp.geom.map(|g| g.aspect).unwrap_or(1.0); - gp.geom = Some(GradientGeom { start, end, aspect }); - gp.angle = GradientGeom { start, end, aspect }.angle_deg(); - let paint = Paint::Gradient(Box::new(gp)); - if stroke { - n.appearance.set_stroke(paint); - } else { - n.appearance.set_fill(paint); - } + let stroke = edits_stroke(s, p, item, &targets, false)?; + let geom = GradientGeom { start, end, aspect: 1.0 }; + edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| { + if matches!(n.kind, NodeKind::Text(_)) { + return Ok(()); } + let mut gp = match n.appearance.paint_at(index, !stroke) { + Some(Paint::Gradient(g)) => (**g).clone(), + _ => GradientPaint::new(Gradient::default()), + }; + let aspect = gp.geom.map(|g| g.aspect).unwrap_or(1.0); + gp.geom = Some(GradientGeom { aspect, ..geom }); + gp.angle = geom.angle_deg(); + n.appearance.set_paint_at(index, !stroke, Paint::Gradient(Box::new(gp))); Ok(()) })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 11231ac4..336d3953 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,6 +1,6 @@ //! The command registry. Ids follow Illustrator's menu structure. -mod appearance; +pub(crate) mod appearance; mod brushsym; mod buildcmds; pub mod clipboard; diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 712d0af5..2d671b77 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -4,6 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, NodeKind}; +use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target}; use super::*; use crate::EngineError; @@ -14,11 +15,13 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind, stops:[{offset,color}], angle?}, ids?} sets selection fill and the default", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind, stops:[{offset,color}], angle?}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?} sets the selection's fill and the default", has_doc, |s, p| set_paint(s, p, true) ), - cmd!("paint.setStroke", "Stroke", [], None, "same as paint.setFill, for the stroke", has_doc, |s, p| set_paint(s, p, false)), + cmd!("paint.setStroke", "Stroke", [], None, "same as paint.setFill, for the stroke (item?: the stroke item to set)", has_doc, |s, p| { + set_paint(s, p, false) + }), cmd!("paint.swap", "Swap Fill and Stroke", [], Some("Shift+X"), "{}", has_doc, swap), cmd!("paint.default", "Default Fill and Stroke", [], Some("D"), "{}", has_doc, default_paint), cmd!("paint.toggleActive", "Toggle Fill/Stroke Focus", [], Some("X"), "{}", always, |s, _| { @@ -34,7 +37,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{opacity?: 0..100, blend?: name, isolate?, knockout?}", + "{opacity?: 0..100, blend?: name, isolate?, knockout?, item?: appearance item index|null, ids?} opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)", has_selection, transparency ), @@ -93,38 +96,32 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { } fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { - let paint = paint_from(s, p)?.ok_or_else(|| bad("paint.setFill", "give color, none, swatch or gradient"))?; + let cmd = if fill { "paint.setFill" } else { "paint.setStroke" }; + let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?; if fill { s.paint.fill = paint.clone(); } else { s.paint.stroke = paint.clone(); } s.fill_active = fill; - let ids = paint_targets(s, p)?; - if ids.is_empty() { - return ok(); - } - s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| { - for id in &ids { - let Some(n) = d.node_mut(*id) else { continue }; - if let NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - if fill { - r.style.fill = paint.clone(); - } else { - r.style.stroke = paint.clone(); - if r.style.stroke_width == 0.0 { - r.style.stroke_width = 1.0; - } + let item = item_target(s, p, cmd)?; + let ids = item.targets(s, p)?; + edit_items(s, &ids, item, cmd, if fill { "Fill Color" } else { "Stroke Color" }, fill, |n, index| { + if index.is_none() + && let NodeKind::Text(t) = &mut n.kind + { + for r in &mut t.runs { + if fill { + r.style.fill = paint.clone(); + } else { + r.style.stroke = paint.clone(); + if r.style.stroke_width == 0.0 { + r.style.stroke_width = 1.0; } } - continue; - } - if fill { - n.appearance.set_fill(paint.clone()); - } else { - n.appearance.set_stroke(paint.clone()); } + } else { + n.appearance.set_paint_at(index, fill, paint.clone()); } Ok(()) })?; @@ -171,9 +168,35 @@ fn default_paint(s: &mut Session, _: &Value) -> Result { } fn transparency(s: &mut Session, p: &Value) -> Result { - let mut q = p.clone(); - if let Some(o) = p.get("opacity").and_then(Value::as_f64) { - q["opacity"] = json!(o / 100.0); + const C: &str = "transparency.set"; + let item = item_target(s, p, C)?; + let mut q = p.as_object().cloned().unwrap_or_default(); + q.remove("item"); + let opacity = q.remove("opacity").and_then(|v| v.as_f64()).map(|o| (o / 100.0).clamp(0.0, 1.0)); + if let ItemTarget::Item { index, explicit } = item { + // Opacity and blend belong to the targeted fill/stroke; isolate/knockout stay object-level. + if explicit { + let d = &s.doc()?.doc; + if !appearance_targets(s, p)?.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { + return Err(bad(C, format!("no appearance item {index}"))); + } + } + let mut set = json!({ "index": index }); + for key in ["blend", "ids", "id"] { + if let Some(v) = p.get(key) { + set[key] = v.clone(); + } + } + if let Some(o) = opacity { + set["opacity"] = json!(o); + } + q.remove("blend"); + s.execute("appearance.setItem", &set)?; + if q.keys().all(|k| k == "ids" || k == "id") { + return ok(); + } + } else if let Some(o) = opacity { + q.insert("opacity".into(), json!(o)); } - s.execute("object.setProps", &q) + s.execute("object.setProps", &Value::Object(q)) } diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index c97ace47..ef645d84 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -2,8 +2,9 @@ use serde_json::Value; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer}; +use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; +use super::appearance::{edit_items, item_target}; use super::*; pub fn specs() -> Vec { @@ -13,7 +14,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", + "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?}", has_doc, stroke_set ), @@ -22,7 +23,7 @@ pub fn specs() -> Vec { "Stroke Options", [], None, - "{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, ids?} Stroke panel extras", + "{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, item?: stroke item index|null, ids?} Stroke panel extras", has_doc, stroke_advanced ), @@ -30,80 +31,74 @@ pub fn specs() -> Vec { } fn stroke_set(s: &mut Session, p: &Value) -> Result { + const C: &str = "stroke.set"; if let Some(w) = p.get("weight").and_then(Value::as_f64) { s.paint.stroke_width = w.max(0.0); } - let ids = paint_targets(s, p)?; - if ids.is_empty() { - return ok(); - } + let item = item_target(s, p, C)?; + let ids = item.targets(s, p)?; let arrow = |k: &str| -> Result>> { match p.get(k) { None => Ok(None), Some(Value::Null) => Ok(Some(None)), Some(Value::String(n)) if n == "none" => Ok(Some(None)), - Some(v) => { - serde_json::from_value::(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad("stroke.set", format!("unknown arrowhead {v}"))) - } + Some(v) => serde_json::from_value::(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))), } }; let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?); - s.edit("Stroke", |d, _| { - for id in &ids { - let Some(n) = d.node_mut(*id) else { continue }; - if n.appearance.stroke().is_none() { - n.appearance.set_stroke(Paint::solid(Color::BLACK)); - } - let st: &mut StrokeLayer = n.appearance.stroke_mut().unwrap(); - if let Some(w) = p.get("weight").and_then(Value::as_f64) { - st.width = w.max(0.0); - } - match str_param(p, "cap") { - Some("round") => st.cap = LineCap::Round, - Some("square") | Some("projecting") => st.cap = LineCap::Square, - Some("butt") => st.cap = LineCap::Butt, - _ => {} - } - match str_param(p, "join") { - Some("round") => st.join = LineJoin::Round, - Some("bevel") => st.join = LineJoin::Bevel, - Some("miter") => st.join = LineJoin::Miter, - _ => {} - } - if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) { - st.miter_limit = m.clamp(1.0, 500.0); - } - match str_param(p, "align") { - Some("inside") => st.align = StrokeAlign::Inside, - Some("outside") => st.align = StrokeAlign::Outside, - Some("center") => st.align = StrokeAlign::Center, - _ => {} - } - match p.get("dash") { - Some(Value::Null) => st.dash = None, - Some(Value::Array(a)) => { - let pattern: Vec = a.iter().filter_map(Value::as_f64).collect(); - st.dash = if pattern.is_empty() { - None - } else { - Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) }) - }; - } - _ => {} - } - if let Some(a) = sa { - st.start_arrow = a; - } - if let Some(a) = ea { - st.end_arrow = a; - } - match str_param(p, "profile") { - Some("uniform") => st.profile = None, - Some("lens") => st.profile = Some(vectorcraft_doc::WidthProfile::lens()), - Some("taperStart") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_start()), - Some("taperEnd") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_end()), - _ => {} + edit_items(s, &ids, item, C, "Stroke", false, |n, index| { + if index.is_none() && n.appearance.stroke().is_none() { + n.appearance.set_stroke(Paint::solid(Color::BLACK)); + } + let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) }; + if let Some(w) = p.get("weight").and_then(Value::as_f64) { + st.width = w.max(0.0); + } + match str_param(p, "cap") { + Some("round") => st.cap = LineCap::Round, + Some("square") | Some("projecting") => st.cap = LineCap::Square, + Some("butt") => st.cap = LineCap::Butt, + _ => {} + } + match str_param(p, "join") { + Some("round") => st.join = LineJoin::Round, + Some("bevel") => st.join = LineJoin::Bevel, + Some("miter") => st.join = LineJoin::Miter, + _ => {} + } + if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) { + st.miter_limit = m.clamp(1.0, 500.0); + } + match str_param(p, "align") { + Some("inside") => st.align = StrokeAlign::Inside, + Some("outside") => st.align = StrokeAlign::Outside, + Some("center") => st.align = StrokeAlign::Center, + _ => {} + } + match p.get("dash") { + Some(Value::Null) => st.dash = None, + Some(Value::Array(a)) => { + let pattern: Vec = a.iter().filter_map(Value::as_f64).collect(); + st.dash = if pattern.is_empty() { + None + } else { + Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) }) + }; } + _ => {} + } + if let Some(a) = sa { + st.start_arrow = a; + } + if let Some(a) = ea { + st.end_arrow = a; + } + match str_param(p, "profile") { + Some("uniform") => st.profile = None, + Some("lens") => st.profile = Some(vectorcraft_doc::WidthProfile::lens()), + Some("taperStart") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_start()), + Some("taperEnd") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_end()), + _ => {} } Ok(()) })?; @@ -144,27 +139,22 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { if scale.is_none() && flip.is_none() && !swap && brush.is_none() { return Err(bad(C, "nothing to change")); } - let ids = paint_targets(s, p)?; - if ids.is_empty() { - return ok(); - } - s.edit("Stroke", |d, _| { - for id in &ids { - let Some(n) = d.node_mut(*id) else { continue }; - let Some(st) = n.appearance.stroke_mut() else { continue }; - if let Some(sc) = scale { - st.arrow_scale = sc; - } - if swap { - std::mem::swap(&mut st.start_arrow, &mut st.end_arrow); - st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0); - } - if let (Some(along), Some(pr)) = (flip, &st.profile) { - st.profile = Some(flip_profile(pr, along)); - } - if let Some(b) = &brush { - st.brush = b.clone(); - } + let item = item_target(s, p, C)?; + let ids = item.targets(s, p)?; + edit_items(s, &ids, item, C, "Stroke", false, |n, index| { + let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) }; + if let Some(sc) = scale { + st.arrow_scale = sc; + } + if swap { + std::mem::swap(&mut st.start_arrow, &mut st.end_arrow); + st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0); + } + if let (Some(along), Some(pr)) = (flip, &st.profile) { + st.profile = Some(flip_profile(pr, along)); + } + if let Some(b) = &brush { + st.brush = b.clone(); } Ok(()) })?; diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 118aa755..bddfaa4f 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -65,6 +65,6 @@ pub fn document(s: &Session) -> Value { "redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::>(), "objects": d.node_count(), "tool": s.tool_id(), - "paint": {"fill": s.paint.fill.label(), "stroke": s.paint.stroke.label(), "strokeWidth": s.paint.stroke_width, "fillActive": s.fill_active}, + "paint": {"fill": s.paint.fill.label(), "stroke": s.paint.stroke.label(), "strokeWidth": s.paint.stroke_width, "fillActive": s.fill_active, "appearanceItem": s.appearance_item()}, }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 020464db..8b32f2d7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -443,6 +443,9 @@ pub struct Session { untitled_counter: u32, /// Session-level state of the menu commands (saved selections, guide lock). pub(crate) menu: cmd::menucmds::MenuState, + /// The Appearance panel's active fill/stroke row (`appearance.setActiveItem`); not saved. Read + /// it through [`Session::appearance_item`], which drops it once the selection changes. + pub(crate) active_appearance_item: Option, } impl Default for Session { @@ -468,6 +471,7 @@ impl Session { draw_inside: None, untitled_counter: 0, menu: Default::default(), + active_appearance_item: None, } } @@ -619,6 +623,7 @@ impl Session { /// Change only the selection (not an undo step). pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> { + self.active_appearance_item = None; let st = self.doc_mut()?; f(&st.doc, &mut st.selection); st.selection.prune(&st.doc); @@ -707,6 +712,8 @@ impl Session { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_appearance; +#[cfg(test)] mod tests_brushsym; #[cfg(test)] mod tests_build; diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs new file mode 100644 index 00000000..7a52854f --- /dev/null +++ b/crates/engine/src/tests_appearance.rs @@ -0,0 +1,196 @@ +//! Appearance stacks: the active item that paint, stroke, gradient and transparency edits target +//! (`appearance.setActiveItem`, the `item` param). + +use serde_json::{Value, json}; +use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; +use vectorcraft_doc::{AppearanceItem, Node}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +/// A selected 100×100 rectangle with the default appearance `[Fill white, Stroke black]`. +fn rect(s: &mut Session, x: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 100, "height": 100})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + id +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn stroke(n: &Node, i: usize) -> &vectorcraft_doc::StrokeLayer { + n.appearance.stroke_at(Some(i)).unwrap_or_else(|| panic!("item {i} is not a stroke")) +} + +fn fill_paint(n: &Node, i: usize) -> &Paint { + &n.appearance.fill_at(Some(i)).unwrap_or_else(|| panic!("item {i} is not a fill")).paint +} + +#[test] +fn stroke_set_with_item_changes_only_that_stroke() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "appearance.addStroke", json!({})); // [Fill, Stroke, Stroke] + run(&mut s, "stroke.set", json!({"item": 1, "dash": [4, 2], "weight": 6})); + let n = node(&s, id); + assert_eq!(stroke(&n, 1).dash.as_ref().unwrap().pattern, vec![4.0, 2.0]); + assert_eq!(stroke(&n, 1).width, 6.0); + assert!(stroke(&n, 2).dash.is_none()); + assert_eq!(stroke(&n, 2).width, 1.0); + run(&mut s, "stroke.setAdvanced", json!({"item": 1, "arrowScale": [50, 200]})); + let n = node(&s, id); + assert_eq!((stroke(&n, 1).arrow_scale, stroke(&n, 2).arrow_scale), ((50.0, 200.0), (100.0, 100.0))); + // An explicit item must be a stroke; nothing changes when it is not. + assert!(s.execute("stroke.set", &json!({"item": 0, "weight": 9})).is_err()); + assert!(s.execute("stroke.set", &json!({"item": 7, "weight": 9})).is_err()); + assert!(s.execute("stroke.set", &json!({"item": "top", "weight": 9})).is_err()); + assert_eq!(stroke(&node(&s, id), 2).width, 1.0); + // Without an item the top stroke changes. + run(&mut s, "stroke.set", json!({"weight": 3})); + let n = node(&s, id); + assert_eq!((stroke(&n, 1).width, stroke(&n, 2).width), (6.0, 3.0)); +} + +#[test] +fn edit_gradient_on_the_lower_fill() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill] + run(&mut s, "paint.editGradient", json!({"item": 0, "kind": "radial"})); + let n = node(&s, id); + let Paint::Gradient(g) = fill_paint(&n, 0) else { panic!("lower fill is not a gradient") }; + assert_eq!(g.gradient.kind, GradientKind::Radial); + assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE)); + // The gradient vector of the lower fill, through the active item (the gradient tool's path). + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "paint.setGradientGeom", json!({"start": [100, 150], "end": [200, 150]})); + let n = node(&s, id); + let Paint::Gradient(g) = fill_paint(&n, 0) else { panic!() }; + assert_eq!(g.geom.unwrap().end.x, 200.0); + assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE)); + // A stroke row makes the gradient vector a stroke gradient. + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); + run(&mut s, "paint.setGradientGeom", json!({"start": [100, 150], "end": [100, 250]})); + assert!(matches!(stroke(&node(&s, id), 1).paint, Paint::Gradient(_))); +} + +#[test] +fn active_item_targets_paint_and_proxies_and_clears_on_selection_change() { + let mut s = session(); + let other = rect(&mut s, 250.0); + let id = rect(&mut s, 100.0); + run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill] + assert!(s.execute("appearance.setActiveItem", &json!({"index": 5})).is_err()); + assert_eq!(run(&mut s, "appearance.setActiveItem", json!({"index": 0}))["index"], 0); + assert_eq!(s.appearance_item(), Some(0)); + assert_eq!(inspect::document(&s)["paint"]["appearanceItem"], 0); + assert!(s.fill_active); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + let n = node(&s, id); + assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); + assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE)); + // The stroke edits fall back to the top stroke while a fill row is active. + run(&mut s, "paint.setStroke", json!({"color": "#00ff00"})); + assert_eq!(stroke(&node(&s, id), 1).paint.color().unwrap().to_hex(), "#00ff00"); + // A stroke row brings the Stroke proxy forward. + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); + assert!(!s.fill_active); + // Changing the selection clears it, also when the same object is selected again. + run(&mut s, "select.set", json!({"ids": [other.0]})); + assert_eq!(s.appearance_item(), None); + run(&mut s, "select.set", json!({"ids": [id.0]})); + assert_eq!(s.appearance_item(), None); + run(&mut s, "paint.setFill", json!({"color": "#0000ff"})); + let n = node(&s, id); + assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#0000ff"); + assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); + // Explicit ids never use the active item, and `null` forces the top one. + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "paint.setFill", json!({"color": "#222222", "ids": [id.0]})); + assert_eq!(fill_paint(&node(&s, id), 2).color().unwrap().to_hex(), "#222222"); + run(&mut s, "paint.setFill", json!({"color": "#111111", "item": null})); + let n = node(&s, id); + assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#111111"); + assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); + run(&mut s, "appearance.setActiveItem", json!({"index": null})); + assert_eq!(s.appearance_item(), None); +} + +#[test] +fn item_rows_follow_remove_and_move() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill] + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); + run(&mut s, "appearance.moveItem", json!({"from": 1, "to": 2})); + assert_eq!(s.appearance_item(), Some(2)); + assert!(matches!(node(&s, id).appearance.items[2], AppearanceItem::Stroke(_))); + run(&mut s, "appearance.removeItem", json!({"index": 0})); + assert_eq!(s.appearance_item(), Some(1)); + run(&mut s, "appearance.removeItem", json!({"index": 1})); + assert_eq!(s.appearance_item(), None); +} + +#[test] +fn transparency_goes_to_the_targeted_item() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "transparency.set", json!({"item": 1, "opacity": 40, "blend": "multiply"})); + let n = node(&s, id); + assert_eq!((stroke(&n, 1).opacity, stroke(&n, 1).blend), (0.4, BlendMode::Multiply)); + assert_eq!((n.opacity, n.blend), (1.0, BlendMode::Normal)); + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + // Opacity and blend go to the active item, isolate stays on the object. + run(&mut s, "transparency.set", json!({"opacity": 1, "isolate": true})); + let n = node(&s, id); + assert!((n.appearance.items[0].opacity() - 0.01).abs() < 1e-6); + assert!(n.isolate); + assert_eq!(n.opacity, 1.0); + run(&mut s, "transparency.set", json!({"item": null, "opacity": 50})); + assert_eq!(node(&s, id).opacity, 0.5); + assert!(s.execute("transparency.set", &json!({"item": 9, "opacity": 50})).is_err()); +} + +#[test] +fn group_rows_are_the_groups_own_stack() { + let mut s = session(); + let a = rect(&mut s, 100.0); + let b = rect(&mut s, 250.0); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({})); + assert_eq!(node(&s, g).appearance.items.len(), 1); + assert_eq!(node(&s, a).appearance.items.len(), 2); + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + assert_eq!(fill_paint(&node(&s, g), 0).color().unwrap().to_hex(), "#ff0000"); + assert_eq!(*fill_paint(&node(&s, a), 0), Paint::solid(Color::WHITE)); + // Without a targeted item, a group's fill edit still recolours its contents. + run(&mut s, "appearance.setActiveItem", json!({"index": null})); + run(&mut s, "paint.setFill", json!({"color": "#00ff00"})); + assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00"); +} + +#[test] +fn text_items_take_item_paint() { + let mut s = session(); + let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Hi"}))["id"].as_u64().unwrap()); + run(&mut s, "select.set", json!({"ids": [t.0]})); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "paint.setFill", json!({"item": 0, "color": "#ff0000"})); + let n = node(&s, t); + assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); + let vectorcraft_doc::NodeKind::Text(tx) = &n.kind else { panic!() }; + assert_ne!(tx.first_style().fill, Paint::solid(Color::from_hex("#ff0000").unwrap())); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 4203b139..2ea533d7 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -78,6 +78,7 @@ impl Session { snap_to_pixel: view.snap_to_pixel, snap_to_point: view.snap_to_point, corner_widgets: view.corner_widgets, + appearance_item: self.appearance_item(), }; f(self.tool.as_mut(), &cx) } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 440ee67c..0dd32178 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -155,6 +155,9 @@ pub struct ToolContext<'a> { pub snap_to_point: bool, /// View → Show Corner Widget: live rectangles show draggable Live Corners widgets. pub corner_widgets: bool, + /// The Appearance panel's active fill/stroke (paint-order index into the first selected + /// object's stack); paint tools show and edit that item. + pub appearance_item: Option, } impl ToolContext<'_> { @@ -324,6 +327,7 @@ pub(crate) mod testutil { snap_to_pixel: false, snap_to_point: true, corner_widgets: true, + appearance_item: None, } } } diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 01df26a6..2f92d775 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -7,6 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; +use vectorcraft_doc::AppearanceItem; use vectorcraft_doc::hit::hit_test; use vectorcraft_geom::{Point, Vec2}; @@ -21,11 +22,18 @@ pub struct GradientTool { drag: Option<(Point, Point, bool)>, } -/// Start/end/stop offsets of the gradient annotator for the first selected object with a gradient fill. +/// Start/end/stop offsets of the gradient annotator for the first selected object with a gradient +/// in the Appearance panel's active item (when it is one), else in its fill. pub fn annotator(cx: &ToolContext) -> Option<(Point, Point, Vec)> { cx.selection.objects.iter().find_map(|id| { let n = cx.doc.node(*id)?; - let Paint::Gradient(g) = n.appearance.fill_paint() else { return None }; + let item = cx.appearance_item.and_then(|i| n.appearance.items.get(i)); + let paint = match item { + Some(AppearanceItem::Stroke(s)) => &s.paint, + Some(AppearanceItem::Fill(f)) => &f.paint, + None => &n.appearance.fill()?.paint, + }; + let Paint::Gradient(g) = paint else { return None }; let geom = g.resolve(n.geometric_bounds()?); Some((geom.start, geom.end, g.gradient.stops.iter().map(|s| s.offset).collect())) }) @@ -156,4 +164,22 @@ mod tests { assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)).is_empty()); } + + #[test] + fn annotator_follows_the_active_appearance_item() { + let (mut d, id) = doc_with_rect(); + let geom = vectorcraft_color::GradientGeom { start: Point::new(100.0, 120.0), end: Point::new(200.0, 120.0), aspect: 1.0 }; + let mut gp = vectorcraft_color::GradientPaint::new(Default::default()); + gp.geom = Some(geom); + d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().paint = Paint::Gradient(Box::new(gp)); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut cx = cx(&d, &s, &p); + // The fill is solid: no annotator until the stroke row (item 1) is the active item. + assert!(annotator(&cx).is_none()); + cx.appearance_item = Some(1); + let (a, b, _) = annotator(&cx).unwrap(); + assert_eq!((a, b), (geom.start, geom.end)); + } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 1ce16d9e..aaac5e0e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -105,11 +105,12 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { }; ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); ui.add_space(6.0); - let (fill, stroke, weight, opacity) = match &first { - Some(n) => { - (n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map(|s| s.width).unwrap_or(0.0), n.opacity) + let (fill, stroke) = crate::panels::current_paints(app); + let (weight, opacity) = match &first { + Some(_) => { + (crate::panels::current_stroke(app).map_or(0.0, |s| s.width), crate::panels::current_transparency(app).map_or(1.0, |t| t.0)) } - None => (app.session.paint.fill.clone(), app.session.paint.stroke.clone(), app.session.paint.stroke_width, 1.0), + None => (app.session.paint.stroke_width, 1.0), }; if chip_button(ui, &fill, false, "Fill").clicked() { app.run("paint.toggleActive", json!({})).ok(); @@ -134,7 +135,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0) && !sel.is_empty() { - app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok(); + app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok(); } ui.separator(); if sel.is_empty() { diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 3be3192e..723d2518 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -17,7 +17,8 @@ use crate::{VectorcraftApp, icons}; const ROW: f32 = 30.0; const EYE_W: f32 = 26.0; -/// What is selected in the stack (for Duplicate / Delete). +/// What is selected in the stack (for Duplicate / Delete). A fill/stroke row is the engine's +/// active appearance item (`appearance.setActiveItem`); effect rows are panel state. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Sel { #[default] @@ -26,6 +27,29 @@ pub enum Sel { Effect(usize), } +/// The selected row: the engine's active item, else a selected effect row. +fn current_sel(app: &VectorcraftApp, ctx: &egui::Context) -> Sel { + match app.session.appearance_item() { + Some(i) => Sel::Item(i), + None => match pstate(ctx, "ap-sel") { + Sel::Item(_) => Sel::None, + other => other, + }, + } +} + +/// Select a row: fill/stroke rows become the active item that paint edits target. +pub(crate) fn select_row(app: &mut VectorcraftApp, ctx: &egui::Context, sel: Sel) { + let index = match sel { + Sel::Item(i) => json!(i), + _ => Value::Null, + }; + if app.session.appearance_item().map_or(Value::Null, |i| json!(i)) != index { + app.run("appearance.setActiveItem", json!({ "index": index })).ok(); + } + set_pstate(ctx, "ap-sel", if matches!(sel, Sel::Item(_)) { Sel::None } else { sel }); +} + fn catalog() -> &'static [(String, String, Vec)] { static C: OnceLock)>> = OnceLock::new(); C.get_or_init(|| { @@ -117,12 +141,15 @@ fn link(ui: &mut Ui, pos: egui::Pos2, id: impl std::hash::Hash + std::fmt::Debug pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let node = first_selected(app); - let sel: Sel = pstate(ui.ctx(), "ap-sel"); + let sel = current_sel(app, ui.ctx()); let hide_thumb: bool = pstate(ui.ctx(), "ap-hide-thumb"); widgets::list_box(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; - // Object row. - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW + 4.0), Sense::hover()); + // Object row (clicking it targets the whole object again). + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW + 4.0), Sense::click()); + if resp.clicked() && sel != Sel::None { + select_row(app, ui.ctx(), Sel::None); + } ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); let label = match (&node, super::selection_len(app)) { (None, _) => "No Selection".to_string(), @@ -186,7 +213,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { let lx = r.left() + EYE_W + 24.0; if is_stroke { if link(ui, pos2(lx, r.center().y), ("ap-link", i), "Stroke:") { - set_pstate(ui.ctx(), "ap-sel", Sel::Item(i)); + select_row(app, ui.ctx(), Sel::Item(i)); app.ui.open_panel = Some("stroke".into()); } } else { @@ -207,7 +234,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { } } if resp.clicked() { - set_pstate(ui.ctx(), "ap-sel", Sel::Item(i)); + select_row(app, ui.ctx(), Sel::Item(i)); } if resp.double_clicked() { app.ui.open_panel = Some(if is_stroke { "stroke" } else { "color" }.into()); @@ -258,8 +285,8 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { if let Some((from, to_row)) = drop && from != to_row { + // The engine keeps the moved row active when it was. app.run("appearance.moveItem", json!({"from": from, "to": to_row})).ok(); - set_pstate(ui.ctx(), "ap-sel", Sel::Item(to_row)); } } @@ -289,11 +316,11 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, k: usize, e: &Effect, selec let lx = r.left() + EYE_W + 24.0; if link(ui, pos2(lx, r.center().y), ("ap-fx-link", k), &effect_label(&e.id)) { set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open); - set_pstate(ui.ctx(), "ap-sel", Sel::Effect(k)); + select_row(app, ui.ctx(), Sel::Effect(k)); } icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon); if resp.clicked() { - set_pstate(ui.ctx(), "ap-sel", Sel::Effect(k)); + select_row(app, ui.ctx(), Sel::Effect(k)); } if resp.double_clicked() { set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open); @@ -430,6 +457,7 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel) } fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) { + // Removing the active item clears it in the engine. let ok = match sel { Sel::Item(i) => app.run("appearance.removeItem", json!({"index": i})).is_ok(), Sel::Effect(k) => app.run("effect.remove", json!({"index": k})).is_ok(), @@ -465,7 +493,7 @@ fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let node = first_selected(app); let has = node.is_some(); - let sel: Sel = pstate(ui.ctx(), "ap-sel"); + let sel = current_sel(app, ui.ctx()); if menu_item(ui, "Add New Fill", has, false) { app.run("appearance.addFill", json!({})).ok(); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0b163e51..d57443b1 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -37,8 +37,8 @@ pub mod transparency; use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Node, NodeKind, StrokeLayer}; use crate::theme::Tokens; use crate::widgets::{Live, dim_label}; @@ -164,19 +164,47 @@ pub(crate) fn paint_target(app: &VectorcraftApp) -> &'static str { if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" } } -/// Fill and stroke as the proxies show them: the first selected object's (text uses its first -/// run's style), else the defaults for new art. +/// The first selected node, borrowed (for per-frame reads that need no copy of it). +fn first_node(app: &VectorcraftApp) -> Option<&Node> { + let st = app.session.active()?; + st.selection.objects.first().and_then(|id| st.doc.node(*id)) +} + +/// Fill and stroke as the proxies show them: the first selected object's, where the Appearance +/// panel's active item stands in for the top fill or stroke of its kind (text without one shows +/// its first run's style); else, and for a group without its own fill or stroke, the defaults +/// for new art. pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) { - match first_selected(app) { - Some(n) => match &n.kind { - NodeKind::Text(t) => { - let s = t.first_style(); - (s.fill, s.stroke) - } - _ => (n.appearance.fill_paint(), n.appearance.stroke_paint()), - }, - None => (app.session.paint.fill.clone(), app.session.paint.stroke.clone()), + let defaults = || (app.session.paint.fill.clone(), app.session.paint.stroke.clone()); + let Some(n) = first_node(app) else { return defaults() }; + let item = app.session.appearance_item(); + if item.is_none() { + if let NodeKind::Text(t) = &n.kind { + let s = t.first_style(); + return (s.fill, s.stroke); + } + if n.is_container() && n.appearance.items.is_empty() { + return defaults(); + } } + let ap = &n.appearance; + (ap.fill_for(item).map_or(Paint::None, |f| f.paint.clone()), ap.stroke_for(item).map_or(Paint::None, |s| s.paint.clone())) +} + +/// The stroke the Stroke panel, Control bar and Properties show: the Appearance panel's active +/// item when it is a stroke, else the first selected object's top stroke. +pub(crate) fn current_stroke(app: &VectorcraftApp) -> Option { + first_node(app)?.appearance.stroke_for(app.session.appearance_item()).cloned() +} + +/// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance +/// panel's active item's, else the first selected object's. +pub(crate) fn current_transparency(app: &VectorcraftApp) -> Option<(f32, BlendMode)> { + let n = first_node(app)?; + Some(match app.session.appearance_item().and_then(|i| n.appearance.items.get(i)) { + Some(it) => (it.opacity(), it.blend()), + None => (n.opacity, n.blend), + }) } /// The paint behind the active proxy. @@ -323,3 +351,5 @@ mod tests { assert_eq!(p["gradient"]["stops"].as_array().unwrap().len(), 2); } } +#[cfg(test)] +mod tests_appearance; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index abf79623..e76530bd 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -265,11 +265,10 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(n) = first_selected(app) else { return }; section_header(ui, "Appearance"); - let (fill, stroke, weight) = if let NodeKind::Text(tx) = &n.kind { - let s = tx.first_style(); - (s.fill.clone(), s.stroke.clone(), s.stroke_width) - } else { - (n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map(|s| s.width).unwrap_or(0.0)) + let (fill, stroke) = super::current_paints(app); + let weight = match &n.kind { + NodeKind::Text(tx) if app.session.appearance_item().is_none() => tx.first_style().stroke_width, + _ => super::current_stroke(app).map_or(0.0, |s| s.width), }; for (label, paint, is_fill) in [("Fill", fill, true), ("Stroke", stroke, false)] { ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 90b456c5..eea0d5ec 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -3,9 +3,9 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{Arrowhead, LineCap, LineJoin, StrokeAlign, StrokeLayer, Unit, WidthProfile}; +use vectorcraft_doc::{Arrowhead, LineCap, LineJoin, StrokeAlign, Unit, WidthProfile}; -use super::{first_selected, pstate, set_pstate}; +use super::{current_stroke, pstate, set_pstate}; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; use crate::{VectorcraftApp, icons}; @@ -71,17 +71,13 @@ fn arrow_label(a: Option) -> String { } } -fn stroke_now(app: &VectorcraftApp) -> Option { - first_selected(app).and_then(|n| n.appearance.stroke().cloned()) -} - fn set(app: &mut VectorcraftApp, p: Value) { app.run("stroke.set", p).ok(); } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let st = stroke_now(app); + let st = current_stroke(app); let weight = st.as_ref().map(|s| s.width).unwrap_or(app.session.paint.stroke_width); let hidden: bool = pstate(ui.ctx(), "stroke-hide-options"); let label_w = 64.0; @@ -380,7 +376,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } menu_item(ui, "Add to Profiles", false, false); menu_item(ui, "Delete Profile", false, false); - if menu_item(ui, "Reset Profile", stroke_now(app).is_some_and(|s| s.profile.is_some()), false) { + if menu_item(ui, "Reset Profile", current_stroke(app).is_some_and(|s| s.profile.is_some()), false) { set(app, json!({"profile": "uniform"})); } } diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs new file mode 100644 index 00000000..186b7bef --- /dev/null +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -0,0 +1,80 @@ +//! Headless frames of the Appearance panel and the panels that follow its active item. + +use serde_json::{Value, json}; +use vectorcraft_doc::Unit; +use vectorcraft_engine::Session; + +use super::*; + +fn app_with_rect() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let id = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100}))["id"].clone(); + run(&mut app, "select.set", json!({ "ids": [id] })); + app +} + +fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value { + app.session.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// Every text drawn while `f` runs one headless frame. +fn frame_texts(app: &mut VectorcraftApp, f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> Vec { + let ctx = egui::Context::default(); + frame_in(&ctx, app, f) +} + +fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> Vec { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)); + out.textures_delta.clear(); + fn collect(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| collect(s, out)), + _ => {} + } + } + let mut texts = vec![]; + for c in &out.shapes { + collect(&c.shape, &mut texts); + } + texts +} + +#[test] +fn stroke_panel_shows_the_active_rows_weight() { + let mut app = app_with_rect(); + run(&mut app, "appearance.addStroke", json!({})); // [Fill, Stroke 1 pt, Stroke 1 pt] + run(&mut app, "stroke.set", json!({"item": 1, "weight": 6})); + let six = Unit::Points.format(6.0); + let texts = frame_texts(&mut app, stroke::show); + assert!(!texts.contains(&six), "top stroke shown: {texts:?}"); + // Clicking the lower stroke row in the Appearance panel targets it. + let ctx = egui::Context::default(); + frame_in(&ctx, &mut app, appearance::show); + appearance::select_row(&mut app, &ctx, appearance::Sel::Item(1)); + assert_eq!(app.session.appearance_item(), Some(1)); + assert!(frame_texts(&mut app, stroke::show).contains(&six)); + let (_, stroke) = current_paints(&app); + assert_eq!(stroke, current_stroke(&app).unwrap().paint); + // The Appearance panel and the proxies draw with the row active; the object row clears it. + frame_in(&ctx, &mut app, |app, ui| { + appearance::show(app, ui); + crate::toolbar::show(app, ui); + transparency::show(app, ui); + properties::show(app, ui); + }); + appearance::select_row(&mut app, &ctx, appearance::Sel::None); + assert_eq!(app.session.appearance_item(), None); + assert!(!frame_texts(&mut app, stroke::show).contains(&six)); +} + +#[test] +fn transparency_panel_reads_the_active_item() { + let mut app = app_with_rect(); + run(&mut app, "transparency.set", json!({"item": 0, "opacity": 40})); + assert_eq!(current_transparency(&app).unwrap().0, 1.0); + run(&mut app, "appearance.setActiveItem", json!({"index": 0})); + assert!((current_transparency(&app).unwrap().0 - 0.4).abs() < 1e-6); + assert!(frame_texts(&mut app, transparency::show).iter().any(|t| t.starts_with("40"))); +} diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 63f24b6c..f04d9019 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -6,7 +6,7 @@ use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; use vectorcraft_color::{BlendMode, Paint}; -use super::{current_paints, first_selected, live_run, pstate, selection_len, set_pstate}; +use super::{current_paints, current_transparency, first_selected, live_run, pstate, selection_len, set_pstate}; use crate::theme::Tokens; use crate::widgets::{self, Live, menu_item}; use crate::{VectorcraftApp, icons}; @@ -20,8 +20,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { None => first_selected(app), }; let has = n.is_some(); - let (blend, op, isolate, knockout) = - n.as_ref().map(|n| (n.blend, n.opacity, n.isolate, n.knockout)).unwrap_or((BlendMode::Normal, 1.0, false, false)); + let (isolate, knockout) = n.as_ref().map_or((false, false), |n| (n.isolate, n.knockout)); + // Opacity and blend of the Appearance panel's active item, else of the object. + let (op, blend) = match editing { + None => current_transparency(app), + Some(_) => n.as_ref().map(|n| (n.opacity, n.blend)), + } + .unwrap_or((1.0, BlendMode::Normal)); ui.horizontal(|ui| { let labels: Vec<&str> = BlendMode::ALL.iter().map(|b| b.label()).collect(); ui.add_enabled_ui(has, |ui| { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index d71475c0..ec8b3b6e 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -178,10 +178,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) { - let (fill, stroke) = match app.session.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id))) { - Some(n) if !n.is_container() => (n.appearance.fill_paint(), n.appearance.stroke_paint()), - _ => (app.session.paint.fill.clone(), app.session.paint.stroke.clone()), - }; + let (fill, stroke) = crate::panels::current_paints(app); ui.vertical_centered(|ui| { let (f, s, swap, def) = widgets::fill_stroke_proxy(ui, &fill, &stroke, app.session.fill_active, 36.0); if f { diff --git a/docs/mcp.md b/docs/mcp.md index 50b51256..e2007935 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -68,6 +68,13 @@ or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name" | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | | `undo` / `redo` | `{}` | | +Appearance stacks: an object can carry several fills and strokes (`appearance.addFill`, `appearance.addStroke`), indexed +in paint order (0 is painted first, the bottom row of the Appearance panel). `paint.setFill`, `paint.setStroke`, +`stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and `transparency.set` take +`item` to edit one of them; `run_command appearance.setActiveItem {"index": n}` makes that row the target of later +calls that omit `item` (as clicking the row in the Appearance panel does) until the selection changes. +`inspect_document` reports it as `paint.appearanceItem`. + Errors (unknown tool, bad arguments, a disabled or failing command) come back as a normal result with `isError: true` and a message. The model can read the message and retry. From 543d09985db3fe3f1efded9c6100374710acfe92 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:15:40 +0700 Subject: [PATCH 2/6] M3.22: effects apply to the active fill or stroke and item effect rows are editable effect.apply, effect.remove, effect.setParams and the new effect.duplicate take item (omitted: the Appearance panel's active item, else the object; null: the object's own effects), so the fx button, the Effect menu, Apply Last Effect and the effect dialogs (effect.dialog {item}) put the effect on the selected fill or stroke. effect.list reports each object's item effects and the active item. appearance.addEffect is an alias of effect.apply. In the Appearance panel, item effect rows sit under their fill or stroke with working eyes and inline editors, and can be selected for Duplicate and Delete; a selected effect row is remembered per object. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/appearance.rs | 35 ++-- crates/engine/src/cmd/effectcmd.rs | 182 ++++++++++-------- crates/engine/src/tests_appearance.rs | 51 +++++ crates/ui-egui/src/dialogs/effect.rs | 23 ++- crates/ui-egui/src/dialogs/tests.rs | 13 ++ crates/ui-egui/src/menus.rs | 10 +- crates/ui-egui/src/panels/appearance.rs | 159 +++++++++------ crates/ui-egui/src/panels/tests_appearance.rs | 56 +++++- docs/mcp.md | 5 +- 9 files changed, 366 insertions(+), 168 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 7c19ef8b..6460199f 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -50,7 +50,15 @@ pub fn specs() -> Vec { set_item ), cmd!("appearance.removeItem", "Remove Item", [], None, "{index, ids?}", has_selection, remove_item), - cmd!("appearance.addEffect", "Add Effect", ["Effect"], None, "{effect: id, params?: {…}} append a live effect", has_selection, add_effect), + cmd!( + "appearance.addEffect", + "Add Effect", + [], + None, + "same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?} → {ids, index, item}", + has_selection, + super::effectcmd::apply + ), cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index, ids?}", has_selection, duplicate_item), cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to, ids?} (paint-order indices)", has_selection, move_item), cmd!( @@ -149,6 +157,16 @@ impl ItemTarget { }, } } + + /// The item whose own effects an effect edit changes; `None` = the object-level effects. + pub(crate) fn effects_item(self, ap: &Appearance, cmd: &str) -> Result> { + match self { + ItemTarget::Top => Ok(None), + ItemTarget::Item { index, .. } if index < ap.items.len() => Ok(Some(index)), + ItemTarget::Item { index, explicit: true } => Err(bad(cmd, format!("no appearance item {index}"))), + ItemTarget::Item { explicit: false, .. } => Ok(None), + } + } } /// Objects whose own appearance stack a command edits: `ids`/`id`, else the selected objects @@ -331,21 +349,6 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { ok() } -fn add_effect(s: &mut Session, p: &Value) -> Result { - let id = str_param(p, "effect").ok_or_else(|| bad("appearance.addEffect", "missing effect id"))?.to_string(); - let params = p.get("params").cloned().unwrap_or(json!({})); - let ids = targets(s, p)?; - s.edit("Add Effect", |d, _| { - for nid in &ids { - if let Some(n) = d.node_mut(*nid) { - n.appearance.effects.push(vectorcraft_doc::Effect { id: id.clone(), params: params.clone(), visible: true }); - } - } - Ok(()) - })?; - ok() -} - fn duplicate_item(s: &mut Session, p: &Value) -> Result { let idx = index_param(p, "index", "appearance.duplicateItem")?; let ids = appearance_targets(s, p)?; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 39b3c271..dd712cf9 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -6,11 +6,11 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Effect, Node, NodeKind}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; -use super::edit::selected_roots; +use super::appearance::{ItemTarget, appearance_targets, index_param, item_target}; use super::*; pub fn specs() -> Vec { @@ -20,17 +20,25 @@ pub fn specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), ids?: [..]} append a live effect to each selected object's appearance → {ids, index}", + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..]} append a live effect to each selected object's appearance → {ids, index, item}", has_selection, apply ), - cmd!(query "effect.list", "Effects", [], None, "{} → {catalog: [{id, label, menu, params, defaults, raster}], applied: [{id, effects}]} for the selection", always, list), + cmd!( + query "effect.list", + "Effects", + [], + None, + "{} → {catalog: [{id, label, menu, params, defaults, raster}], applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection", + always, + list + ), cmd!( "effect.remove", "Remove Effect", [], None, - "{index: int (position in the object's effect list), ids?: [..]} → {ids}", + "{index: int (position in the effect list), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?: [..]} → {ids}", has_selection, remove ), @@ -39,7 +47,7 @@ pub fn specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), visible?: bool, ids?: [..]} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?: [..]} → {ids}", has_selection, set_params ), @@ -52,21 +60,52 @@ pub fn specs() -> Vec { has_selection, expand_appearance ), + cmd!( + "effect.duplicate", + "Duplicate Effect", + [], + None, + "{index: int, item?: appearance item index|null (as effect.remove), ids?: [..]} insert a copy of the effect right after it → {ids}", + has_selection, + duplicate + ), ] } -fn target_roots(s: &Session, p: &Value) -> Result> { - if p.get("ids").is_some() || p.get("id").is_some() { - return targets(s, p); - } - selected_roots(s) -} - fn ids_json(ids: &[NodeId]) -> Value { json!(ids.iter().map(|i| i.0).collect::>()) } -fn apply(s: &mut Session, p: &Value) -> Result { +/// One undo step (`label`) running `f` on the effect list `item` addresses on each target (an +/// appearance item's own effects, or the object's). Errors with `none` when `f` changed no object; +/// returns the changed ids. +fn edit_effects( + s: &mut Session, + p: &Value, + item: ItemTarget, + cmd: &str, + label: &str, + none: &str, + mut f: impl FnMut(&mut Vec) -> bool, +) -> Result> { + let roots = appearance_targets(s, p)?; + s.edit(label, |d, _| { + let mut done = vec![]; + for id in &roots { + let Some(n) = d.node_mut(*id) else { continue }; + let index = item.effects_item(&n.appearance, cmd)?; + if n.appearance.effects_mut(index).is_some_and(&mut f) { + done.push(*id); + } + } + if done.is_empty() { + return Err(EngineError::Other(none.into())); + } + Ok(done) + }) +} + +pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.apply"; let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?; let params = p.get("params").cloned().unwrap_or(Value::Null); @@ -74,26 +113,18 @@ fn apply(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`params` must be an object")); } let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?; - let roots = target_roots(s, p)?; let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default(); - let (ids, index) = s.edit(&label, |d, _| { - let mut done = vec![]; - let mut index = 0; - for id in &roots { - let Some(n) = d.node_mut(*id) else { continue }; - if n.is_layer() { - continue; - } - n.appearance.effects.push(effect.clone()); - index = n.appearance.effects.len() - 1; - done.push(*id); - } - if done.is_empty() { - return Err(EngineError::Other("Apply Effect: select objects".into())); - } - Ok((done, index)) + let item = item_target(s, p, C)?; + let mut index = 0; + let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| { + fx.push(effect.clone()); + index = fx.len() - 1; + true })?; - Ok(json!({ "ids": ids_json(&ids), "index": index })) + // The item the effect landed on in the first object (the active item applies where it fits). + let first = ids.first().and_then(|id| s.doc().ok()?.doc.node(*id)); + let landed = first.and_then(|n| item.effects_item(&n.appearance, C).ok().flatten()); + Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed })) } fn list(s: &mut Session, p: &Value) -> Result { @@ -103,73 +134,72 @@ fn list(s: &mut Session, p: &Value) -> Result { .collect(); let mut applied = vec![]; if s.active().is_some() { - for id in target_roots(s, p)? { + let fx = |e: &[Effect]| serde_json::to_value(e).unwrap_or(Value::Null); + for id in appearance_targets(s, p)? { if let Some(n) = s.doc()?.doc.node(id) { - applied.push(json!({"id": id.0, "effects": serde_json::to_value(&n.appearance.effects).unwrap_or(Value::Null)})); + let items: Vec = n + .appearance + .items + .iter() + .enumerate() + .map(|(i, it)| json!({"index": i, "kind": it.kind_name(), "effects": fx(it.effects())})) + .collect(); + applied.push(json!({"id": id.0, "effects": fx(&n.appearance.effects), "items": items})); } } } - Ok(json!({ "catalog": catalog, "applied": applied })) -} - -fn index_param(p: &Value, cmd: &str) -> Result { - p.get("index").and_then(Value::as_u64).map(|i| i as usize).ok_or_else(|| bad(cmd, "missing integer `index`")) + Ok(json!({ "catalog": catalog, "applied": applied, "activeItem": s.appearance_item() })) } fn remove(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.remove"; - let index = index_param(p, C)?; - let roots = target_roots(s, p)?; - let ids = s.edit("Remove Effect", |d, _| { - let mut done = vec![]; - for id in &roots { - let Some(n) = d.node_mut(*id) else { continue }; - if index < n.appearance.effects.len() { - n.appearance.effects.remove(index); - done.push(*id); - } + let index = index_param(p, "index", C)?; + let item = item_target(s, p, C)?; + let ids = edit_effects(s, p, item, C, "Remove Effect", &format!("{C}: no effect at index {index}"), |fx| { + (index < fx.len()).then(|| fx.remove(index)).is_some() + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +fn duplicate(s: &mut Session, p: &Value) -> Result { + const C: &str = "effect.duplicate"; + let index = index_param(p, "index", C)?; + let item = item_target(s, p, C)?; + let ids = edit_effects(s, p, item, C, "Duplicate Effect", &format!("{C}: no effect at index {index}"), |fx| match fx.get(index).cloned() { + Some(e) => { + fx.insert(index + 1, e); + true } - if done.is_empty() { - return Err(bad(C, format!("no effect at index {index}"))); - } - Ok(done) + None => false, })?; Ok(json!({ "ids": ids_json(&ids) })) } fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; - let index = index_param(p, C)?; + let index = index_param(p, "index", C)?; let params = p.get("params").cloned().unwrap_or(Value::Null); if !params.is_null() && !params.is_object() { return Err(bad(C, "`params` must be an object")); } let visible = p.get("visible").and_then(Value::as_bool); - let roots = target_roots(s, p)?; - let ids = s.edit("Effect Options", |d, _| { - let mut done = vec![]; - for id in &roots { - let Some(n) = d.node_mut(*id) else { continue }; - let Some(e) = n.appearance.effects.get_mut(index) else { continue }; - if let (Value::Object(new), cur) = (¶ms, &mut e.params) { - if !cur.is_object() { - *cur = effects::merged_params(&e.id, &Value::Null); - } - if let Value::Object(m) = cur { - for (k, v) in new { - m.insert(k.clone(), v.clone()); - } + let item = item_target(s, p, C)?; + let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| { + let Some(e) = fx.get_mut(index) else { return false }; + if let (Value::Object(new), cur) = (¶ms, &mut e.params) { + if !cur.is_object() { + *cur = effects::merged_params(&e.id, &Value::Null); + } + if let Value::Object(m) = cur { + for (k, v) in new { + m.insert(k.clone(), v.clone()); } } - if let Some(v) = visible { - e.visible = v; - } - done.push(*id); } - if done.is_empty() { - return Err(bad(C, format!("no effect at index {index}"))); + if let Some(v) = visible { + e.visible = v; } - Ok(done) + true })?; Ok(json!({ "ids": ids_json(&ids) })) } @@ -242,7 +272,7 @@ fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec Result { - let roots = target_roots(s, p)?; + let roots = appearance_targets(s, p)?; let ids = s.edit("Expand Appearance", |d, _| { let mut out = vec![]; for id in &roots { diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs index 7a52854f..cad8e7ac 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests_appearance.rs @@ -194,3 +194,54 @@ fn text_items_take_item_paint() { let vectorcraft_doc::NodeKind::Text(tx) = &n.kind else { panic!() }; assert_ne!(tx.first_style().fill, Paint::solid(Color::from_hex("#ff0000").unwrap())); } + +#[test] +fn effects_land_on_the_targeted_item() { + let mut s = session(); + let id = rect(&mut s, 100.0); + let r = run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1})); + assert_eq!((r["index"].clone(), r["item"].clone()), (json!(0), json!(1))); + let n = node(&s, id); + assert_eq!(stroke(&n, 1).effects.len(), 1); + assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty()); + run(&mut s, "edit.undo", json!({})); + assert!(stroke(&node(&s, id), 1).effects.is_empty()); + assert!(s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": 9})).is_err()); + assert!(s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": -1})).is_err()); + // The active item takes the Effect menu's effects (and its alias). + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "effect.apply", json!({"effect": "distort.twist"})); + run(&mut s, "appearance.addEffect", json!({"effect": "stylize.roundCorners"})); + let n = node(&s, id); + assert_eq!(n.appearance.items[0].effects().iter().map(|e| e.id.as_str()).collect::>(), ["distort.twist", "stylize.roundCorners"]); + let l = run(&mut s, "effect.list", json!({})); + assert_eq!(l["activeItem"], 0); + assert_eq!(l["applied"][0]["items"][0]["kind"], "fill"); + assert_eq!(l["applied"][0]["items"][0]["effects"].as_array().unwrap().len(), 2); + // Sub-row eye, duplicate and delete address the item's list. + run(&mut s, "effect.setParams", json!({"index": 1, "visible": false})); + run(&mut s, "effect.duplicate", json!({"index": 0})); + run(&mut s, "effect.remove", json!({"index": 2})); + let fx = node(&s, id).appearance.items[0].effects().clone(); + assert_eq!(fx.iter().map(|e| (e.id.as_str(), e.visible)).collect::>(), [("distort.twist", true), ("distort.twist", true)]); + assert!(s.execute("effect.remove", &json!({"index": 5})).is_err()); + // `item: null` addresses the object's own effects. + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null})); + assert_eq!(node(&s, id).appearance.effects.len(), 1); +} + +#[test] +fn per_fill_offset_path_renders() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let render = |s: &Session| { + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()) + }; + assert_eq!(render(&s).pixel(90, 150)[3], 0); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 20}, "item": 0})); + assert_eq!(node(&s, id).appearance.items[0].effects().len(), 1); + let px = render(&s).pixel(90, 150); + assert!(px[0] > 200 && px[3] > 200, "{px:?}"); +} diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 99fe895d..dbd4e67f 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -1,5 +1,6 @@ //! Live effect dialogs (Effect menu): the effect's parameters (`__effect`, headed `__label`) with a -//! live Preview that OK keeps as one undo step and Cancel rolls back. +//! live Preview that OK keeps as one undo step and Cancel rolls back. `__item`, when present, is +//! the appearance item the effect goes to (else the Appearance panel's active item applies). use serde_json::{Value, json}; @@ -18,24 +19,32 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if pv && (changed || pv_changed || !app.session.in_interaction()) { let label = d.str("__label"); let _ = app.session.begin_interaction(&label); - let _ = app.session.preview("effect.apply", &json!({"effect": d.str("__effect"), "params": form::params(d)})); + let _ = app.session.preview("effect.apply", &apply_params(d)); } else if !pv && pv_changed { let _ = app.session.cancel_interaction(); } false } +/// `effect.apply` params for the dialog's effect, values and target item. +fn apply_params(d: &Dialog) -> Value { + let mut p = json!({"effect": d.str("__effect"), "params": form::params(d)}); + if let Some(item) = d.fields.get("__item") { + p["item"] = item.clone(); + } + p +} + fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let effect = d.str("__effect"); - let params = form::params(d); + let p = apply_params(d); let r = if app.session.in_interaction() { // Live preview already applied: keep it (the interaction commits as one undo step). - let _ = app.session.preview("effect.apply", &json!({"effect": effect, "params": params})); + let _ = app.session.preview("effect.apply", &p); app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()) } else { - app.run("effect.apply", json!({"effect": effect, "params": params})) + app.run("effect.apply", p.clone()) }; - app.last_effect = Some((effect, params)); + app.last_effect = Some((d.str("__effect"), p["params"].clone())); app.ui.dialog = None; r } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 5c42eb2a..0b459ef8 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -163,3 +163,16 @@ fn effect_preview_is_kept_as_one_undo_step() { app.run("edit.undo", json!({})).unwrap(); assert_eq!(app.session.doc().unwrap().doc, before); } + +#[test] +fn effect_dialog_applies_to_its_appearance_item() { + let mut app = app(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": "distort.roughen", "item": 1})).unwrap(); + frame(&mut app, Default::default()); + confirm(&mut app).unwrap(); + let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + assert_eq!(n.appearance.items[1].effects().len(), 1, "the effect goes to the stroke"); + assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty()); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7c61cf88..3f8048e9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -152,7 +152,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.newWindow", "New Window", "", "{}"), ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), ("tool.setOption", "Tool Option", "", "{key, value}"), - ("effect.dialog", "Effect…", "", "{effect: id} open the effect's dialog with live preview"), + ( + "effect.dialog", + "Effect…", + "", + "{effect: id, item?: appearance item index|null (default: the Appearance panel's active item)} open the effect's dialog with live preview", + ), ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), ("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), @@ -439,6 +444,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Sel { - match app.session.appearance_item() { - Some(i) => Sel::Item(i), - None => match pstate(ctx, "ap-sel") { - Sel::Item(_) => Sel::None, - other => other, - }, +impl Sel { + /// The fill/stroke row this selection makes the active item. + fn item(self) -> Option { + match self { + Sel::Item(i) | Sel::ItemEffect(i, _) => Some(i), + Sel::None | Sel::Effect(_) => None, + } + } + /// `{item, index}` of a selected effect row (`item: null` for the object's own effects). + fn effect(self) -> Option { + match self { + Sel::Effect(k) => Some(json!({"item": null, "index": k})), + Sel::ItemEffect(i, k) => Some(json!({"item": i, "index": k})), + Sel::None | Sel::Item(_) => None, + } } } -/// Select a row: fill/stroke rows become the active item that paint edits target. +/// The selected row of `node` (the first selected object): a selected effect row while that +/// effect still exists and agrees with the engine's active item, else the active item's row. +fn current_sel(app: &VectorcraftApp, ctx: &egui::Context, node: Option<&Node>) -> Sel { + let Some(n) = node else { return Sel::None }; + let (owner, sel): (Option, Sel) = pstate(ctx, "ap-sel"); + let exists = |item: Option, k: usize| owner == Some(n.id) && n.appearance.effects_at(item).is_some_and(|fx| k < fx.len()); + match (app.session.appearance_item(), sel) { + (Some(i), Sel::ItemEffect(j, k)) if i == j && exists(Some(i), k) => sel, + (Some(i), _) => Sel::Item(i), + (None, Sel::Effect(k)) if exists(None, k) => sel, + (None, _) => Sel::None, + } +} + +/// Select a row; its fill/stroke becomes the active item that paint and effect edits target. pub(crate) fn select_row(app: &mut VectorcraftApp, ctx: &egui::Context, sel: Sel) { - let index = match sel { - Sel::Item(i) => json!(i), - _ => Value::Null, - }; - if app.session.appearance_item().map_or(Value::Null, |i| json!(i)) != index { - app.run("appearance.setActiveItem", json!({ "index": index })).ok(); + if app.session.appearance_item() != sel.item() { + app.run("appearance.setActiveItem", json!({ "index": sel.item() })).ok(); } - set_pstate(ctx, "ap-sel", if matches!(sel, Sel::Item(_)) { Sel::None } else { sel }); + let owner = app.session.active().and_then(|d| d.selection.objects.first().copied()); + set_pstate(ctx, "ap-sel", (owner, sel)); } fn catalog() -> &'static [(String, String, Vec)] { @@ -141,7 +163,7 @@ fn link(ui: &mut Ui, pos: egui::Pos2, id: impl std::hash::Hash + std::fmt::Debug pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let node = first_selected(app); - let sel = current_sel(app, ui.ctx()); + let sel = current_sel(app, ui.ctx(), node.as_ref()); let hide_thumb: bool = pstate(ui.ctx(), "ap-hide-thumb"); widgets::list_box(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; @@ -191,19 +213,20 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { let t = Tokens::get(ui.ctx()); - let items: Vec<(usize, AppearanceItem)> = n.appearance.items.iter().cloned().enumerate().rev().collect(); let mut drop: Option<(usize, usize)> = None; let mut stopped = false; let dragging: Option = pstate(ui.ctx(), "ap-drag"); let mut row_rects = vec![]; - for (i, it) in items { + for (i, it) in n.appearance.items.iter().enumerate().rev() { let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}")); let (r, resp) = row(ui, sel == Sel::Item(i)); row_rects.push((i, r)); - let (visible, paint, is_stroke, width, opacity, blend, effects) = match &it { - AppearanceItem::Fill(f) => (f.visible, f.paint.clone(), false, 0.0, f.opacity, f.blend, f.effects.clone()), - AppearanceItem::Stroke(s) => (s.visible, s.paint.clone(), true, s.width, s.opacity, s.blend, s.effects.clone()), + let visible = it.visible(); + let (paint, width) = match it { + AppearanceItem::Fill(f) => (&f.paint, 0.0), + AppearanceItem::Stroke(s) => (&s.paint, s.width), }; + let is_stroke = !it.is_fill(); if eye(ui, r, ("ap-eye", i), visible, true) { app.run("appearance.setItem", json!({"index": i, "visible": !visible})).ok(); } @@ -221,7 +244,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { } // Swatch with a swatches popup that sets this item's paint. let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0)); - chip(ui, cr, &paint); + chip(ui, cr, paint); let cresp = ui.interact(cr.expand(2.0), ui.id().with(("ap-chip", i)), Sense::click()).on_hover_text("Click to choose a swatch"); egui::Popup::menu(&cresp).show(|ui| { swatch_picker(app, ui, i); @@ -246,9 +269,9 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { stopped = true; } if open { - sub_row(app, ui, ("ap-sub-op", i), &format!("Opacity: {}", opacity_text(opacity, blend)), true, 1); - for (k, e) in effects.iter().enumerate() { - sub_row(app, ui, ("ap-sub-fx", i, k), &effect_label(&e.id), e.visible, 1); + sub_row(app, ui, ("ap-sub-op", i), &format!("Opacity: {}", opacity_text(it.opacity(), it.blend())), true, 1); + for (k, e) in it.effects().iter().enumerate() { + effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k)); } } } @@ -272,7 +295,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { } // Object-level effects. for (k, e) in n.appearance.effects.iter().enumerate() { - effect_row(app, ui, k, e, sel == Sel::Effect(k)); + effect_row(app, ui, None, k, e, sel == Sel::Effect(k)); } // Object opacity row. let (r, resp) = row(ui, false); @@ -303,36 +326,41 @@ fn sub_row(_app: &mut VectorcraftApp, ui: &mut Ui, id: impl std::hash::Hash + st text(ui, r.left_center() + vec2(EYE_W + 24.0 + depth as f32 * 12.0, 0.0), label, false); } -fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, k: usize, e: &Effect, selected: bool) { +/// An effect row: of the object (`item: None`) or, indented under it, of fill/stroke `item`. +fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usize, e: &Effect, selected: bool) { let t = Tokens::get(ui.ctx()); - let open: bool = pstate(ui.ctx(), &format!("ap-fx-open-{k}")); + let open_key = format!("ap-fx-open-{item:?}-{k}"); + let open: bool = pstate(ui.ctx(), &open_key); + let this = item.map_or(Sel::Effect(k), |i| Sel::ItemEffect(i, k)); let (r, resp) = row(ui, selected); - if eye(ui, r, ("ap-fx-eye", k), e.visible, true) { - app.run("effect.setParams", json!({"index": k, "visible": !e.visible})).ok(); + if eye(ui, r, ("ap-fx-eye", item, k), e.visible, true) { + app.run("effect.setParams", json!({"item": item, "index": k, "visible": !e.visible})).ok(); } - if chevron(ui, r, ("ap-fx-chev", k), open) { - set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open); + let indent = if item.is_some() { 12.0 } else { 0.0 }; + let r = Rect::from_min_max(pos2(r.left() + indent, r.top()), r.max); + if chevron(ui, r, ("ap-fx-chev", item, k), open) { + set_pstate(ui.ctx(), &open_key, !open); } let lx = r.left() + EYE_W + 24.0; - if link(ui, pos2(lx, r.center().y), ("ap-fx-link", k), &effect_label(&e.id)) { - set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open); - select_row(app, ui.ctx(), Sel::Effect(k)); + if link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &effect_label(&e.id)) { + set_pstate(ui.ctx(), &open_key, !open); + select_row(app, ui.ctx(), this); } icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon); if resp.clicked() { - select_row(app, ui.ctx(), Sel::Effect(k)); + select_row(app, ui.ctx(), this); } if resp.double_clicked() { - set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open); + set_pstate(ui.ctx(), &open_key, !open); } if open { - effect_editor(app, ui, k, e); + effect_editor(app, ui, item, k, e); } } /// Inline editor for an applied effect's parameters (numbers, booleans, strings, colours); /// edits go through `effect.setParams`. -fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, k: usize, e: &Effect) { +fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usize, e: &Effect) { let t = Tokens::get(ui.ctx()); let defaults = vectorcraft_effects::effect_catalog().into_iter().find(|c| c.id == e.id).map(|c| c.defaults).unwrap_or(Value::Null); let mut params = defaults.as_object().cloned().unwrap_or_default(); @@ -359,7 +387,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, k: usize, e: &Effect) { } Value::Number(n) => { let x = n.as_f64().unwrap_or(0.0); - if let Some(nx) = widgets::plain_field(ui, ("fx", k, key.as_str()), x, "", 2, 70.0) { + if let Some(nx) = widgets::plain_field(ui, ("fx", item, k, key.as_str()), x, "", 2, 70.0) { change = Some((key.clone(), json!(nx))); } } @@ -378,7 +406,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, k: usize, e: &Effect) { } }); if let Some((key, v)) = change { - app.run("effect.setParams", json!({"index": k, "params": {key: v}})).ok(); + app.run("effect.setParams", json!({"item": item, "index": k, "params": {key: v}})).ok(); } } @@ -442,11 +470,9 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel) if widgets::icon_button_enabled(ui, "dc-clear", "Clear Appearance", false, has, 24.0).clicked() { app.run("appearance.clear", json!({})).ok(); } - let can_dup = has && matches!(sel, Sel::Item(_)); - if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can_dup, 24.0).clicked() - && let Sel::Item(i) = sel - { - app.run("appearance.duplicateItem", json!({"index": i})).ok(); + let can_dup = has && sel != Sel::None; + if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can_dup, 24.0).clicked() { + duplicate_selected(app, sel); } let can_del = has && sel != Sel::None; if widgets::icon_button_enabled(ui, "trash-2", "Delete Selected Item", false, can_del, 24.0).clicked() { @@ -456,15 +482,24 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel) }); } -fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) { - // Removing the active item clears it in the engine. - let ok = match sel { - Sel::Item(i) => app.run("appearance.removeItem", json!({"index": i})).is_ok(), - Sel::Effect(k) => app.run("effect.remove", json!({"index": k})).is_ok(), - Sel::None => false, +fn duplicate_selected(app: &mut VectorcraftApp, sel: Sel) { + match (sel, sel.effect()) { + (Sel::Item(i), _) => app.run("appearance.duplicateItem", json!({"index": i})).ok(), + (_, Some(fx)) => app.run("effect.duplicate", fx).ok(), + _ => None, }; +} + +fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) { + let ok = match (sel, sel.effect()) { + (Sel::Item(i), _) => app.run("appearance.removeItem", json!({"index": i})).is_ok(), + (_, Some(fx)) => app.run("effect.remove", fx).is_ok(), + _ => false, + }; + // Removing the active item clears it in the engine; a removed item effect leaves its item + // selected. if ok { - set_pstate(ui.ctx(), "ap-sel", Sel::None); + select_row(app, ui.ctx(), if let Sel::ItemEffect(i, _) = sel { Sel::Item(i) } else { Sel::None }); } } @@ -493,7 +528,7 @@ fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let node = first_selected(app); let has = node.is_some(); - let sel = current_sel(app, ui.ctx()); + let sel = current_sel(app, ui.ctx(), node.as_ref()); if menu_item(ui, "Add New Fill", has, false) { app.run("appearance.addFill", json!({})).ok(); } @@ -501,10 +536,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("appearance.addStroke", json!({})).ok(); } ui.separator(); - if menu_item(ui, "Duplicate Item", has && matches!(sel, Sel::Item(_)), false) - && let Sel::Item(i) = sel - { - app.run("appearance.duplicateItem", json!({"index": i})).ok(); + if menu_item(ui, "Duplicate Item", has && sel != Sel::None, false) { + duplicate_selected(app, sel); } if menu_item(ui, "Remove Item", has && sel != Sel::None, false) { delete_selected(app, ui, sel); diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index 186b7bef..1ffb59d2 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -24,12 +24,22 @@ fn frame_texts(app: &mut VectorcraftApp, f: impl FnMut(&mut VectorcraftApp, &mut frame_in(&ctx, app, f) } -fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> Vec { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)); +fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> Vec { + frame_events(ctx, app, vec![], f).into_iter().map(|(s, _)| s).collect() +} + +/// One headless frame with input `events`: every text drawn, with its screen rect. +fn frame_events( + ctx: &egui::Context, + app: &mut VectorcraftApp, + events: Vec, + mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui), +) -> Vec<(String, egui::Rect)> { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| f(app, ui)); out.textures_delta.clear(); - fn collect(s: &egui::Shape, out: &mut Vec) { + fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { match s { - egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), _ => {} } @@ -41,6 +51,19 @@ fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, mut f: impl FnMut(&mu texts } +/// Click at `pos` over three frames of `f` (hover, press, release). +fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) { + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for e in [egui::Event::PointerMoved(pos), button(true), button(false)] { + frame_events(ctx, app, vec![e], &mut f); + } +} + +/// The rect of the first drawn text equal to `s`. +fn text_rect(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect { + texts.iter().find(|(t, _)| t == s).unwrap_or_else(|| panic!("`{s}` not drawn: {texts:?}")).1 +} + #[test] fn stroke_panel_shows_the_active_rows_weight() { let mut app = app_with_rect(); @@ -78,3 +101,28 @@ fn transparency_panel_reads_the_active_item() { assert!((current_transparency(&app).unwrap().0 - 0.4).abs() < 1e-6); assert!(frame_texts(&mut app, transparency::show).iter().any(|t| t.starts_with("40"))); } + +#[test] +fn item_effect_rows_toggle_their_eye() { + let mut app = app_with_rect(); + run(&mut app, "effect.apply", json!({"effect": "distort.roughen", "item": 1})); + let ctx = egui::Context::default(); + // The stroke row's disclosure is open: its Opacity sub-row and its effect show under it. + super::set_pstate(&ctx, "ap-open-1", true); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + let label = text_rect(&texts, "Roughen"); + // The eye sits in the first column of the effect's row (the label is indented one level). + let eye = egui::pos2(label.left() - 12.0 - 24.0 - appearance::EYE_W / 2.0, label.center().y); + click(&ctx, &mut app, eye, appearance::show); + let visible = |app: &VectorcraftApp| current_stroke_effects(app)[0].visible; + assert!(!visible(&app)); + click(&ctx, &mut app, eye, appearance::show); + assert!(visible(&app)); + // Clicking the effect's row selects it and makes its stroke the active item. + click(&ctx, &mut app, egui::pos2(label.right() + 40.0, label.center().y), appearance::show); + assert_eq!(app.session.appearance_item(), Some(1)); +} + +fn current_stroke_effects(app: &VectorcraftApp) -> Vec { + first_selected(app).unwrap().appearance.items[1].effects().clone() +} diff --git a/docs/mcp.md b/docs/mcp.md index e2007935..858947fd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -73,7 +73,10 @@ in paint order (0 is painted first, the bottom row of the Appearance panel). `pa `stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and `transparency.set` take `item` to edit one of them; `run_command appearance.setActiveItem {"index": n}` makes that row the target of later calls that omit `item` (as clicking the row in the Appearance panel does) until the selection changes. -`inspect_document` reports it as `paint.appearanceItem`. +`inspect_document` reports it as `paint.appearanceItem`. Live effects take the same `item` to apply to one fill or +stroke instead of the whole object: `run_command effect.apply {"effect": "path.offsetPath", "item": 0}`, and +`effect.remove`, `effect.setParams` (`visible` toggles one) and `effect.duplicate` address that item's effects; +`apply_effect` uses the active item. `effect.list` reports each object's item effects under `applied[].items`. Errors (unknown tool, bad arguments, a disabled or failing command) come back as a normal result with `isError: true` and a message. The model can read the message and retry. From b6b87ee9b6124c53cff0a58c1bf24532f5a9756f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:21:28 +0700 Subject: [PATCH 3/6] M3.23: per-item Opacity popups and one grouped blend-mode widget Clicking an Opacity row in the Appearance panel (a fill's or stroke's, or the object's) opens a popup with the same blend-mode dropdown, opacity field and slider as the Transparency panel (widgets::opacity_blend), and edits that item through transparency.set. The blend-mode dropdown draws a separator between the BlendMode::group() groups, also for the blend-mode parameters of effect dialogs and inline effect editors. Fills and strokes of type objects composite with their own opacity and blend mode. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 6 + crates/engine/src/tests_appearance.rs | 22 +++ crates/render/src/lib.rs | 15 +- crates/ui-egui/src/dialogs/form.rs | 8 ++ crates/ui-egui/src/panels/appearance.rs | 53 +++++--- crates/ui-egui/src/panels/tests_appearance.rs | 31 +++++ crates/ui-egui/src/panels/transparency.rs | 39 ++---- crates/ui-egui/src/widgets.rs | 128 ++++++++++++++++-- 8 files changed, 235 insertions(+), 67 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 4ed6f749..fb845a8e 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -231,6 +231,12 @@ impl AppearanceItem { pub fn kind_name(&self) -> &'static str { if self.is_fill() { "fill" } else { "stroke" } } + pub fn paint(&self) -> &Paint { + match self { + AppearanceItem::Fill(f) => &f.paint, + AppearanceItem::Stroke(s) => &s.paint, + } + } pub fn visible(&self) -> bool { match self { AppearanceItem::Fill(f) => f.visible, diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs index cad8e7ac..fb39cdfd 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests_appearance.rs @@ -245,3 +245,25 @@ fn per_fill_offset_path_renders() { let px = render(&s).pixel(90, 150); assert!(px[0] > 200 && px[3] > 200, "{px:?}"); } + +#[test] +fn text_fill_items_composite_with_their_opacity() { + let mut s = session(); + let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 200, "text": "HIM", "size": 150}))["id"].as_u64().unwrap()); + run(&mut s, "select.set", json!({"ids": [t.0]})); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#ff0000"})); + let render = |s: &Session| { + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()) + }; + let opaque = render(&s); + // A pixel the red fill covers fully (over the black character fill). + let (x, y) = (0..400u32).flat_map(|y| (0..400u32).map(move |x| (x, y))).find(|&(x, y)| opaque.pixel(x, y) == [255, 0, 0, 255]).expect("red text"); + run(&mut s, "appearance.setItem", json!({"index": 0, "opacity": 0.5, "blend": "multiply"})); + let px = render(&s).pixel(x, y); + // Half of red multiplied over black: black. + assert!(px[0] < 20 && px[3] == 255, "{px:?}"); + run(&mut s, "appearance.setItem", json!({"index": 0, "blend": "normal"})); + let px = render(&s).pixel(x, y); + assert!((110..=145).contains(&px[0]) && px[1] < 10, "{px:?}"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 10cfadc6..87458127 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -798,19 +798,28 @@ impl Renderer { if !n.appearance.items.is_empty() { let mut all = g.all.clone(); all.apply_affine(t.xf); - for item in &n.appearance.items { + for item in n.appearance.items.iter().filter(|i| i.visible() && !i.paint().is_none()) { + // Each item composites with its own opacity and blend mode. + let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None); + } ctx.set_transform(f.view); match item { - AppearanceItem::Fill(fl) if fl.visible && paint::set_paint(ctx, &fl.paint, tb, f.doc) => { + AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => { ctx.set_fill_rule(peniko::Fill::NonZero); ctx.fill_path(&all); } - AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && paint::set_paint(ctx, &st.paint, tb, f.doc) => { + AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, tb, f.doc) => { ctx.set_stroke(kurbo::Stroke::new(st.width)); ctx.stroke_path(&all); } _ => {} } + if layered { + ctx.pop_layer(); + } } } } diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index d30b461a..106a912a 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -59,6 +59,14 @@ pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect(); for (k, v) in keys { ui.label(egui::RichText::new(humanize(&k)).color(t.text)); + if let Some(cur) = crate::widgets::blend_param(&k, &v) { + if let Some(m) = crate::widgets::blend_param_dropdown(ui, ("fx-blend", &k), cur) { + d.fields.insert(k, m); + changed = true; + } + ui.end_row(); + continue; + } match v { Value::Number(n) => { let mut x = n.as_f64().unwrap_or(0.0); diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 900e93da..afa6ac34 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -9,9 +9,9 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId}; -use super::{first_selected, pstate, set_pstate}; +use super::{first_selected, live_run, pstate, set_pstate}; use crate::theme::Tokens; -use crate::widgets::{self, menu_item}; +use crate::widgets::{self, TransparencyEdit, menu_item}; use crate::{VectorcraftApp, icons}; const ROW: f32 = 30.0; @@ -221,11 +221,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}")); let (r, resp) = row(ui, sel == Sel::Item(i)); row_rects.push((i, r)); - let visible = it.visible(); - let (paint, width) = match it { - AppearanceItem::Fill(f) => (&f.paint, 0.0), - AppearanceItem::Stroke(s) => (&s.paint, s.width), - }; + let (visible, paint) = (it.visible(), it.paint()); let is_stroke = !it.is_fill(); if eye(ui, r, ("ap-eye", i), visible, true) { app.run("appearance.setItem", json!({"index": i, "visible": !visible})).ok(); @@ -249,10 +245,10 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { egui::Popup::menu(&cresp).show(|ui| { swatch_picker(app, ui, i); }); - if is_stroke { + if let AppearanceItem::Stroke(st) = it { let fr = Rect::from_min_size(pos2(r.left() + EYE_W + 104.0, r.center().y - 12.0), vec2(56.0, 24.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(fr).layout(egui::Layout::left_to_right(egui::Align::Center))); - if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(width), vectorcraft_doc::Unit::Points, 56.0) { + if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(st.width), vectorcraft_doc::Unit::Points, 56.0) { app.run("appearance.setItem", json!({"index": i, "weight": w})).ok(); } } @@ -269,7 +265,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { stopped = true; } if open { - sub_row(app, ui, ("ap-sub-op", i), &format!("Opacity: {}", opacity_text(it.opacity(), it.blend())), true, 1); + opacity_row(app, ui, Some(i), it.opacity(), it.blend()); for (k, e) in it.effects().iter().enumerate() { effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k)); } @@ -297,14 +293,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { for (k, e) in n.appearance.effects.iter().enumerate() { effect_row(app, ui, None, k, e, sel == Sel::Effect(k)); } - // Object opacity row. - let (r, resp) = row(ui, false); - eye(ui, r, "ap-op-eye", true, false); - let lx = r.left() + EYE_W + 24.0; - if link(ui, pos2(lx, r.center().y), "ap-op-link", "Opacity:") || resp.double_clicked() { - app.ui.open_panel = Some("transparency".into()); - } - text(ui, pos2(lx + 56.0, r.center().y), &opacity_text(n.opacity, n.blend), false); + opacity_row(app, ui, None, n.opacity, n.blend); if let Some((from, to_row)) = drop && from != to_row { @@ -320,10 +309,24 @@ fn target_index(rows: &[(usize, Rect)], y: f32) -> usize { }) } -fn sub_row(_app: &mut VectorcraftApp, ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug + Copy, label: &str, visible: bool, depth: usize) { - let (r, _) = row(ui, false); - eye(ui, r, (id, "eye"), visible, false); - text(ui, r.left_center() + vec2(EYE_W + 24.0 + depth as f32 * 12.0, 0.0), label, false); +/// The Opacity row of the object (`item: None`) or, indented, of fill/stroke `item`. Clicking it +/// opens a popup with the shared opacity and blend controls (`transparency.set` on that item). +fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, opacity: f32, blend: BlendMode) { + let (r, resp) = row(ui, false); + eye(ui, r, ("ap-op-eye", item), true, false); + let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 }; + let clicked = link(ui, pos2(lx, r.center().y), ("ap-op-link", item), "Opacity:") || resp.clicked(); + text(ui, pos2(lx + 56.0, r.center().y), &opacity_text(opacity, blend), false); + egui::Popup::menu(&resp) + .open_memory(clicked.then_some(egui::SetOpenCommand::Toggle)) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| match widgets::opacity_blend(ui, ("ap-op", item), opacity, blend, true) { + Some(TransparencyEdit::Blend(b)) => { + app.run("transparency.set", json!({"item": item, "blend": b.label()})).ok(); + } + Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase), + None => {} + }); } /// An effect row: of the object (`item: None`) or, indented under it, of fill/stroke `item`. @@ -379,6 +382,12 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: ui.horizontal(|ui| { let label = humanize(key); ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(label).size(11.5).color(t.text)).truncate()); + if let Some(cur) = widgets::blend_param(key, v) { + if let Some(m) = widgets::blend_param_dropdown(ui, ("fx-blend", item, k, key.as_str()), cur) { + change = Some((key.clone(), m)); + } + return; + } match v { Value::Bool(b) => { if widgets::check(ui, "", *b, true) { diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index 1ffb59d2..a49bfcad 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -126,3 +126,34 @@ fn item_effect_rows_toggle_their_eye() { fn current_stroke_effects(app: &VectorcraftApp) -> Vec { first_selected(app).unwrap().appearance.items[1].effects().clone() } + +#[test] +fn blend_separators_follow_the_groups() { + use vectorcraft_color::BlendMode; + let starts: Vec = + (0..BlendMode::ALL.len()).filter(|i| crate::widgets::blend_separator_before(*i)).map(|i| BlendMode::ALL[i]).collect(); + assert_eq!(starts, [BlendMode::Darken, BlendMode::Lighten, BlendMode::Overlay, BlendMode::Difference, BlendMode::Hue]); + assert!(starts.iter().all(|m| m.group() > 0) && !crate::widgets::blend_separator_before(BlendMode::ALL.len())); +} + +#[test] +fn item_opacity_popup_sets_the_items_opacity() { + let mut app = app_with_rect(); + let ctx = egui::Context::default(); + super::set_pstate(&ctx, "ap-open-1", true); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + // The stroke's Opacity sub-row (listed first; the object's own Opacity row is last). + let link = texts.iter().find(|(t, _)| t == "Opacity:").expect("item opacity row").1; + click(&ctx, &mut app, link.center(), appearance::show); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + assert!(texts.iter().any(|(t, _)| t == "Normal"), "the popup shows the blend mode: {texts:?}"); + // Type 50 into the popup's opacity field. + let field = text_rect(&texts, "100%"); + click(&ctx, &mut app, field.center(), appearance::show); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("50".into())], appearance::show); + frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, Default::default())], appearance::show); + let n = first_selected(&app).unwrap(); + assert_eq!(n.appearance.items[1].opacity(), 0.5); + assert_eq!((n.appearance.items[0].opacity(), n.opacity), (1.0, 1.0)); +} diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index f04d9019..c0a75dca 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -8,7 +8,7 @@ use vectorcraft_color::{BlendMode, Paint}; use super::{current_paints, current_transparency, first_selected, live_run, pstate, selection_len, set_pstate}; use crate::theme::Tokens; -use crate::widgets::{self, Live, menu_item}; +use crate::widgets::{self, TransparencyEdit, menu_item}; use crate::{VectorcraftApp, icons}; pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -27,36 +27,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { Some(_) => n.as_ref().map(|n| (n.opacity, n.blend)), } .unwrap_or((1.0, BlendMode::Normal)); - ui.horizontal(|ui| { - let labels: Vec<&str> = BlendMode::ALL.iter().map(|b| b.label()).collect(); - ui.add_enabled_ui(has, |ui| { - if let Some(i) = widgets::dropdown(ui, "tr-blend", blend.label(), &labels, 104.0) { - app.run("transparency.set", json!({"blend": labels[i]})).ok(); - } - }); - widgets::dim_label(ui, "Opacity:"); - ui.spacing_mut().item_spacing.x = 0.0; - ui.add_enabled_ui(has, |ui| { - if let Some(o) = widgets::plain_field(ui, "tr-op", op as f64 * 100.0, "%", 0, 50.0) { - app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok(); - } - }); - let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if has { Sense::click() } else { Sense::hover() }); - ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if has { t.icon } else { t.text_disabled }); - egui::Popup::menu(&resp).show(|ui| { - let mut o = op * 100.0; - let r = ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)); - let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) { - Live::Released - } else if r.changed() { - Live::Dragging - } else { - Live::Idle - }; - live_run(app, "Opacity", "transparency.set", json!({"opacity": o.round()}), phase); - }); - }); + match widgets::opacity_blend(ui, "tr", op, blend, has) { + Some(TransparencyEdit::Blend(b)) => { + app.run("transparency.set", json!({"blend": b.label()})).ok(); + } + Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({ "opacity": o }), phase), + None => {} + } widgets::divider(ui); // Thumbnails and the opacity-mask controls. let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs"); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 968b6e9b..f51fef63 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1,7 +1,7 @@ //! Shared widgets in Illustrator's panel style. Colours come from theme tokens. use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; -use vectorcraft_color::Paint; +use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::Unit; use crate::icons; @@ -269,19 +269,125 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: /// A compact dropdown. Returns the chosen index. pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option { - let t = Tokens::get(ui.ctx()); - let mut chosen = None; - let resp = egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(CornerRadius::same(3)).show(ui, |ui| { - egui::ComboBox::from_id_salt(ui.id().with(id)).selected_text(egui::RichText::new(current).size(12.0)).width(width - 4.0).show_ui(ui, |ui| { - for (i, o) in options.iter().enumerate() { - if ui.selectable_label(*o == current, *o).clicked() { - chosen = Some(i); - } + combo(ui, id, current, width, |ui| { + let mut chosen = None; + for (i, o) in options.iter().enumerate() { + if ui.selectable_label(*o == current, *o).clicked() { + chosen = Some(i); } + } + chosen + }) +} + +/// The recessed combo box of [`dropdown`] showing `current`; `list` draws the options and returns +/// the chosen one. +fn combo( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + current: &str, + width: f32, + list: impl FnOnce(&mut Ui) -> Option, +) -> Option { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, t.input_border)) + .corner_radius(CornerRadius::same(3)) + .show(ui, |ui| { + egui::ComboBox::from_id_salt(ui.id().with(id)) + .selected_text(egui::RichText::new(current).size(12.0)) + .width(width - 4.0) + .show_ui(ui, list) + .inner + .flatten() }) + .inner +} + +/// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a +/// [`BlendMode::group`] starts. +pub fn blend_separator_before(i: usize) -> bool { + i > 0 && i < BlendMode::ALL.len() && BlendMode::ALL[i].group() != BlendMode::ALL[i - 1].group() +} + +/// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion +/// | component modes). Returns the chosen mode. +pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: BlendMode, width: f32) -> Option { + combo(ui, id, current.label(), width, |ui| { + let mut chosen = None; + for (i, m) in BlendMode::ALL.into_iter().enumerate() { + if blend_separator_before(i) { + ui.separator(); + } + if ui.selectable_label(m == current, m.label()).clicked() { + chosen = Some(m); + } + } + chosen + }) +} + +/// The blend mode an effect parameter names (`"mode": "multiply"`), which editors show as a +/// [`blend_param_dropdown`]. +pub fn blend_param(key: &str, value: &serde_json::Value) -> Option { + value.as_str().filter(|_| key == "mode").and_then(BlendMode::parse) +} + +/// [`blend_dropdown`] for a blend-mode parameter; returns the chosen mode's parameter value. +pub fn blend_param_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: BlendMode) -> Option { + blend_dropdown(ui, id, current, 120.0).map(|m| serde_json::Value::String(m.label().to_ascii_lowercase())) +} + +/// An edit made with [`opacity_blend`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TransparencyEdit { + Blend(BlendMode), + /// Opacity in percent, with the slider's drag phase (`Released` for a typed value). + Opacity(f64, Live), +} + +/// The transparency controls shared by the Transparency panel and the Appearance panel's Opacity +/// popups: the grouped blend-mode dropdown, the opacity percent field and its slider popup. +pub fn opacity_blend( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug + Copy, + opacity: f32, + blend: BlendMode, + enabled: bool, +) -> Option { + let t = Tokens::get(ui.ctx()); + let mut edit = None; + ui.horizontal(|ui| { + ui.add_enabled_ui(enabled, |ui| { + if let Some(m) = blend_dropdown(ui, (id, "blend"), blend, 104.0) { + edit = Some(TransparencyEdit::Blend(m)); + } + }); + dim_label(ui, "Opacity:"); + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_enabled_ui(enabled, |ui| { + if let Some(o) = plain_field(ui, (id, "opacity"), opacity as f64 * 100.0, "%", 0, 50.0) { + edit = Some(TransparencyEdit::Opacity(o.clamp(0.0, 100.0), Live::Released)); + } + }); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() }); + ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled }); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + let mut o = opacity * 100.0; + let r = ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)); + let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) { + Live::Released + } else if r.changed() { + Live::Dragging + } else { + return; + }; + edit = Some(TransparencyEdit::Opacity(o.round() as f64, phase)); + }); }); - let _ = resp; - chosen + edit } /// The 3×3 reference point locator. Returns a new index when clicked. From 3692b9d2334c19a25a67e7cef57fe47f80bb327f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:30:03 +0700 Subject: [PATCH 4/6] M3.24: basic appearance, Layers target dots, Mixed Appearances and Clear resets opacity An appearance is basic with at most one fill and one stroke, none hidden or with its own opacity, blend mode or effects, and no object effects; Reduce to Basic keeps the topmost visible fill and stroke. The Layers panel fills an object's target circle for any non-default transparency (blend mode, isolation, knockout or an opacity mask: Node::has_default_transparency). A multi-selection whose objects differ shows "Mixed Appearances" with no rows. The Appearance panel's object row and the Control bar name the selection from one source, and type objects are "Type". Expand Appearance is only in the Object menu. Clear Appearance also resets the object's opacity and blend mode. Tests cover addFill/addStroke, setItem/removeItem, clear, reduceToBasic and graphicStyle.apply. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 34 +++++++-- crates/doc/src/node.rs | 25 ++++++- crates/engine/src/cmd/appearance.rs | 33 +++++++-- crates/engine/src/tests_appearance.rs | 74 +++++++++++++++++++ crates/ui-egui/src/chrome.rs | 14 +--- crates/ui-egui/src/menus.rs | 11 ++- crates/ui-egui/src/panels/appearance.rs | 30 ++++++-- crates/ui-egui/src/panels/layers.rs | 41 +++++++++- crates/ui-egui/src/panels/tests_appearance.rs | 27 +++++++ 9 files changed, 256 insertions(+), 33 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index fb845a8e..894b33bb 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -326,15 +326,14 @@ impl Appearance { pub fn stroke_width(&self) -> f64 { self.stroke().filter(|s| !s.paint.is_none()).map(|s| s.width).unwrap_or(0.0) } - /// Is this the basic one-fill-one-stroke appearance without effects? + /// Is this a basic appearance: at most one fill and one stroke, none hidden or with its own + /// opacity, blend mode or effects, and no object effects? pub fn is_basic(&self) -> bool { + let count = |fill: bool| self.items.iter().filter(|i| i.is_fill() == fill).count(); self.effects.is_empty() - && self.items.len() <= 2 - && self.items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count() <= 1 - && self.items.iter().all(|i| match i { - AppearanceItem::Fill(f) => f.effects.is_empty() && f.opacity == 1.0 && f.blend == BlendMode::Normal, - AppearanceItem::Stroke(s) => s.effects.is_empty() && s.opacity == 1.0 && s.blend == BlendMode::Normal, - }) + && count(true) <= 1 + && count(false) <= 1 + && self.items.iter().all(|i| i.visible() && i.effects().is_empty() && i.opacity() == 1.0 && i.blend() == BlendMode::Normal) } /// Fill item `index`, or the topmost fill for `None`. `None` when that item is not a fill. pub fn fill_at(&self, index: Option) -> Option<&FillLayer> { @@ -488,6 +487,27 @@ mod tests { assert_eq!((a.items[1].kind_name(), a.items[2].kind_name()), ("stroke", "fill")); } + #[test] + fn basic_means_one_plain_fill_and_stroke() { + let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)); + let mut a = Appearance { items: vec![stroke(), stroke()], effects: vec![] }; + assert!(!a.is_basic(), "two strokes"); + a.items.pop(); + assert!(a.is_basic() && Appearance::default().is_basic()); + let mut b = Appearance::default_art(); + b.stroke_mut().unwrap().visible = false; + assert!(!b.is_basic(), "a hidden stroke"); + let mut c = Appearance::default_art(); + c.fill_mut().unwrap().opacity = 0.5; + assert!(!c.is_basic(), "fill opacity"); + let mut d = Appearance::default_art(); + d.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); + assert!(!d.is_basic(), "two fills"); + let mut e = Appearance::default_art(); + e.effects.push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true }); + assert!(!e.is_basic(), "an effect"); + } + #[test] fn set_creates_missing() { let mut a = Appearance::default(); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index d85a7929..0a81fd3e 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -357,6 +357,11 @@ impl Node { pub fn is_container(&self) -> bool { self.children().is_some() } + /// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel + /// fills an object's target circle otherwise). + pub fn has_default_transparency(&self) -> bool { + self.opacity >= 1.0 && self.blend == BlendMode::Normal && !self.isolate && !self.knockout && self.mask.is_none() + } /// Kind name as the Layers panel / Properties panel shows it. pub fn kind_label(&self) -> &'static str { match &self.kind { @@ -367,7 +372,7 @@ impl Node { NodeKind::Path { guide: true, .. } => "Guide", NodeKind::Path { .. } => "Path", NodeKind::Compound { .. } => "Compound Path", - NodeKind::Text(_) => "Text", + NodeKind::Text(_) => "Type", NodeKind::Image(_) => "Image", NodeKind::SymbolInstance { .. } => "Symbol", NodeKind::Blend { .. } => "Blend", @@ -545,6 +550,24 @@ mod tests { assert_eq!(l.label(), "Rectangle"); } + #[test] + fn default_transparency() { + let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + assert!(p().has_default_transparency()); + let tweaks: [fn(&mut Node); 5] = [ + |n| n.opacity = 0.5, + |n| n.blend = BlendMode::Multiply, + |n| n.isolate = true, + |n| n.knockout = true, + |n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))), + ]; + for tweak in tweaks { + let mut n = p(); + tweak(&mut n); + assert!(!n.has_default_transparency(), "{n:?}"); + } + } + #[test] fn transform_group_recurses() { let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 6460199f..18a00cdc 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -38,8 +38,24 @@ pub fn specs() -> Vec { has_selection, |s, p| add_item(s, p, false) ), - cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{ids?}", has_selection, clear_appearance), - cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{ids?}", has_selection, reduce_basic), + cmd!( + "appearance.clear", + "Clear Appearance", + ["Window", "Appearance"], + None, + "{ids?} leave each object one empty fill and stroke (None) and reset its opacity and blend mode", + has_selection, + clear_appearance + ), + cmd!( + "appearance.reduceToBasic", + "Reduce to Basic Appearance", + ["Window", "Appearance"], + None, + "{ids?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects, and drop the object's effects", + has_selection, + reduce_basic + ), cmd!( "appearance.setItem", "Appearance Item", @@ -261,7 +277,10 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { s.edit("Clear Appearance", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { + // No fill, no stroke, and the object's Opacity row back to Default. n.appearance = Appearance::basic(Paint::None, Paint::None, 1.0); + n.opacity = 1.0; + n.blend = BlendMode::Normal; } } Ok(()) @@ -275,10 +294,12 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { s.edit("Reduce to Basic Appearance", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { - let f = n.appearance.fill_paint(); - let st = n.appearance.stroke().cloned(); - n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(f))], effects: vec![] }; - if let Some(mut st) = st { + // The topmost visible fill and stroke stay, without their own transparency or effects. + let top = |fill: bool| n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(); + let fill = top(true).map_or(Paint::None, |f| f.paint().clone()); + let stroke = top(false); + n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(fill))], effects: vec![] }; + if let Some(AppearanceItem::Stroke(mut st)) = stroke { st.effects.clear(); st.opacity = 1.0; st.blend = BlendMode::Normal; diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs index fb39cdfd..413dac6c 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests_appearance.rs @@ -267,3 +267,77 @@ fn text_fill_items_composite_with_their_opacity() { let px = render(&s).pixel(x, y); assert!((110..=145).contains(&px[0]) && px[1] < 10, "{px:?}"); } + +#[test] +fn add_fill_and_stroke_copy_the_top_ones() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "stroke.set", json!({"weight": 4})); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "appearance.addStroke", json!({})); // [Fill, Stroke, Fill, Stroke] + let n = node(&s, id); + assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::>(), ["fill", "stroke", "fill", "stroke"]); + assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#ff0000"); + assert_eq!(stroke(&n, 3).width, 4.0); + assert!(!n.appearance.is_basic()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, id).appearance.items.len(), 3); +} + +#[test] +fn set_item_and_remove_item() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 50, "blend": "screen", "weight": 3, "color": "#0000ff"})); + let n = node(&s, id); + let st = stroke(&n, 1); + assert_eq!((st.opacity, st.blend, st.width), (0.5, BlendMode::Screen, 3.0)); + assert_eq!(st.paint.color().unwrap().to_hex(), "#0000ff"); + run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false, "opacity": 0.25})); + let n = node(&s, id); + assert!(!n.appearance.items[0].visible() && n.appearance.items[0].opacity() == 0.25); + assert!(s.execute("appearance.setItem", &json!({})).is_err()); + run(&mut s, "appearance.removeItem", json!({"index": 0})); + let n = node(&s, id); + assert_eq!(n.appearance.items.len(), 1); + assert!(n.appearance.fill().is_none() && n.appearance.stroke().is_some()); +} + +#[test] +fn clear_and_reduce_to_basic() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "appearance.addStroke", json!({})); + run(&mut s, "appearance.setItem", json!({"index": 2, "visible": false, "color": "#00ff00"})); + run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 0.5})); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1})); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null})); + run(&mut s, "transparency.set", json!({"opacity": 40, "blend": "multiply"})); + // Reduce keeps the topmost visible fill and stroke, plain, and the object's transparency. + run(&mut s, "appearance.reduceToBasic", json!({})); + let n = node(&s, id); + assert!(n.appearance.is_basic(), "{:?}", n.appearance); + assert_eq!(stroke(&n, 1).paint, Paint::solid(Color::BLACK)); + assert_eq!((n.opacity, n.blend), (0.4, BlendMode::Multiply)); + // Clear leaves one None fill and stroke, and resets the object's Opacity row. + run(&mut s, "appearance.clear", json!({})); + let n = node(&s, id); + assert_eq!(n.appearance, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0)); + assert_eq!((n.opacity, n.blend), (1.0, BlendMode::Normal)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, id).opacity, 0.4); +} + +#[test] +fn graphic_style_apply_replaces_the_stack() { + let mut s = session(); + let a = rect(&mut s, 100.0); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); + let name = run(&mut s, "graphicStyle.new", json!({"name": "Rough"}))["name"].clone(); + let b = rect(&mut s, 250.0); + run(&mut s, "graphicStyle.apply", json!({ "name": name })); + assert_eq!(node(&s, b).appearance, node(&s, a).appearance); + assert!(s.execute("graphicStyle.apply", &json!({"name": "nope"})).is_err()); +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index aaac5e0e..8be42e7d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -92,16 +92,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let units = st.doc.units; let first = sel.first().and_then(|id| st.doc.node(*id)).cloned(); let anchor_mode = !st.selection.anchors.is_empty(); - let label = match (&first, sel.len()) { - (None, _) => "No Selection".to_string(), - (_, n) if n > 1 => "Mixed Objects".to_string(), - (Some(_), _) if anchor_mode => "Anchor Point".to_string(), - (Some(n), _) => match &n.kind { - NodeKind::Text(_) => "Type".into(), - NodeKind::Image(_) => "Embedded".into(), - NodeKind::Path { live: Some(_), .. } => n.kind_label().to_string(), - _ => n.kind_label().to_string(), - }, + let label = match &first { + Some(_) if sel.len() == 1 && anchor_mode => "Anchor Point", + Some(n) if sel.len() == 1 && matches!(n.kind, NodeKind::Image(_)) => "Embedded", + _ => crate::panels::appearance::object_label(app), }; ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); ui.add_space(6.0); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3f8048e9..55488867 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1638,8 +1638,6 @@ fn effect_menu() -> Vec { for e in cat.iter().filter(|e| !e.menu.last().is_some_and(|m| order.contains(m))) { out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))); } - out.push(Sep); - out.push(c("Expand Appearance", "effect.expandAppearance")); out } @@ -1771,4 +1769,13 @@ mod tests { } assert_eq!(pretty_shortcut(""), ""); } + + #[test] + fn expand_appearance_is_an_object_menu_item_only() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let paths: Vec> = + menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some("effect.expandAppearance")).map(|e| e.path).collect(); + assert_eq!(paths, [vec!["Object".to_string()]]); + } } diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index afa6ac34..1d7f86c0 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -173,18 +173,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { select_row(app, ui.ctx(), Sel::None); } ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); - let label = match (&node, super::selection_len(app)) { - (None, _) => "No Selection".to_string(), - (Some(_), n) if n > 1 => "Mixed Objects".to_string(), - (Some(n), _) => n.kind_label().to_string(), - }; + let mixed = mixed_appearances(app); + let label = if mixed { "Mixed Appearances" } else { object_label(app) }; 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 => {} Some(n) => stack(app, ui, n, sel), None => default_stack(app, ui), } @@ -194,6 +193,25 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { bottom(app, ui, node.as_ref(), sel); } +/// The selection as the Control bar and the object row name it: "No Selection", "Mixed Objects" +/// or the object's kind ("Path", "Type", …). +pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str { + let Some(st) = app.session.active() else { return "No Selection" }; + match st.selection.objects.as_slice() { + [] => "No Selection", + [id] => st.doc.node(*id).map_or("No Selection", Node::kind_label), + _ => "Mixed Objects", + } +} + +/// Do the selected objects differ in appearance (fills, strokes, effects, opacity or blend mode)? +fn mixed_appearances(app: &VectorcraftApp) -> bool { + let Some(st) = app.session.active() else { return false }; + let mut nodes = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)); + let Some(first) = nodes.next() else { return false }; + nodes.any(|n| n.appearance != first.appearance || n.opacity != first.opacity || n.blend != first.blend) +} + /// The defaults for new art when nothing is selected (read-only rows). fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { let p = app.session.paint.clone(); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 547bc7f6..af0a676f 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -221,7 +221,7 @@ fn row( let col_x = r.right() - 43.5; ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); let tc = egui::pos2(r.right() - 28.0, r.center().y); - let styled = !n.appearance.is_basic() || n.opacity < 1.0; + let styled = has_styled_target(n); ui.painter().circle_stroke(tc, 5.0, Stroke::new(1.0, t.icon)); if is_sel { ui.painter().circle_stroke(tc, 2.8, Stroke::new(1.0, t.icon)); @@ -292,6 +292,12 @@ fn row( /// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc` /// allocation, so the address is a free change detector). Only rendered for visible rows. +/// Whether an object's target circle is filled: its appearance is not basic or its transparency +/// (opacity, blend mode, isolation, knockout or an opacity mask) is not the default. +fn has_styled_target(n: &Node) -> bool { + !n.appearance.is_basic() || !n.has_default_transparency() +} + fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r: egui::Rect) -> bool { use std::cell::RefCell; use std::collections::HashMap; @@ -351,3 +357,36 @@ fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { } }); } + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + + use super::*; + + /// Filled target circles drawn by one headless frame of the panel. + fn filled_targets(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Circle(cs) if cs.radius == 3.2 && cs.fill != Color32::TRANSPARENT)).count() + } + + #[test] + fn target_circle_fills_for_non_default_transparency() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let id = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + run(&mut app, "select.set", json!({ "ids": [id] })); + let ctx = egui::Context::default(); + assert_eq!(filled_targets(&mut app, &ctx), 0); + run(&mut app, "transparency.set", json!({"blend": "multiply"})); + assert_eq!(filled_targets(&mut app, &ctx), 1, "a Multiply object"); + run(&mut app, "transparency.set", json!({"blend": "normal", "knockout": true})); + assert_eq!(filled_targets(&mut app, &ctx), 1, "a knockout group"); + run(&mut app, "transparency.set", json!({"knockout": false})); + run(&mut app, "appearance.addStroke", json!({})); + assert_eq!(filled_targets(&mut app, &ctx), 1, "two strokes"); + } +} diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index a49bfcad..fdfc53d4 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -157,3 +157,30 @@ fn item_opacity_popup_sets_the_items_opacity() { assert_eq!(n.appearance.items[1].opacity(), 0.5); assert_eq!((n.appearance.items[0].opacity(), n.opacity), (1.0, 1.0)); } + +#[test] +fn object_row_names_the_selection_like_the_control_bar() { + let mut app = app_with_rect(); + let a = first_selected(&app).unwrap().id.0; + let ctx = egui::Context::default(); + assert!(frame_in(&ctx, &mut app, appearance::show).contains(&"Rectangle".to_string())); + let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].clone(); + // Objects with one appearance list it under "Mixed Objects". + run(&mut app, "select.set", json!({"ids": [a, b]})); + let texts = frame_in(&ctx, &mut app, appearance::show); + assert!(texts.contains(&"Mixed Objects".to_string()) && texts.contains(&"Stroke:".to_string()), "{texts:?}"); + // Objects that differ list nothing. + run(&mut app, "select.set", json!({"ids": [b]})); + run(&mut app, "transparency.set", json!({"opacity": 50})); + run(&mut app, "select.set", json!({"ids": [a, b]})); + let texts = frame_in(&ctx, &mut app, appearance::show); + assert!(texts.contains(&"Mixed Appearances".to_string()) && !texts.contains(&"Stroke:".to_string()), "{texts:?}"); + // Type objects are "Type" in both places. + let t = run(&mut app, "text.create", json!({"x": 10, "y": 150, "text": "Hi"}))["id"].clone(); + run(&mut app, "select.set", json!({ "ids": [t] })); + assert_eq!(appearance::object_label(&app), "Type"); + assert!(frame_in(&ctx, &mut app, appearance::show).contains(&"Type".to_string())); + let fonts = egui::Context::default(); + crate::theme::install_fonts(&fonts); + assert!(frame_in(&fonts, &mut app, crate::chrome::control_bar).contains(&"Type".to_string())); +} From 1b3e7110a365bbf17cbdb7bc3929dc6810bd80cb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:50:46 +0700 Subject: [PATCH 5/6] M3.21: active-row edits of the other kind stay on the painted leaves, item transparency is one undo step - A fill row no longer turns stroke edits (stroke.set, setAdvanced, paint.setStroke, gradients) into a new stroke on a selected group's own stack: an active row only stands for edits of its kind. - Removing, duplicating or moving items of other objects (`ids`) no longer re-points the active row. - transparency.set with an item touches the item only for opacity/blend (isolate or knockout alone is one undo step) and rejects unknown blend modes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/appearance.rs | 31 ++++++++++++------ crates/engine/src/cmd/gradient.rs | 6 ++-- crates/engine/src/cmd/paint.rs | 46 +++++++++++++++------------ crates/engine/src/cmd/stroke.rs | 4 +-- crates/engine/src/tests_appearance.rs | 44 +++++++++++++++++++++++++ crates/tools/src/xform/gradient.rs | 7 ++-- crates/ui-egui/src/panels/mod.rs | 3 +- 7 files changed, 101 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 18a00cdc..89b67604 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -119,9 +119,12 @@ impl Session { (a.index < n.appearance.items.len()).then_some(a.index) } - /// Re-point the active item after its stack changed (`None` drops it). - fn remap_appearance_item(&mut self, f: impl FnOnce(usize) -> Option) { - if let Some(a) = &mut self.active_appearance_item { + /// Re-point the active item after an edit of `ids` changed its stack (`None` drops it). Edits + /// of other objects leave it alone. + fn remap_appearance_item(&mut self, ids: &[NodeId], f: impl FnOnce(usize) -> Option) { + if let Some(a) = &mut self.active_appearance_item + && a.objects.first().is_some_and(|o| ids.contains(o)) + { match f(a.index) { Some(i) => a.index = i, None => self.active_appearance_item = None, @@ -163,6 +166,15 @@ impl ItemTarget { } } + /// The target of a fill (`fill`) or stroke edit: the panel's active item stands only for edits + /// of its own kind, so a fill row leaves stroke edits on the topmost stroke of the painted + /// leaves (as without an active item) and vice versa. Explicit items are kept (and checked). + pub(crate) fn of_kind(self, s: &Session, fill: bool) -> Self { + let ItemTarget::Item { index, explicit: false } = self else { return self }; + let fits = s.active().and_then(|st| st.doc.node(*st.selection.objects.first()?)?.appearance.items.get(index).map(|it| it.is_fill() == fill)); + if fits == Some(true) { self } else { ItemTarget::Top } + } + /// The item of `ap` a fill (`fill`) or stroke edit changes; `None` = the topmost one. pub(crate) fn resolve(self, ap: &Appearance, fill: bool, cmd: &str) -> Result> { match self { @@ -219,13 +231,14 @@ pub(crate) fn edit_items( }) } -/// Whether an edit aimed at `item` on `ids` changes a stroke: the `stroke` param, else the kind of -/// the targeted item (on the first target), else `default`. -pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, ids: &[NodeId], default: bool) -> Result { +/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the +/// targeted item (on the first target object), else `default`. +pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool) -> Result { if let Some(b) = p.get("stroke").and_then(Value::as_bool) { return Ok(b); } let ItemTarget::Item { index, .. } = item else { return Ok(default) }; + let ids = appearance_targets(s, p)?; let d = &s.doc()?.doc; Ok(ids.first().and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } @@ -362,7 +375,7 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; - s.remap_appearance_item(|a| match a.cmp(&idx) { + s.remap_appearance_item(&ids, |a| match a.cmp(&idx) { std::cmp::Ordering::Less => Some(a), std::cmp::Ordering::Equal => None, std::cmp::Ordering::Greater => Some(a - 1), @@ -384,7 +397,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { } if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) } })?; - s.remap_appearance_item(|a| Some(if a > idx { a + 1 } else { a })); + s.remap_appearance_item(&ids, |a| Some(if a > idx { a + 1 } else { a })); ok() } @@ -408,7 +421,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result { Ok(()) })?; // The moved row stays the active one; the rows it passed shift by one. - s.remap_appearance_item(|a| { + s.remap_appearance_item(&ids, |a| { Some(if a == from { landed } else if from < a && a <= landed { diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 5cace287..b3da6652 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -114,8 +114,9 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option Result { const C: &str = "paint.editGradient"; let item = item_target(s, p, C)?; + let stroke = edits_stroke(s, p, item, !s.fill_active)?; + let item = item.of_kind(s, !stroke); let ids = item.targets(s, p)?; - let stroke = edits_stroke(s, p, item, &ids, !s.fill_active)?; // Validate against the defaults first so bad params fail without touching the document. let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }; let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?; @@ -147,6 +148,8 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { let start = point_param(p, "start").ok_or_else(|| bad(C, "missing start [x,y]"))?; let end = point_param(p, "end").ok_or_else(|| bad(C, "missing end [x,y]"))?; let item = item_target(s, p, C)?; + let stroke = edits_stroke(s, p, item, false)?; + let item = item.of_kind(s, !stroke); let targets = match item { ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), Ok)?)?, _ => item.targets(s, p)?, @@ -154,7 +157,6 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { if targets.is_empty() { return Err(EngineError::Other("nothing selected".into())); } - let stroke = edits_stroke(s, p, item, &targets, false)?; let geom = GradientGeom { start, end, aspect: 1.0 }; edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| { if matches!(n.kind, NodeKind::Text(_)) { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 2d671b77..b1ce6c74 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1,7 +1,7 @@ //! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel. use serde_json::{Value, json}; -use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, NodeKind}; use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target}; @@ -104,7 +104,7 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { s.paint.stroke = paint.clone(); } s.fill_active = fill; - let item = item_target(s, p, cmd)?; + let item = item_target(s, p, cmd)?.of_kind(s, fill); let ids = item.targets(s, p)?; edit_items(s, &ids, item, cmd, if fill { "Fill Color" } else { "Stroke Color" }, fill, |n, index| { if index.is_none() @@ -173,30 +173,36 @@ fn transparency(s: &mut Session, p: &Value) -> Result { let mut q = p.as_object().cloned().unwrap_or_default(); q.remove("item"); let opacity = q.remove("opacity").and_then(|v| v.as_f64()).map(|o| (o / 100.0).clamp(0.0, 1.0)); - if let ItemTarget::Item { index, explicit } = item { - // Opacity and blend belong to the targeted fill/stroke; isolate/knockout stay object-level. - if explicit { - let d = &s.doc()?.doc; - if !appearance_targets(s, p)?.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { - return Err(bad(C, format!("no appearance item {index}"))); - } + let ItemTarget::Item { index, explicit } = item else { + if let Some(o) = opacity { + q.insert("opacity".into(), json!(o)); } - let mut set = json!({ "index": index }); - for key in ["blend", "ids", "id"] { + return s.execute("object.setProps", &Value::Object(q)); + }; + // Opacity and blend belong to the targeted fill/stroke; isolate/knockout stay object-level. + if explicit { + let d = &s.doc()?.doc; + if !appearance_targets(s, p)?.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { + return Err(bad(C, format!("no appearance item {index}"))); + } + } + let blend = q.remove("blend"); + if let Some(b) = &blend + && b.as_str().and_then(BlendMode::parse).is_none() + { + return Err(bad(C, format!("unknown blend mode {b}"))); + } + if opacity.is_some() || blend.is_some() { + let mut set = json!({ "index": index, "opacity": opacity, "blend": blend }); + for key in ["ids", "id"] { if let Some(v) = p.get(key) { set[key] = v.clone(); } } - if let Some(o) = opacity { - set["opacity"] = json!(o); - } - q.remove("blend"); s.execute("appearance.setItem", &set)?; - if q.keys().all(|k| k == "ids" || k == "id") { - return ok(); - } - } else if let Some(o) = opacity { - q.insert("opacity".into(), json!(o)); + } + if q.keys().all(|k| k == "ids" || k == "id") { + return ok(); } s.execute("object.setProps", &Value::Object(q)) } diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index ef645d84..e78b621f 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -35,7 +35,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { if let Some(w) = p.get("weight").and_then(Value::as_f64) { s.paint.stroke_width = w.max(0.0); } - let item = item_target(s, p, C)?; + let item = item_target(s, p, C)?.of_kind(s, false); let ids = item.targets(s, p)?; let arrow = |k: &str| -> Result>> { match p.get(k) { @@ -139,7 +139,7 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { if scale.is_none() && flip.is_none() && !swap && brush.is_none() { return Err(bad(C, "nothing to change")); } - let item = item_target(s, p, C)?; + let item = item_target(s, p, C)?.of_kind(s, false); let ids = item.targets(s, p)?; edit_items(s, &ids, item, C, "Stroke", false, |n, index| { let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) }; diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs index 413dac6c..30ed6917 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests_appearance.rs @@ -341,3 +341,47 @@ fn graphic_style_apply_replaces_the_stack() { assert_eq!(node(&s, b).appearance, node(&s, a).appearance); assert!(s.execute("graphicStyle.apply", &json!({"name": "nope"})).is_err()); } + +#[test] +fn an_active_row_of_the_other_kind_leaves_edits_on_the_painted_leaves() { + let mut s = session(); + let a = rect(&mut s, 100.0); + let b = rect(&mut s, 250.0); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({})); + // The group's own fill row is active: stroke edits still go to the members' strokes. + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "stroke.set", json!({"weight": 5})); + run(&mut s, "paint.setStroke", json!({"color": "#00ff00"})); + assert_eq!(node(&s, g).appearance.items.len(), 1, "no stroke row added to the group"); + for id in [a, b] { + assert_eq!(stroke(&node(&s, id), 1).width, 5.0); + assert_eq!(stroke(&node(&s, id), 1).paint.color().unwrap().to_hex(), "#00ff00"); + } +} + +#[test] +fn the_active_row_follows_only_edits_of_its_object() { + let mut s = session(); + let other = rect(&mut s, 250.0); + rect(&mut s, 100.0); + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); + run(&mut s, "appearance.removeItem", json!({"index": 0, "ids": [other.0]})); + run(&mut s, "appearance.duplicateItem", json!({"index": 0, "ids": [other.0]})); + assert_eq!(s.appearance_item(), Some(1)); +} + +#[test] +fn item_transparency_is_one_step_and_checks_the_blend_mode() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + run(&mut s, "transparency.set", json!({"knockout": true})); + assert_eq!(steps(&s), before + 1, "isolate/knockout alone do not touch the item"); + assert!(node(&s, id).knockout); + assert!(s.execute("transparency.set", &json!({"blend": "nope"})).is_err()); + assert_eq!(stroke(&node(&s, id), 1).blend, BlendMode::Normal); +} diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 2f92d775..af6cfb98 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -7,7 +7,6 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::AppearanceItem; use vectorcraft_doc::hit::hit_test; use vectorcraft_geom::{Point, Vec2}; @@ -27,10 +26,8 @@ pub struct GradientTool { pub fn annotator(cx: &ToolContext) -> Option<(Point, Point, Vec)> { cx.selection.objects.iter().find_map(|id| { let n = cx.doc.node(*id)?; - let item = cx.appearance_item.and_then(|i| n.appearance.items.get(i)); - let paint = match item { - Some(AppearanceItem::Stroke(s)) => &s.paint, - Some(AppearanceItem::Fill(f)) => &f.paint, + let paint = match cx.appearance_item.and_then(|i| n.appearance.items.get(i)) { + Some(item) => item.paint(), None => &n.appearance.fill()?.paint, }; let Paint::Gradient(g) = paint else { return None }; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d57443b1..db6a3ead 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -46,8 +46,7 @@ use crate::{VectorcraftApp, icons}; /// The first selected node (cloned), if any. pub fn first_selected(app: &VectorcraftApp) -> Option { - let st = app.session.active()?; - st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned() + first_node(app).cloned() } /// Number of selected objects. From 6ebffb9156c37b555c07e10be3626bb32e7f0eb1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:52:46 +0700 Subject: [PATCH 6/6] M3.24: Clear Appearance and Reduce to Basic add no fill or stroke rows to a group A group's own stack now stays empty after Clear (and keeps only the rows it had after Reduce to Basic), so the proxies keep showing the defaults for it. The Layers target-dot helper no longer takes over the thumbnail function's doc comment. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/appearance.rs | 22 +++++++++++++++------- crates/engine/src/tests_appearance.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/panels/layers.rs | 4 ++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 89b67604..7e794f70 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?} leave each object one empty fill and stroke (None) and reset its opacity and blend mode", + "{ids?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode", has_selection, clear_appearance ), @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects, and drop the object's effects", + "{ids?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", has_selection, reduce_basic ), @@ -290,8 +290,9 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { s.edit("Clear Appearance", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { - // No fill, no stroke, and the object's Opacity row back to Default. - n.appearance = Appearance::basic(Paint::None, Paint::None, 1.0); + // No fill, no stroke, and the object's Opacity row back to Default. A group keeps no + // fill or stroke rows of its own. + n.appearance = if is_group(n) { Appearance::default() } else { Appearance::basic(Paint::None, Paint::None, 1.0) }; n.opacity = 1.0; n.blend = BlendMode::Normal; } @@ -302,6 +303,10 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { ok() } +fn is_group(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { .. }) +} + fn reduce_basic(s: &mut Session, p: &Value) -> Result { let ids = appearance_targets(s, p)?; s.edit("Reduce to Basic Appearance", |d, _| { @@ -309,9 +314,12 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { if let Some(n) = d.node_mut(*id) { // The topmost visible fill and stroke stay, without their own transparency or effects. let top = |fill: bool| n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(); - let fill = top(true).map_or(Paint::None, |f| f.paint().clone()); - let stroke = top(false); - n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(fill))], effects: vec![] }; + let (fill, stroke) = (top(true), top(false)); + // Other objects always keep a fill row (None when there was none); a group only + // keeps the rows it had. + let fill = + (fill.is_some() || !is_group(n)).then(|| AppearanceItem::Fill(FillLayer::new(fill.map_or(Paint::None, |f| f.paint().clone())))); + n.appearance = Appearance { items: fill.into_iter().collect(), effects: vec![] }; if let Some(AppearanceItem::Stroke(mut st)) = stroke { st.effects.clear(); st.opacity = 1.0; diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs index 30ed6917..d55627a6 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests_appearance.rs @@ -385,3 +385,24 @@ fn item_transparency_is_one_step_and_checks_the_blend_mode() { assert!(s.execute("transparency.set", &json!({"blend": "nope"})).is_err()); assert_eq!(stroke(&node(&s, id), 1).blend, BlendMode::Normal); } + +#[test] +fn clear_and_reduce_keep_a_groups_stack_empty() { + let mut s = session(); + let a = rect(&mut s, 100.0); + let b = rect(&mut s, 250.0); + let members = node(&s, a).appearance; + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.reduceToBasic", json!({})); + assert!(node(&s, g).appearance.items.is_empty()); + run(&mut s, "appearance.addStroke", json!({})); + run(&mut s, "appearance.reduceToBasic", json!({})); + assert_eq!(node(&s, g).appearance.items.iter().map(AppearanceItem::kind_name).collect::>(), ["stroke"]); + run(&mut s, "transparency.set", json!({"opacity": 50})); + run(&mut s, "appearance.clear", json!({})); + let n = node(&s, g); + assert!(n.appearance.items.is_empty() && n.opacity == 1.0); + // The members keep their own appearance. + assert_eq!(node(&s, a).appearance, members); +} diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index af0a676f..585c4ba8 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -290,14 +290,14 @@ fn row( } } -/// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc` -/// allocation, so the address is a free change detector). Only rendered for visible rows. /// Whether an object's target circle is filled: its appearance is not basic or its transparency /// (opacity, blend mode, isolation, knockout or an opacity mask) is not the default. fn has_styled_target(n: &Node) -> bool { !n.appearance.is_basic() || !n.has_default_transparency() } +/// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc` +/// allocation, so the address is a free change detector). Only rendered for visible rows. fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r: egui::Rect) -> bool { use std::cell::RefCell; use std::collections::HashMap;