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.