mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
M3.21: the Appearance panel's active item drives paint, stroke, gradient and transparency edits
Clicking a fill or stroke row runs appearance.setActiveItem; the toolbar, Control bar and Properties proxies and the Color, Swatches, Gradient, Stroke and Transparency panels show and edit that item, and the gradient tool's annotator follows it. paint.setFill/setStroke, stroke.set/ setAdvanced, paint.editGradient/setGradientGeom and transparency.set take item (omitted: the active item, null: the top one). Item edits and the appearance.* commands address the selected objects' own stacks, so a group's own fills no longer leak into its contents. Any selection change clears the active item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fb8c41d8e3
commit
018bed29db
@@ -1,9 +1,17 @@
|
||||
//! The Appearance panel: fill and stroke items, live effects, Clear / Reduce to Basic, and the
|
||||
//! The Appearance panel: fill and stroke items, the active item that paint, stroke, gradient,
|
||||
//! transparency and effect edits target, live effects, Clear / Reduce to Basic, and the
|
||||
//! Eyedropper's appearance copy.
|
||||
//!
|
||||
//! **Item targeting.** Commands that edit a fill or stroke take `item?`: a paint-order index into
|
||||
//! the appearance stack (`items[0]` is painted first, the bottom row of the panel). Omitted, they
|
||||
//! edit the Appearance panel's active item (`appearance.setActiveItem`) when one is set for the
|
||||
//! current selection and no `ids` are given, else the topmost fill or stroke; `null` always means
|
||||
//! the topmost one. A targeted item lives in the selected objects' own stacks (a group's own fill,
|
||||
//! not its contents'), so item edits apply to the selected objects themselves.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::paint::paint_from;
|
||||
@@ -12,23 +20,39 @@ use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("appearance.addFill", "Add New Fill", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, true)),
|
||||
cmd!("appearance.addStroke", "Add New Stroke", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, false)),
|
||||
cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{}", has_selection, clear_appearance),
|
||||
cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{}", has_selection, reduce_basic),
|
||||
cmd!(
|
||||
"appearance.addFill",
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?} add a fill on top of each selected object's own stack",
|
||||
has_selection,
|
||||
|s, p| add_item(s, p, true)
|
||||
),
|
||||
cmd!(
|
||||
"appearance.addStroke",
|
||||
"Add New Stroke",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?} add a stroke on top of each selected object's own stack",
|
||||
has_selection,
|
||||
|s, p| add_item(s, p, false)
|
||||
),
|
||||
cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{ids?}", has_selection, clear_appearance),
|
||||
cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{ids?}", has_selection, reduce_basic),
|
||||
cmd!(
|
||||
"appearance.setItem",
|
||||
"Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack",
|
||||
"{index: paint-order item index, opacity?: 0..1 (values above 1 are percent), blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), ids?} edit one fill/stroke of each selected object's own appearance stack",
|
||||
has_selection,
|
||||
set_item
|
||||
),
|
||||
cmd!("appearance.removeItem", "Remove Item", [], None, "{index}", has_selection, remove_item),
|
||||
cmd!("appearance.removeItem", "Remove Item", [], None, "{index, ids?}", has_selection, remove_item),
|
||||
cmd!("appearance.addEffect", "Add Effect", ["Effect"], None, "{effect: id, params?: {…}} append a live effect", has_selection, add_effect),
|
||||
cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index}", has_selection, duplicate_item),
|
||||
cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to} (paint-order indices)", has_selection, move_item),
|
||||
cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index, ids?}", has_selection, duplicate_item),
|
||||
cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to, ids?} (paint-order indices)", has_selection, move_item),
|
||||
cmd!(
|
||||
"appearance.copyFrom",
|
||||
"Eyedropper",
|
||||
@@ -38,11 +62,166 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
cmd!(
|
||||
"appearance.setActiveItem",
|
||||
"Select Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{index: paint-order item index in the first selected object's stack | null} make that fill/stroke row the target of the paint.setFill/setStroke, stroke.set/setAdvanced, paint.editGradient/setGradientGeom, transparency.set and effect.* calls that omit `item` (a fill row brings the Fill proxy forward, a stroke row the Stroke proxy); null or any selection change clears it → {index}",
|
||||
has_selection,
|
||||
set_active_item
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn add_item(s: &mut Session, fill: bool) -> Result<Value> {
|
||||
let ids = paint_targets(s, &json!({}))?;
|
||||
/// The Appearance panel's active row, remembered for the selection it was chosen in.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ActiveItem {
|
||||
doc: u64,
|
||||
objects: Vec<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 its stack changed (`None` drops it).
|
||||
fn remap_appearance_item(&mut self, f: impl FnOnce(usize) -> Option<usize>) {
|
||||
if let Some(a) = &mut self.active_appearance_item {
|
||||
match f(a.index) {
|
||||
Some(i) => a.index = i,
|
||||
None => self.active_appearance_item = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which fill or stroke of an appearance stack an edit changes (the `item` param).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ItemTarget {
|
||||
/// The topmost fill or stroke (for effects: the object-level effects).
|
||||
Top,
|
||||
/// Item `index`. `explicit`: named by the `item` param, so it must exist and be of the edited
|
||||
/// kind; otherwise it is the panel's active item, which applies only where it fits.
|
||||
Item { index: usize, explicit: bool },
|
||||
}
|
||||
|
||||
/// Parse the `item` param of `cmd` (see the module docs).
|
||||
pub(crate) fn item_target(s: &Session, p: &Value, cmd: &str) -> Result<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 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, format!("appearance item {index} is not a {}", if fill { "fill" } else { "stroke" }))),
|
||||
found => Ok(found),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects whose own appearance stack a command edits: `ids`/`id`, else the selected objects
|
||||
/// (a selected compound-path member stands for its compound). Layers are skipped.
|
||||
pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result<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` on `ids` changes a stroke: the `stroke` param, else the kind of
|
||||
/// the targeted item (on the first target), else `default`.
|
||||
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, ids: &[NodeId], default: bool) -> Result<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 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, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -59,8 +238,8 @@ fn add_item(s: &mut Session, fill: bool) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn clear_appearance(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = paint_targets(s, &json!({}))?;
|
||||
fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = appearance_targets(s, p)?;
|
||||
s.edit("Clear Appearance", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -69,11 +248,12 @@ fn clear_appearance(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn reduce_basic(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = paint_targets(s, &json!({}))?;
|
||||
fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = appearance_targets(s, p)?;
|
||||
s.edit("Reduce to Basic Appearance", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -90,13 +270,14 @@ fn reduce_basic(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
}
|
||||
|
||||
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 ids = paint_targets(s, p)?;
|
||||
let ids = appearance_targets(s, p)?;
|
||||
let blend = str_param(p, "blend").and_then(BlendMode::parse);
|
||||
s.edit("Appearance", |d, _| {
|
||||
for id in &ids {
|
||||
@@ -130,8 +311,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result<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 ids = paint_targets(s, p)?;
|
||||
let idx = index_param(p, "index", "appearance.removeItem")?;
|
||||
let ids = appearance_targets(s, p)?;
|
||||
s.edit("Remove Item", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
@@ -142,6 +323,11 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
s.remap_appearance_item(|a| match a.cmp(&idx) {
|
||||
std::cmp::Ordering::Less => Some(a),
|
||||
std::cmp::Ordering::Equal => None,
|
||||
std::cmp::Ordering::Greater => Some(a - 1),
|
||||
});
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -161,8 +347,8 @@ fn add_effect(s: &mut Session, p: &Value) -> Result<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 ids = paint_targets(s, p)?;
|
||||
let idx = index_param(p, "index", "appearance.duplicateItem")?;
|
||||
let ids = appearance_targets(s, p)?;
|
||||
s.edit("Duplicate Item", |d, _| {
|
||||
let mut any = false;
|
||||
for id in &ids {
|
||||
@@ -174,14 +360,16 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) }
|
||||
})?;
|
||||
s.remap_appearance_item(|a| Some(if a > idx { a + 1 } else { a }));
|
||||
ok()
|
||||
}
|
||||
|
||||
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "appearance.moveItem";
|
||||
let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing from"))? as usize;
|
||||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
|
||||
let ids = paint_targets(s, p)?;
|
||||
let from = index_param(p, "from", C)?;
|
||||
let to = index_param(p, "to", C)?;
|
||||
let ids = appearance_targets(s, p)?;
|
||||
let mut landed = to;
|
||||
s.edit("Reorder Appearance", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
@@ -190,11 +378,23 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("no item at index {from}")));
|
||||
}
|
||||
let it = items.remove(from);
|
||||
let to = to.min(items.len());
|
||||
items.insert(to, it);
|
||||
landed = to.min(items.len());
|
||||
items.insert(landed, it);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// The moved row stays the active one; the rows it passed shift by one.
|
||||
s.remap_appearance_item(|a| {
|
||||
Some(if a == from {
|
||||
landed
|
||||
} else if from < a && a <= landed {
|
||||
a - 1
|
||||
} else if landed <= a && a < from {
|
||||
a + 1
|
||||
} else {
|
||||
a
|
||||
})
|
||||
});
|
||||
ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::appearance::{ItemTarget, edit_items, edits_stroke, item_target};
|
||||
use super::edit::selected_roots;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -15,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
@@ -24,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient Vector",
|
||||
[],
|
||||
None,
|
||||
"{start: [x,y], end: [x,y], ids?, stroke?: bool} set the gradient vector (solid paints become the default gradient)",
|
||||
"{start: [x,y], end: [x,y], ids?, stroke?: bool (default: the targeted item's kind, else the fill), item?: fill/stroke item index|null} set the gradient vector (solid paints become the default gradient)",
|
||||
has_doc,
|
||||
set_gradient_geom
|
||||
),
|
||||
@@ -112,51 +113,26 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<vecto
|
||||
|
||||
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.editGradient";
|
||||
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
|
||||
let ids = paint_targets(s, p)?;
|
||||
let item = item_target(s, p, C)?;
|
||||
let ids = item.targets(s, p)?;
|
||||
let stroke = edits_stroke(s, p, item, &ids, !s.fill_active)?;
|
||||
// Validate against the defaults first so bad params fail without touching the document.
|
||||
let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() };
|
||||
let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?;
|
||||
if ids.is_empty() {
|
||||
if stroke {
|
||||
s.paint.stroke = new_default;
|
||||
} else {
|
||||
s.paint.fill = new_default;
|
||||
}
|
||||
return Ok(json!({"ids": []}));
|
||||
}
|
||||
let mut err = None;
|
||||
s.edit("Gradient", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
let cur = if stroke { &mut r.style.stroke } else { &mut r.style.fill };
|
||||
match apply_gradient_edit(cur, p, None) {
|
||||
Ok(np) => *cur = np,
|
||||
Err(e) => err = Some(e),
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let b = n.geometric_bounds();
|
||||
let ap = &mut n.appearance;
|
||||
let cur = if stroke { ap.stroke_paint() } else { ap.fill_paint() };
|
||||
match apply_gradient_edit(&cur, p, b) {
|
||||
Ok(np) => {
|
||||
if stroke {
|
||||
ap.set_stroke(np)
|
||||
} else {
|
||||
ap.set_fill(np)
|
||||
}
|
||||
}
|
||||
Err(e) => err = Some(e),
|
||||
edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
for r in &mut t.runs {
|
||||
let cur = if stroke { &mut r.style.stroke } else { &mut r.style.fill };
|
||||
*cur = apply_gradient_edit(cur, p, None).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
match err.take() {
|
||||
Some(e) => Err(bad(C, e)),
|
||||
None => Ok(()),
|
||||
}
|
||||
let b = n.geometric_bounds();
|
||||
let np = apply_gradient_edit(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b).map_err(|e| bad(C, e))?;
|
||||
n.appearance.set_paint_at(index, !stroke, np);
|
||||
Ok(())
|
||||
})?;
|
||||
if stroke {
|
||||
s.paint.stroke = new_default;
|
||||
@@ -167,38 +143,31 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let start = point_param(p, "start").ok_or_else(|| bad("paint.setGradientGeom", "missing start [x,y]"))?;
|
||||
let end = point_param(p, "end").ok_or_else(|| bad("paint.setGradientGeom", "missing end [x,y]"))?;
|
||||
let stroke = bool_or(p, "stroke", false);
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(v) => v,
|
||||
None => selected_roots(s)?,
|
||||
const C: &str = "paint.setGradientGeom";
|
||||
let start = point_param(p, "start").ok_or_else(|| bad(C, "missing start [x,y]"))?;
|
||||
let end = point_param(p, "end").ok_or_else(|| bad(C, "missing end [x,y]"))?;
|
||||
let item = item_target(s, p, C)?;
|
||||
let targets = match item {
|
||||
ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), Ok)?)?,
|
||||
_ => item.targets(s, p)?,
|
||||
};
|
||||
let targets = leaf_targets(s, &ids)?;
|
||||
if targets.is_empty() {
|
||||
return Err(EngineError::Other("nothing selected".into()));
|
||||
}
|
||||
s.edit("Gradient", |d, _| {
|
||||
for id in &targets {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
continue;
|
||||
}
|
||||
let cur = if stroke { n.appearance.stroke_paint() } else { n.appearance.fill_paint() };
|
||||
let mut gp = match cur {
|
||||
Paint::Gradient(g) => *g,
|
||||
_ => GradientPaint::new(Gradient::default()),
|
||||
};
|
||||
let aspect = gp.geom.map(|g| g.aspect).unwrap_or(1.0);
|
||||
gp.geom = Some(GradientGeom { start, end, aspect });
|
||||
gp.angle = GradientGeom { start, end, aspect }.angle_deg();
|
||||
let paint = Paint::Gradient(Box::new(gp));
|
||||
if stroke {
|
||||
n.appearance.set_stroke(paint);
|
||||
} else {
|
||||
n.appearance.set_fill(paint);
|
||||
}
|
||||
let stroke = edits_stroke(s, p, item, &targets, false)?;
|
||||
let geom = GradientGeom { start, end, aspect: 1.0 };
|
||||
edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| {
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut gp = match n.appearance.paint_at(index, !stroke) {
|
||||
Some(Paint::Gradient(g)) => (**g).clone(),
|
||||
_ => GradientPaint::new(Gradient::default()),
|
||||
};
|
||||
let aspect = gp.geom.map(|g| g.aspect).unwrap_or(1.0);
|
||||
gp.geom = Some(GradientGeom { aspect, ..geom });
|
||||
gp.angle = geom.angle_deg();
|
||||
n.appearance.set_paint_at(index, !stroke, Paint::Gradient(Box::new(gp)));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The command registry. Ids follow Illustrator's menu structure.
|
||||
|
||||
mod appearance;
|
||||
pub(crate) mod appearance;
|
||||
mod brushsym;
|
||||
mod buildcmds;
|
||||
pub mod clipboard;
|
||||
|
||||
@@ -4,6 +4,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{Appearance, NodeKind};
|
||||
|
||||
use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -14,11 +15,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind, stops:[{offset,color}], angle?}, ids?} sets selection fill and the default",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind, stops:[{offset,color}], angle?}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?} sets the selection's fill and the default",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
cmd!("paint.setStroke", "Stroke", [], None, "same as paint.setFill, for the stroke", has_doc, |s, p| set_paint(s, p, false)),
|
||||
cmd!("paint.setStroke", "Stroke", [], None, "same as paint.setFill, for the stroke (item?: the stroke item to set)", has_doc, |s, p| {
|
||||
set_paint(s, p, false)
|
||||
}),
|
||||
cmd!("paint.swap", "Swap Fill and Stroke", [], Some("Shift+X"), "{}", has_doc, swap),
|
||||
cmd!("paint.default", "Default Fill and Stroke", [], Some("D"), "{}", has_doc, default_paint),
|
||||
cmd!("paint.toggleActive", "Toggle Fill/Stroke Focus", [], Some("X"), "{}", always, |s, _| {
|
||||
@@ -34,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transparency",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{opacity?: 0..100, blend?: name, isolate?, knockout?}",
|
||||
"{opacity?: 0..100, blend?: name, isolate?, knockout?, item?: appearance item index|null, ids?} opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)",
|
||||
has_selection,
|
||||
transparency
|
||||
),
|
||||
@@ -93,38 +96,32 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
||||
}
|
||||
|
||||
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"))?;
|
||||
if fill {
|
||||
s.paint.fill = paint.clone();
|
||||
} else {
|
||||
s.paint.stroke = paint.clone();
|
||||
}
|
||||
s.fill_active = fill;
|
||||
let ids = paint_targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return ok();
|
||||
}
|
||||
s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
if fill {
|
||||
r.style.fill = paint.clone();
|
||||
} else {
|
||||
r.style.stroke = paint.clone();
|
||||
if r.style.stroke_width == 0.0 {
|
||||
r.style.stroke_width = 1.0;
|
||||
}
|
||||
let item = item_target(s, p, cmd)?;
|
||||
let ids = item.targets(s, p)?;
|
||||
edit_items(s, &ids, item, cmd, if fill { "Fill Color" } else { "Stroke Color" }, fill, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
for r in &mut t.runs {
|
||||
if fill {
|
||||
r.style.fill = paint.clone();
|
||||
} else {
|
||||
r.style.stroke = paint.clone();
|
||||
if r.style.stroke_width == 0.0 {
|
||||
r.style.stroke_width = 1.0;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if fill {
|
||||
n.appearance.set_fill(paint.clone());
|
||||
} else {
|
||||
n.appearance.set_stroke(paint.clone());
|
||||
}
|
||||
} else {
|
||||
n.appearance.set_paint_at(index, fill, paint.clone());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -171,9 +168,35 @@ fn default_paint(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
|
||||
q["opacity"] = json!(o / 100.0);
|
||||
const C: &str = "transparency.set";
|
||||
let item = item_target(s, p, C)?;
|
||||
let mut q = p.as_object().cloned().unwrap_or_default();
|
||||
q.remove("item");
|
||||
let opacity = q.remove("opacity").and_then(|v| v.as_f64()).map(|o| (o / 100.0).clamp(0.0, 1.0));
|
||||
if let ItemTarget::Item { index, explicit } = item {
|
||||
// Opacity and blend belong to the targeted fill/stroke; isolate/knockout stay object-level.
|
||||
if explicit {
|
||||
let d = &s.doc()?.doc;
|
||||
if !appearance_targets(s, p)?.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) {
|
||||
return Err(bad(C, format!("no appearance item {index}")));
|
||||
}
|
||||
}
|
||||
let mut set = json!({ "index": index });
|
||||
for key in ["blend", "ids", "id"] {
|
||||
if let Some(v) = p.get(key) {
|
||||
set[key] = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(o) = opacity {
|
||||
set["opacity"] = json!(o);
|
||||
}
|
||||
q.remove("blend");
|
||||
s.execute("appearance.setItem", &set)?;
|
||||
if q.keys().all(|k| k == "ids" || k == "id") {
|
||||
return ok();
|
||||
}
|
||||
} else if let Some(o) = opacity {
|
||||
q.insert("opacity".into(), json!(o));
|
||||
}
|
||||
s.execute("object.setProps", &q)
|
||||
s.execute("object.setProps", &Value::Object(q))
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
|
||||
|
||||
use super::appearance::{edit_items, item_target};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -13,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}",
|
||||
"{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?}",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
@@ -22,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
[],
|
||||
None,
|
||||
"{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, ids?} Stroke panel extras",
|
||||
"{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, item?: stroke item index|null, ids?} Stroke panel extras",
|
||||
has_doc,
|
||||
stroke_advanced
|
||||
),
|
||||
@@ -30,80 +31,74 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
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) {
|
||||
s.paint.stroke_width = w.max(0.0);
|
||||
}
|
||||
let ids = paint_targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return ok();
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
let ids = item.targets(s, p)?;
|
||||
let arrow = |k: &str| -> Result<Option<Option<Arrowhead>>> {
|
||||
match p.get(k) {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::String(n)) if n == "none" => Ok(Some(None)),
|
||||
Some(v) => {
|
||||
serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad("stroke.set", format!("unknown arrowhead {v}")))
|
||||
}
|
||||
Some(v) => serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))),
|
||||
}
|
||||
};
|
||||
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
|
||||
s.edit("Stroke", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
if n.appearance.stroke().is_none() {
|
||||
n.appearance.set_stroke(Paint::solid(Color::BLACK));
|
||||
}
|
||||
let st: &mut StrokeLayer = n.appearance.stroke_mut().unwrap();
|
||||
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
|
||||
st.width = w.max(0.0);
|
||||
}
|
||||
match str_param(p, "cap") {
|
||||
Some("round") => st.cap = LineCap::Round,
|
||||
Some("square") | Some("projecting") => st.cap = LineCap::Square,
|
||||
Some("butt") => st.cap = LineCap::Butt,
|
||||
_ => {}
|
||||
}
|
||||
match str_param(p, "join") {
|
||||
Some("round") => st.join = LineJoin::Round,
|
||||
Some("bevel") => st.join = LineJoin::Bevel,
|
||||
Some("miter") => st.join = LineJoin::Miter,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) {
|
||||
st.miter_limit = m.clamp(1.0, 500.0);
|
||||
}
|
||||
match str_param(p, "align") {
|
||||
Some("inside") => st.align = StrokeAlign::Inside,
|
||||
Some("outside") => st.align = StrokeAlign::Outside,
|
||||
Some("center") => st.align = StrokeAlign::Center,
|
||||
_ => {}
|
||||
}
|
||||
match p.get("dash") {
|
||||
Some(Value::Null) => st.dash = None,
|
||||
Some(Value::Array(a)) => {
|
||||
let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
||||
st.dash = if pattern.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) })
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(a) = sa {
|
||||
st.start_arrow = a;
|
||||
}
|
||||
if let Some(a) = ea {
|
||||
st.end_arrow = a;
|
||||
}
|
||||
match str_param(p, "profile") {
|
||||
Some("uniform") => st.profile = None,
|
||||
Some("lens") => st.profile = Some(vectorcraft_doc::WidthProfile::lens()),
|
||||
Some("taperStart") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_start()),
|
||||
Some("taperEnd") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_end()),
|
||||
_ => {}
|
||||
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
|
||||
if index.is_none() && n.appearance.stroke().is_none() {
|
||||
n.appearance.set_stroke(Paint::solid(Color::BLACK));
|
||||
}
|
||||
let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) };
|
||||
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
|
||||
st.width = w.max(0.0);
|
||||
}
|
||||
match str_param(p, "cap") {
|
||||
Some("round") => st.cap = LineCap::Round,
|
||||
Some("square") | Some("projecting") => st.cap = LineCap::Square,
|
||||
Some("butt") => st.cap = LineCap::Butt,
|
||||
_ => {}
|
||||
}
|
||||
match str_param(p, "join") {
|
||||
Some("round") => st.join = LineJoin::Round,
|
||||
Some("bevel") => st.join = LineJoin::Bevel,
|
||||
Some("miter") => st.join = LineJoin::Miter,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) {
|
||||
st.miter_limit = m.clamp(1.0, 500.0);
|
||||
}
|
||||
match str_param(p, "align") {
|
||||
Some("inside") => st.align = StrokeAlign::Inside,
|
||||
Some("outside") => st.align = StrokeAlign::Outside,
|
||||
Some("center") => st.align = StrokeAlign::Center,
|
||||
_ => {}
|
||||
}
|
||||
match p.get("dash") {
|
||||
Some(Value::Null) => st.dash = None,
|
||||
Some(Value::Array(a)) => {
|
||||
let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
||||
st.dash = if pattern.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) })
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(a) = sa {
|
||||
st.start_arrow = a;
|
||||
}
|
||||
if let Some(a) = ea {
|
||||
st.end_arrow = a;
|
||||
}
|
||||
match str_param(p, "profile") {
|
||||
Some("uniform") => st.profile = None,
|
||||
Some("lens") => st.profile = Some(vectorcraft_doc::WidthProfile::lens()),
|
||||
Some("taperStart") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_start()),
|
||||
Some("taperEnd") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_end()),
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -144,27 +139,22 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if scale.is_none() && flip.is_none() && !swap && brush.is_none() {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let ids = paint_targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Stroke", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let Some(st) = n.appearance.stroke_mut() else { continue };
|
||||
if let Some(sc) = scale {
|
||||
st.arrow_scale = sc;
|
||||
}
|
||||
if swap {
|
||||
std::mem::swap(&mut st.start_arrow, &mut st.end_arrow);
|
||||
st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0);
|
||||
}
|
||||
if let (Some(along), Some(pr)) = (flip, &st.profile) {
|
||||
st.profile = Some(flip_profile(pr, along));
|
||||
}
|
||||
if let Some(b) = &brush {
|
||||
st.brush = b.clone();
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
let ids = item.targets(s, p)?;
|
||||
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
|
||||
let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) };
|
||||
if let Some(sc) = scale {
|
||||
st.arrow_scale = sc;
|
||||
}
|
||||
if swap {
|
||||
std::mem::swap(&mut st.start_arrow, &mut st.end_arrow);
|
||||
st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0);
|
||||
}
|
||||
if let (Some(along), Some(pr)) = (flip, &st.profile) {
|
||||
st.profile = Some(flip_profile(pr, along));
|
||||
}
|
||||
if let Some(b) = &brush {
|
||||
st.brush = b.clone();
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -65,6 +65,6 @@ pub fn document(s: &Session) -> Value {
|
||||
"redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::<Vec<_>>(),
|
||||
"objects": d.node_count(),
|
||||
"tool": s.tool_id(),
|
||||
"paint": {"fill": s.paint.fill.label(), "stroke": s.paint.stroke.label(), "strokeWidth": s.paint.stroke_width, "fillActive": s.fill_active},
|
||||
"paint": {"fill": s.paint.fill.label(), "stroke": s.paint.stroke.label(), "strokeWidth": s.paint.stroke_width, "fillActive": s.fill_active, "appearanceItem": s.appearance_item()},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -443,6 +443,9 @@ pub struct Session {
|
||||
untitled_counter: u32,
|
||||
/// Session-level state of the menu commands (saved selections, guide lock).
|
||||
pub(crate) menu: cmd::menucmds::MenuState,
|
||||
/// The Appearance panel's active fill/stroke row (`appearance.setActiveItem`); not saved. Read
|
||||
/// it through [`Session::appearance_item`], which drops it once the selection changes.
|
||||
pub(crate) active_appearance_item: Option<cmd::appearance::ActiveItem>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -468,6 +471,7 @@ impl Session {
|
||||
draw_inside: None,
|
||||
untitled_counter: 0,
|
||||
menu: Default::default(),
|
||||
active_appearance_item: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,6 +623,7 @@ impl Session {
|
||||
|
||||
/// Change only the selection (not an undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
|
||||
self.active_appearance_item = None;
|
||||
let st = self.doc_mut()?;
|
||||
f(&st.doc, &mut st.selection);
|
||||
st.selection.prune(&st.doc);
|
||||
@@ -707,6 +712,8 @@ impl Session {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_appearance;
|
||||
#[cfg(test)]
|
||||
mod tests_brushsym;
|
||||
#[cfg(test)]
|
||||
mod tests_build;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Appearance stacks: the active item that paint, stroke, gradient and transparency edits target
|
||||
//! (`appearance.setActiveItem`, the `item` param).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Node};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A selected 100×100 rectangle with the default appearance `[Fill white, Stroke black]`.
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 100, "height": 100})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn stroke(n: &Node, i: usize) -> &vectorcraft_doc::StrokeLayer {
|
||||
n.appearance.stroke_at(Some(i)).unwrap_or_else(|| panic!("item {i} is not a stroke"))
|
||||
}
|
||||
|
||||
fn fill_paint(n: &Node, i: usize) -> &Paint {
|
||||
&n.appearance.fill_at(Some(i)).unwrap_or_else(|| panic!("item {i} is not a fill")).paint
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_set_with_item_changes_only_that_stroke() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0);
|
||||
run(&mut s, "appearance.addStroke", json!({})); // [Fill, Stroke, Stroke]
|
||||
run(&mut s, "stroke.set", json!({"item": 1, "dash": [4, 2], "weight": 6}));
|
||||
let n = node(&s, id);
|
||||
assert_eq!(stroke(&n, 1).dash.as_ref().unwrap().pattern, vec![4.0, 2.0]);
|
||||
assert_eq!(stroke(&n, 1).width, 6.0);
|
||||
assert!(stroke(&n, 2).dash.is_none());
|
||||
assert_eq!(stroke(&n, 2).width, 1.0);
|
||||
run(&mut s, "stroke.setAdvanced", json!({"item": 1, "arrowScale": [50, 200]}));
|
||||
let n = node(&s, id);
|
||||
assert_eq!((stroke(&n, 1).arrow_scale, stroke(&n, 2).arrow_scale), ((50.0, 200.0), (100.0, 100.0)));
|
||||
// An explicit item must be a stroke; nothing changes when it is not.
|
||||
assert!(s.execute("stroke.set", &json!({"item": 0, "weight": 9})).is_err());
|
||||
assert!(s.execute("stroke.set", &json!({"item": 7, "weight": 9})).is_err());
|
||||
assert!(s.execute("stroke.set", &json!({"item": "top", "weight": 9})).is_err());
|
||||
assert_eq!(stroke(&node(&s, id), 2).width, 1.0);
|
||||
// Without an item the top stroke changes.
|
||||
run(&mut s, "stroke.set", json!({"weight": 3}));
|
||||
let n = node(&s, id);
|
||||
assert_eq!((stroke(&n, 1).width, stroke(&n, 2).width), (6.0, 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_gradient_on_the_lower_fill() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0);
|
||||
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill]
|
||||
run(&mut s, "paint.editGradient", json!({"item": 0, "kind": "radial"}));
|
||||
let n = node(&s, id);
|
||||
let Paint::Gradient(g) = fill_paint(&n, 0) else { panic!("lower fill is not a gradient") };
|
||||
assert_eq!(g.gradient.kind, GradientKind::Radial);
|
||||
assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE));
|
||||
// The gradient vector of the lower fill, through the active item (the gradient tool's path).
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
|
||||
run(&mut s, "paint.setGradientGeom", json!({"start": [100, 150], "end": [200, 150]}));
|
||||
let n = node(&s, id);
|
||||
let Paint::Gradient(g) = fill_paint(&n, 0) else { panic!() };
|
||||
assert_eq!(g.geom.unwrap().end.x, 200.0);
|
||||
assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE));
|
||||
// A stroke row makes the gradient vector a stroke gradient.
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
|
||||
run(&mut s, "paint.setGradientGeom", json!({"start": [100, 150], "end": [100, 250]}));
|
||||
assert!(matches!(stroke(&node(&s, id), 1).paint, Paint::Gradient(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_item_targets_paint_and_proxies_and_clears_on_selection_change() {
|
||||
let mut s = session();
|
||||
let other = rect(&mut s, 250.0);
|
||||
let id = rect(&mut s, 100.0);
|
||||
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill]
|
||||
assert!(s.execute("appearance.setActiveItem", &json!({"index": 5})).is_err());
|
||||
assert_eq!(run(&mut s, "appearance.setActiveItem", json!({"index": 0}))["index"], 0);
|
||||
assert_eq!(s.appearance_item(), Some(0));
|
||||
assert_eq!(inspect::document(&s)["paint"]["appearanceItem"], 0);
|
||||
assert!(s.fill_active);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
let n = node(&s, id);
|
||||
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
|
||||
assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE));
|
||||
// The stroke edits fall back to the top stroke while a fill row is active.
|
||||
run(&mut s, "paint.setStroke", json!({"color": "#00ff00"}));
|
||||
assert_eq!(stroke(&node(&s, id), 1).paint.color().unwrap().to_hex(), "#00ff00");
|
||||
// A stroke row brings the Stroke proxy forward.
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
|
||||
assert!(!s.fill_active);
|
||||
// Changing the selection clears it, also when the same object is selected again.
|
||||
run(&mut s, "select.set", json!({"ids": [other.0]}));
|
||||
assert_eq!(s.appearance_item(), None);
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
assert_eq!(s.appearance_item(), None);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#0000ff"}));
|
||||
let n = node(&s, id);
|
||||
assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#0000ff");
|
||||
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
|
||||
// Explicit ids never use the active item, and `null` forces the top one.
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#222222", "ids": [id.0]}));
|
||||
assert_eq!(fill_paint(&node(&s, id), 2).color().unwrap().to_hex(), "#222222");
|
||||
run(&mut s, "paint.setFill", json!({"color": "#111111", "item": null}));
|
||||
let n = node(&s, id);
|
||||
assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#111111");
|
||||
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": null}));
|
||||
assert_eq!(s.appearance_item(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_rows_follow_remove_and_move() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0);
|
||||
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill]
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
|
||||
run(&mut s, "appearance.moveItem", json!({"from": 1, "to": 2}));
|
||||
assert_eq!(s.appearance_item(), Some(2));
|
||||
assert!(matches!(node(&s, id).appearance.items[2], AppearanceItem::Stroke(_)));
|
||||
run(&mut s, "appearance.removeItem", json!({"index": 0}));
|
||||
assert_eq!(s.appearance_item(), Some(1));
|
||||
run(&mut s, "appearance.removeItem", json!({"index": 1}));
|
||||
assert_eq!(s.appearance_item(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_goes_to_the_targeted_item() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0);
|
||||
run(&mut s, "transparency.set", json!({"item": 1, "opacity": 40, "blend": "multiply"}));
|
||||
let n = node(&s, id);
|
||||
assert_eq!((stroke(&n, 1).opacity, stroke(&n, 1).blend), (0.4, BlendMode::Multiply));
|
||||
assert_eq!((n.opacity, n.blend), (1.0, BlendMode::Normal));
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
|
||||
// Opacity and blend go to the active item, isolate stays on the object.
|
||||
run(&mut s, "transparency.set", json!({"opacity": 1, "isolate": true}));
|
||||
let n = node(&s, id);
|
||||
assert!((n.appearance.items[0].opacity() - 0.01).abs() < 1e-6);
|
||||
assert!(n.isolate);
|
||||
assert_eq!(n.opacity, 1.0);
|
||||
run(&mut s, "transparency.set", json!({"item": null, "opacity": 50}));
|
||||
assert_eq!(node(&s, id).opacity, 0.5);
|
||||
assert!(s.execute("transparency.set", &json!({"item": 9, "opacity": 50})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_rows_are_the_groups_own_stack() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0);
|
||||
let b = rect(&mut s, 250.0);
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
assert_eq!(node(&s, g).appearance.items.len(), 1);
|
||||
assert_eq!(node(&s, a).appearance.items.len(), 2);
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
assert_eq!(fill_paint(&node(&s, g), 0).color().unwrap().to_hex(), "#ff0000");
|
||||
assert_eq!(*fill_paint(&node(&s, a), 0), Paint::solid(Color::WHITE));
|
||||
// Without a targeted item, a group's fill edit still recolours its contents.
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": null}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
|
||||
assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_items_take_item_paint() {
|
||||
let mut s = session();
|
||||
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Hi"}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
run(&mut s, "paint.setFill", json!({"item": 0, "color": "#ff0000"}));
|
||||
let n = node(&s, t);
|
||||
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
|
||||
let vectorcraft_doc::NodeKind::Text(tx) = &n.kind else { panic!() };
|
||||
assert_ne!(tx.first_style().fill, Paint::solid(Color::from_hex("#ff0000").unwrap()));
|
||||
}
|
||||
@@ -78,6 +78,7 @@ impl Session {
|
||||
snap_to_pixel: view.snap_to_pixel,
|
||||
snap_to_point: view.snap_to_point,
|
||||
corner_widgets: view.corner_widgets,
|
||||
appearance_item: self.appearance_item(),
|
||||
};
|
||||
f(self.tool.as_mut(), &cx)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user