Merge wp/D1

# Conflicts:
#	crates/engine/src/cmd/appearance.rs
#	crates/engine/src/cmd/gradient.rs
#	crates/engine/src/cmd/paint.rs
#	crates/engine/src/cmd/stroke.rs
#	crates/engine/src/lib.rs
#	crates/engine/src/tooling.rs
#	crates/render/src/lib.rs
#	crates/tools/src/lib.rs
#	crates/tools/src/xform/gradient.rs
#	crates/ui-egui/src/panels/stroke.rs
This commit is contained in:
bflatastic 2026-10-04 22:01:05 +07:00
commit 721bb483c3
32 changed files with 1958 additions and 557 deletions

View File

@ -299,6 +299,53 @@ pub enum AppearanceItem {
Stroke(StrokeLayer), 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<Effect> {
match self {
AppearanceItem::Fill(f) => &f.effects,
AppearanceItem::Stroke(s) => &s.effects,
}
}
pub fn effects_mut(&mut self) -> &mut Vec<Effect> {
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 attributes. `items` is in paint order: `items[0]` is painted first (the bottom of the
/// Appearance panel list). /// Appearance panel list).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@ -355,15 +402,105 @@ impl Appearance {
pub fn stroke_width(&self) -> f64 { pub fn stroke_width(&self) -> f64 {
self.stroke().filter(|s| !s.paint.is_none()).map(|s| s.width).unwrap_or(0.0) 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 { 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.effects.is_empty()
&& self.items.len() <= 2 && count(true) <= 1
&& self.items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count() <= 1 && count(false) <= 1
&& self.items.iter().all(|i| match i { && self.items.iter().all(|i| i.visible() && i.effects().is_empty() && i.opacity() == 1.0 && i.blend() == BlendMode::Normal)
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, /// Fill item `index`, or the topmost fill for `None`. `None` when that item is not a fill.
}) pub fn fill_at(&self, index: Option<usize>) -> 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<usize>) -> 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<usize>) -> 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<usize>) -> 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<usize>, fill: bool) -> Option<usize> {
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<usize>) -> 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<usize>) -> 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<usize>, 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<usize>, 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<usize>, 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<usize>, 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<usize>) -> Option<&Vec<Effect>> {
match index {
None => Some(&self.effects),
Some(i) => self.items.get(i).map(AppearanceItem::effects),
}
}
pub fn effects_mut(&mut self, index: Option<usize>) -> Option<&mut Vec<Effect>> {
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). /// Largest distance the painted area extends beyond the geometry (for visual bounds).
pub fn outset(&self) -> f64 { pub fn outset(&self) -> f64 {
@ -457,6 +594,47 @@ mod tests {
assert_eq!(a.stroke_width(), 1.0); 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] #[test]
fn set_creates_missing() { fn set_creates_missing() {
let mut a = Appearance::default(); let mut a = Appearance::default();

View File

@ -357,6 +357,11 @@ impl Node {
pub fn is_container(&self) -> bool { pub fn is_container(&self) -> bool {
self.children().is_some() 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. /// Kind name as the Layers panel / Properties panel shows it.
pub fn kind_label(&self) -> &'static str { pub fn kind_label(&self) -> &'static str {
match &self.kind { match &self.kind {
@ -367,7 +372,7 @@ impl Node {
NodeKind::Path { guide: true, .. } => "Guide", NodeKind::Path { guide: true, .. } => "Guide",
NodeKind::Path { .. } => "Path", NodeKind::Path { .. } => "Path",
NodeKind::Compound { .. } => "Compound Path", NodeKind::Compound { .. } => "Compound Path",
NodeKind::Text(_) => "Text", NodeKind::Text(_) => "Type",
NodeKind::Image(_) => "Image", NodeKind::Image(_) => "Image",
NodeKind::SymbolInstance { .. } => "Symbol", NodeKind::SymbolInstance { .. } => "Symbol",
NodeKind::Blend { .. } => "Blend", 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 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 /// the box (in that space) an unplaced gradient fits. `item` (the Appearance panel's active
/// run speaks for all) in text space, fitted to the layout bounds; everything else paints its /// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise
/// top fill or stroke in the document, fitted to the geometric bounds. Strokes fit the box /// type objects paint their runs (the first run speaks for all) in text space, fitted to the
/// grown by half their weight. /// layout bounds; everything else paints its fill or stroke in the document, fitted to the
pub fn proxy_paint(&self, stroke: bool) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> { /// geometric bounds. Strokes fit the box grown by half their weight.
if let NodeKind::Text(t) = &self.kind { pub fn proxy_paint(&self, stroke: bool, item: Option<usize>) -> 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 st = &t.runs.first()?.style;
let b = t.local_bounds(); 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) }); 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()?; let b = self.geometric_bounds()?;
if stroke { 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 { } 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 /// 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. /// (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)> { pub fn proxy_gradient(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> {
let (paint, to_doc, b) = self.proxy_paint(stroke)?; let (paint, to_doc, b) = self.proxy_paint(stroke, item)?;
let vectorcraft_color::Paint::Gradient(g) = paint else { return None }; let vectorcraft_color::Paint::Gradient(g) = paint else { return None };
let mut geom = g.resolve(b); let mut geom = g.resolve(b);
if to_doc != Affine::IDENTITY { if to_doc != Affine::IDENTITY {
@ -578,6 +587,24 @@ mod tests {
assert_eq!(l.label(), "Rectangle"); 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] #[test]
fn transform_group_recurses() { 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()); let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());

View File

@ -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. //! 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 serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint}; 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::edit::selected_roots;
use super::paint::paint_from; use super::paint::paint_from;
@ -12,23 +20,63 @@ use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
cmd!("appearance.addFill", "Add New Fill", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, true)), cmd!(
cmd!("appearance.addStroke", "Add New Stroke", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, false)), "appearance.addFill",
cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{}", has_selection, clear_appearance), "Add New Fill",
cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{}", has_selection, reduce_basic), ["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!( cmd!(
"appearance.setItem", "appearance.setItem",
"Appearance Item", "Appearance Item",
[], [],
None, 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, has_selection,
set_item set_item
), ),
cmd!("appearance.removeItem", "Remove Item", [], None, "{index}", has_selection, remove_item), cmd!("appearance.removeItem", "Remove Item", [], None, "{index, ids?}", has_selection, remove_item),
cmd!("appearance.addEffect", "Add Effect", ["Effect"], None, "{effect: id, params?: {…}} append a live effect", has_selection, add_effect), cmd!(
cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index}", has_selection, duplicate_item), "appearance.addEffect",
cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to} (paint-order indices)", has_selection, move_item), "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!( cmd!(
"appearance.copyFrom", "appearance.copyFrom",
"Eyedropper", "Eyedropper",
@ -38,11 +86,195 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc, has_doc,
copy_from 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<Value> { /// The Appearance panel's active row, remembered for the selection it was chosen in.
let ids = paint_targets(s, &json!({}))?; #[derive(Clone, Debug)]
pub(crate) struct ActiveItem {
doc: u64,
objects: Vec<NodeId>,
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<usize> {
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<usize>) {
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<ItemTarget> {
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<Vec<NodeId>> {
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<Option<usize>> {
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<Option<usize>> {
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<Vec<NodeId>> {
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<usize>) -> 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<bool> {
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<usize> {
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<Value> {
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<Value> {
let ids = appearance_targets(s, p)?;
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| { s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
for id in &ids { for id in &ids {
if let Some(n) = d.node_mut(*id) { if let Some(n) = d.node_mut(*id) {
@ -59,28 +291,42 @@ fn add_item(s: &mut Session, fill: bool) -> Result<Value> {
ok() ok()
} }
fn clear_appearance(s: &mut Session, _: &Value) -> Result<Value> { fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?; let ids = appearance_targets(s, p)?;
s.edit("Clear Appearance", |d, _| { s.edit("Clear Appearance", |d, _| {
for id in &ids { for id in &ids {
if let Some(n) = d.node_mut(*id) { 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(()) Ok(())
})?; })?;
s.active_appearance_item = None;
ok() ok()
} }
fn reduce_basic(s: &mut Session, _: &Value) -> Result<Value> { fn is_group(n: &Node) -> bool {
let ids = paint_targets(s, &json!({}))?; matches!(n.kind, NodeKind::Group { .. })
}
fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
let ids = appearance_targets(s, p)?;
s.edit("Reduce to Basic Appearance", |d, _| { s.edit("Reduce to Basic Appearance", |d, _| {
for id in &ids { for id in &ids {
if let Some(n) = d.node_mut(*id) { if let Some(n) = d.node_mut(*id) {
let f = n.appearance.fill_paint(); // The topmost visible fill and stroke stay, without their own transparency or effects.
let st = n.appearance.stroke().cloned(); let top = |fill: bool| n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned();
n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(f))], effects: vec![] }; let (fill, stroke) = (top(true), top(false));
if let Some(mut st) = st { // 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.effects.clear();
st.opacity = 1.0; st.opacity = 1.0;
st.blend = BlendMode::Normal; st.blend = BlendMode::Normal;
@ -90,13 +336,14 @@ fn reduce_basic(s: &mut Session, _: &Value) -> Result<Value> {
} }
Ok(()) Ok(())
})?; })?;
s.active_appearance_item = None;
ok() ok()
} }
fn set_item(s: &mut Session, p: &Value) -> Result<Value> { fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
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 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); let blend = str_param(p, "blend").and_then(BlendMode::parse);
s.edit("Appearance", |d, _| { s.edit("Appearance", |d, _| {
for id in &ids { for id in &ids {
@ -132,8 +379,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
} }
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> { fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.removeItem", "missing index"))? as usize; let idx = index_param(p, "index", "appearance.removeItem")?;
let ids = paint_targets(s, p)?; let ids = appearance_targets(s, p)?;
s.edit("Remove Item", |d, _| { s.edit("Remove Item", |d, _| {
for id in &ids { for id in &ids {
if let Some(n) = d.node_mut(*id) if let Some(n) = d.node_mut(*id)
@ -144,27 +391,17 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
} }
Ok(()) Ok(())
})?; })?;
ok() s.remap_appearance_item(&ids, |a| match a.cmp(&idx) {
} std::cmp::Ordering::Less => Some(a),
std::cmp::Ordering::Equal => None,
fn add_effect(s: &mut Session, p: &Value) -> Result<Value> { std::cmp::Ordering::Greater => Some(a - 1),
let id = str_param(p, "effect").ok_or_else(|| bad("appearance.addEffect", "missing effect id"))?.to_string(); });
let params = p.get("params").cloned().unwrap_or(json!({}));
let ids = targets(s, p)?;
s.edit("Add Effect", |d, _| {
for nid in &ids {
if let Some(n) = d.node_mut(*nid) {
n.appearance.effects.push(vectorcraft_doc::Effect { id: id.clone(), params: params.clone(), visible: true });
}
}
Ok(())
})?;
ok() ok()
} }
fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> { fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.duplicateItem", "missing index"))? as usize; let idx = index_param(p, "index", "appearance.duplicateItem")?;
let ids = paint_targets(s, p)?; let ids = appearance_targets(s, p)?;
s.edit("Duplicate Item", |d, _| { s.edit("Duplicate Item", |d, _| {
let mut any = false; let mut any = false;
for id in &ids { for id in &ids {
@ -176,14 +413,16 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
} }
if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) } 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() ok()
} }
fn move_item(s: &mut Session, p: &Value) -> Result<Value> { fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.moveItem"; 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 from = index_param(p, "from", C)?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize; let to = index_param(p, "to", C)?;
let ids = paint_targets(s, p)?; let ids = appearance_targets(s, p)?;
let mut landed = to;
s.edit("Reorder Appearance", |d, _| { s.edit("Reorder Appearance", |d, _| {
for id in &ids { for id in &ids {
let Some(n) = d.node_mut(*id) else { continue }; let Some(n) = d.node_mut(*id) else { continue };
@ -192,11 +431,23 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("no item at index {from}"))); return Err(bad(C, format!("no item at index {from}")));
} }
let it = items.remove(from); let it = items.remove(from);
let to = to.min(items.len()); landed = to.min(items.len());
items.insert(to, it); items.insert(landed, it);
} }
Ok(()) 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() ok()
} }

View File

@ -6,11 +6,11 @@
use std::sync::Arc; use std::sync::Arc;
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_doc::{Effect, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_render::effects; use vectorcraft_render::effects;
use super::edit::selected_roots; use super::appearance::{ItemTarget, appearance_targets, index_param, item_target};
use super::*; use super::*;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
@ -20,17 +20,25 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Effect", "Apply Effect",
[], [],
None, 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, has_selection,
apply 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!( cmd!(
"effect.remove", "effect.remove",
"Remove Effect", "Remove Effect",
[], [],
None, 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, has_selection,
remove remove
), ),
@ -39,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effect Options", "Effect Options",
[], [],
None, 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, has_selection,
set_params set_params
), ),
@ -52,21 +60,52 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection, has_selection,
expand_appearance 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<Vec<NodeId>> {
if p.get("ids").is_some() || p.get("id").is_some() {
return targets(s, p);
}
selected_roots(s)
}
fn ids_json(ids: &[NodeId]) -> Value { fn ids_json(ids: &[NodeId]) -> Value {
json!(ids.iter().map(|i| i.0).collect::<Vec<_>>()) json!(ids.iter().map(|i| i.0).collect::<Vec<_>>())
} }
fn apply(s: &mut Session, p: &Value) -> Result<Value> { /// 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<Effect>) -> bool,
) -> Result<Vec<NodeId>> {
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<Value> {
const C: &str = "effect.apply"; 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 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); let params = p.get("params").cloned().unwrap_or(Value::Null);
@ -74,26 +113,18 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`params` must be an object")); return Err(bad(C, "`params` must be an object"));
} }
let effect = effects::new_effect(id, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?; let effect = effects::new_effect(id, &params).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 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 item = item_target(s, p, C)?;
let mut done = vec![]; let mut index = 0;
let mut index = 0; let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
for id in &roots { fx.push(effect.clone());
let Some(n) = d.node_mut(*id) else { continue }; index = fx.len() - 1;
if n.is_layer() { true
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))
})?; })?;
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<Value> { fn list(s: &mut Session, p: &Value) -> Result<Value> {
@ -103,73 +134,72 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
.collect(); .collect();
let mut applied = vec![]; let mut applied = vec![];
if s.active().is_some() { 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) { 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<Value> = 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 })) Ok(json!({ "catalog": catalog, "applied": applied, "activeItem": s.appearance_item() }))
}
fn index_param(p: &Value, cmd: &str) -> Result<usize> {
p.get("index").and_then(Value::as_u64).map(|i| i as usize).ok_or_else(|| bad(cmd, "missing integer `index`"))
} }
fn remove(s: &mut Session, p: &Value) -> Result<Value> { fn remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.remove"; const C: &str = "effect.remove";
let index = index_param(p, C)?; let index = index_param(p, "index", C)?;
let roots = target_roots(s, p)?; let item = item_target(s, p, C)?;
let ids = s.edit("Remove Effect", |d, _| { let ids = edit_effects(s, p, item, C, "Remove Effect", &format!("{C}: no effect at index {index}"), |fx| {
let mut done = vec![]; (index < fx.len()).then(|| fx.remove(index)).is_some()
for id in &roots { })?;
let Some(n) = d.node_mut(*id) else { continue }; Ok(json!({ "ids": ids_json(&ids) }))
if index < n.appearance.effects.len() { }
n.appearance.effects.remove(index);
done.push(*id); fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
} 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() { None => false,
return Err(bad(C, format!("no effect at index {index}")));
}
Ok(done)
})?; })?;
Ok(json!({ "ids": ids_json(&ids) })) Ok(json!({ "ids": ids_json(&ids) }))
} }
fn set_params(s: &mut Session, p: &Value) -> Result<Value> { fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams"; 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); let params = p.get("params").cloned().unwrap_or(Value::Null);
if !params.is_null() && !params.is_object() { if !params.is_null() && !params.is_object() {
return Err(bad(C, "`params` must be an object")); return Err(bad(C, "`params` must be an object"));
} }
let visible = p.get("visible").and_then(Value::as_bool); let visible = p.get("visible").and_then(Value::as_bool);
let roots = target_roots(s, p)?; let item = item_target(s, p, C)?;
let ids = s.edit("Effect Options", |d, _| { let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
let mut done = vec![]; let Some(e) = fx.get_mut(index) else { return false };
for id in &roots { if let (Value::Object(new), cur) = (&params, &mut e.params) {
let Some(n) = d.node_mut(*id) else { continue }; if !cur.is_object() {
let Some(e) = n.appearance.effects.get_mut(index) else { continue }; *cur = effects::merged_params(&e.id, &Value::Null);
if let (Value::Object(new), cur) = (&params, &mut e.params) { }
if !cur.is_object() { if let Value::Object(m) = cur {
*cur = effects::merged_params(&e.id, &Value::Null); for (k, v) in new {
} m.insert(k.clone(), v.clone());
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() { if let Some(v) = visible {
return Err(bad(C, format!("no effect at index {index}"))); e.visible = v;
} }
Ok(done) true
})?; })?;
Ok(json!({ "ids": ids_json(&ids) })) Ok(json!({ "ids": ids_json(&ids) }))
} }
@ -242,7 +272,7 @@ fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec<Node
} }
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> { fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let roots = target_roots(s, p)?; let roots = appearance_targets(s, p)?;
let ids = s.edit("Expand Appearance", |d, _| { let ids = s.edit("Expand Appearance", |d, _| {
let mut out = vec![]; let mut out = vec![];
for id in &roots { for id in &roots {

View File

@ -3,9 +3,10 @@
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::appearance::stroke_paint_bounds;
use vectorcraft_doc::{AppearanceItem, NodeKind}; use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_geom::{Affine, Point, Rect};
use super::appearance::{ItemTarget, edit_items, edits_stroke, item_target};
use super::edit::selected_roots; use super::edit::selected_roots;
use super::*; use super::*;
use crate::EngineError; use crate::EngineError;
@ -17,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient", "Gradient",
["Window", "Gradient"], ["Window", "Gradient"],
None, 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, has_doc,
edit_gradient edit_gradient
), ),
@ -26,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient Vector", "Gradient Vector",
[], [],
None, 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, has_doc,
set_gradient_geom set_gradient_geom
), ),
@ -43,12 +44,13 @@ pub fn specs() -> Vec<CommandSpec> {
} }
/// The gradient behind the active proxy: the first selected object's (see /// 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<GradientPaint> { pub(crate) fn active_gradient(s: &Session) -> Option<GradientPaint> {
let stroke = !s.fill_active; let stroke = !s.fill_active;
let first = s.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id))); let first = s.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id)));
let paint = match first { 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() }), None => Some(if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }),
}; };
match paint { match paint {
@ -76,18 +78,19 @@ fn select_stop(s: &mut Session, p: &Value) -> Result<Value> {
} }
/// Whose gradient a selected stop belongs to: the active document, its first selected object /// 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 /// (None: the default paint), the proxy in front and the Appearance panel's active item.
/// leaves no stop selected. /// Selecting other art, toggling the proxy or picking another item leaves no stop selected.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct StopOwner { pub(crate) struct StopOwner {
doc: Option<usize>, doc: Option<usize>,
object: Option<NodeId>, object: Option<NodeId>,
fill: bool, fill: bool,
item: Option<usize>,
} }
impl StopOwner { impl StopOwner {
fn of(s: &Session) -> Self { 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<Rect>
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> { fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.editGradient"; const C: &str = "paint.editGradient";
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); let item = item_target(s, p, C)?;
let ids = paint_targets(s, p)?; 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. // 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 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))?; let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?;
if ids.is_empty() { edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| {
if stroke { if index.is_none()
s.paint.stroke = new_default; && let NodeKind::Text(t) = &mut n.kind
} else { {
s.paint.fill = new_default; let lb = t.local_bounds();
} for r in &mut t.runs {
return Ok(json!({"ids": []})); let (cur, b) = run_paint_mut(r, stroke, lb);
} *cur = apply_gradient_edit(cur, p, Some(b)).map_err(|e| bad(C, e))?;
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),
} }
return Ok(());
} }
match err.take() { let b = item_paint_bounds(n, index, !stroke);
Some(e) => Err(bad(C, e)), let np = apply_gradient_edit(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b).map_err(|e| bad(C, e))?;
None => Ok(()), n.appearance.set_paint_at(index, !stroke, np);
} Ok(())
})?; })?;
if stroke { if stroke {
s.paint.stroke = new_default; 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<usize>, fill: bool) -> Option<Rect> {
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 /// `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 /// document coordinates moves into text space, and an aspect without a vector places the gradient
/// on the run's `bounds` (text space). /// on the run's `bounds` (text space).
@ -369,65 +355,43 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.setGradientGeom"; const C: &str = "paint.setGradientGeom";
let start = point_param(p, "start").ok_or_else(|| bad(C, "missing start [x,y]"))?; 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 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); // `index` is an alias of `item`.
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 mut p = p.clone();
let ids = match ids_param(p, "ids") { if let (None, Some(i)) = (p.get("item"), p.get("index").cloned()) {
Some(v) => v, p["item"] = i;
None => selected_roots(s)?, }
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() { if targets.is_empty() {
return Err(EngineError::Other("nothing selected".into())); return Err(EngineError::Other("nothing selected".into()));
} }
s.edit("Gradient", |d, _| { edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| {
let mut hit = false; if index.is_none()
for id in &targets { && let NodeKind::Text(t) = &mut n.kind
let Some(n) = d.node_mut(*id) else { continue }; {
let b = n.geometric_bounds(); let (xf, lb) = (t.xf, t.local_bounds());
if let Some(i) = index { for r in &mut t.runs {
let Some(item) = n.appearance.items.get_mut(i) else { continue }; if stroke && r.style.stroke_width == 0.0 {
let (paint, b) = match item { r.style.stroke_width = 1.0;
AppearanceItem::Fill(f) => (&mut f.paint, b), }
AppearanceItem::Stroke(st) => { let (paint, b) = run_paint_mut(r, stroke, lb);
let b = b.map(|b| st.paint_bounds(b)); if let Some(np) = vector_paint(paint, start, end, xf, Some(b)) {
(&mut st.paint, b)
}
};
if let Some(np) = vector_paint(paint, start, end, Affine::IDENTITY, b) {
*paint = np; *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) { let b = item_paint_bounds(n, index, !stroke);
(false, Some(i)) => Err(bad(C, format!("no appearance item at index {i}"))), if let Some(np) = vector_paint(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), start, end, Affine::IDENTITY, b) {
_ => Ok(()), n.appearance.set_paint_at(index, !stroke, np);
} }
Ok(())
})?; })?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() })) Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
} }

View File

@ -1,6 +1,6 @@
//! The command registry. Ids follow Illustrator's menu structure. //! The command registry. Ids follow Illustrator's menu structure.
mod appearance; pub(crate) mod appearance;
mod brushsym; mod brushsym;
mod buildcmds; mod buildcmds;
pub mod clipboard; pub mod clipboard;

View File

@ -1,10 +1,11 @@
//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel. //! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel.
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint}; use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, NodeKind}; 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 super::*;
use crate::EngineError; use crate::EngineError;
@ -15,11 +16,13 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill", "Fill",
[], [],
None, 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, has_doc,
|s, p| set_paint(s, p, true) |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.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.default", "Default Fill and Stroke", [], Some("D"), "{}", has_doc, default_paint),
cmd!("paint.toggleActive", "Toggle Fill/Stroke Focus", [], Some("X"), "{}", always, |s, _| { cmd!("paint.toggleActive", "Toggle Fill/Stroke Focus", [], Some("X"), "{}", always, |s, _| {
@ -35,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transparency", "Transparency",
["Window", "Transparency"], ["Window", "Transparency"],
None, 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, has_selection,
transparency transparency
), ),
@ -74,7 +77,8 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
} }
fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> { fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
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. // New art gets the paint fitted to itself, not placed where this one is.
if fill { if fill {
s.paint.fill = unplaced(&paint); s.paint.fill = unplaced(&paint);
@ -82,34 +86,29 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
s.paint.stroke = unplaced(&paint); s.paint.stroke = unplaced(&paint);
} }
s.fill_active = fill; s.fill_active = fill;
let ids = paint_targets(s, p)?; let item = item_target(s, p, cmd)?.of_kind(s, fill);
if ids.is_empty() { let ids = item.targets(s, p)?;
return ok(); edit_items(s, &ids, item, cmd, if fill { "Fill Color" } else { "Stroke Color" }, fill, |n, index| {
} if index.is_none()
s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| { && let NodeKind::Text(t) = &mut n.kind
for id in &ids { {
let Some(n) = d.node_mut(*id) else { continue }; let (xf, lb) = (t.xf, t.local_bounds());
if let NodeKind::Text(t) = &mut n.kind { for r in &mut t.runs {
let (xf, lb) = (t.xf, t.local_bounds()); if !fill && r.style.stroke_width == 0.0 {
for r in &mut t.runs { r.style.stroke_width = 1.0;
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 (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(())
})?; })?;
ok() ok()
@ -155,9 +154,41 @@ fn default_paint(s: &mut Session, _: &Value) -> Result<Value> {
} }
fn transparency(s: &mut Session, p: &Value) -> Result<Value> { fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
let mut q = p.clone(); const C: &str = "transparency.set";
if let Some(o) = p.get("opacity").and_then(Value::as_f64) { let item = item_target(s, p, C)?;
q["opacity"] = json!(o / 100.0); 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))
} }

View File

@ -2,8 +2,9 @@
use serde_json::Value; use serde_json::Value;
use vectorcraft_color::{Color, Paint}; 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::*; use super::*;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
@ -13,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options", "Stroke Options",
["Window", "Stroke"], ["Window", "Stroke"],
None, 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, has_doc,
stroke_set stroke_set
), ),
@ -22,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options", "Stroke Options",
[], [],
None, 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, has_doc,
stroke_advanced stroke_advanced
), ),
@ -30,21 +31,18 @@ pub fn specs() -> Vec<CommandSpec> {
} }
fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> { fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.set";
if let Some(w) = p.get("weight").and_then(Value::as_f64) { if let Some(w) = p.get("weight").and_then(Value::as_f64) {
s.paint.stroke_width = w.max(0.0); s.paint.stroke_width = w.max(0.0);
} }
let ids = paint_targets(s, p)?; let item = item_target(s, p, C)?.of_kind(s, false);
if ids.is_empty() { let ids = item.targets(s, p)?;
return ok();
}
let arrow = |k: &str| -> Result<Option<Option<Arrowhead>>> { let arrow = |k: &str| -> Result<Option<Option<Arrowhead>>> {
match p.get(k) { match p.get(k) {
None => Ok(None), None => Ok(None),
Some(Value::Null) => Ok(Some(None)), Some(Value::Null) => Ok(Some(None)),
Some(Value::String(n)) if n == "none" => Ok(Some(None)), Some(Value::String(n)) if n == "none" => Ok(Some(None)),
Some(v) => { Some(v) => serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))),
serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad("stroke.set", format!("unknown arrowhead {v}")))
}
} }
}; };
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?); let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
@ -52,68 +50,65 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
None => None, None => None,
Some("extend") => Some(ArrowAlign::Extend), Some("extend") => Some(ArrowAlign::Extend),
Some("tip") => Some(ArrowAlign::Tip), 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") { let profile = match str_param(p, "profile") {
None => None, None => None,
Some("uniform") => Some(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, _| { edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
for id in &ids { if index.is_none() && n.appearance.stroke().is_none() {
let Some(n) = d.node_mut(*id) else { continue }; n.appearance.set_stroke(Paint::solid(Color::BLACK));
if 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) {
let st: &mut StrokeLayer = n.appearance.stroke_mut().unwrap(); st.width = w.max(0.0);
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,
match str_param(p, "cap") { Some("square") | Some("projecting") => st.cap = LineCap::Square,
Some("round") => st.cap = LineCap::Round, Some("butt") => st.cap = LineCap::Butt,
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,
match str_param(p, "join") { Some("bevel") => st.join = LineJoin::Bevel,
Some("round") => st.join = LineJoin::Round, Some("miter") => st.join = LineJoin::Miter,
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);
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,
match str_param(p, "align") { Some("outside") => st.align = StrokeAlign::Outside,
Some("inside") => st.align = StrokeAlign::Inside, Some("center") => st.align = StrokeAlign::Center,
Some("outside") => st.align = StrokeAlign::Outside, _ => {}
Some("center") => st.align = StrokeAlign::Center, }
_ => {} match p.get("dash") {
} Some(Value::Null) => st.dash = None,
match p.get("dash") { Some(Value::Array(a)) => {
Some(Value::Null) => st.dash = None, let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
Some(Value::Array(a)) => { st.dash = if pattern.is_empty() {
let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect(); None
st.dash = if pattern.is_empty() { } else {
None Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) })
} 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();
} }
_ => {}
}
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(()) Ok(())
})?; })?;
@ -154,27 +149,22 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
if scale.is_none() && flip.is_none() && !swap && brush.is_none() { if scale.is_none() && flip.is_none() && !swap && brush.is_none() {
return Err(bad(C, "nothing to change")); return Err(bad(C, "nothing to change"));
} }
let ids = paint_targets(s, p)?; let item = item_target(s, p, C)?.of_kind(s, false);
if ids.is_empty() { let ids = item.targets(s, p)?;
return ok(); edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
} let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) };
s.edit("Stroke", |d, _| { if let Some(sc) = scale {
for id in &ids { st.arrow_scale = sc;
let Some(n) = d.node_mut(*id) else { continue }; }
let Some(st) = n.appearance.stroke_mut() else { continue }; if swap {
if let Some(sc) = scale { std::mem::swap(&mut st.start_arrow, &mut st.end_arrow);
st.arrow_scale = sc; st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0);
} }
if swap { if let (Some(along), Some(pr)) = (flip, &st.profile) {
std::mem::swap(&mut st.start_arrow, &mut st.end_arrow); st.profile = Some(flip_profile(pr, along));
st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0); }
} if let Some(b) = &brush {
if let (Some(along), Some(pr)) = (flip, &st.profile) { st.brush = b.clone();
st.profile = Some(flip_profile(pr, along));
}
if let Some(b) = &brush {
st.brush = b.clone();
}
} }
Ok(()) Ok(())
})?; })?;

