From 018bed29dbd48da6250e68038bd146ade2611449 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 12:28:05 +0700 Subject: [PATCH] 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.