diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index b72f79c0..53dde758 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -299,6 +299,53 @@ 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 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, + 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)] @@ -355,15 +402,105 @@ 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> { + 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 { @@ -457,6 +594,47 @@ 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 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 ac658fd2..9ed084cf 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", @@ -530,27 +535,31 @@ impl Node { } } /// The paint behind the Fill (or Stroke) proxy, the map from its space to the document and - /// the box (in that space) an unplaced gradient fits. Type objects paint their runs (the first - /// run speaks for all) in text space, fitted to the layout bounds; everything else paints its - /// top fill or stroke in the document, fitted to the geometric bounds. Strokes fit the box - /// grown by half their weight. - pub fn proxy_paint(&self, stroke: bool) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> { - if let NodeKind::Text(t) = &self.kind { + /// the box (in that space) an unplaced gradient fits. `item` (the Appearance panel's active + /// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise + /// type objects paint their runs (the first run speaks for all) in text space, fitted to the + /// layout bounds; everything else paints its fill or stroke in the document, fitted to the + /// geometric bounds. Strokes fit the box grown by half their weight. + pub fn proxy_paint(&self, stroke: bool, item: Option) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> { + let item = self.appearance.item_of_kind(item, !stroke); + if item.is_none() + && let NodeKind::Text(t) = &self.kind + { let st = &t.runs.first()?.style; let b = t.local_bounds(); return Some(if stroke { (&st.stroke, t.xf, crate::appearance::stroke_paint_bounds(b, st.stroke_width)) } else { (&st.fill, t.xf, b) }); } let b = self.geometric_bounds()?; if stroke { - self.appearance.stroke().map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b))) + self.appearance.stroke_at(item).map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b))) } else { - self.appearance.fill().map(|f| (&f.paint, Affine::IDENTITY, b)) + self.appearance.fill_at(item).map(|f| (&f.paint, Affine::IDENTITY, b)) } } /// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates /// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits. - pub fn proxy_gradient(&self, stroke: bool) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> { - let (paint, to_doc, b) = self.proxy_paint(stroke)?; + pub fn proxy_gradient(&self, stroke: bool, item: Option) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> { + let (paint, to_doc, b) = self.proxy_paint(stroke, item)?; let vectorcraft_color::Paint::Gradient(g) = paint else { return None }; let mut geom = g.resolve(b); if to_doc != Affine::IDENTITY { @@ -578,6 +587,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 f3e6f506..e3d04104 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,63 @@ 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?} 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 + ), + 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 (a group keeps no rows it lacked), and drop the object's effects", + has_selection, + reduce_basic + ), cmd!( "appearance.setItem", "Appearance Item", [], None, - "{index, opacity?, blend?, visible?, weight?, color?|none?|swatch?|gradient? (as paint.setFill)} 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.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.removeItem", "Remove Item", [], None, "{index, ids?}", has_selection, remove_item), + 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!( "appearance.copyFrom", "Eyedropper", @@ -38,11 +86,195 @@ 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 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, + } + } + } +} + +/// 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 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 { + ItemTarget::Top => Ok(None), + ItemTarget::Item { index, explicit } => match ap.item_of_kind(Some(index), fill) { + None if explicit => Err(bad( + cmd, + match ap.items.get(index) { + None => format!("no appearance item {index}"), + Some(_) => format!("appearance item {index} is not a {}", if fill { "fill" } else { "stroke" }), + }, + )), + found => Ok(found), + }, + } + } + + /// 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 +/// (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` 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())) +} + +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,28 +291,42 @@ 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) { - 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; } } Ok(()) })?; + s.active_appearance_item = None; ok() } -fn reduce_basic(s: &mut Session, _: &Value) -> Result { - let ids = paint_targets(s, &json!({}))?; +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, _| { 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, 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; st.blend = BlendMode::Normal; @@ -90,13 +336,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 { @@ -132,8 +379,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) @@ -144,27 +391,17 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; - 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(()) - })?; + 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), + }); ok() } 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 { @@ -176,14 +413,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(&ids, |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 }; @@ -192,11 +431,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(&ids, |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/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/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index d31edd85..70e301d7 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -3,9 +3,10 @@ use serde_json::{Value, json}; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::appearance::stroke_paint_bounds; -use vectorcraft_doc::{AppearanceItem, NodeKind}; +use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Affine, Point, Rect}; +use super::appearance::{ItemTarget, edit_items, edits_stroke, item_target}; use super::edit::selected_roots; use super::*; use crate::EngineError; @@ -17,7 +18,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 (or 0..100), midpoint? 0.13..0.87}] (at least 2), 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 (or 0..100), midpoint? 0.13..0.87}] (at least 2), 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 ), @@ -26,7 +27,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start: [x,y], end: [x,y] (document coordinates), ids?, stroke?: bool (default: the active proxy), index?: appearance item index (as appearance.setItem; targets that fill or stroke instead of the top one)} set the gradient vector (solid paints become the default gradient; type objects set it on their runs, in text space; the aspect ratio is kept)", + "{start: [x,y], end: [x,y] (document coordinates), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind)} set the gradient vector (solid paints become the default gradient; type objects set it on their runs, in text space; the aspect ratio is kept)", has_doc, set_gradient_geom ), @@ -43,12 +44,13 @@ pub fn specs() -> Vec { } /// The gradient behind the active proxy: the first selected object's (see -/// `Node::proxy_paint`), else the default paint for new art. +/// `Node::proxy_paint`; the Appearance panel's active item when it is of the proxy's kind), else +/// the default paint for new art. pub(crate) fn active_gradient(s: &Session) -> Option { let stroke = !s.fill_active; let first = s.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id))); let paint = match first { - Some(n) => n.proxy_paint(stroke).map(|(p, ..)| p.clone()), + Some(n) => n.proxy_paint(stroke, s.appearance_item()).map(|(p, ..)| p.clone()), None => Some(if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }), }; match paint { @@ -76,18 +78,19 @@ fn select_stop(s: &mut Session, p: &Value) -> Result { } /// Whose gradient a selected stop belongs to: the active document, its first selected object -/// (None: the default paint) and the proxy in front. Selecting other art or toggling the proxy -/// leaves no stop selected. +/// (None: the default paint), the proxy in front and the Appearance panel's active item. +/// Selecting other art, toggling the proxy or picking another item leaves no stop selected. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct StopOwner { doc: Option, object: Option, fill: bool, + item: Option, } impl StopOwner { fn of(s: &Session) -> Self { - Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active } + Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active, item: s.appearance_item() } } } @@ -255,52 +258,28 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option fn edit_gradient(s: &mut Session, p: &Value) -> 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 stroke = edits_stroke(s, p, item, !s.fill_active)?; + let item = item.of_kind(s, !stroke); + let ids = item.targets(s, p)?; // 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 { - let lb = t.local_bounds(); - for r in &mut t.runs { - let (cur, b) = run_paint_mut(r, stroke, lb); - match apply_gradient_edit(cur, p, Some(b)) { - Ok(np) => *cur = np, - Err(e) => err = Some(e), - } - } - continue; - } - let b = n.geometric_bounds(); - let ap = &mut n.appearance; - let (cur, b) = if stroke { (ap.stroke_paint(), b.zip(ap.stroke()).map(|(b, st)| st.paint_bounds(b))) } else { (ap.fill_paint(), b) }; - 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 + { + let lb = t.local_bounds(); + for r in &mut t.runs { + let (cur, b) = run_paint_mut(r, stroke, lb); + *cur = apply_gradient_edit(cur, p, Some(b)).map_err(|e| bad(C, e))?; } + return Ok(()); } - match err.take() { - Some(e) => Err(bad(C, e)), - None => Ok(()), - } + let b = item_paint_bounds(n, index, !stroke); + 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; @@ -321,6 +300,13 @@ pub(crate) fn run_paint_mut(r: &mut vectorcraft_doc::TextRun, stroke: bool, lb: } } +/// The box an unplaced gradient on fill or stroke `index` of `n` fits (`None`: the topmost): the +/// geometric bounds, grown by half the weight for a stroke (`None` without that stroke). +pub(crate) fn item_paint_bounds(n: &Node, index: Option, fill: bool) -> Option { + let b = n.geometric_bounds()?; + if fill { Some(b) } else { n.appearance.stroke_at(index).map(|st| st.paint_bounds(b)) } +} + /// `paint` as applied to a type run whose text space `xf` maps to the document: a vector given in /// document coordinates moves into text space, and an aspect without a vector places the gradient /// on the run's `bounds` (text space). @@ -369,65 +355,43 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { 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 stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); - let index = p.get("index").map(|v| v.as_u64().map(|i| i as usize).ok_or_else(|| bad(C, "`index` must be a whole number"))).transpose()?; - let ids = match ids_param(p, "ids") { - Some(v) => v, - None => selected_roots(s)?, + // `index` is an alias of `item`. + let mut p = p.clone(); + if let (None, Some(i)) = (p.get("item"), p.get("index").cloned()) { + p["item"] = i; + } + let p = &p; + 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 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, _| { - let mut hit = false; - for id in &targets { - let Some(n) = d.node_mut(*id) else { continue }; - let b = n.geometric_bounds(); - if let Some(i) = index { - let Some(item) = n.appearance.items.get_mut(i) else { continue }; - let (paint, b) = match item { - AppearanceItem::Fill(f) => (&mut f.paint, b), - AppearanceItem::Stroke(st) => { - let b = b.map(|b| st.paint_bounds(b)); - (&mut st.paint, b) - } - }; - if let Some(np) = vector_paint(paint, start, end, Affine::IDENTITY, b) { + edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| { + if index.is_none() + && let NodeKind::Text(t) = &mut n.kind + { + let (xf, lb) = (t.xf, t.local_bounds()); + for r in &mut t.runs { + if stroke && r.style.stroke_width == 0.0 { + r.style.stroke_width = 1.0; + } + let (paint, b) = run_paint_mut(r, stroke, lb); + if let Some(np) = vector_paint(paint, start, end, xf, Some(b)) { *paint = np; - hit = true; } - continue; - } - if let NodeKind::Text(t) = &mut n.kind { - let (xf, lb) = (t.xf, t.local_bounds()); - for r in &mut t.runs { - if stroke && r.style.stroke_width == 0.0 { - r.style.stroke_width = 1.0; - } - let (paint, b) = run_paint_mut(r, stroke, lb); - if let Some(np) = vector_paint(paint, start, end, xf, Some(b)) { - *paint = np; - hit = true; - } - } - continue; - } - let ap = &mut n.appearance; - let (cur, b) = if stroke { (ap.stroke_paint(), b.zip(ap.stroke()).map(|(b, st)| st.paint_bounds(b))) } else { (ap.fill_paint(), b) }; - if let Some(np) = vector_paint(&cur, start, end, Affine::IDENTITY, b) { - if stroke { - ap.set_stroke(np); - } else { - ap.set_fill(np); - } - hit = true; } + return Ok(()); } - match (hit, index) { - (false, Some(i)) => Err(bad(C, format!("no appearance item at index {i}"))), - _ => Ok(()), + let b = item_paint_bounds(n, index, !stroke); + if let Some(np) = vector_paint(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), start, end, Affine::IDENTITY, b) { + n.appearance.set_paint_at(index, !stroke, np); } + 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 e1e1818e..d58bdd37 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 6d21a454..4da442fa 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1,10 +1,11 @@ //! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel. use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{Appearance, NodeKind}; -use super::gradient::{place_paint, place_run_paint, run_paint_mut, unplaced}; +use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target}; +use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, unplaced}; use super::*; use crate::EngineError; @@ -15,11 +16,13 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, ids?} sets selection fill and the default (new art fits a gradient to itself)", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, 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 (new art fits a gradient to itself)", 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, _| { @@ -35,7 +38,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 ), @@ -74,7 +77,8 @@ 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"))?; // New art gets the paint fitted to itself, not placed where this one is. if fill { s.paint.fill = unplaced(&paint); @@ -82,34 +86,29 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { s.paint.stroke = unplaced(&paint); } 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 { - let (xf, lb) = (t.xf, t.local_bounds()); - for r in &mut t.runs { - if !fill && r.style.stroke_width == 0.0 { - r.style.stroke_width = 1.0; - } - let (cur, b) = run_paint_mut(r, !fill, lb); - *cur = place_run_paint(&paint, p, xf, b); - } - continue; - } - let b = n.geometric_bounds(); - if fill { - n.appearance.set_fill(place_paint(&paint, p, b)); - } else { - n.appearance.set_stroke(paint.clone()); - if let Some(st) = n.appearance.stroke_mut() { - st.paint = place_paint(&paint, p, b.map(|b| st.paint_bounds(b))); + 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() + && let NodeKind::Text(t) = &mut n.kind + { + let (xf, lb) = (t.xf, t.local_bounds()); + for r in &mut t.runs { + if !fill && r.style.stroke_width == 0.0 { + r.style.stroke_width = 1.0; } + let (cur, b) = run_paint_mut(r, !fill, lb); + *cur = place_run_paint(&paint, p, xf, b); } + return Ok(()); } + // A missing top fill or stroke is created first: a new stroke's weight sizes the box its + // gradient fits. + if n.appearance.paint_at(index, fill).is_none() { + n.appearance.set_paint_at(index, fill, Paint::None); + } + let placed = place_paint(&paint, p, item_paint_bounds(n, index, fill)); + n.appearance.set_paint_at(index, fill, placed); Ok(()) })?; ok() @@ -155,9 +154,41 @@ 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)); + let ItemTarget::Item { index, explicit } = item else { + if let Some(o) = opacity { + q.insert("opacity".into(), json!(o)); + } + 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}"))); + } } - s.execute("object.setProps", &q) + 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(); + } + } + s.execute("appearance.setItem", &set)?; + } + 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 2f89ef7c..1e2c7e91 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::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, WidthProfile}; +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 (a 0 dash with a round or projecting cap draws dots or squares), dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", + "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares), dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), 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,21 +31,18 @@ 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)?.of_kind(s, false); + 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")?); @@ -52,68 +50,65 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { None => None, Some("extend") => Some(ArrowAlign::Extend), Some("tip") => Some(ArrowAlign::Tip), - Some(o) => return Err(bad("stroke.set", format!("arrowAlign must be extend|tip, got {o}"))), + Some(o) => return Err(bad(C, format!("arrowAlign must be extend|tip, got {o}"))), }; let profile = match str_param(p, "profile") { None => None, Some("uniform") => Some(None), - Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad("stroke.set", format!("unknown profile {id}")))?)), + Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad(C, format!("unknown profile {id}")))?)), }; - 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; - } - if let Some(a) = arrow_align { - st.arrow_align = a; - } - if let Some(pr) = &profile { - st.profile = pr.clone(); + 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; + } + if let Some(a) = arrow_align { + st.arrow_align = a; + } + if let Some(pr) = &profile { + st.profile = pr.clone(); } Ok(()) })?; @@ -154,27 +149,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)?.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(()) }; + 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 7b325cf6..1e8b17bf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -447,6 +447,9 @@ pub struct Session { /// shared by the Gradient tool's annotator, the Gradient and Color panels and agents: read it /// with [`Session::selected_stop`]. pub(crate) gradient_stop: Option<(usize, cmd::gradient::StopOwner)>, + /// 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 { @@ -473,6 +476,7 @@ impl Session { untitled_counter: 0, menu: Default::default(), gradient_stop: None, + active_appearance_item: None, } } @@ -624,6 +628,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); @@ -712,6 +717,8 @@ impl Session { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_appearance; +#[cfg(test)] mod tests_brushsym; #[cfg(test)] mod tests_build; @@ -740,10 +747,10 @@ mod tests_pattern; #[cfg(test)] mod tests_prefs; #[cfg(test)] -mod tests_swatches; -#[cfg(test)] mod tests_strokegeom; #[cfg(test)] +mod tests_swatches; +#[cfg(test)] mod tests_textedit; #[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs new file mode 100644 index 00000000..d55627a6 --- /dev/null +++ b/crates/engine/src/tests_appearance.rs @@ -0,0 +1,408 @@ +//! 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())); +} + +#[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:?}"); +} + +#[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:?}"); +} + +#[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()); +} + +#[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); +} + +#[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/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs index 5db0f87b..6fadaa41 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests_gradient.rs @@ -213,7 +213,7 @@ fn gradient_vector_follows_the_active_proxy_or_an_item_index() { s.execute("paint.setGradientGeom", &json!({"start": [50, 0], "end": [50, 100], "index": 0})).unwrap(); assert_eq!(fill_geom(&s, id).start, Point::new(50.0, 0.0)); let err = s.execute("paint.setGradientGeom", &json!({"start": [0, 0], "end": [1, 1], "index": 7})).unwrap_err(); - assert!(err.to_string().contains("index 7"), "{err}"); + assert!(err.to_string().contains("item 7"), "{err}"); } #[test] @@ -226,14 +226,14 @@ fn type_objects_take_the_vector_on_their_runs_in_text_space() { // Text space starts at the first baseline (100, 200). assert!(geom.start.distance(Point::new(0.0, -10.0)) < 1e-9 && geom.end.distance(Point::new(200.0, -10.0)) < 1e-9, "{geom:?}"); // The annotator reads it back in document coordinates, and it turns with the text. - let (_, doc) = node(&s, id).proxy_gradient(false).unwrap(); + let (_, doc) = node(&s, id).proxy_gradient(false, None).unwrap(); assert!(doc.start.distance(Point::new(100.0, 190.0)) < 1e-9, "{doc:?}"); s.execute("object.rotate", &json!({"angle": 90, "origin": [100, 200]})).unwrap(); - let (_, doc) = node(&s, id).proxy_gradient(false).unwrap(); + let (_, doc) = node(&s, id).proxy_gradient(false, None).unwrap(); assert!(doc.start.distance(Point::new(90.0, 200.0)) < 1e-9 && doc.end.distance(Point::new(90.0, 0.0)) < 1e-9, "{doc:?}"); // paint.setFill's vector is in document coordinates too. s.execute("paint.setFill", &json!({"gradient": {"start": [90, 200], "end": [90, 100]}})).unwrap(); - let (_, doc) = node(&s, id).proxy_gradient(false).unwrap(); + let (_, doc) = node(&s, id).proxy_gradient(false, None).unwrap(); assert!(doc.end.distance(Point::new(90.0, 100.0)) < 1e-9, "{doc:?}"); } @@ -255,7 +255,7 @@ fn aspect_works_on_type_and_run_strokes_fit_the_inflated_box() { assert!((geom.length() - (lb.width().max(lb.height()) + w) / 2.0).abs() < 1e-9, "{geom:?}"); // The Stroke proxy's vector lands on the runs' strokes. s.execute("paint.setGradientGeom", &json!({"start": [100, 190], "end": [150, 190], "stroke": true})).unwrap(); - let (g, doc) = node(&s, id).proxy_gradient(true).unwrap(); + let (g, doc) = node(&s, id).proxy_gradient(true, None).unwrap(); assert!(doc.end.distance(Point::new(150.0, 190.0)) < 1e-9 && (doc.aspect - 2.0).abs() < 1e-9, "aspect kept: {doc:?}"); assert_eq!(g.gradient.kind, GradientKind::Radial); } @@ -370,7 +370,7 @@ fn swatches_and_new_art_fit_a_placed_gradient_to_themselves() { // On type, in text space. let t = text(&mut s, 100.0, 400.0); s.execute("paint.setFill", &json!({"swatch": "Placed"})).unwrap(); - let (_, doc) = node(&s, t).proxy_gradient(false).unwrap(); + let (_, doc) = node(&s, t).proxy_gradient(false, None).unwrap(); let tb = node(&s, t).geometric_bounds().unwrap(); assert!(doc.start.distance(tb.center()) < 1e-9 && (doc.aspect - 0.5).abs() < 1e-9, "{doc:?} on {tb:?}"); } diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index d0ca84df..9f040abc 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -80,6 +80,7 @@ impl Session { corner_widgets: view.corner_widgets, fill_active: self.fill_active, gradient_stop: self.selected_stop(), + appearance_item: self.appearance_item(), }; f(self.tool.as_mut(), &cx) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 19579ef7..9538d623 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, st.paint_bounds(tb), f.doc) => { + AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => { ctx.set_stroke(kurbo::Stroke::new(st.width)); ctx.stroke_path(&all); } _ => {} } + if layered { + ctx.pop_layer(); + } } } } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f0f6c0a1..605403fe 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -160,6 +160,9 @@ pub struct ToolContext<'a> { pub fill_active: bool, /// The selected gradient stop (`gradient.selectStop`), marked on the gradient annotator. pub gradient_stop: Option, + /// 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<'_> { @@ -342,6 +345,7 @@ pub(crate) mod testutil { corner_widgets: true, fill_active: true, gradient_stop: None, + appearance_item: None, } } } diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 71891418..ef6d7e84 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -42,8 +42,8 @@ impl Tool for EyedropperTool { let src = paint_owner(cx.doc, h.leaf); let Some(n) = cx.doc.node(src) else { return vec![] }; if ev.mods.shift { - let fill_none = n.proxy_paint(false).is_none_or(|(p, ..)| p.is_none()); - let Some((paint, to_doc, bounds)) = n.proxy_paint(h.kind == HitKind::Stroke || fill_none) else { return vec![] }; + let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none()); + let Some((paint, to_doc, bounds)) = n.proxy_paint(h.kind == HitKind::Stroke || fill_none, None) else { return vec![] }; // Sample in the paint's own space (text space for type runs) and box. let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * ev.pos } else { ev.pos }; return match paint_color_at(paint, Some(bounds), p) { diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 8c4cc188..2c823f0f 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -72,7 +72,7 @@ pub enum Part { impl Annotator { pub fn of(cx: &ToolContext) -> Option { cx.selection.objects.iter().find_map(|id| { - let (g, geom) = cx.doc.node(*id)?.proxy_gradient(!cx.fill_active)?; + let (g, geom) = cx.doc.node(*id)?.proxy_gradient(!cx.fill_active, cx.appearance_item)?; Some(Self { geom, gradient: g.gradient.clone() }) }) } @@ -216,10 +216,10 @@ fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec { fn click_vector(cx: &ToolContext, id: NodeId) -> Option { let stroke = !cx.fill_active; let n = cx.doc.node(id)?; - if let Some((_, g)) = n.proxy_gradient(stroke) { + if let Some((_, g)) = n.proxy_gradient(stroke, cx.appearance_item) { return Some(g.end - g.start); } - let (to_doc, b) = match n.proxy_paint(stroke) { + let (to_doc, b) = match n.proxy_paint(stroke, cx.appearance_item) { Some((_, a, b)) => (a, b), None => (Affine::IDENTITY, n.geometric_bounds()?), }; @@ -343,6 +343,10 @@ impl GradientTool { let Some(v) = cx.selection.objects.contains(&id).then(|| click_vector(cx, id)).flatten() else { return vec![] }; let mut params = Self::geom_params(cx, p, p + v); params["ids"] = json!([id.0]); + // With `ids` the engine edits the topmost fill or stroke unless an item is named. + if let Some(i) = cx.doc.node(id).and_then(|n| n.appearance.item_of_kind(cx.appearance_item, cx.fill_active)) { + params["item"] = json!(i); + } vec![Action::Exec("paint.setGradientGeom".into(), params)] } _ => vec![], @@ -699,4 +703,29 @@ mod tests { assert_eq!(chips, vec![false, true]); assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { .. })).count(), 1, "one midpoint diamond"); } + + #[test] + fn annotator_follows_the_active_appearance_item() { + let (mut d, id) = doc_with_rect(); + let geom = GradientGeom { start: Point::new(100.0, 120.0), end: Point::new(200.0, 120.0), aspect: 1.0 }; + let mut gp = 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, which + // brings the Stroke proxy forward. + assert!(Annotator::of(&cx).is_none()); + cx.appearance_item = Some(1); + assert!(Annotator::of(&cx).is_none(), "a stroke row does not stand in for the fill"); + cx.fill_active = false; + assert_eq!(Annotator::of(&cx).unwrap().geom, geom); + // A click inside the art edits that item. + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, Point::new(150.0, 180.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 180.0))); + assert_eq!(exec(&a[0]).1["item"], 1); + } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 1ce16d9e..8be42e7d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -92,24 +92,19 @@ 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); - 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 +129,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/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/form.rs b/crates/ui-egui/src/dialogs/form.rs index 9a6d1416..e6b7aa06 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -67,6 +67,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/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 326640dd..7d9f2325 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", "{}"), @@ -457,6 +462,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option 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 } @@ -1791,4 +1797,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 3be3192e..1d7f86c0 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -7,23 +7,69 @@ use std::sync::OnceLock; use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{AppearanceItem, Effect, Node}; +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; -const EYE_W: f32 = 26.0; +pub(super) 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 (also the owner of a +/// selected item effect) is the engine's active appearance item (`appearance.setActiveItem`), so +/// paint edits, the fx button and the Effect menu target it; effect rows are panel state. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Sel { #[default] None, Item(usize), + /// An object-level effect. Effect(usize), + /// Effect `k` of fill/stroke item `i`. + ItemEffect(usize, usize), +} + +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, + } + } +} + +/// 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) { + if app.session.appearance_item() != sel.item() { + app.run("appearance.setActiveItem", json!({ "index": sel.item() })).ok(); + } + 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)] { @@ -117,25 +163,27 @@ 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(), 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; - // 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(), - (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), } @@ -145,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(); @@ -164,19 +231,16 @@ 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, 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(); } @@ -186,7 +250,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 { @@ -194,20 +258,20 @@ 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); }); - 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(); } } 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()); @@ -219,9 +283,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); + 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)); } } } @@ -245,21 +309,14 @@ 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); - 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 { + // 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)); } } @@ -270,42 +327,61 @@ 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 => {} + }); } -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); - set_pstate(ui.ctx(), "ap-sel", 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() { - set_pstate(ui.ctx(), "ap-sel", 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(); @@ -324,6 +400,12 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, k: usize, e: &Effect) { 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) { @@ -332,7 +414,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))); } } @@ -351,7 +433,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(); } } @@ -415,11 +497,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() { @@ -429,14 +509,24 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel) }); } -fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) { - 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 }); } } @@ -465,7 +555,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(), node.as_ref()); if menu_item(ui, "Add New Fill", has, false) { app.run("appearance.addFill", json!({})).ok(); } @@ -473,10 +563,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/layers.rs b/crates/ui-egui/src/panels/layers.rs index 547bc7f6..585c4ba8 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)); @@ -290,6 +290,12 @@ fn row( } } +/// 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 { @@ -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/mod.rs b/crates/ui-egui/src/panels/mod.rs index f5772de1..20b4807a 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}; @@ -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. @@ -164,19 +163,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. @@ -315,3 +342,5 @@ mod tests { assert_eq!(p["gradient"]["stops"][0]["midpoint"], json!(0.5)); } } +#[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 e9a466d0..542ee333 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -5,7 +5,7 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{ArrowAlign, Arrowhead, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, 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}; @@ -64,17 +64,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; @@ -377,7 +373,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"})); } } @@ -444,7 +440,7 @@ mod tests { r(&mut app, "stroke.set", p); frame(&mut app); } - let st = stroke_now(&app).unwrap(); + let st = current_stroke(&app).unwrap(); assert_eq!((st.end_arrow, st.arrow_align), (Some(Arrowhead::ArrowOpen), ArrowAlign::Tip)); assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "lens"); } 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..fdfc53d4 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -0,0 +1,186 @@ +//! 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, 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<(String, egui::Rect)>) { + match s { + 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)), + _ => {} + } + } + let mut texts = vec![]; + for c in &out.shapes { + collect(&c.shape, &mut texts); + } + 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(); + 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"))); +} + +#[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() +} + +#[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)); +} + +#[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())); +} diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 63f24b6c..c0a75dca 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -6,9 +6,9 @@ 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::widgets::{self, TransparencyEdit, menu_item}; use crate::{VectorcraftApp, icons}; pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -20,38 +20,20 @@ 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)); - 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); - }); - }); + 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)); + 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/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/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 02c7bac9..881996f8 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. diff --git a/docs/mcp.md b/docs/mcp.md index 37435054..19e0bf18 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -68,6 +68,16 @@ 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`. 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.