View File

@ -65,6 +65,6 @@ pub fn document(s: &Session) -> Value {
"redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::<Vec<_>>(), "redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::<Vec<_>>(),
"objects": d.node_count(), "objects": d.node_count(),
"tool": s.tool_id(), "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()},
}) })
} }

View File

@ -447,6 +447,9 @@ pub struct Session {
/// shared by the Gradient tool's annotator, the Gradient and Color panels and agents: read it /// shared by the Gradient tool's annotator, the Gradient and Color panels and agents: read it
/// with [`Session::selected_stop`]. /// with [`Session::selected_stop`].
pub(crate) gradient_stop: Option<(usize, cmd::gradient::StopOwner)>, 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<cmd::appearance::ActiveItem>,
} }
impl Default for Session { impl Default for Session {
@ -473,6 +476,7 @@ impl Session {
untitled_counter: 0, untitled_counter: 0,
menu: Default::default(), menu: Default::default(),
gradient_stop: None, gradient_stop: None,
active_appearance_item: None,
} }
} }
@ -624,6 +628,7 @@ impl Session {
/// Change only the selection (not an undo step). /// Change only the selection (not an undo step).
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> { pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
self.active_appearance_item = None;
let st = self.doc_mut()?; let st = self.doc_mut()?;
f(&st.doc, &mut st.selection); f(&st.doc, &mut st.selection);
st.selection.prune(&st.doc); st.selection.prune(&st.doc);
@ -712,6 +717,8 @@ impl Session {
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
#[cfg(test)] #[cfg(test)]
mod tests_appearance;
#[cfg(test)]
mod tests_brushsym; mod tests_brushsym;
#[cfg(test)] #[cfg(test)]
mod tests_build; mod tests_build;
@ -740,10 +747,10 @@ mod tests_pattern;
#[cfg(test)] #[cfg(test)]
mod tests_prefs; mod tests_prefs;
#[cfg(test)] #[cfg(test)]
mod tests_swatches;
#[cfg(test)]
mod tests_strokegeom; mod tests_strokegeom;
#[cfg(test)] #[cfg(test)]
mod tests_swatches;
#[cfg(test)]
mod tests_textedit; mod tests_textedit;
#[cfg(test)] #[cfg(test)]
mod tests_xform; mod tests_xform;

View File

@ -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::<Vec<_>>(), ["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::<Vec<_>>(), [("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::<Vec<_>>(), ["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::<Vec<_>>(), ["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);
}

View File

@ -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(); 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)); 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(); 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] #[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). // 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:?}"); 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. // 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:?}"); assert!(doc.start.distance(Point::new(100.0, 190.0)) < 1e-9, "{doc:?}");
s.execute("object.rotate", &json!({"angle": 90, "origin": [100, 200]})).unwrap(); 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:?}"); 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. // paint.setFill's vector is in document coordinates too.
s.execute("paint.setFill", &json!({"gradient": {"start": [90, 200], "end": [90, 100]}})).unwrap(); 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:?}"); 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:?}"); 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. // The Stroke proxy's vector lands on the runs' strokes.
s.execute("paint.setGradientGeom", &json!({"start": [100, 190], "end": [150, 190], "stroke": true})).unwrap(); 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!(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); 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. // On type, in text space.
let t = text(&mut s, 100.0, 400.0); let t = text(&mut s, 100.0, 400.0);
s.execute("paint.setFill", &json!({"swatch": "Placed"})).unwrap(); 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(); 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:?}"); assert!(doc.start.distance(tb.center()) < 1e-9 && (doc.aspect - 0.5).abs() < 1e-9, "{doc:?} on {tb:?}");
} }

