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:
commit
721bb483c3
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
||||||
let roots = target_roots(s, p)?;
|
|
||||||
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
|
let 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) = (¶ms, &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) = (¶ms, &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 {
|
||||||
|
|
|
||||||
|
|
@ -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<_>>() }))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
})?;
|
})?;
|
||||||
|
|
|
||||||
|
|
@ -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()},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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:?}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()]]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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| {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
10
docs/mcp.md
10
docs/mcp.md
|
|
@ -68,6 +68,16 @@ or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"
|
||||||
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
|
| `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.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue