M3.22: effects apply to the active fill or stroke and item effect rows are editable

effect.apply, effect.remove, effect.setParams and the new effect.duplicate
take item (omitted: the Appearance panel's active item, else the object;
null: the object's own effects), so the fx button, the Effect menu, Apply
Last Effect and the effect dialogs (effect.dialog {item}) put the effect on
the selected fill or stroke. effect.list reports each object's item effects
and the active item. appearance.addEffect is an alias of effect.apply.
In the Appearance panel, item effect rows sit under their fill or stroke
with working eyes and inline editors, and can be selected for Duplicate
and Delete; a selected effect row is remembered per object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-04 17:15:40 +07:00
co-authored by Claude Opus 5.5
parent 018bed29db
commit 543d09985d
9 changed files with 366 additions and 168 deletions
+19 -16
View File
@@ -50,7 +50,15 @@ pub fn specs() -> Vec<CommandSpec> {
set_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.addEffect",
"Add Effect",
[],
None,
"same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?} → {ids, index, item}",
has_selection,
super::effectcmd::apply
),
cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index, ids?}", has_selection, duplicate_item),
cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to, ids?} (paint-order indices)", has_selection, move_item),
cmd!(
@@ -149,6 +157,16 @@ impl ItemTarget {
},
}
}
/// 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
@@ -331,21 +349,6 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn add_effect(s: &mut Session, p: &Value) -> Result<Value> {
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()
}
fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = index_param(p, "index", "appearance.duplicateItem")?;
let ids = appearance_targets(s, p)?;
+106 -76
View File
@@ -6,11 +6,11 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{Effect, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_render::effects;
use super::edit::selected_roots;
use super::appearance::{ItemTarget, appearance_targets, index_param, item_target};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -20,17 +20,25 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Effect",
[],
None,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), ids?: [..]} append a live effect to each selected object's appearance → {ids, index}",
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..]} append a live effect to each selected object's appearance → {ids, index, item}",
has_selection,
apply
),
cmd!(query "effect.list", "Effects", [], None, "{} → {catalog: [{id, label, menu, params, defaults, raster}], applied: [{id, effects}]} for the selection", always, list),
cmd!(
query "effect.list",
"Effects",
[],
None,
"{} → {catalog: [{id, label, menu, params, defaults, raster}], applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
always,
list
),
cmd!(
"effect.remove",
"Remove Effect",
[],
None,
"{index: int (position in the object's effect list), ids?: [..]} → {ids}",
"{index: int (position in the effect list), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?: [..]} → {ids}",
has_selection,
remove
),
@@ -39,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effect Options",
[],
None,
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, ids?: [..]} → {ids}",
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?: [..]} → {ids}",
has_selection,
set_params
),
@@ -52,21 +60,52 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
expand_appearance
),
cmd!(
"effect.duplicate",
"Duplicate Effect",
[],
None,
"{index: int, item?: appearance item index|null (as effect.remove), ids?: [..]} insert a copy of the effect right after it → {ids}",
has_selection,
duplicate
),
]
}
fn target_roots(s: &Session, p: &Value) -> Result<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 {
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";
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
@@ -74,26 +113,18 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`params` must be an object"));
}
let effect = effects::new_effect(id, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
let roots = target_roots(s, p)?;
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
let (ids, index) = s.edit(&label, |d, _| {
let mut done = vec![];
let mut index = 0;
for id in &roots {
let Some(n) = d.node_mut(*id) else { continue };
if n.is_layer() {
continue;
}
n.appearance.effects.push(effect.clone());
index = n.appearance.effects.len() - 1;
done.push(*id);
}
if done.is_empty() {
return Err(EngineError::Other("Apply Effect: select objects".into()));
}
Ok((done, index))
let item = item_target(s, p, C)?;
let mut index = 0;
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
fx.push(effect.clone());
index = fx.len() - 1;
true
})?;
Ok(json!({ "ids": ids_json(&ids), "index": index }))
// The item the effect landed on in the first object (the active item applies where it fits).
let first = ids.first().and_then(|id| s.doc().ok()?.doc.node(*id));
let landed = first.and_then(|n| item.effects_item(&n.appearance, C).ok().flatten());
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed }))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
@@ -103,73 +134,72 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
.collect();
let mut applied = vec![];
if s.active().is_some() {
for id in target_roots(s, p)? {
let fx = |e: &[Effect]| serde_json::to_value(e).unwrap_or(Value::Null);
for id in appearance_targets(s, p)? {
if let Some(n) = s.doc()?.doc.node(id) {
applied.push(json!({"id": id.0, "effects": serde_json::to_value(&n.appearance.effects).unwrap_or(Value::Null)}));
let items: Vec<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 }))
}
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`"))
Ok(json!({ "catalog": catalog, "applied": applied, "activeItem": s.appearance_item() }))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.remove";
let index = index_param(p, C)?;
let roots = target_roots(s, p)?;
let ids = s.edit("Remove Effect", |d, _| {
let mut done = vec![];
for id in &roots {
let Some(n) = d.node_mut(*id) else { continue };
if index < n.appearance.effects.len() {
n.appearance.effects.remove(index);
done.push(*id);
}
let index = index_param(p, "index", C)?;
let item = item_target(s, p, C)?;
let ids = edit_effects(s, p, item, C, "Remove Effect", &format!("{C}: no effect at index {index}"), |fx| {
(index < fx.len()).then(|| fx.remove(index)).is_some()
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
fn duplicate(s: &mut Session, p: &Value) -> Result<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() {
return Err(bad(C, format!("no effect at index {index}")));
}
Ok(done)
None => false,
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, C)?;
let index = index_param(p, "index", C)?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
if !params.is_null() && !params.is_object() {
return Err(bad(C, "`params` must be an object"));
}
let visible = p.get("visible").and_then(Value::as_bool);
let roots = target_roots(s, p)?;
let ids = s.edit("Effect Options", |d, _| {
let mut done = vec![];
for id in &roots {
let Some(n) = d.node_mut(*id) else { continue };
let Some(e) = n.appearance.effects.get_mut(index) else { continue };
if let (Value::Object(new), cur) = (&params, &mut e.params) {
if !cur.is_object() {
*cur = effects::merged_params(&e.id, &Value::Null);
}
if let Value::Object(m) = cur {
for (k, v) in new {
m.insert(k.clone(), v.clone());
}
let item = item_target(s, p, C)?;
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
let Some(e) = fx.get_mut(index) else { return false };
if let (Value::Object(new), cur) = (&params, &mut e.params) {
if !cur.is_object() {
*cur = effects::merged_params(&e.id, &Value::Null);
}
if let Value::Object(m) = cur {
for (k, v) in new {
m.insert(k.clone(), v.clone());
}
}
if let Some(v) = visible {
e.visible = v;
}
done.push(*id);
}
if done.is_empty() {
return Err(bad(C, format!("no effect at index {index}")));
if let Some(v) = visible {
e.visible = v;
}
Ok(done)
true
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
@@ -242,7 +272,7 @@ fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec<Node
}
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 mut out = vec![];
for id in &roots {
+51
View File
@@ -194,3 +194,54 @@ fn text_items_take_item_paint() {
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:?}");
}
+16 -7
View File
@@ -1,5 +1,6 @@
//! Live effect dialogs (Effect menu): the effect's parameters (`__effect`, headed `__label`) with a
//! live Preview that OK keeps as one undo step and Cancel rolls back.
//! live Preview that OK keeps as one undo step and Cancel rolls back. `__item`, when present, is
//! the appearance item the effect goes to (else the Appearance panel's active item applies).
use serde_json::{Value, json};
@@ -18,24 +19,32 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if pv && (changed || pv_changed || !app.session.in_interaction()) {
let label = d.str("__label");
let _ = app.session.begin_interaction(&label);
let _ = app.session.preview("effect.apply", &json!({"effect": d.str("__effect"), "params": form::params(d)}));
let _ = app.session.preview("effect.apply", &apply_params(d));
} else if !pv && pv_changed {
let _ = app.session.cancel_interaction();
}
false
}
/// `effect.apply` params for the dialog's effect, values and target item.
fn apply_params(d: &Dialog) -> Value {
let mut p = json!({"effect": d.str("__effect"), "params": form::params(d)});
if let Some(item) = d.fields.get("__item") {
p["item"] = item.clone();
}
p
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let effect = d.str("__effect");
let params = form::params(d);
let p = apply_params(d);
let r = if app.session.in_interaction() {
// Live preview already applied: keep it (the interaction commits as one undo step).
let _ = app.session.preview("effect.apply", &json!({"effect": effect, "params": params}));
let _ = app.session.preview("effect.apply", &p);
app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string())
} else {
app.run("effect.apply", json!({"effect": effect, "params": params}))
app.run("effect.apply", p.clone())
};
app.last_effect = Some((effect, params));
app.last_effect = Some((d.str("__effect"), p["params"].clone()));
app.ui.dialog = None;
r
}
+13
View File
@@ -163,3 +163,16 @@ fn effect_preview_is_kept_as_one_undo_step() {
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
#[test]
fn effect_dialog_applies_to_its_appearance_item() {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
app.run("effect.dialog", json!({"effect": "distort.roughen", "item": 1})).unwrap();
frame(&mut app, Default::default());
confirm(&mut app).unwrap();
let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
assert_eq!(n.appearance.items[1].effects().len(), 1, "the effect goes to the stroke");
assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty());
}
+9 -1
View File
@@ -152,7 +152,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.newWindow", "New Window", "", "{}"),
("tool.select", "Select Tool", "", "{tool: id} (see tools)"),
("tool.setOption", "Tool Option", "", "{key, value}"),
("effect.dialog", "Effect…", "", "{effect: id} open the effect's dialog with live preview"),
(
"effect.dialog",
"Effect…",
"",
"{effect: id, item?: appearance item index|null (default: the Appearance panel's active item)} open the effect's dialog with live preview",
),
("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"),
("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"),
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
@@ -439,6 +444,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
fields.insert("__effect".into(), json!(e.id));
fields.insert("__label".into(), json!(e.label.trim_end_matches('…')));
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 });
Ok(Value::Null)
}
+96 -63
View File
@@ -7,7 +7,7 @@ use std::sync::OnceLock;
use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{AppearanceItem, Effect, Node};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId};
use super::{first_selected, pstate, set_pstate};
use crate::theme::Tokens;
@@ -15,39 +15,61 @@ use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
const ROW: f32 = 30.0;
const EYE_W: f32 = 26.0;
pub(super) const EYE_W: f32 = 26.0;
/// What is selected in the stack (for Duplicate / Delete). A fill/stroke row is the engine's
/// active appearance item (`appearance.setActiveItem`); effect rows are panel state.
/// What is selected in the stack (for Duplicate / Delete). A fill/stroke row (also the owner of a
/// selected item effect) is the engine's active appearance item (`appearance.setActiveItem`), so
/// paint edits, the fx button and the Effect menu target it; effect rows are panel state.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Sel {
#[default]
None,
Item(usize),
/// An object-level effect.
Effect(usize),
/// Effect `k` of fill/stroke item `i`.
ItemEffect(usize, usize),
}
/// The selected row: the engine's active item, else a selected effect row.
fn current_sel(app: &VectorcraftApp, ctx: &egui::Context) -> Sel {
match app.session.appearance_item() {
Some(i) => Sel::Item(i),
None => match pstate(ctx, "ap-sel") {
Sel::Item(_) => Sel::None,
other => other,
},
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,
}
}
}
/// Select a row: fill/stroke rows become the active item that paint edits target.
/// 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) {
let index = match sel {
Sel::Item(i) => json!(i),
_ => Value::Null,
};
if app.session.appearance_item().map_or(Value::Null, |i| json!(i)) != index {
app.run("appearance.setActiveItem", json!({ "index": index })).ok();
if app.session.appearance_item() != sel.item() {
app.run("appearance.setActiveItem", json!({ "index": sel.item() })).ok();
}
set_pstate(ctx, "ap-sel", if matches!(sel, Sel::Item(_)) { Sel::None } else { sel });
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>)] {
@@ -141,7 +163,7 @@ fn link(ui: &mut Ui, pos: egui::Pos2, id: impl std::hash::Hash + std::fmt::Debug
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let node = first_selected(app);
let sel = current_sel(app, ui.ctx());
let sel = current_sel(app, ui.ctx(), node.as_ref());
let hide_thumb: bool = pstate(ui.ctx(), "ap-hide-thumb");
widgets::list_box(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
@@ -191,19 +213,20 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) {
fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
let t = Tokens::get(ui.ctx());
let items: Vec<(usize, AppearanceItem)> = n.appearance.items.iter().cloned().enumerate().rev().collect();
let mut drop: Option<(usize, usize)> = None;
let mut stopped = false;
let dragging: Option<usize> = pstate(ui.ctx(), "ap-drag");
let mut row_rects = vec![];
for (i, it) in items {
for (i, it) in n.appearance.items.iter().enumerate().rev() {
let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}"));
let (r, resp) = row(ui, sel == Sel::Item(i));
row_rects.push((i, r));
let (visible, paint, is_stroke, width, opacity, blend, effects) = match &it {
AppearanceItem::Fill(f) => (f.visible, f.paint.clone(), false, 0.0, f.opacity, f.blend, f.effects.clone()),
AppearanceItem::Stroke(s) => (s.visible, s.paint.clone(), true, s.width, s.opacity, s.blend, s.effects.clone()),
let visible = it.visible();
let (paint, width) = match it {
AppearanceItem::Fill(f) => (&f.paint, 0.0),
AppearanceItem::Stroke(s) => (&s.paint, s.width),
};
let is_stroke = !it.is_fill();
if eye(ui, r, ("ap-eye", i), visible, true) {
app.run("appearance.setItem", json!({"index": i, "visible": !visible})).ok();
}
@@ -221,7 +244,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
}
// Swatch with a swatches popup that sets this item's paint.
let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0));
chip(ui, cr, &paint);
chip(ui, cr, paint);
let cresp = ui.interact(cr.expand(2.0), ui.id().with(("ap-chip", i)), Sense::click()).on_hover_text("Click to choose a swatch");
egui::Popup::menu(&cresp).show(|ui| {
swatch_picker(app, ui, i);
@@ -246,9 +269,9 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
stopped = true;
}
if open {
sub_row(app, ui, ("ap-sub-op", i), &format!("Opacity: {}", opacity_text(opacity, blend)), true, 1);
for (k, e) in effects.iter().enumerate() {
sub_row(app, ui, ("ap-sub-fx", i, k), &effect_label(&e.id), e.visible, 1);
sub_row(app, ui, ("ap-sub-op", i), &format!("Opacity: {}", opacity_text(it.opacity(), it.blend())), true, 1);
for (k, e) in it.effects().iter().enumerate() {
effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k));
}
}
}
@@ -272,7 +295,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
}
// Object-level effects.
for (k, e) in n.appearance.effects.iter().enumerate() {
effect_row(app, ui, k, e, sel == Sel::Effect(k));
effect_row(app, ui, None, k, e, sel == Sel::Effect(k));
}
// Object opacity row.
let (r, resp) = row(ui, false);
@@ -303,36 +326,41 @@ fn sub_row(_app: &mut VectorcraftApp, ui: &mut Ui, id: impl std::hash::Hash + st
text(ui, r.left_center() + vec2(EYE_W + 24.0 + depth as f32 * 12.0, 0.0), label, false);
}
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 open: bool = pstate(ui.ctx(), &format!("ap-fx-open-{k}"));
let open_key = format!("ap-fx-open-{item:?}-{k}");
let open: bool = pstate(ui.ctx(), &open_key);
let this = item.map_or(Sel::Effect(k), |i| Sel::ItemEffect(i, k));
let (r, resp) = row(ui, selected);
if eye(ui, r, ("ap-fx-eye", k), e.visible, true) {
app.run("effect.setParams", json!({"index": k, "visible": !e.visible})).ok();
if eye(ui, r, ("ap-fx-eye", item, k), e.visible, true) {
app.run("effect.setParams", json!({"item": item, "index": k, "visible": !e.visible})).ok();
}
if chevron(ui, r, ("ap-fx-chev", k), open) {
set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open);
let indent = if item.is_some() { 12.0 } else { 0.0 };
let r = Rect::from_min_max(pos2(r.left() + indent, r.top()), r.max);
if chevron(ui, r, ("ap-fx-chev", item, k), open) {
set_pstate(ui.ctx(), &open_key, !open);
}
let lx = r.left() + EYE_W + 24.0;
if link(ui, pos2(lx, r.center().y), ("ap-fx-link", k), &effect_label(&e.id)) {
set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open);
select_row(app, ui.ctx(), Sel::Effect(k));
if link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &effect_label(&e.id)) {
set_pstate(ui.ctx(), &open_key, !open);
select_row(app, ui.ctx(), this);
}
icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon);
if resp.clicked() {
select_row(app, ui.ctx(), Sel::Effect(k));
select_row(app, ui.ctx(), this);
}
if resp.double_clicked() {
set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open);
set_pstate(ui.ctx(), &open_key, !open);
}
if open {
effect_editor(app, ui, k, e);
effect_editor(app, ui, item, k, e);
}
}
/// Inline editor for an applied effect's parameters (numbers, booleans, strings, colours);
/// edits go through `effect.setParams`.
fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, k: usize, e: &Effect) {
fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usize, e: &Effect) {
let t = Tokens::get(ui.ctx());
let defaults = vectorcraft_effects::effect_catalog().into_iter().find(|c| c.id == e.id).map(|c| c.defaults).unwrap_or(Value::Null);
let mut params = defaults.as_object().cloned().unwrap_or_default();
@@ -359,7 +387,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, k: usize, e: &Effect) {
}
Value::Number(n) => {
let x = n.as_f64().unwrap_or(0.0);
if let Some(nx) = widgets::plain_field(ui, ("fx", k, key.as_str()), x, "", 2, 70.0) {
if let Some(nx) = widgets::plain_field(ui, ("fx", item, k, key.as_str()), x, "", 2, 70.0) {
change = Some((key.clone(), json!(nx)));
}
}
@@ -378,7 +406,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, k: usize, e: &Effect) {
}
});
if let Some((key, v)) = change {
app.run("effect.setParams", json!({"index": k, "params": {key: v}})).ok();
app.run("effect.setParams", json!({"item": item, "index": k, "params": {key: v}})).ok();
}
}
@@ -442,11 +470,9 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel)
if widgets::icon_button_enabled(ui, "dc-clear", "Clear Appearance", false, has, 24.0).clicked() {
app.run("appearance.clear", json!({})).ok();
}
let can_dup = has && matches!(sel, Sel::Item(_));
if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can_dup, 24.0).clicked()
&& let Sel::Item(i) = sel
{
app.run("appearance.duplicateItem", json!({"index": i})).ok();
let can_dup = has && sel != Sel::None;
if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can_dup, 24.0).clicked() {
duplicate_selected(app, sel);
}
let can_del = has && sel != Sel::None;
if widgets::icon_button_enabled(ui, "trash-2", "Delete Selected Item", false, can_del, 24.0).clicked() {
@@ -456,15 +482,24 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel)
});
}
fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) {
// Removing the active item clears it in the engine.
let ok = match sel {
Sel::Item(i) => app.run("appearance.removeItem", json!({"index": i})).is_ok(),
Sel::Effect(k) => app.run("effect.remove", json!({"index": k})).is_ok(),
Sel::None => false,
fn duplicate_selected(app: &mut VectorcraftApp, sel: Sel) {
match (sel, sel.effect()) {
(Sel::Item(i), _) => app.run("appearance.duplicateItem", json!({"index": i})).ok(),
(_, Some(fx)) => app.run("effect.duplicate", fx).ok(),
_ => None,
};
}
fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) {
let ok = match (sel, sel.effect()) {
(Sel::Item(i), _) => app.run("appearance.removeItem", json!({"index": i})).is_ok(),
(_, Some(fx)) => app.run("effect.remove", fx).is_ok(),
_ => false,
};
// Removing the active item clears it in the engine; a removed item effect leaves its item
// selected.
if ok {
set_pstate(ui.ctx(), "ap-sel", Sel::None);
select_row(app, ui.ctx(), if let Sel::ItemEffect(i, _) = sel { Sel::Item(i) } else { Sel::None });
}
}
@@ -493,7 +528,7 @@ fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let node = first_selected(app);
let has = node.is_some();
let sel = current_sel(app, ui.ctx());
let sel = current_sel(app, ui.ctx(), node.as_ref());
if menu_item(ui, "Add New Fill", has, false) {
app.run("appearance.addFill", json!({})).ok();
}
@@ -501,10 +536,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("appearance.addStroke", json!({})).ok();
}
ui.separator();
if menu_item(ui, "Duplicate Item", has && matches!(sel, Sel::Item(_)), false)
&& let Sel::Item(i) = sel
{
app.run("appearance.duplicateItem", json!({"index": i})).ok();
if menu_item(ui, "Duplicate Item", has && sel != Sel::None, false) {
duplicate_selected(app, sel);
}
if menu_item(ui, "Remove Item", has && sel != Sel::None, false) {
delete_selected(app, ui, sel);
+52 -4
View File
@@ -24,12 +24,22 @@ fn frame_texts(app: &mut VectorcraftApp, f: impl FnMut(&mut VectorcraftApp, &mut
frame_in(&ctx, app, f)
}
fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> Vec<String> {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui));
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>) {
fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
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)),
_ => {}
}
@@ -41,6 +51,19 @@ fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, mut f: impl FnMut(&mu
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();
@@ -78,3 +101,28 @@ fn transparency_panel_reads_the_active_item() {
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()
}
+4 -1
View File
@@ -73,7 +73,10 @@ in paint order (0 is painted first, the bottom row of the Appearance panel). `pa
`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`.
`inspect_document` reports it as `paint.appearanceItem`. Live effects take the same `item` to apply to one fill or
stroke instead of the whole object: `run_command effect.apply {"effect": "path.offsetPath", "item": 0}`, and
`effect.remove`, `effect.setParams` (`visible` toggles one) and `effect.duplicate` address that item's effects;
`apply_effect` uses the active item. `effect.list` reports each object's item effects under `applied[].items`.
Errors (unknown tool, bad arguments, a disabled or failing command) come back as a normal result with
`isError: true` and a message. The model can read the message and retry.