View File

@ -80,6 +80,7 @@ impl Session {
corner_widgets: view.corner_widgets, corner_widgets: view.corner_widgets,
fill_active: self.fill_active, fill_active: self.fill_active,
gradient_stop: self.selected_stop(), gradient_stop: self.selected_stop(),
appearance_item: self.appearance_item(),
}; };
f(self.tool.as_mut(), &cx) f(self.tool.as_mut(), &cx)
} }

View File

@ -798,19 +798,28 @@ impl Renderer {
if !n.appearance.items.is_empty() { if !n.appearance.items.is_empty() {
let mut all = g.all.clone(); let mut all = g.all.clone();
all.apply_affine(t.xf); 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); ctx.set_transform(f.view);
match item { 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.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(&all); 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.set_stroke(kurbo::Stroke::new(st.width));
ctx.stroke_path(&all); ctx.stroke_path(&all);
} }
_ => {} _ => {}
} }
if layered {
ctx.pop_layer();
}
} }
} }
} }

View File

@ -160,6 +160,9 @@ pub struct ToolContext<'a> {
pub fill_active: bool, pub fill_active: bool,
/// The selected gradient stop (`gradient.selectStop`), marked on the gradient annotator. /// The selected gradient stop (`gradient.selectStop`), marked on the gradient annotator.
pub gradient_stop: Option<usize>, pub gradient_stop: Option<usize>,
/// 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<usize>,
} }
impl ToolContext<'_> { impl ToolContext<'_> {
@ -342,6 +345,7 @@ pub(crate) mod testutil {
corner_widgets: true, corner_widgets: true,
fill_active: true, fill_active: true,
gradient_stop: None, gradient_stop: None,
appearance_item: None,
} }
} }
} }

