From 543d09985db3fe3f1efded9c6100374710acfe92 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:15:40 +0700 Subject: [PATCH] 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 --- crates/engine/src/cmd/appearance.rs | 35 ++-- crates/engine/src/cmd/effectcmd.rs | 182 ++++++++++-------- crates/engine/src/tests_appearance.rs | 51 +++++ crates/ui-egui/src/dialogs/effect.rs | 23 ++- crates/ui-egui/src/dialogs/tests.rs | 13 ++ crates/ui-egui/src/menus.rs | 10 +- crates/ui-egui/src/panels/appearance.rs | 159 +++++++++------ crates/ui-egui/src/panels/tests_appearance.rs | 56 +++++- docs/mcp.md | 5 +- 9 files changed, 366 insertions(+), 168 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 7c19ef8bc..6460199f3 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -50,7 +50,15 @@ pub fn specs() -> Vec { 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> { + 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 { ok() } -fn add_effect(s: &mut Session, p: &Value) -> Result { - let id = str_param(p, "effect").ok_or_else(|| bad("appearance.addEffect", "missing effect id"))?.to_string(); - let params = p.get("params").cloned().unwrap_or(json!({})); - let ids = targets(s, p)?; - s.edit("Add Effect", |d, _| { - for nid in &ids { - if let Some(n) = d.node_mut(*nid) { - n.appearance.effects.push(vectorcraft_doc::Effect { id: id.clone(), params: params.clone(), visible: true }); - } - } - Ok(()) - })?; - ok() -} - fn duplicate_item(s: &mut Session, p: &Value) -> Result { let idx = index_param(p, "index", "appearance.duplicateItem")?; let ids = appearance_targets(s, p)?; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 39b3c271d..dd712cf98 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -6,11 +6,11 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Effect, Node, NodeKind}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; -use super::edit::selected_roots; +use super::appearance::{ItemTarget, appearance_targets, index_param, item_target}; use super::*; pub fn specs() -> Vec { @@ -20,17 +20,25 @@ pub fn specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), ids?: [..]} append a live effect to each selected object's appearance → {ids, index}", + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..]} append a live effect to each selected object's appearance → {ids, index, item}", has_selection, apply ), - cmd!(query "effect.list", "Effects", [], None, "{} → {catalog: [{id, label, menu, params, defaults, raster}], applied: [{id, effects}]} for the selection", always, list), + cmd!( + query "effect.list", + "Effects", + [], + None, + "{} → {catalog: [{id, label, menu, params, defaults, raster}], applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection", + always, + list + ), cmd!( "effect.remove", "Remove Effect", [], None, - "{index: int (position in the object's effect list), ids?: [..]} → {ids}", + "{index: int (position in the effect list), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?: [..]} → {ids}", has_selection, remove ), @@ -39,7 +47,7 @@ pub fn specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), visible?: bool, ids?: [..]} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?: [..]} → {ids}", has_selection, set_params ), @@ -52,21 +60,52 @@ pub fn specs() -> Vec { has_selection, expand_appearance ), + cmd!( + "effect.duplicate", + "Duplicate Effect", + [], + None, + "{index: int, item?: appearance item index|null (as effect.remove), ids?: [..]} insert a copy of the effect right after it → {ids}", + has_selection, + duplicate + ), ] } -fn target_roots(s: &Session, p: &Value) -> Result> { - if p.get("ids").is_some() || p.get("id").is_some() { - return targets(s, p); - } - selected_roots(s) -} - fn ids_json(ids: &[NodeId]) -> Value { json!(ids.iter().map(|i| i.0).collect::>()) } -fn apply(s: &mut Session, p: &Value) -> Result { +/// One undo step (`label`) running `f` on the effect list `item` addresses on each target (an +/// appearance item's own effects, or the object's). Errors with `none` when `f` changed no object; +/// returns the changed ids. +fn edit_effects( + s: &mut Session, + p: &Value, + item: ItemTarget, + cmd: &str, + label: &str, + none: &str, + mut f: impl FnMut(&mut Vec) -> bool, +) -> Result> { + let roots = appearance_targets(s, p)?; + s.edit(label, |d, _| { + let mut done = vec![]; + for id in &roots { + let Some(n) = d.node_mut(*id) else { continue }; + let index = item.effects_item(&n.appearance, cmd)?; + if n.appearance.effects_mut(index).is_some_and(&mut f) { + done.push(*id); + } + } + if done.is_empty() { + return Err(EngineError::Other(none.into())); + } + Ok(done) + }) +} + +pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.apply"; let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?; let params = p.get("params").cloned().unwrap_or(Value::Null); @@ -74,26 +113,18 @@ fn apply(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`params` must be an object")); } let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?; - let roots = target_roots(s, p)?; let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default(); - let (ids, index) = s.edit(&label, |d, _| { - let mut done = vec![]; - let mut index = 0; - for id in &roots { - let Some(n) = d.node_mut(*id) else { continue }; - if n.is_layer() { - continue; - } - n.appearance.effects.push(effect.clone()); - index = n.appearance.effects.len() - 1; - done.push(*id); - } - if done.is_empty() { - return Err(EngineError::Other("Apply Effect: select objects".into())); - } - Ok((done, index)) + let item = item_target(s, p, C)?; + let mut index = 0; + let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| { + fx.push(effect.clone()); + index = fx.len() - 1; + true })?; - Ok(json!({ "ids": ids_json(&ids), "index": index })) + // The item the effect landed on in the first object (the active item applies where it fits). + let first = ids.first().and_then(|id| s.doc().ok()?.doc.node(*id)); + let landed = first.and_then(|n| item.effects_item(&n.appearance, C).ok().flatten()); + Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed })) } fn list(s: &mut Session, p: &Value) -> Result { @@ -103,73 +134,72 @@ fn list(s: &mut Session, p: &Value) -> Result { .collect(); let mut applied = vec![]; if s.active().is_some() { - for id in target_roots(s, p)? { + let fx = |e: &[Effect]| serde_json::to_value(e).unwrap_or(Value::Null); + for id in appearance_targets(s, p)? { if let Some(n) = s.doc()?.doc.node(id) { - applied.push(json!({"id": id.0, "effects": serde_json::to_value(&n.appearance.effects).unwrap_or(Value::Null)})); + let items: Vec = n + .appearance + .items + .iter() + .enumerate() + .map(|(i, it)| json!({"index": i, "kind": it.kind_name(), "effects": fx(it.effects())})) + .collect(); + applied.push(json!({"id": id.0, "effects": fx(&n.appearance.effects), "items": items})); } } } - Ok(json!({ "catalog": catalog, "applied": applied })) -} - -fn index_param(p: &Value, cmd: &str) -> Result { - p.get("index").and_then(Value::as_u64).map(|i| i as usize).ok_or_else(|| bad(cmd, "missing integer `index`")) + Ok(json!({ "catalog": catalog, "applied": applied, "activeItem": s.appearance_item() })) } fn remove(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.remove"; - let index = index_param(p, C)?; - let roots = target_roots(s, p)?; - let ids = s.edit("Remove Effect", |d, _| { - let mut done = vec![]; - for id in &roots { - let Some(n) = d.node_mut(*id) else { continue }; - if index < n.appearance.effects.len() { - n.appearance.effects.remove(index); - done.push(*id); - } + let index = index_param(p, "index", C)?; + let item = item_target(s, p, C)?; + let ids = edit_effects(s, p, item, C, "Remove Effect", &format!("{C}: no effect at index {index}"), |fx| { + (index < fx.len()).then(|| fx.remove(index)).is_some() + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +fn duplicate(s: &mut Session, p: &Value) -> Result { + const C: &str = "effect.duplicate"; + let index = index_param(p, "index", C)?; + let item = item_target(s, p, C)?; + let ids = edit_effects(s, p, item, C, "Duplicate Effect", &format!("{C}: no effect at index {index}"), |fx| match fx.get(index).cloned() { + Some(e) => { + fx.insert(index + 1, e); + true } - if done.is_empty() { - return Err(bad(C, format!("no effect at index {index}"))); - } - Ok(done) + None => false, })?; Ok(json!({ "ids": ids_json(&ids) })) } fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; - let index = index_param(p, C)?; + let index = index_param(p, "index", C)?; let params = p.get("params").cloned().unwrap_or(Value::Null); if !params.is_null() && !params.is_object() { return Err(bad(C, "`params` must be an object")); } let visible = p.get("visible").and_then(Value::as_bool); - let roots = target_roots(s, p)?; - let ids = s.edit("Effect Options", |d, _| { - let mut done = vec![]; - for id in &roots { - let Some(n) = d.node_mut(*id) else { continue }; - let Some(e) = n.appearance.effects.get_mut(index) else { continue }; - if let (Value::Object(new), cur) = (¶ms, &mut e.params) { - if !cur.is_object() { - *cur = effects::merged_params(&e.id, &Value::Null); - } - if let Value::Object(m) = cur { - for (k, v) in new { - m.insert(k.clone(), v.clone()); - } + let item = item_target(s, p, C)?; + let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| { + let Some(e) = fx.get_mut(index) else { return false }; + if let (Value::Object(new), cur) = (¶ms, &mut e.params) { + if !cur.is_object() { + *cur = effects::merged_params(&e.id, &Value::Null); + } + if let Value::Object(m) = cur { + for (k, v) in new { + m.insert(k.clone(), v.clone()); } } - if let Some(v) = visible { - e.visible = v; - } - done.push(*id); } - if done.is_empty() { - return Err(bad(C, format!("no effect at index {index}"))); + if let Some(v) = visible { + e.visible = v; } - Ok(done) + true })?; Ok(json!({ "ids": ids_json(&ids) })) } @@ -242,7 +272,7 @@ fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec Result { - let roots = target_roots(s, p)?; + let roots = appearance_targets(s, p)?; let ids = s.edit("Expand Appearance", |d, _| { let mut out = vec![]; for id in &roots { diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs index 7a52854f4..cad8e7ac3 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests_appearance.rs @@ -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::>(), ["distort.twist", "stylize.roundCorners"]); + let l = run(&mut s, "effect.list", json!({})); + assert_eq!(l["activeItem"], 0); + assert_eq!(l["applied"][0]["items"][0]["kind"], "fill"); + assert_eq!(l["applied"][0]["items"][0]["effects"].as_array().unwrap().len(), 2); + // Sub-row eye, duplicate and delete address the item's list. + run(&mut s, "effect.setParams", json!({"index": 1, "visible": false})); + run(&mut s, "effect.duplicate", json!({"index": 0})); + run(&mut s, "effect.remove", json!({"index": 2})); + let fx = node(&s, id).appearance.items[0].effects().clone(); + assert_eq!(fx.iter().map(|e| (e.id.as_str(), e.visible)).collect::>(), [("distort.twist", true), ("distort.twist", true)]); + assert!(s.execute("effect.remove", &json!({"index": 5})).is_err()); + // `item: null` addresses the object's own effects. + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null})); + assert_eq!(node(&s, id).appearance.effects.len(), 1); +} + +#[test] +fn per_fill_offset_path_renders() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let render = |s: &Session| { + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()) + }; + assert_eq!(render(&s).pixel(90, 150)[3], 0); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 20}, "item": 0})); + assert_eq!(node(&s, id).appearance.items[0].effects().len(), 1); + let px = render(&s).pixel(90, 150); + assert!(px[0] > 200 && px[3] > 200, "{px:?}"); +} diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 99fe895de..dbd4e67fc 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -1,5 +1,6 @@ //! Live effect dialogs (Effect menu): the effect's parameters (`__effect`, headed `__label`) with a -//! live Preview that OK keeps as one undo step and Cancel rolls back. +//! live Preview that OK keeps as one undo step and Cancel rolls back. `__item`, when present, is +//! the appearance item the effect goes to (else the Appearance panel's active item applies). use serde_json::{Value, json}; @@ -18,24 +19,32 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if pv && (changed || pv_changed || !app.session.in_interaction()) { let label = d.str("__label"); let _ = app.session.begin_interaction(&label); - let _ = app.session.preview("effect.apply", &json!({"effect": d.str("__effect"), "params": form::params(d)})); + let _ = app.session.preview("effect.apply", &apply_params(d)); } else if !pv && pv_changed { let _ = app.session.cancel_interaction(); } false } +/// `effect.apply` params for the dialog's effect, values and target item. +fn apply_params(d: &Dialog) -> Value { + let mut p = json!({"effect": d.str("__effect"), "params": form::params(d)}); + if let Some(item) = d.fields.get("__item") { + p["item"] = item.clone(); + } + p +} + fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let effect = d.str("__effect"); - let params = form::params(d); + let p = apply_params(d); let r = if app.session.in_interaction() { // Live preview already applied: keep it (the interaction commits as one undo step). - let _ = app.session.preview("effect.apply", &json!({"effect": effect, "params": params})); + let _ = app.session.preview("effect.apply", &p); app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()) } else { - app.run("effect.apply", json!({"effect": effect, "params": params})) + app.run("effect.apply", p.clone()) }; - app.last_effect = Some((effect, params)); + app.last_effect = Some((d.str("__effect"), p["params"].clone())); app.ui.dialog = None; r } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 5c42eb2ae..0b459ef88 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -163,3 +163,16 @@ fn effect_preview_is_kept_as_one_undo_step() { app.run("edit.undo", json!({})).unwrap(); assert_eq!(app.session.doc().unwrap().doc, before); } + +#[test] +fn effect_dialog_applies_to_its_appearance_item() { + let mut app = app(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": "distort.roughen", "item": 1})).unwrap(); + frame(&mut app, Default::default()); + confirm(&mut app).unwrap(); + let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + assert_eq!(n.appearance.items[1].effects().len(), 1, "the effect goes to the stroke"); + assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty()); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7c61cf88f..3f8048e9b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -152,7 +152,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.newWindow", "New Window", "", "{}"), ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), ("tool.setOption", "Tool Option", "", "{key, value}"), - ("effect.dialog", "Effect…", "", "{effect: id} open the effect's dialog with live preview"), + ( + "effect.dialog", + "Effect…", + "", + "{effect: id, item?: appearance item index|null (default: the Appearance panel's active item)} open the effect's dialog with live preview", + ), ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), ("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), @@ -439,6 +444,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option 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 { + match self { + Sel::Item(i) | Sel::ItemEffect(i, _) => Some(i), + Sel::None | Sel::Effect(_) => None, + } + } + /// `{item, index}` of a selected effect row (`item: null` for the object's own effects). + fn effect(self) -> Option { + match self { + Sel::Effect(k) => Some(json!({"item": null, "index": k})), + Sel::ItemEffect(i, k) => Some(json!({"item": i, "index": k})), + Sel::None | Sel::Item(_) => None, + } } } -/// 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, Sel) = pstate(ctx, "ap-sel"); + let exists = |item: Option, k: usize| owner == Some(n.id) && n.appearance.effects_at(item).is_some_and(|fx| k < fx.len()); + match (app.session.appearance_item(), sel) { + (Some(i), Sel::ItemEffect(j, k)) if i == j && exists(Some(i), k) => sel, + (Some(i), _) => Sel::Item(i), + (None, Sel::Effect(k)) if exists(None, k) => sel, + (None, _) => Sel::None, + } +} + +/// Select a row; its fill/stroke becomes the active item that paint and effect edits target. pub(crate) fn select_row(app: &mut VectorcraftApp, ctx: &egui::Context, sel: Sel) { - 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)] { @@ -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 = 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, 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, 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); diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index 186b7beff..1ffb59d2d 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -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 { - 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 { + frame_events(ctx, app, vec![], f).into_iter().map(|(s, _)| s).collect() +} + +/// One headless frame with input `events`: every text drawn, with its screen rect. +fn frame_events( + ctx: &egui::Context, + app: &mut VectorcraftApp, + events: Vec, + mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui), +) -> Vec<(String, egui::Rect)> { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| f(app, ui)); out.textures_delta.clear(); - fn collect(s: &egui::Shape, out: &mut Vec) { + 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 { + first_selected(app).unwrap().appearance.items[1].effects().clone() +} diff --git a/docs/mcp.md b/docs/mcp.md index e20079354..858947fdc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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.