View File

@ -42,8 +42,8 @@ impl Tool for EyedropperTool {
let src = paint_owner(cx.doc, h.leaf); let src = paint_owner(cx.doc, h.leaf);
let Some(n) = cx.doc.node(src) else { return vec![] }; let Some(n) = cx.doc.node(src) else { return vec![] };
if ev.mods.shift { if ev.mods.shift {
let fill_none = n.proxy_paint(false).is_none_or(|(p, ..)| p.is_none()); 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) else { return vec![] }; 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. // 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 }; 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) { return match paint_color_at(paint, Some(bounds), p) {

View File

@ -72,7 +72,7 @@ pub enum Part {
impl Annotator { impl Annotator {
pub fn of(cx: &ToolContext) -> Option<Self> { pub fn of(cx: &ToolContext) -> Option<Self> {
cx.selection.objects.iter().find_map(|id| { 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() }) Some(Self { geom, gradient: g.gradient.clone() })
}) })
} }
@ -216,10 +216,10 @@ fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec<Overlay> {
fn click_vector(cx: &ToolContext, id: NodeId) -> Option<Vec2> { fn click_vector(cx: &ToolContext, id: NodeId) -> Option<Vec2> {
let stroke = !cx.fill_active; let stroke = !cx.fill_active;
let n = cx.doc.node(id)?; 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); 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), Some((_, a, b)) => (a, b),
None => (Affine::IDENTITY, n.geometric_bounds()?), 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 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); let mut params = Self::geom_params(cx, p, p + v);
params["ids"] = json!([id.0]); 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![Action::Exec("paint.setGradientGeom".into(), params)]
} }
_ => vec![], _ => vec![],
@ -699,4 +703,29 @@ mod tests {
assert_eq!(chips, vec![false, true]); assert_eq!(chips, vec![false, true]);
assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { .. })).count(), 1, "one midpoint diamond"); 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);
}
} }

View File

@ -92,24 +92,19 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let units = st.doc.units; let units = st.doc.units;
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned(); let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
let anchor_mode = !st.selection.anchors.is_empty(); let anchor_mode = !st.selection.anchors.is_empty();
let label = match (&first, sel.len()) { let label = match &first {
(None, _) => "No Selection".to_string(), Some(_) if sel.len() == 1 && anchor_mode => "Anchor Point",
(_, n) if n > 1 => "Mixed Objects".to_string(), Some(n) if sel.len() == 1 && matches!(n.kind, NodeKind::Image(_)) => "Embedded",
(Some(_), _) if anchor_mode => "Anchor Point".to_string(), _ => crate::panels::appearance::object_label(app),
(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(),
},
}; };
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
ui.add_space(6.0); ui.add_space(6.0);
let (fill, stroke, weight, opacity) = match &first { let (fill, stroke) = crate::panels::current_paints(app);
Some(n) => { let (weight, opacity) = match &first {
(n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map(|s| s.width).unwrap_or(0.0), n.opacity) 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() { if chip_button(ui, &fill, false, "Fill").clicked() {
app.run("paint.toggleActive", json!({})).ok(); 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) if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0)
&& !sel.is_empty() && !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(); ui.separator();
if sel.is_empty() { if sel.is_empty() {

View File

@ -1,5 +1,6 @@
//! Live effect dialogs (Effect menu): the effect's parameters (`__effect`, headed `__label`) with a //! 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}; 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()) { if pv && (changed || pv_changed || !app.session.in_interaction()) {
let label = d.str("__label"); let label = d.str("__label");
let _ = app.session.begin_interaction(&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 { } else if !pv && pv_changed {
let _ = app.session.cancel_interaction(); let _ = app.session.cancel_interaction();
} }
false 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<Value, String> { fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let effect = d.str("__effect"); let p = apply_params(d);
let params = form::params(d);
let r = if app.session.in_interaction() { let r = if app.session.in_interaction() {
// Live preview already applied: keep it (the interaction commits as one undo step). // 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()) app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string())
} else { } 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; app.ui.dialog = None;
r r
} }

View File

@ -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(); 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 { for (k, v) in keys {
ui.label(egui::RichText::new(humanize(&k)).color(t.text)); 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 { match v {
Value::Number(n) => { Value::Number(n) => {
let mut x = n.as_f64().unwrap_or(0.0); let mut x = n.as_f64().unwrap_or(0.0);

View File

@ -163,3 +163,16 @@ fn effect_preview_is_kept_as_one_undo_step() {
app.run("edit.undo", json!({})).unwrap(); app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before); 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());
}

View File

@ -152,7 +152,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.newWindow", "New Window", "", "{}"), ("window.newWindow", "New Window", "", "{}"),
("tool.select", "Select Tool", "", "{tool: id} (see tools)"), ("tool.select", "Select Tool", "", "{tool: id} (see tools)"),
("tool.setOption", "Tool Option", "", "{key, value}"), ("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.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)"), ("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"),
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), ("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<R
fields.insert("__effect".into(), json!(e.id)); fields.insert("__effect".into(), json!(e.id));
fields.insert("__label".into(), json!(e.label.trim_end_matches('…'))); fields.insert("__label".into(), json!(e.label.trim_end_matches('…')));
fields.insert("preview".into(), json!(true)); fields.insert("preview".into(), json!(true));
if let Some(item) = p.get("item") {
fields.insert("__item".into(), item.clone());
}
app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields }); app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields });
Ok(Value::Null) Ok(Value::Null)
} }
@ -1658,8 +1666,6 @@ fn effect_menu() -> Vec<Item> {
for e in cat.iter().filter(|e| !e.menu.last().is_some_and(|m| order.contains(m))) { 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(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })));
} }
out.push(Sep);
out.push(c("Expand Appearance", "effect.expandAppearance"));
out out
} }
@ -1791,4 +1797,13 @@ mod tests {
} }
assert_eq!(pretty_shortcut(""), ""); 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<Vec<String>> =
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()]]);
}
} }

View File

@ -7,23 +7,69 @@ use std::sync::OnceLock;
use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint}; 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::theme::Tokens;
use crate::widgets::{self, menu_item}; use crate::widgets::{self, TransparencyEdit, menu_item};
use crate::{VectorcraftApp, icons}; use crate::{VectorcraftApp, icons};
const ROW: f32 = 30.0; 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)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Sel { pub enum Sel {
#[default] #[default]
None, None,
Item(usize), Item(usize),
/// An object-level effect.
Effect(usize), 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<usize> {
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<Value> {
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<NodeId>, Sel) = pstate(ctx, "ap-sel");
let exists = |item: Option<usize>, 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<String>)] { fn catalog() -> &'static [(String, String, Vec<String>)] {
@ -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) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
let node = first_selected(app); 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"); let hide_thumb: bool = pstate(ui.ctx(), "ap-hide-thumb");
widgets::list_box(ui, |ui| { widgets::list_box(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0; ui.spacing_mut().item_spacing.y = 0.0;
// Object row. // Object row (clicking it targets the whole object again).
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW + 4.0), Sense::hover()); 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)); 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)) { let mixed = mixed_appearances(app);
(None, _) => "No Selection".to_string(), let label = if mixed { "Mixed Appearances" } else { object_label(app) };
(Some(_), n) if n > 1 => "Mixed Objects".to_string(),
(Some(n), _) => n.kind_label().to_string(),
};
if !hide_thumb { if !hide_thumb {
let th = Rect::from_min_size(r.left_center() + vec2(6.0, -12.0), vec2(24.0, 24.0)); 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()); let fill = node.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or_else(|| app.session.paint.fill.clone());
chip(ui, th, &fill); 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 { match &node {
// Objects that differ have no common stack to list.
Some(_) if mixed => {}
Some(n) => stack(app, ui, n, sel), Some(n) => stack(app, ui, n, sel),
None => default_stack(app, ui), 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); 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). /// The defaults for new art when nothing is selected (read-only rows).
fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) {
let p = app.session.paint.clone(); 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) { fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
let t = Tokens::get(ui.ctx()); 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 drop: Option<(usize, usize)> = None;
let mut stopped = false; let mut stopped = false;
let dragging: Option<usize> = pstate(ui.ctx(), "ap-drag"); let dragging: Option<usize> = pstate(ui.ctx(), "ap-drag");
let mut row_rects = vec![]; 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 open: bool = pstate(ui.ctx(), &format!("ap-open-{i}"));
let (r, resp) = row(ui, sel == Sel::Item(i)); let (r, resp) = row(ui, sel == Sel::Item(i));
row_rects.push((i, r)); row_rects.push((i, r));
let (visible, paint, is_stroke, width, opacity, blend, effects) = match &it { let (visible, paint) = (it.visible(), it.paint());
AppearanceItem::Fill(f) => (f.visible, f.paint.clone(), false, 0.0, f.opacity, f.blend, f.effects.clone()), let is_stroke = !it.is_fill();
AppearanceItem::Stroke(s) => (s.visible, s.paint.clone(), true, s.width, s.opacity, s.blend, s.effects.clone()),
};
if eye(ui, r, ("ap-eye", i), visible, true) { if eye(ui, r, ("ap-eye", i), visible, true) {
app.run("appearance.setItem", json!({"index": i, "visible": !visible})).ok(); 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; let lx = r.left() + EYE_W + 24.0;
if is_stroke { if is_stroke {
if link(ui, pos2(lx, r.center().y), ("ap-link", i), "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()); app.ui.open_panel = Some("stroke".into());
} }
} else { } 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. // 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)); 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"); 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| { egui::Popup::menu(&cresp).show(|ui| {
swatch_picker(app, ui, i); 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 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))); 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(); app.run("appearance.setItem", json!({"index": i, "weight": w})).ok();
} }
} }
if resp.clicked() { if resp.clicked() {
set_pstate(ui.ctx(), "ap-sel", Sel::Item(i)); select_row(app, ui.ctx(), Sel::Item(i));
} }
if resp.double_clicked() { if resp.double_clicked() {
app.ui.open_panel = Some(if is_stroke { "stroke" } else { "color" }.into()); 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; stopped = true;
} }
if open { if open {
sub_row(app, ui, ("ap-sub-op", i), &format!("Opacity: {}", opacity_text(opacity, blend)), true, 1); opacity_row(app, ui, Some(i), it.opacity(), it.blend());
for (k, e) in effects.iter().enumerate() { for (k, e) in it.effects().iter().enumerate() {
sub_row(app, ui, ("ap-sub-fx", i, k), &effect_label(&e.id), e.visible, 1); 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. // Object-level effects.
for (k, e) in n.appearance.effects.iter().enumerate() { 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. opacity_row(app, ui, None, n.opacity, n.blend);
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);
if let Some((from, to_row)) = drop if let Some((from, to_row)) = drop
&& from != to_row && from != to_row
{ {
// The engine keeps the moved row active when it was.
app.run("appearance.moveItem", json!({"from": from, "to": to_row})).ok(); 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) { /// The Opacity row of the object (`item: None`) or, indented, of fill/stroke `item`. Clicking it
let (r, _) = row(ui, false); /// opens a popup with the shared opacity and blend controls (`transparency.set` on that item).
eye(ui, r, (id, "eye"), visible, false); fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opacity: f32, blend: BlendMode) {
text(ui, r.left_center() + vec2(EYE_W + 24.0 + depth as f32 * 12.0, 0.0), label, false); 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<usize>, k: usize, e: &Effect, selected: bool) {
let t = Tokens::get(ui.ctx()); 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); let (r, resp) = row(ui, selected);
if eye(ui, r, ("ap-fx-eye", k), e.visible, true) { if eye(ui, r, ("ap-fx-eye", item, k), e.visible, true) {
app.run("effect.setParams", json!({"index": k, "visible": !e.visible})).ok(); app.run("effect.setParams", json!({"item": item, "index": k, "visible": !e.visible})).ok();
} }
if chevron(ui, r, ("ap-fx-chev", k), open) { let indent = if item.is_some() { 12.0 } else { 0.0 };
set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open); 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; let lx = r.left() + EYE_W + 24.0;
if link(ui, pos2(lx, r.center().y), ("ap-fx-link", k), &effect_label(&e.id)) { if link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &effect_label(&e.id)) {
set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open); set_pstate(ui.ctx(), &open_key, !open);
set_pstate(ui.ctx(), "ap-sel", Sel::Effect(k)); 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); 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() { if resp.clicked() {
set_pstate(ui.ctx(), "ap-sel", Sel::Effect(k)); select_row(app, ui.ctx(), this);
} }
if resp.double_clicked() { if resp.double_clicked() {
set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open); set_pstate(ui.ctx(), &open_key, !open);
} }
if 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); /// Inline editor for an applied effect's parameters (numbers, booleans, strings, colours);
/// edits go through `effect.setParams`. /// 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<usize>, k: usize, e: &Effect) {
let t = Tokens::get(ui.ctx()); 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 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(); 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| { ui.horizontal(|ui| {
let label = humanize(key); 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()); 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 { match v {
Value::Bool(b) => { Value::Bool(b) => {
if widgets::check(ui, "", *b, true) { 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) => { Value::Number(n) => {
let x = n.as_f64().unwrap_or(0.0); 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))); 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 { 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() { if widgets::icon_button_enabled(ui, "dc-clear", "Clear Appearance", false, has, 24.0).clicked() {
app.run("appearance.clear", json!({})).ok(); app.run("appearance.clear", json!({})).ok();
} }
let can_dup = has && matches!(sel, Sel::Item(_)); 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() if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can_dup, 24.0).clicked() {
&& let Sel::Item(i) = sel duplicate_selected(app, sel);
{
app.run("appearance.duplicateItem", json!({"index": i})).ok();
} }
let can_del = has && sel != Sel::None; let can_del = has && sel != Sel::None;
if widgets::icon_button_enabled(ui, "trash-2", "Delete Selected Item", false, can_del, 24.0).clicked() { 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) { fn duplicate_selected(app: &mut VectorcraftApp, sel: Sel) {
let ok = match sel { match (sel, sel.effect()) {
Sel::Item(i) => app.run("appearance.removeItem", json!({"index": i})).is_ok(), (Sel::Item(i), _) => app.run("appearance.duplicateItem", json!({"index": i})).ok(),
Sel::Effect(k) => app.run("effect.remove", json!({"index": k})).is_ok(), (_, Some(fx)) => app.run("effect.duplicate", fx).ok(),
Sel::None => false, _ => 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 { 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) { pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let node = first_selected(app); let node = first_selected(app);
let has = node.is_some(); 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) { if menu_item(ui, "Add New Fill", has, false) {
app.run("appearance.addFill", json!({})).ok(); 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(); app.run("appearance.addStroke", json!({})).ok();
} }
ui.separator(); ui.separator();
if menu_item(ui, "Duplicate Item", has && matches!(sel, Sel::Item(_)), false) if menu_item(ui, "Duplicate Item", has && sel != Sel::None, false) {
&& let Sel::Item(i) = sel duplicate_selected(app, sel);
{
app.run("appearance.duplicateItem", json!({"index": i})).ok();
} }
if menu_item(ui, "Remove Item", has && sel != Sel::None, false) { if menu_item(ui, "Remove Item", has && sel != Sel::None, false) {
delete_selected(app, ui, sel); delete_selected(app, ui, sel);

View File

@ -221,7 +221,7 @@ fn row(
let col_x = r.right() - 43.5; 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)); 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 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)); ui.painter().circle_stroke(tc, 5.0, Stroke::new(1.0, t.icon));
if is_sel { if is_sel {
ui.painter().circle_stroke(tc, 2.8, Stroke::new(1.0, t.icon)); 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` /// 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. /// 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 { 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");
}
}

View File

@ -37,8 +37,8 @@ pub mod transparency;
use egui::{Rect, Sense, Ui, vec2}; use egui::{Rect, Sense, Ui, vec2};
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint}; use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_doc::{Node, NodeKind, StrokeLayer};
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::widgets::{Live, dim_label}; use crate::widgets::{Live, dim_label};
@ -46,8 +46,7 @@ use crate::{VectorcraftApp, icons};
/// The first selected node (cloned), if any. /// The first selected node (cloned), if any.
pub fn first_selected(app: &VectorcraftApp) -> Option<Node> { pub fn first_selected(app: &VectorcraftApp) -> Option<Node> {
let st = app.session.active()?; first_node(app).cloned()
st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned()
} }
/// Number of selected objects. /// 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" } 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 /// The first selected node, borrowed (for per-frame reads that need no copy of it).
/// run's style), else the defaults for new art. 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) { pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) {
match first_selected(app) { let defaults = || (app.session.paint.fill.clone(), app.session.paint.stroke.clone());
Some(n) => match &n.kind { let Some(n) = first_node(app) else { return defaults() };
NodeKind::Text(t) => { let item = app.session.appearance_item();
let s = t.first_style(); if item.is_none() {
(s.fill, s.stroke) if let NodeKind::Text(t) = &n.kind {
} let s = t.first_style();
_ => (n.appearance.fill_paint(), n.appearance.stroke_paint()), return (s.fill, s.stroke);
}, }
None => (app.session.paint.fill.clone(), app.session.paint.stroke.clone()), 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<StrokeLayer> {
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. /// The paint behind the active proxy.
@ -315,3 +342,5 @@ mod tests {
assert_eq!(p["gradient"]["stops"][0]["midpoint"], json!(0.5)); assert_eq!(p["gradient"]["stops"][0]["midpoint"], json!(0.5));
} }
} }
#[cfg(test)]
mod tests_appearance;

View File

@ -265,11 +265,10 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
let Some(n) = first_selected(app) else { return }; let Some(n) = first_selected(app) else { return };
section_header(ui, "Appearance"); section_header(ui, "Appearance");
let (fill, stroke, weight) = if let NodeKind::Text(tx) = &n.kind { let (fill, stroke) = super::current_paints(app);
let s = tx.first_style(); let weight = match &n.kind {
(s.fill.clone(), s.stroke.clone(), s.stroke_width) NodeKind::Text(tx) if app.session.appearance_item().is_none() => tx.first_style().stroke_width,
} else { _ => super::current_stroke(app).map_or(0.0, |s| s.width),
(n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map(|s| s.width).unwrap_or(0.0))
}; };
for (label, paint, is_fill) in [("Fill", fill, true), ("Stroke", stroke, false)] { for (label, paint, is_fill) in [("Fill", fill, true), ("Stroke", stroke, false)] {
ui.horizontal(|ui| { ui.horizontal(|ui| {

View File

@ -5,7 +5,7 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_doc::{ArrowAlign, Arrowhead, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile}; 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::theme::Tokens;
use crate::widgets::{self, menu_item}; use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons}; use crate::{VectorcraftApp, icons};
@ -64,17 +64,13 @@ fn arrow_label(a: Option<Arrowhead>) -> String {
} }
} }
fn stroke_now(app: &VectorcraftApp) -> Option<StrokeLayer> {
first_selected(app).and_then(|n| n.appearance.stroke().cloned())
}
fn set(app: &mut VectorcraftApp, p: Value) { fn set(app: &mut VectorcraftApp, p: Value) {
app.run("stroke.set", p).ok(); app.run("stroke.set", p).ok();
} }
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx()); 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 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 hidden: bool = pstate(ui.ctx(), "stroke-hide-options");
let label_w = 64.0; 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, "Add to Profiles", false, false);
menu_item(ui, "Delete Profile", 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"})); set(app, json!({"profile": "uniform"}));
} }
} }
@ -444,7 +440,7 @@ mod tests {
r(&mut app, "stroke.set", p); r(&mut app, "stroke.set", p);
frame(&mut app); 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!((st.end_arrow, st.arrow_align), (Some(Arrowhead::ArrowOpen), ArrowAlign::Tip));
assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "lens"); assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "lens");
} }

View File

@ -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<String> {
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<String> {
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<egui::Event>,
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<vectorcraft_doc::Effect> {
first_selected(app).unwrap().appearance.items[1].effects().clone()
}
#[test]
fn blend_separators_follow_the_groups() {
use vectorcraft_color::BlendMode;
let starts: Vec<BlendMode> =
(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()));
}

View File

@ -6,9 +6,9 @@ use egui::{Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json; use serde_json::json;
use vectorcraft_color::{BlendMode, Paint}; 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::theme::Tokens;
use crate::widgets::{self, Live, menu_item}; use crate::widgets::{self, TransparencyEdit, menu_item};
use crate::{VectorcraftApp, icons}; use crate::{VectorcraftApp, icons};
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { 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), None => first_selected(app),
}; };
let has = n.is_some(); let has = n.is_some();
let (blend, op, isolate, knockout) = let (isolate, knockout) = n.as_ref().map_or((false, false), |n| (n.isolate, n.knockout));
n.as_ref().map(|n| (n.blend, n.opacity, n.isolate, n.knockout)).unwrap_or((BlendMode::Normal, 1.0, false, false)); // Opacity and blend of the Appearance panel's active item, else of the object.
ui.horizontal(|ui| { let (op, blend) = match editing {
let labels: Vec<&str> = BlendMode::ALL.iter().map(|b| b.label()).collect(); None => current_transparency(app),
ui.add_enabled_ui(has, |ui| { Some(_) => n.as_ref().map(|n| (n.opacity, n.blend)),
if let Some(i) = widgets::dropdown(ui, "tr-blend", blend.label(), &labels, 104.0) { }
app.run("transparency.set", json!({"blend": labels[i]})).ok(); .unwrap_or((1.0, BlendMode::Normal));
} match widgets::opacity_blend(ui, "tr", op, blend, has) {
}); Some(TransparencyEdit::Blend(b)) => {
widgets::dim_label(ui, "Opacity:"); app.run("transparency.set", json!({"blend": b.label()})).ok();
ui.spacing_mut().item_spacing.x = 0.0; }
ui.add_enabled_ui(has, |ui| { Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({ "opacity": o }), phase),
if let Some(o) = widgets::plain_field(ui, "tr-op", op as f64 * 100.0, "%", 0, 50.0) { None => {}
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);
});
});
widgets::divider(ui); widgets::divider(ui);
// Thumbnails and the opacity-mask controls. // Thumbnails and the opacity-mask controls.
let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs"); let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs");

View File

@ -178,10 +178,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} }
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) { 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))) { let (fill, stroke) = crate::panels::current_paints(app);
Some(n) if !n.is_container() => (n.appearance.fill_paint(), n.appearance.stroke_paint()),
_ => (app.session.paint.fill.clone(), app.session.paint.stroke.clone()),
};
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
let (f, s, swap, def) = widgets::fill_stroke_proxy(ui, &fill, &stroke, app.session.fill_active, 36.0); let (f, s, swap, def) = widgets::fill_stroke_proxy(ui, &fill, &stroke, app.session.fill_active, 36.0);
if f { if f {

View File

@ -1,7 +1,7 @@
//! Shared widgets in Illustrator's panel style. Colours come from theme tokens. //! 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 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 vectorcraft_doc::Unit;
use crate::icons; 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. /// 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<usize> { pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option<usize> {
let t = Tokens::get(ui.ctx()); combo(ui, id, current, width, |ui| {
let mut chosen = None; 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| { for (i, o) in options.iter().enumerate() {
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| { if ui.selectable_label(*o == current, *o).clicked() {
for (i, o) in options.iter().enumerate() { chosen = Some(i);
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<R>(
ui: &mut Ui,
id: impl std::hash::Hash + std::fmt::Debug,
current: &str,
width: f32,
list: impl FnOnce(&mut Ui) -> Option<R>,
) -> Option<R> {
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<BlendMode> {
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<BlendMode> {
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<serde_json::Value> {
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<TransparencyEdit> {
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; edit
chosen
} }
/// The 3×3 reference point locator. Returns a new index when clicked. /// The 3×3 reference point locator. Returns a new index when clicked.

View File

@ -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. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `undo` / `redo` | `{}` | | | `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 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. `isError: true` and a message. The model can read the message and retry.