From 10ea4d5daf10927d32cae06c9736e839ca63110d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:01:34 +0700 Subject: [PATCH] =?UTF-8?q?M3.42:=20Appearance=20editing=20depth=20?= =?UTF-8?q?=E2=80=94=20drag=20effect=20rows=20to=20reorder=20them=20or=20m?= =?UTF-8?q?ove=20them=20into/out=20of=20fills=20and=20strokes=20(Alt=20cop?= =?UTF-8?q?ies),=20click=20an=20effect=20name=20to=20edit=20it=20in=20its?= =?UTF-8?q?=20prefilled=20dialog,=20choosing=20an=20applied=20effect=20ask?= =?UTF-8?q?s=20to=20edit=20it=20or=20add=20another,=20Show=20All=20Hidden?= =?UTF-8?q?=20Attributes,=20multi-select=20and=20Alt-drag=20duplicate=20of?= =?UTF-8?q?=20fills/strokes,=20drag=20the=20thumbnail=20onto=20art=20to=20?= =?UTF-8?q?copy=20the=20appearance,=20stroke=20rows=20note=20brush/Dashed/?= =?UTF-8?q?profile,=20Shift-click=20a=20chip=20for=20the=20mixer,=20Proper?= =?UTF-8?q?ties=20fx=20opens=20the=20effect=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine: effect.move {from, to, fromItem?, toItem?, copy?}; appearance.showAllHidden; appearance.duplicateItem takes to? and indices?; appearance.removeItem takes indices?; Appearance::has_hidden/show_all. UI: effect.dialog takes index? (edit in place via effect.setParams) and opens the effectExists question for an effect already in the list. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 35 ++ crates/engine/src/cmd/appearance.rs | 117 +++++- crates/engine/src/cmd/effectcmd.rs | 43 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_effectedit.rs | 121 ++++++ crates/ui-egui/src/canvas.rs | 48 ++- crates/ui-egui/src/dialogs/effect.rs | 140 ++++++- crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/menus.rs | 20 +- crates/ui-egui/src/panels/appearance.rs | 392 ++++++++++++++---- crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/properties.rs | 7 +- crates/ui-egui/src/panels/tests_appearance.rs | 22 +- crates/ui-egui/src/panels/tests_effectedit.rs | 196 +++++++++ docs/control-protocol.md | 7 + docs/mcp.md | 9 + 16 files changed, 1016 insertions(+), 147 deletions(-) create mode 100644 crates/engine/src/tests_effectedit.rs create mode 100644 crates/ui-egui/src/panels/tests_effectedit.rs diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 53dde758..447bec56 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -582,10 +582,45 @@ impl Appearance { } } +impl Appearance { + /// Is any fill, stroke or effect (the object's or an item's) hidden? + pub fn has_hidden(&self) -> bool { + let hidden = |fx: &[Effect]| fx.iter().any(|e| !e.visible); + hidden(&self.effects) || self.items.iter().any(|i| !i.visible() || hidden(i.effects())) + } + + /// Make every hidden fill, stroke and effect visible (Show All Hidden Attributes). Returns + /// whether anything was hidden. + pub fn show_all(&mut self) -> bool { + let had = self.has_hidden(); + let show = |fx: &mut Vec| fx.iter_mut().for_each(|e| e.visible = true); + show(&mut self.effects); + for i in &mut self.items { + match i { + AppearanceItem::Fill(f) => f.visible = true, + AppearanceItem::Stroke(s) => s.visible = true, + } + show(i.effects_mut()); + } + had + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn hidden_attributes_show_again() { + let mut a = Appearance::default_art(); + assert!(!a.has_hidden() && !a.show_all()); + a.stroke_mut().unwrap().visible = false; + a.items[0].effects_mut().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: false }); + assert!(a.has_hidden()); + assert!(a.show_all()); + assert!(!a.has_hidden() && a.items.iter().all(AppearanceItem::visible) && a.items[0].effects()[0].visible); + } + #[test] fn basic_appearance() { let a = Appearance::default_art(); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 0a5010da..0651d673 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -66,7 +66,15 @@ pub fn specs() -> Vec { has_doc, set_item ), - cmd!("appearance.removeItem", "Remove Item", [], None, "{index, ids?}", has_selection, remove_item), + cmd!( + "appearance.removeItem", + "Remove Item", + [], + None, + "{index | indices: [..] (paint-order item indices), ids?} remove those fills/strokes from each target object's own stack", + has_selection, + remove_item + ), cmd!( "appearance.addEffect", "Add Effect", @@ -76,7 +84,15 @@ pub fn specs() -> Vec { has_selection, super::effectcmd::apply ), - cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index, ids?}", has_selection, duplicate_item), + cmd!( + "appearance.duplicateItem", + "Duplicate Item", + ["Window", "Appearance"], + None, + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it), ids?} copy fills/strokes with their effects", + has_selection, + duplicate_item + ), cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to, ids?} (paint-order indices)", has_selection, move_item), cmd!( "appearance.copyFrom", @@ -96,6 +112,15 @@ pub fn specs() -> Vec { has_selection, set_active_item ), + cmd!( + "appearance.showAllHidden", + "Show All Hidden Attributes", + ["Window", "Appearance"], + None, + "{ids?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}", + has_selection, + show_all_hidden + ), ] } @@ -146,12 +171,17 @@ pub(crate) enum ItemTarget { /// Parse the `item` param of `cmd` (see the module docs). pub(crate) fn item_target(s: &Session, p: &Value, cmd: &str) -> Result { - match p.get("item") { + item_target_at(s, p, "item", cmd) +} + +/// [`item_target`] read from param `key` (e.g. `fromItem`). +pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Result { + match p.get(key) { Some(Value::Null) => Ok(ItemTarget::Top), Some(v) => v .as_u64() .map(|i| ItemTarget::Item { index: i as usize, explicit: true }) - .ok_or_else(|| bad(cmd, "`item` must be an appearance item index (paint order) or null")), + .ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))), None if p.get("ids").is_some() || p.get("id").is_some() => Ok(ItemTarget::Top), None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false })), } @@ -385,45 +415,92 @@ fn set_item(s: &mut Session, p: &Value) -> Result { ok() } +/// The paint-order item indices a command acts on (`indices`, else `index`), ascending and +/// without repeats. +fn item_indices(p: &Value, cmd: &str) -> Result> { + let mut v = match p.get("indices") { + Some(a) => a + .as_array() + .and_then(|a| a.iter().map(|x| x.as_u64().map(|i| i as usize)).collect::>>()) + .filter(|v| !v.is_empty()) + .ok_or_else(|| bad(cmd, "`indices` must be a non-empty array of item indices"))?, + None => vec![index_param(p, "index", cmd)?], + }; + v.sort_unstable(); + v.dedup(); + Ok(v) +} + fn remove_item(s: &mut Session, p: &Value) -> Result { - let idx = index_param(p, "index", "appearance.removeItem")?; + let idx = item_indices(p, "appearance.removeItem")?; let ids = appearance_targets(s, p)?; s.edit("Remove Item", |d, _| { for id in &ids { - if let Some(n) = d.node_mut(*id) - && idx < n.appearance.items.len() - { - n.appearance.items.remove(idx); + if let Some(n) = d.node_mut(*id) { + let items = &mut n.appearance.items; + // From the top down, so the lower indices stay valid. + for &i in idx.iter().rev() { + if i < items.len() { + items.remove(i); + } + } } } Ok(()) })?; - s.remap_appearance_item(&ids, |a| match a.cmp(&idx) { - std::cmp::Ordering::Less => Some(a), - std::cmp::Ordering::Equal => None, - std::cmp::Ordering::Greater => Some(a - 1), - }); + s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count())); ok() } fn duplicate_item(s: &mut Session, p: &Value) -> Result { - let idx = index_param(p, "index", "appearance.duplicateItem")?; + const C: &str = "appearance.duplicateItem"; + let idx = item_indices(p, C)?; + let to = p.get("to").map(|_| index_param(p, "to", C)).transpose()?; + if to.is_some() && idx.len() > 1 { + return Err(bad(C, "`to` takes a single `index`")); + } let ids = appearance_targets(s, p)?; + // Where the first object's copy landed (the active item lives in the first object's stack). + let mut first_at = None; s.edit("Duplicate Item", |d, _| { let mut any = false; for id in &ids { let Some(n) = d.node_mut(*id) else { continue }; - if let Some(item) = n.appearance.items.get(idx).cloned() { - n.appearance.items.insert(idx + 1, item); - any = true; + let items = &mut n.appearance.items; + // From the top down, so the lower indices stay valid. + for &i in idx.iter().rev() { + if let Some(item) = items.get(i).cloned() { + let at = to.map_or(i + 1, |t| t.min(items.len())); + first_at.get_or_insert(at); + items.insert(at, item); + any = true; + } } } - if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) } + if any { Ok(()) } else { Err(bad(C, format!("no item at index {}", idx[0]))) } })?; - s.remap_appearance_item(&ids, |a| Some(if a > idx { a + 1 } else { a })); + // Rows at or above each copy move up by one. + s.remap_appearance_item(&ids, |a| { + Some(match to { + Some(_) => a + usize::from(first_at.is_some_and(|at| at <= a)), + None => a + idx.iter().filter(|i| **i < a).count(), + }) + }); ok() } +fn show_all_hidden(s: &mut Session, p: &Value) -> Result { + let roots = appearance_targets(s, p)?; + let ids = s.edit("Show All Hidden Attributes", |d, _| { + let shown: Vec = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect(); + if shown.is_empty() { + return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into())); + } + Ok(shown) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + fn move_item(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.moveItem"; let from = index_param(p, "from", C)?; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index dd712cf9..66f4dd55 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -10,7 +10,7 @@ use vectorcraft_doc::{Effect, Node, NodeKind}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; -use super::appearance::{ItemTarget, appearance_targets, index_param, item_target}; +use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at}; use super::*; pub fn specs() -> Vec { @@ -69,6 +69,15 @@ pub fn specs() -> Vec { has_selection, duplicate ), + cmd!( + "effect.move", + "Move Effect", + [], + None, + "{from: int (position in the source list), to: int (its position in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?: [..]} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", + has_selection, + move_effect + ), ] } @@ -175,6 +184,38 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&ids) })) } +fn move_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "effect.move"; + let from = index_param(p, "from", C)?; + let to = index_param(p, "to", C)?; + let src = item_target_at(s, p, "fromItem", C)?; + let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src }; + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let roots = appearance_targets(s, p)?; + // Where the effect landed in the first object. + let mut landed = None; + let ids = s.edit(if copy { "Duplicate Effect" } else { "Move Effect" }, |d, _| { + let mut done = vec![]; + for id in &roots { + let Some(n) = d.node_mut(*id) else { continue }; + let (si, di) = (src.effects_item(&n.appearance, C)?, dst.effects_item(&n.appearance, C)?); + let Some(fx) = n.appearance.effects_mut(si).filter(|fx| from < fx.len()) else { continue }; + let e = if copy { fx[from].clone() } else { fx.remove(from) }; + let Some(fx) = n.appearance.effects_mut(di) else { continue }; + let at = to.min(fx.len()); + fx.insert(at, e); + landed.get_or_insert((at, di)); + done.push(*id); + } + if done.is_empty() { + return Err(EngineError::Other(format!("{C}: no effect at index {from}"))); + } + Ok(done) + })?; + let (index, item) = landed.unzip(); + Ok(json!({ "ids": ids_json(&ids), "index": index, "item": item.flatten() })) +} + fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; let index = index_param(p, "index", C)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6ea09da7..eaaaecee 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -750,6 +750,8 @@ mod tests_distort; #[cfg(test)] mod tests_draw2; #[cfg(test)] +mod tests_effectedit; +#[cfg(test)] mod tests_file; #[cfg(test)] mod tests_gradient; diff --git a/crates/engine/src/tests_effectedit.rs b/crates/engine/src/tests_effectedit.rs new file mode 100644 index 00000000..04242589 --- /dev/null +++ b/crates/engine/src/tests_effectedit.rs @@ -0,0 +1,121 @@ +//! Appearance editing depth: moving and copying effects between the object and its fills and +//! strokes, duplicating and removing several items, Show All Hidden Attributes. + +use serde_json::{Value, json}; +use vectorcraft_doc::Node; + +use super::*; + +/// A selected 100×100 rectangle with the default appearance `[Fill white, Stroke black]`. +fn session_with_rect() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +/// Effect ids of the object (`None`) or of item `i`. +fn fx(s: &Session, id: NodeId, item: Option) -> Vec { + node(s, id).appearance.effects_at(item).unwrap().iter().map(|e| e.id.clone()).collect() +} + +#[test] +fn effects_reorder_within_a_list_and_undo_in_one_step() { + let (mut s, id) = session_with_rect(); + for e in ["distort.twist", "distort.roughen", "stylize.dropShadow"] { + run(&mut s, "effect.apply", json!({ "effect": e })); + } + let r = run(&mut s, "effect.move", json!({"from": 0, "to": 2})); + assert_eq!((r["index"].clone(), r["item"].clone()), (json!(2), Value::Null)); + assert_eq!(fx(&s, id, None), ["distort.roughen", "stylize.dropShadow", "distort.twist"]); + // Past the end lands last. + run(&mut s, "effect.move", json!({"from": 0, "to": 9})); + assert_eq!(fx(&s, id, None), ["stylize.dropShadow", "distort.twist", "distort.roughen"]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fx(&s, id, None), ["distort.twist", "distort.roughen", "stylize.dropShadow"]); + assert!(s.execute("effect.move", &json!({"from": 3, "to": 0})).is_err()); +} + +#[test] +fn effects_move_and_copy_between_the_object_and_its_items() { + let (mut s, id) = session_with_rect(); + run(&mut s, "effect.apply", json!({"effect": "distort.twist"})); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "item": 1})); + // Into the stroke, above its own effect. + let r = run(&mut s, "effect.move", json!({"from": 0, "fromItem": null, "to": 1, "toItem": 1})); + assert_eq!((r["index"].clone(), r["item"].clone()), (json!(1), json!(1))); + assert!(fx(&s, id, None).is_empty()); + assert_eq!(fx(&s, id, Some(1)), ["path.offsetPath", "distort.twist"]); + // Out of the stroke onto the fill, as a copy (Alt-drag): the stroke keeps it. + run(&mut s, "effect.move", json!({"from": 0, "fromItem": 1, "to": 0, "toItem": 0, "copy": true})); + assert_eq!(fx(&s, id, Some(0)), ["path.offsetPath"]); + assert_eq!(fx(&s, id, Some(1)), ["path.offsetPath", "distort.twist"]); + // Back out to the object, one undo step each. + run(&mut s, "effect.move", json!({"from": 1, "fromItem": 1, "to": 0, "toItem": null})); + assert_eq!(fx(&s, id, None), ["distort.twist"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(fx(&s, id, None).is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(fx(&s, id, Some(0)).is_empty()); + // A missing item is an error, not a silent no-op. + assert!(s.execute("effect.move", &json!({"from": 0, "fromItem": 1, "to": 0, "toItem": 7})).is_err()); +} + +#[test] +fn duplicate_selected_works_for_effects() { + let (mut s, id) = session_with_rect(); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 0})); + run(&mut s, "effect.duplicate", json!({"index": 0, "item": 0})); + assert_eq!(fx(&s, id, Some(0)), ["distort.roughen", "distort.roughen"]); +} + +#[test] +fn items_duplicate_to_a_position_and_several_remove_at_once() { + let (mut s, id) = session_with_rect(); + run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill] + run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); + // Alt-dragging the top fill to the bottom copies it there. + run(&mut s, "appearance.duplicateItem", json!({"index": 2, "to": 0})); + let kinds = |s: &Session| node(s, id).appearance.items.iter().map(|i| i.kind_name()).collect::>(); + assert_eq!(kinds(&s), ["fill", "fill", "stroke", "fill"]); + assert_eq!(node(&s, id).appearance.items[0].paint(), node(&s, id).appearance.items[3].paint()); + // Duplicating several copies each above itself. + run(&mut s, "appearance.duplicateItem", json!({"indices": [0, 2]})); + assert_eq!(kinds(&s), ["fill", "fill", "fill", "stroke", "stroke", "fill"]); + assert!(s.execute("appearance.duplicateItem", &json!({"indices": [0, 1], "to": 3})).is_err()); + // Removing several in one step; the active item follows. + run(&mut s, "appearance.setActiveItem", json!({"index": 5})); + run(&mut s, "appearance.removeItem", json!({"indices": [4, 0, 1]})); + assert_eq!(kinds(&s), ["fill", "stroke", "fill"]); + assert_eq!(s.appearance_item(), Some(2)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).appearance.items.len(), 6); +} + +#[test] +fn show_all_hidden_attributes() { + let (mut s, id) = session_with_rect(); + run(&mut s, "effect.apply", json!({"effect": "distort.twist"})); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1})); + assert!(s.execute("appearance.showAllHidden", &json!({})).is_err(), "nothing hidden yet"); + run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false})); + run(&mut s, "effect.setParams", json!({"index": 0, "visible": false, "item": null})); + run(&mut s, "effect.setParams", json!({"index": 0, "visible": false, "item": 1})); + assert!(node(&s, id).appearance.has_hidden()); + let r = run(&mut s, "appearance.showAllHidden", json!({})); + assert_eq!(r["ids"], json!([id.0])); + let n = node(&s, id); + assert!(!n.appearance.has_hidden()); + assert!(n.appearance.effects[0].visible && n.appearance.items[1].effects()[0].visible && n.appearance.items[0].visible()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, id).appearance.has_hidden()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7382f4f2..5bb3865f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3,7 +3,7 @@ use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect}; use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind}; @@ -136,6 +136,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { handle_input(app, ui, &resp, rect); let v = *app.view().unwrap_or(&View::default()); let xf = Xf::new(rect, &v); + if let Some(drag) = resp.dnd_release_payload::() + && let Some(p) = ui.ctx().pointer_interact_pos() + { + drop_appearance(app, drag.0, xf.to_doc(p), xf.zoom); + } let painter = ui.painter_at(rect); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); @@ -482,6 +487,19 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } } +/// The Appearance panel's thumbnail dropped at `pos` (at `zoom`): the object there takes the +/// appearance of `source` (`appearance.copyFrom`). +pub(crate) fn drop_appearance(app: &mut VectorcraftApp, source: NodeId, pos: Point, zoom: f64) { + let Some(st) = app.session.active() else { return }; + let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / zoom, outline: app.ui.view.outline, path_only: false }; + let Some(target) = vectorcraft_doc::hit::hit_test(&st.doc, pos, opt).map(|h| h.top_object(st.isolation)) else { return }; + if target != source + && let Err(e) = app.run("appearance.copyFrom", json!({"source": source.0, "ids": [target.0]})) + { + app.status(e); + } +} + /// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) { match app.session.pointer(ev, view) { @@ -1081,4 +1099,32 @@ mod tests { assert_eq!(app.view().unwrap().center, after.center); assert_eq!(app.session.active().unwrap().doc.art_bounds(), None); } + #[test] + fn dropping_the_appearance_thumbnail_on_art_copies_the_appearance() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 400, "height": 300})); + let a = run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap(); + run(&mut app, "effect.apply", json!({"effect": "distort.twist"})); + run(&mut app, "transparency.set", json!({"opacity": 40})); + let b = run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap(); + run(&mut app, "select.set", json!({ "ids": [a] })); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + // The Appearance panel's thumbnail (its drag payload) released over the second rectangle. + egui::DragAndDrop::set_payload(&ctx, crate::panels::appearance::AppearanceDrag(NodeId(a))); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(250.0, 70.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![up]); + let doc = &app.session.active().unwrap().doc; + let (na, nb) = (doc.node(NodeId(a)).unwrap(), doc.node(NodeId(b)).unwrap()); + assert_eq!((&nb.appearance, nb.opacity), (&na.appearance, na.opacity)); + assert_eq!(nb.appearance.effects[0].id, "distort.twist"); + // Dropped on empty canvas: nothing changes. + let undo_len = app.session.active().unwrap().history.undo.len(); + drop_appearance(&mut app, NodeId(a), Point::new(350.0, 250.0), 1.0); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo_len); + } } diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index dbd4e67f..a8333562 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -1,15 +1,115 @@ -//! 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. `__item`, when present, is -//! the appearance item the effect goes to (else the Appearance panel's active item applies). +//! Live effect dialogs (Effect menu, Appearance panel): the effect's parameters (`__effect`, headed +//! `__label`) with a 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). With `__index` the dialog edits that applied effect in place +//! (`effect.setParams`) instead of adding one (`effect.apply`). +//! +//! Choosing an effect that the target already carries first asks ([`EXISTS`]) whether to edit it +//! or add another one. -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use super::{DialogSpec, form}; use crate::VectorcraftApp; use crate::state::Dialog; +use crate::theme::Tokens; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM }; +/// The dialog kind asking whether to edit an effect that is already applied or add another. +/// Fields: `__effect`, `__label`, `__item?` (as given to `effect.dialog`), `__target` and +/// `__index` (the applied effect's item and position). OK edits it; `discard: true` then OK adds +/// a new one. +pub const EXISTS: &str = "effectExists"; + +pub(super) const EXISTS_SPEC: DialogSpec = DialogSpec { + heading: |d| format!("{} is already applied", d.str("__label")), + body: |_, ui, _| { + ui.label(egui::RichText::new("Edit the applied effect, or add another one?").color(Tokens::get(ui.ctx()).text_dim)); + false + }, + confirm: confirm_exists, + ok: Some("Edit"), + discard: Some("Add New"), + max_width: Some(420.0), + ..DialogSpec::FORM +}; + +/// `effect.dialog {effect, index?, item?}`: edit applied effect `index` of `item` (null: the +/// object's effects; omitted: the active item's, else the object's) prefilled with its values, +/// or add `effect`, first asking when that list already has it. +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let id = p.get("effect").and_then(Value::as_str).unwrap_or_default(); + let info = vectorcraft_effects::effect_info(id).ok_or_else(|| format!("unknown effect `{id}`"))?; + // The list the effect goes to: `item` as given, else the active item's (the object's without + // one). A new effect keeps `item` unresolved, so `effect.apply` targets each object as usual. + let target = p.get("item").cloned().unwrap_or_else(|| json!(app.session.appearance_item())); + let applied = target_effects(app, &target); + let mut fields = Map::new(); + fields.insert("__effect".into(), json!(id)); + fields.insert("__label".into(), json!(info.label.trim_end_matches('…'))); + if let Some(item) = p.get("item") { + fields.insert("__item".into(), item.clone()); + } + match p.get("index").and_then(Value::as_u64) { + Some(k) => { + let e = applied.get(k as usize).ok_or_else(|| format!("no effect at index {k}"))?; + if e.id != id { + return Err(format!("effect {k} is `{}`, not `{id}`", e.id)); + } + fields.insert("__item".into(), target); + fields.insert("__index".into(), json!(k)); + fields.extend(params_of(e)); + } + None => { + if let Some(k) = applied.iter().rposition(|e| e.id == id) { + fields.insert("__target".into(), target); + fields.insert("__index".into(), json!(k)); + app.ui.dialog = Some(Dialog { kind: EXISTS.into(), fields }); + return Ok(json!({ "pending": EXISTS })); + } + fields.extend(info.defaults.as_object().cloned().unwrap_or_default()); + } + } + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(Dialog { kind: "effect".into(), fields }); + Ok(Value::Null) +} + +/// The effects of the first selected object that `item` (an index or null) addresses. +fn target_effects(app: &VectorcraftApp, item: &Value) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let node = st.selection.objects.first().and_then(|id| st.doc.node(*id)); + node.and_then(|n| n.appearance.effects_at(item.as_u64().map(|i| i as usize))).cloned().unwrap_or_default() +} + +/// An applied effect's values over its defaults. +fn params_of(e: &vectorcraft_doc::Effect) -> Map { + match vectorcraft_effects::merged_params(&e.id, &e.params) { + Value::Object(m) => m, + _ => Map::new(), + } +} + +/// OK in [`EXISTS`]: the dialog editing the applied effect, or (`discard`) a fresh one. +fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut fields = d.fields.clone(); + fields.remove("discard"); + let target = fields.remove("__target").unwrap_or(Value::Null); + if d.bool("discard") { + fields.remove("__index"); + let defaults = vectorcraft_effects::default_params(&d.str("__effect")); + fields.extend(defaults.and_then(|v| v.as_object().cloned()).unwrap_or_default()); + } else { + let k = fields.get("__index").and_then(Value::as_u64).unwrap_or(0) as usize; + fields.extend(target_effects(app, &target).get(k).map(params_of).unwrap_or_default()); + fields.insert("__item".into(), target); + } + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(Dialog { kind: "effect".into(), fields }); + Ok(Value::Null) +} + fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let changed = form::param_fields(ui, d); ui.add_space(6.0); @@ -19,32 +119,42 @@ 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", &apply_params(d)); + let (cmd, p) = command(d); + let _ = app.session.preview(cmd, &p); } 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(); +/// What OK runs: `effect.setParams` on the edited effect (`__index`), else `effect.apply` of the +/// dialog's effect with its values on the target item. +fn command(d: &Dialog) -> (&'static str, Value) { + let params = form::params(d); + let item = d.fields.get("__item").cloned(); + if let Some(index) = d.fields.get("__index") { + return ("effect.setParams", json!({"index": index, "item": item.unwrap_or(Value::Null), "params": params})); } - p + let mut p = json!({"effect": d.str("__effect"), "params": params}); + if let Some(item) = item { + p["item"] = item; + } + ("effect.apply", p) } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let p = apply_params(d); + let (cmd, p) = command(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", &p); + let _ = app.session.preview(cmd, &p); app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()) } else { - app.run("effect.apply", p.clone()) + app.run(cmd, p.clone()) }; - app.last_effect = Some((d.str("__effect"), p["params"].clone())); + // Last Effect repeats applied effects, not edits. + if cmd == "effect.apply" { + app.last_effect = Some((d.str("__effect"), p["params"].clone())); + } app.ui.dialog = None; r } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 5d207293..85ba0f1a 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -31,6 +31,7 @@ mod transform; use serde_json::{Value, json}; pub use color_picker::open as open_color_picker; +pub use effect::open as open_effect_dialog; pub use tools::open_tool_dialog; use crate::state::Dialog; @@ -140,6 +141,7 @@ registry! { NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC, GradientStop: ["gradientStop"] => gradient_stop::SPEC, ColorPicker: [color_picker::KIND] => color_picker::SPEC, + EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9f09c495..6442631b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -156,7 +156,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "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", + "{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}", ), ("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)"), @@ -460,23 +460,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - let id = s("effect").unwrap_or_default(); - match vectorcraft_effects::effect_catalog().into_iter().find(|e| e.id == id) { - Some(e) => { - let mut fields = e.defaults.as_object().cloned().unwrap_or_default(); - 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) - } - None => Err(format!("unknown effect `{id}`")), - } - } + "effect.dialog" => crate::dialogs::open_effect_dialog(app, p), "ui.recolorDialog" => match app.run("recolor.colors", json!({})) { Ok(v) => { let pairs: Vec = v["colors"].as_array().cloned().unwrap_or_default().iter().map(|c| json!([c["hex"], c["hex"]])).collect(); diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 05ccd07d..73502e01 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -1,6 +1,11 @@ -//! Appearance panel: the full stack like Illustrator — the object row, each stroke/fill row (eye, -//! disclosure, link label, swatch, weight) with "Opacity: Default" sub-rows, fx rows with inline -//! parameter editors (effect.setParams), the object's Opacity row and the bottom bar. +//! Appearance panel: the full stack as in the reference app — the object row (its thumbnail drags +//! the appearance onto art), each stroke/fill row (eye, disclosure, link label, swatch, weight, +//! brush/dash/profile notes) with "Opacity: Default" sub-rows, fx rows (the name opens the +//! effect's dialog; the disclosure an inline editor), the object's Opacity row and the bottom bar. +//! +//! Rows drag: a fill/stroke row reorders the stack, an effect row reorders its list or moves into +//! another item (dropped on its row) or onto the object; Alt copies instead. Cmd-click and +//! Shift-click select several fills/strokes for Duplicate and Delete. use std::sync::OnceLock; @@ -10,6 +15,7 @@ use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId}; use super::{first_selected, live_run, pstate, set_pstate}; +use crate::panels::stroke::paint_profile; use crate::theme::Tokens; use crate::widgets::{self, TransparencyEdit, menu_item}; use crate::{VectorcraftApp, icons}; @@ -64,14 +70,63 @@ fn current_sel(app: &VectorcraftApp, ctx: &egui::Context, node: Option<&Node>) - } /// Select a row; its fill/stroke becomes the active item that paint and effect edits target. +/// Other selected fills/strokes are deselected. pub(crate) fn select_row(app: &mut VectorcraftApp, ctx: &egui::Context, sel: Sel) { if app.session.appearance_item() != sel.item() { app.run("appearance.setActiveItem", json!({ "index": sel.item() })).ok(); } let owner = app.session.active().and_then(|d| d.selection.objects.first().copied()); set_pstate(ctx, "ap-sel", (owner, sel)); + set_pstate(ctx, "ap-multi", (owner, sel.item().into_iter().collect::>())); } +/// The selected fill/stroke rows of `n` (paint order, ascending): the active row plus those +/// Cmd/Shift-clicked with it. +fn selected_items(ctx: &egui::Context, n: Option<&Node>, sel: Sel) -> Vec { + let (Some(n), Sel::Item(active)) = (n, sel) else { return vec![] }; + let (owner, mut set): (Option, Vec) = pstate(ctx, "ap-multi"); + if owner != Some(n.id) || !set.contains(&active) { + return vec![active]; + } + set.retain(|i| *i < n.appearance.items.len()); + set.sort_unstable(); + set.dedup(); + set +} + +/// Click on fill/stroke row `i`: Shift extends the selection from the active row, Cmd toggles +/// the row, a plain click selects only it. +fn click_item(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, sel: Sel, i: usize, m: egui::Modifiers) { + let mut set = selected_items(ctx, Some(n), sel); + let active = match sel { + Sel::Item(a) if m.shift => { + set = (a.min(i)..=a.max(i)).collect(); + a + } + Sel::Item(a) if m.command => { + match set.iter().position(|x| *x == i) { + Some(p) if set.len() > 1 => { + set.remove(p); + } + Some(_) => {} + None => set.push(i), + } + if set.contains(&a) { a } else { set[0] } + } + _ => { + set = vec![i]; + i + } + }; + select_row(app, ctx, Sel::Item(active)); + set_pstate(ctx, "ap-multi", (Some(n.id), set)); +} + +/// Dragged from the object row's thumbnail and dropped on art: the object under the pointer takes +/// this object's appearance (`appearance.copyFrom`, see `canvas::drop_appearance`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AppearanceDrag(pub NodeId); + fn catalog() -> &'static [(String, String, Vec)] { static C: OnceLock)>> = OnceLock::new(); C.get_or_init(|| { @@ -179,12 +234,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let th = Rect::from_min_size(r.left_center() + vec2(6.0, -12.0), vec2(24.0, 24.0)); let fill = node.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or_else(|| app.session.paint.fill.clone()); chip(ui, th, &fill); + if let Some(n) = node.as_ref().filter(|_| !mixed) { + thumbnail_drag(ui, th, n.id, &fill); + } } text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), label, true); match &node { // Objects that differ have no common stack to list. Some(_) if mixed => {} - Some(n) => stack(app, ui, n, sel), + Some(n) => stack(app, ui, n, sel, r), None => default_stack(app, ui), } // Spacer row like Illustrator's empty tail. @@ -193,6 +251,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { bottom(app, ui, node.as_ref(), sel); } +/// The object row's thumbnail drags this object's appearance onto art (a chip follows the pointer). +fn thumbnail_drag(ui: &mut Ui, th: Rect, id: NodeId, fill: &Paint) { + let resp = ui.interact(th, ui.id().with("ap-thumb"), Sense::drag()).on_hover_text("Drag onto art to apply this appearance"); + resp.dnd_set_drag_payload(AppearanceDrag(id)); + if resp.dragged() + && let Some(p) = ui.ctx().pointer_interact_pos() + { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let layer = egui::LayerId::new(egui::Order::Tooltip, ui.id().with("ap-thumb-drag")); + let r = Rect::from_min_size(p + vec2(8.0, 8.0), vec2(18.0, 18.0)); + let ghost = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(r.expand(1.0))); + chip(&ghost, r, fill); + } +} + /// The selection as the Control bar and the object row name it: "No Selection", "Mixed Objects" /// or the object's kind ("Path", "Type", …). pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str { @@ -229,16 +302,43 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { text(ui, r.left_center() + vec2(EYE_W + 24.0, 0.0), "Opacity: Default", false); } -fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { +/// A stack row being dragged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Dragged { + Item(usize), + /// Effect `k` of fill/stroke `item` (`None`: of the object). + Effect(Option, usize), +} + +/// What a stack row stands for when a dragged row is dropped on it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Slot { + /// Fill/stroke row `index` (`sub`: one of its indented sub-rows). + Item { index: usize, sub: bool }, + /// Effect `k` of fill/stroke `item` (`None`: of the object). + Effect(Option, usize), + /// The object row (`top`) or the object's Opacity row. + Object { top: bool }, +} + +fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: Rect) { let t = Tokens::get(ui.ctx()); - let mut drop: Option<(usize, usize)> = None; + let ctx = ui.ctx().clone(); + let dragging: Option = pstate(&ctx, "ap-drag"); + let items = selected_items(&ctx, Some(n), sel); + let mut rows = vec![(Slot::Object { top: true }, object_row)]; let mut stopped = false; - let dragging: Option = pstate(ui.ctx(), "ap-drag"); - let mut row_rects = vec![]; + // Starts or ends a row drag. + let mut track = |resp: &egui::Response, what: Dragged| { + if resp.drag_started() { + set_pstate(&ctx, "ap-drag", Some(what)); + } + stopped |= resp.drag_stopped(); + }; 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 (r, resp) = row(ui, items.contains(&i)); + rows.push((Slot::Item { index: i, sub: false }, r)); let (visible, paint) = (it.visible(), it.paint()); let is_stroke = !it.is_fill(); if eye(ui, r, ("ap-eye", i), visible, true) { @@ -256,11 +356,19 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { } else { text(ui, pos2(lx, r.center().y), "Fill:", false); } - // Swatch with a swatches popup that sets this item's paint. + // Swatch with a swatches popup that sets this item's paint; Shift-click opens the Color + // panel's mixer on this row instead. 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); - 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| { + let cresp = ui + .interact(cr.expand(2.0), ui.id().with(("ap-chip", i)), Sense::click()) + .on_hover_text("Click to choose a swatch, Shift-click to mix a colour"); + let mixer = cresp.clicked() && ui.input(|inp| inp.modifiers.shift); + if mixer { + select_row(app, ui.ctx(), Sel::Item(i)); + app.ui.open_panel = Some("color".into()); + } + egui::Popup::menu(&cresp).open_memory((cresp.clicked() && !mixer).then_some(egui::SetOpenCommand::Toggle)).show(|ui| { swatch_picker(app, ui, i); }); if let AppearanceItem::Stroke(st) = it { @@ -269,67 +377,139 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(st.width), vectorcraft_doc::Unit::Points, 56.0) { app.run("appearance.setItem", json!({"index": i, "weight": w})).ok(); } + stroke_notes(ui, Rect::from_min_max(pos2(fr.right() + 6.0, r.top()), pos2(r.right() - 4.0, r.bottom())), st); } if resp.clicked() { - select_row(app, ui.ctx(), Sel::Item(i)); + let m = ui.input(|inp| inp.modifiers); + click_item(app, ui.ctx(), n, sel, i, m); } if resp.double_clicked() { app.ui.open_panel = Some(if is_stroke { "stroke" } else { "color" }.into()); } - if resp.drag_started() { - set_pstate(ui.ctx(), "ap-drag", Some(i)); - } - if resp.drag_stopped() { - stopped = true; - } + track(&resp, Dragged::Item(i)); if open { - opacity_row(app, ui, Some(i), it.opacity(), it.blend()); + let r = opacity_row(app, ui, Some(i), it.opacity(), it.blend()); + rows.push((Slot::Item { index: i, sub: true }, r)); for (k, e) in it.effects().iter().enumerate() { - effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k)); + let (r, resp) = effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k)); + rows.push((Slot::Effect(Some(i), k), r)); + track(&resp, Dragged::Effect(Some(i), k)); } } } - if stopped - && let Some(from) = dragging - && let Some(p) = ui.ctx().pointer_interact_pos() - { - drop = Some((from, target_index(&row_rects, p.y))); - } - if dragging.is_some() && !ui.ctx().input(|i| i.pointer.any_down()) { - set_pstate::>(ui.ctx(), "ap-drag", None); - } - if let Some(p) = ui.ctx().pointer_interact_pos() - && dragging.is_some() - { - // Insertion marker. - if let Some((_, r)) = row_rects.iter().find(|(_, r)| r.y_range().contains(p.y)) { - let y = if p.y < r.center().y { r.top() } else { r.bottom() }; - ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent)); - } - } // Object-level effects. for (k, e) in n.appearance.effects.iter().enumerate() { - effect_row(app, ui, None, k, e, sel == Sel::Effect(k)); + let (r, resp) = effect_row(app, ui, None, k, e, sel == Sel::Effect(k)); + rows.push((Slot::Effect(None, k), r)); + track(&resp, Dragged::Effect(None, k)); } - opacity_row(app, ui, None, n.opacity, n.blend); - if let Some((from, to_row)) = drop - && from != to_row + let r = opacity_row(app, ui, None, n.opacity, n.blend); + rows.push((Slot::Object { top: false }, r)); + let Some(what) = dragging else { return }; + let pointer = ui.ctx().pointer_interact_pos(); + if let Some(p) = pointer + && let Some((slot, r)) = slot_at(&rows, p) { - // The engine keeps the moved row active when it was. - app.run("appearance.moveItem", json!({"from": from, "to": to_row})).ok(); + // Drop marker: a line where the row lands, or a frame round the row it goes into. + let stroke = Stroke::new(2.0, t.accent); + let line = |y: f32| { + ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], stroke); + }; + match (what, slot) { + (Dragged::Effect(..), Slot::Effect(..)) => line(if p.y < r.center().y { r.top() } else { r.bottom() }), + (Dragged::Effect(..), _) => { + ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside); + } + (Dragged::Item(from), _) => line(if item_at(n, slot) > from { r.top() } else { r.bottom() }), + } + } + if !ui.ctx().input(|i| i.pointer.any_down()) { + set_pstate::>(ui.ctx(), "ap-drag", None); + } + if stopped + && let Some(p) = pointer + && let Some((cmd, params)) = drop_command(n, &rows, what, p, ui.input(|i| i.modifiers.alt)) + && let Ok(r) = app.run(cmd, params) + && let Some(k) = r["index"].as_u64().filter(|_| cmd == "effect.move") + { + // The dropped effect becomes the selected row (a moved fill/stroke stays active in the engine). + let k = k as usize; + select_row(app, ui.ctx(), r["item"].as_u64().map_or(Sel::Effect(k), |i| Sel::ItemEffect(i as usize, k))); } } -/// Paint-order index a drop at `y` lands on (rows are listed top = last item). -fn target_index(rows: &[(usize, Rect)], y: f32) -> usize { - rows.iter().find(|(_, r)| r.y_range().contains(y)).map(|(i, _)| *i).unwrap_or_else(|| { - if rows.first().is_some_and(|(_, r)| y < r.top()) { rows.first().map(|r| r.0).unwrap_or(0) } else { rows.last().map(|r| r.0).unwrap_or(0) } - }) +/// The row under `p`. +fn slot_at(rows: &[(Slot, Rect)], p: egui::Pos2) -> Option<(Slot, Rect)> { + rows.iter().find(|(_, r)| r.contains(p)).copied() +} + +/// Paint-order index a dragged fill/stroke lands on when dropped on row `slot`: that row's item; +/// the object row stands above the topmost item, the rows below the items under item 0. +fn item_at(n: &Node, slot: Slot) -> usize { + match slot { + Slot::Item { index, .. } | Slot::Effect(Some(index), _) => index, + Slot::Object { top: true } => n.appearance.items.len().saturating_sub(1), + Slot::Object { top: false } | Slot::Effect(None, _) => 0, + } +} + +/// The command a drop of `what` at `p` runs (`copy`: Alt held), or None when it would change +/// nothing or lands outside the stack. +fn drop_command(n: &Node, rows: &[(Slot, Rect)], what: Dragged, p: egui::Pos2, copy: bool) -> Option<(&'static str, Value)> { + let (slot, r) = slot_at(rows, p)?; + match what { + Dragged::Item(from) => { + let to = item_at(n, slot); + if copy { + // The copy goes where the row was dropped: above that row when dragged up, below it + // when dragged down. + let at = if to >= from { to + 1 } else { to }; + Some(("appearance.duplicateItem", json!({"index": from, "to": at}))) + } else { + (to != from).then(|| ("appearance.moveItem", json!({"from": from, "to": to}))) + } + } + Dragged::Effect(from_item, k) => { + let (item, pos) = match slot { + Slot::Effect(item, k2) => (item, k2 + usize::from(p.y > r.center().y)), + Slot::Item { index, .. } => (Some(index), n.appearance.effects_at(Some(index))?.len()), + Slot::Object { .. } => (None, n.appearance.effects.len()), + }; + // Within its own list the effect leaves its old place first. + let same = item == from_item && !copy; + let pos = if same && pos > k { pos - 1 } else { pos }; + if same && pos == k { + return None; + } + Some(("effect.move", json!({"from": k, "fromItem": from_item, "to": pos, "toItem": item, "copy": copy}))) + } + } +} + +/// A stroke row's notes after the weight: its brush, "Dashed" and its width profile (drawn). +fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) { + let t = Tokens::get(ui.ctx()); + let painter = ui.painter_at(r); + let dashed = st.dash.as_ref().is_some_and(|d| d.is_dashed()).then_some("Dashed"); + let notes: Vec<&str> = st.brush.as_deref().into_iter().chain(dashed).collect(); + let mut x = r.left(); + if !notes.is_empty() { + let g = painter.layout_no_wrap(notes.join(", "), egui::FontId::proportional(11.5), t.text_dim); + x += g.size().x + 6.0; + painter.galley(pos2(r.left(), r.center().y - g.size().y / 2.0), g, t.text_dim); + } + if let Some(p) = st.profile.as_ref().filter(|p| p.preset_id() != Some("uniform")) { + let pr = Rect::from_min_size(pos2(x, r.center().y - 5.0), vec2(28.0, 10.0)); + if pr.right() <= r.right() { + paint_profile(ui, pr, Some(p), t.text); + } + } } /// The Opacity row of the object (`item: None`) or, indented, of fill/stroke `item`. Clicking it /// opens a popup with the shared opacity and blend controls (`transparency.set` on that item). -fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, opacity: f32, blend: BlendMode) { +/// Returns the row's rect. +fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, opacity: f32, blend: BlendMode) -> Rect { let (r, resp) = row(ui, false); eye(ui, r, ("ap-op-eye", item), true, false); let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 }; @@ -345,15 +525,19 @@ fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, opaci Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase), None => {} }); + r } /// 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) { +/// Clicking the name opens the effect's dialog to edit it (the disclosure: an inline editor). +/// Returns the row's rect and response (for dragging). +fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usize, e: &Effect, selected: bool) -> (Rect, egui::Response) { let t = Tokens::get(ui.ctx()); let open_key = format!("ap-fx-open-{item:?}-{k}"); let open: bool = pstate(ui.ctx(), &open_key); let this = item.map_or(Sel::Effect(k), |i| Sel::ItemEffect(i, k)); - let (r, resp) = row(ui, selected); + let (row_rect, resp) = row(ui, selected); + let r = row_rect; 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(); } @@ -364,8 +548,12 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usi } let lx = r.left() + EYE_W + 24.0; 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); + if has_options(&e.id) { + app.run("effect.dialog", json!({"effect": e.id, "index": k, "item": item})).ok(); + } else { + set_pstate(ui.ctx(), &open_key, !open); + } } 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() { @@ -377,6 +565,12 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usi if open { effect_editor(app, ui, item, k, e); } + (row_rect, resp) +} + +/// Does the effect have a dialog (any parameters)? +fn has_options(id: &str) -> bool { + vectorcraft_effects::default_params(id).is_some_and(|d| d.as_object().is_some_and(|o| !o.is_empty())) } /// Inline editor for an applied effect's parameters (numbers, booleans, strings, colours); @@ -497,31 +691,31 @@ 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 && 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 = has && sel != Sel::None; + if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can, 24.0).clicked() { + duplicate_selected(app, ui, node, 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() { - delete_selected(app, ui, sel); + if widgets::icon_button_enabled(ui, "trash-2", "Delete Selected Item", false, can, 24.0).clicked() { + delete_selected(app, ui, node, sel); } let _ = basic; }); } -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, +/// Duplicate the selected fills/strokes (each copy right above its row) or the selected effect. +fn duplicate_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) { + match sel.effect() { + Some(fx) => app.run("effect.duplicate", fx).ok(), + None if sel.item().is_some() => app.run("appearance.duplicateItem", json!({"indices": selected_items(ui.ctx(), node, sel)})).ok(), + None => 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, +fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) { + let ok = match sel.effect() { + Some(fx) => app.run("effect.remove", fx).is_ok(), + None if sel.item().is_some() => app.run("appearance.removeItem", json!({"indices": selected_items(ui.ctx(), node, sel)})).is_ok(), + None => false, }; // Removing the active item clears it in the engine; a removed item effect leaves its item // selected. @@ -530,8 +724,9 @@ fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) { } } -/// The fx menu: effects grouped by their Effect-menu submenu; opens the effect dialog. -fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { +/// The fx menu (Appearance panel, Properties): effects grouped by their Effect-menu submenu; +/// opens the effect dialog. +pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { let mut groups: Vec<(String, Vec<(String, String)>)> = vec![]; for (id, label, menu) in catalog() { let g = menu.get(1).cloned().unwrap_or_else(|| "Other".into()); @@ -564,10 +759,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.separator(); if menu_item(ui, "Duplicate Item", has && sel != Sel::None, false) { - duplicate_selected(app, sel); + duplicate_selected(app, ui, node.as_ref(), sel); } if menu_item(ui, "Remove Item", has && sel != Sel::None, false) { - delete_selected(app, ui, sel); + delete_selected(app, ui, node.as_ref(), sel); } if menu_item(ui, "Clear Appearance", has, false) { app.run("appearance.clear", json!({})).ok(); @@ -583,7 +778,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.separator(); menu_item(ui, "Redefine Graphic Style", false, false); - menu_item(ui, "Show All Hidden Attributes", false, false); + if menu_item(ui, "Show All Hidden Attributes", node.as_ref().is_some_and(|n| n.appearance.has_hidden()), false) { + app.run("appearance.showAllHidden", json!({})).ok(); + } } #[cfg(test)] @@ -606,11 +803,42 @@ mod tests { } #[test] - fn drop_targets() { - let r = |y: f32| Rect::from_min_size(pos2(0.0, y), vec2(100.0, ROW)); - let rows = vec![(2, r(0.0)), (1, r(30.0)), (0, r(60.0))]; - assert_eq!(target_index(&rows, 45.0), 1); - assert_eq!(target_index(&rows, -10.0), 2); - assert_eq!(target_index(&rows, 500.0), 0); + fn drops_pick_the_command() { + use vectorcraft_doc::{Appearance, FillLayer}; + let fx = |id: &str| Effect { id: id.into(), params: Value::Null, visible: true }; + let mut n = Node::path(NodeId(1), vectorcraft_geom::PathData::default(), Appearance::default_art()); + n.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); // [F, S, F] + n.appearance.items[1].effects_mut().extend([fx("distort.twist"), fx("distort.roughen")]); + n.appearance.effects.push(fx("stylize.dropShadow")); + let slots = [ + Slot::Object { top: true }, + Slot::Item { index: 2, sub: false }, + Slot::Item { index: 1, sub: false }, + Slot::Effect(Some(1), 0), + Slot::Effect(Some(1), 1), + Slot::Item { index: 0, sub: false }, + Slot::Effect(None, 0), + Slot::Object { top: false }, + ]; + let rows: Vec<(Slot, Rect)> = + slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect(); + // The upper or lower half of row `i`. + let at = |i: usize, lower: bool| pos2(50.0, i as f32 * ROW + if lower { ROW * 0.75 } else { ROW * 0.25 }); + let drop = |what, p, copy| drop_command(&n, &rows, what, p, copy); + // Fill/stroke rows: moves, Alt copies to where it was dropped. + assert_eq!(drop(Dragged::Item(0), at(1, false), false), Some(("appearance.moveItem", json!({"from": 0, "to": 2})))); + assert_eq!(drop(Dragged::Item(0), at(0, false), true), Some(("appearance.duplicateItem", json!({"index": 0, "to": 3})))); + assert_eq!(drop(Dragged::Item(2), at(7, false), true), Some(("appearance.duplicateItem", json!({"index": 2, "to": 0})))); + assert_eq!(drop(Dragged::Item(1), at(3, false), false), None, "onto its own effect"); + // Effects: within the list (below the next one), into a fill, out onto the object. + let mv = |from: usize, fi: Value, to: usize, ti: Value, copy: bool| { + Some(("effect.move", json!({"from": from, "fromItem": fi, "to": to, "toItem": ti, "copy": copy}))) + }; + assert_eq!(drop(Dragged::Effect(Some(1), 0), at(4, true), false), mv(0, json!(1), 1, json!(1), false)); + assert_eq!(drop(Dragged::Effect(Some(1), 0), at(3, true), false), None, "dropped on itself"); + assert_eq!(drop(Dragged::Effect(Some(1), 1), at(5, false), true), mv(1, json!(1), 0, json!(0), true)); + assert_eq!(drop(Dragged::Effect(Some(1), 1), at(7, false), false), mv(1, json!(1), 1, Value::Null, false)); + assert_eq!(drop(Dragged::Effect(None, 0), at(3, false), false), mv(0, Value::Null, 0, json!(1), false)); + assert_eq!(drop(Dragged::Effect(None, 0), pos2(500.0, 10.0), false), None, "outside the stack"); } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 6efe1ca8..65672903 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -356,3 +356,5 @@ mod tests { } #[cfg(test)] mod tests_appearance; +#[cfg(test)] +mod tests_effectedit; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d88342ea..36a8b857 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -307,10 +307,9 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) { } }); ui.horizontal(|ui| { - let r = widgets::flat_button(ui, "fx", 34.0); - if r.on_hover_text("Effects").clicked() { - app.ui.open_panel = Some("appearance".into()); - } + // The fx button opens the effect menu, as the Appearance panel's does. + let r = widgets::flat_button(ui, "fx", 34.0).on_hover_text("Add New Effect"); + egui::Popup::menu(&r).show(|ui| super::appearance::fx_menu(app, ui)); if widgets::icon_button(ui, "ellipsis", "Appearance panel", false, 22.0).clicked() { app.ui.open_panel = Some("appearance".into()); } diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index fdfc53d4..52dccceb 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -6,7 +6,7 @@ use vectorcraft_engine::Session; use super::*; -fn app_with_rect() -> VectorcraftApp { +pub(super) fn app_with_rect() -> VectorcraftApp { let mut app = VectorcraftApp::new(Session::new(), Default::default()); run(&mut app, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100}))["id"].clone(); @@ -14,7 +14,7 @@ fn app_with_rect() -> VectorcraftApp { app } -fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value { +pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value { app.session.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) } @@ -29,13 +29,23 @@ fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, f: impl FnMut(&mut Ve } /// One headless frame with input `events`: every text drawn, with its screen rect. -fn frame_events( +pub(super) fn frame_events( ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, + f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui), +) -> Vec<(String, egui::Rect)> { + frame_raw(ctx, app, egui::RawInput { events, ..Default::default() }, f) +} + +/// One headless frame with input `raw`: every text drawn, with its screen rect. +pub(super) fn frame_raw( + ctx: &egui::Context, + app: &mut VectorcraftApp, + raw: egui::RawInput, 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)); + let mut out = ctx.run_ui(raw, |ui| f(app, ui)); out.textures_delta.clear(); fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { match s { @@ -52,7 +62,7 @@ fn frame_events( } /// 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)) { +pub(super) 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); @@ -60,7 +70,7 @@ fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f: } /// The rect of the first drawn text equal to `s`. -fn text_rect(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect { +pub(super) 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 } diff --git a/crates/ui-egui/src/panels/tests_effectedit.rs b/crates/ui-egui/src/panels/tests_effectedit.rs new file mode 100644 index 00000000..9a31792c --- /dev/null +++ b/crates/ui-egui/src/panels/tests_effectedit.rs @@ -0,0 +1,196 @@ +//! Headless frames of the Appearance panel's editing depth: effect names open their dialog +//! prefilled, rows drag (Alt copies), several fills/strokes select together, stroke notes, the +//! chip's Shift-click, the thumbnail drag and the Properties fx button. + +use serde_json::{Value, json}; + +use super::tests_appearance::{app_with_rect, click, frame_events, frame_raw, run, text_rect}; +use super::*; + +fn pointer(pos: egui::Pos2, pressed: bool, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers } +} + +/// One frame of the Appearance panel with `events` and `modifiers` held. +fn panel_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, modifiers: egui::Modifiers) -> Vec<(String, egui::Rect)> { + let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); + frame_raw(ctx, app, egui::RawInput { events, ..Default::default() }, appearance::show) +} + +/// Drag from `from` to `to` in the Appearance panel, `modifiers` held on release. +fn drag(ctx: &egui::Context, app: &mut VectorcraftApp, from: egui::Pos2, to: egui::Pos2, modifiers: egui::Modifiers) { + let none = egui::Modifiers::NONE; + panel_frame(ctx, app, vec![egui::Event::PointerMoved(from)], none); + panel_frame(ctx, app, vec![pointer(from, true, none)], none); + panel_frame(ctx, app, vec![egui::Event::PointerMoved(from + egui::vec2(0.0, 8.0))], none); + panel_frame(ctx, app, vec![egui::Event::PointerMoved(to)], modifiers); + panel_frame(ctx, app, vec![pointer(to, false, modifiers)], modifiers); + panel_frame(ctx, app, vec![], none); +} + +/// Click `pos` with `modifiers` held. +fn click_with(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, modifiers: egui::Modifiers) { + for e in [egui::Event::PointerMoved(pos), pointer(pos, true, modifiers), pointer(pos, false, modifiers)] { + panel_frame(ctx, app, vec![e], modifiers); + } +} + +/// A point in the row of the label `s` (right of its chip, weight and notes). +fn row_of(texts: &[(String, egui::Rect)], s: &str) -> egui::Pos2 { + let r = text_rect(texts, s); + egui::pos2(r.left() + 200.0, r.center().y) +} + +/// The rect of the panel-menu entry `s` (entries are drawn after a check-mark column). +fn menu_item(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect { + texts.iter().find(|(t, _)| t.trim_start() == s).unwrap_or_else(|| panic!("`{s}` not in the menu: {texts:?}")).1 +} + +fn effects(app: &VectorcraftApp, item: Option) -> Vec { + first_selected(app).unwrap().appearance.effects_at(item).cloned().unwrap_or_default() +} + +#[test] +fn an_effect_name_opens_its_dialog_prefilled_and_ok_edits_it_in_place() { + let mut app = app_with_rect(); + run(&mut app, "effect.apply", json!({"effect": "distort.roughen", "params": {"size": 9}})); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + click(&ctx, &mut app, text_rect(&texts, "Roughen").center(), appearance::show); + let d = app.ui.dialog.clone().expect("the effect's dialog"); + assert_eq!((d.kind.as_str(), d.f64("size", 0.0), d.fields["__index"].clone()), ("effect", 9.0, json!(0))); + // The dialog previews; OK keeps the new value on the same effect as one undo step. + let mut out = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!(20)); + crate::dialogs::confirm(&mut app).unwrap(); + let fx = effects(&app, None); + assert_eq!((fx.len(), fx[0].params["size"].clone()), (1, json!(20))); + assert!(app.ui.dialog.is_none() && app.last_effect.is_none(), "an edit is not a new Last Effect"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(effects(&app, None)[0].params["size"], json!(9)); +} + +#[test] +fn choosing_an_applied_effect_asks_to_edit_it_or_add_another() { + let mut app = app_with_rect(); + run(&mut app, "effect.apply", json!({"effect": "distort.roughen", "item": 0, "params": {"size": 9}})); + run(&mut app, "appearance.setActiveItem", json!({"index": 0})); + // The active fill already has it: the question comes first; OK edits the applied one. + assert_eq!(app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap(), json!({"pending": "effectExists"})); + crate::dialogs::confirm(&mut app).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.kind.as_str(), d.f64("size", 0.0), d.fields["__index"].clone(), d.fields["__item"].clone()), ("effect", 9.0, json!(0), json!(0))); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(effects(&app, Some(0)).len(), 1); + // "Add New": a fresh dialog with the defaults, which adds a second one. + app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + crate::dialogs::confirm(&mut app).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert!(d.fields.get("__index").is_none() && d.f64("size", 0.0) == 5.0, "{:?}", d.fields); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(effects(&app, Some(0)).len(), 2); + // Another list without it opens the dialog directly. + assert_eq!(app.run("effect.dialog", json!({"effect": "distort.roughen", "item": null})).unwrap(), Value::Null); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, "effect"); +} + +#[test] +fn effect_rows_drag_to_reorder_and_into_an_item() { + let mut app = app_with_rect(); + run(&mut app, "effect.apply", json!({"effect": "distort.twist"})); + run(&mut app, "effect.apply", json!({"effect": "distort.roughen"})); + let ctx = egui::Context::default(); + let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + // Twist below Roughen. + let below = text_rect(&texts, "Roughen").center() + egui::vec2(60.0, 9.0); + drag(&ctx, &mut app, row_of(&texts, "Twist"), below, egui::Modifiers::NONE); + let ids = |fx: Vec| fx.into_iter().map(|e| e.id).collect::>(); + assert_eq!(ids(effects(&app, None)), ["distort.roughen", "distort.twist"]); + // Alt-dragging Twist onto the stroke row copies it into the stroke's effects. + let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + drag(&ctx, &mut app, row_of(&texts, "Twist"), row_of(&texts, "Stroke:"), egui::Modifiers::ALT); + assert_eq!(ids(effects(&app, None)), ["distort.roughen", "distort.twist"]); + assert_eq!(ids(effects(&app, Some(1))), ["distort.twist"]); + // Dropped effects become the selected row (their item active). + assert_eq!(app.session.appearance_item(), Some(1)); + app.run("edit.undo", json!({})).unwrap(); + assert!(effects(&app, Some(1)).is_empty()); +} + +#[test] +fn alt_dragging_an_item_duplicates_it_and_several_items_delete_together() { + let mut app = app_with_rect(); // [Fill, Stroke] + let ctx = egui::Context::default(); + let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + drag(&ctx, &mut app, row_of(&texts, "Fill:"), row_of(&texts, "Stroke:"), egui::Modifiers::ALT); + let kinds = |app: &VectorcraftApp| first_selected(app).unwrap().appearance.items.iter().map(|i| i.kind_name()).collect::>(); + assert_eq!(kinds(&app), ["fill", "stroke", "fill"]); + // Click the stroke, Cmd-click the top fill: both go with Remove Item. + let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + click_with(&ctx, &mut app, row_of(&texts, "Stroke:"), egui::Modifiers::NONE); + let top_fill = texts.iter().filter(|(t, _)| t == "Fill:").map(|(_, r)| *r).min_by(|a, b| a.top().total_cmp(&b.top())).unwrap(); + click_with(&ctx, &mut app, egui::pos2(top_fill.left() + 200.0, top_fill.center().y), egui::Modifiers::COMMAND); + let menu = frame_events(&ctx, &mut app, vec![], appearance::menu); + click(&ctx, &mut app, menu_item(&menu, "Remove Item").center(), appearance::menu); + assert_eq!(kinds(&app), ["fill"]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(kinds(&app).len(), 3, "one undo step"); +} + +#[test] +fn stroke_rows_note_dashes_brushes_and_show_all_hidden_attributes() { + let mut app = app_with_rect(); + let ctx = egui::Context::default(); + assert!(!frame_events(&ctx, &mut app, vec![], appearance::show).iter().any(|(t, _)| t == "Dashed")); + run(&mut app, "stroke.set", json!({"dash": [4, 2], "profile": "lens"})); + let brush = run(&mut app, "brush.list", json!({}))["brushes"][0]["name"].as_str().unwrap().to_string(); + run(&mut app, "brush.apply", json!({ "name": brush })); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + assert!(texts.iter().any(|(t, _)| *t == format!("{brush}, Dashed")), "{texts:?}"); + // Show All Hidden Attributes is enabled once something is hidden, and shows it. + let enabled = |app: &mut VectorcraftApp| { + let menu = frame_events(&ctx, app, vec![], appearance::menu); + let r = menu_item(&menu, "Show All Hidden Attributes"); + click(&ctx, app, r.center(), appearance::menu); + }; + run(&mut app, "appearance.setItem", json!({"index": 0, "visible": false})); + enabled(&mut app); + assert!(first_selected(&app).unwrap().appearance.items[0].visible()); +} + +#[test] +fn shift_clicking_a_chip_opens_the_mixer_on_that_row() { + let mut app = app_with_rect(); + let ctx = egui::Context::default(); + let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + // The chip sits 52 pt right of the label's start (label at EYE_W + 24, chip at EYE_W + 76). + let label = text_rect(&texts, "Fill:"); + click_with(&ctx, &mut app, egui::pos2(label.left() + 52.0 + 9.0, label.center().y), egui::Modifiers::SHIFT); + assert_eq!(app.ui.open_panel.as_deref(), Some("color")); + assert_eq!(app.session.appearance_item(), Some(0)); +} + +#[test] +fn the_thumbnail_drags_the_appearance_and_the_properties_fx_button_opens_the_effects() { + let mut app = app_with_rect(); + let ctx = egui::Context::default(); + let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + // The thumbnail is left of the object row's label. + let label = text_rect(&texts, "Rectangle"); + let thumb = egui::pos2(label.left() - 28.0, label.center().y); + let none = egui::Modifiers::NONE; + for e in [egui::Event::PointerMoved(thumb), pointer(thumb, true, none), egui::Event::PointerMoved(thumb + egui::vec2(30.0, 30.0))] { + panel_frame(&ctx, &mut app, vec![e], none); + } + let id = first_selected(&app).unwrap().id; + assert_eq!(egui::DragAndDrop::payload::(&ctx).as_deref(), Some(&appearance::AppearanceDrag(id))); + // Properties: the fx button lists the effect groups. + let props = egui::Context::default(); + let texts = frame_raw(&props, &mut app, Default::default(), properties::show); + click(&props, &mut app, text_rect(&texts, "fx").center(), properties::show); + let texts = frame_raw(&props, &mut app, Default::default(), properties::show); + assert!(texts.iter().any(|(t, _)| t == "Distort & Transform"), "{texts:?}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4aacf46a..15cc0165 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -36,3 +36,10 @@ fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern `swatches`); `ui.dialog.confirm` runs `swatch.new` / `swatch.newGroup`. The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`. + +Effect dialogs: `engine.execute {command: "effect.dialog", params: {effect, index?, item?}}` opens the `effect` dialog +(fields: the effect's parameters, `preview`). With `index` it edits that applied effect of `item` (null: the object's +effects) prefilled with its values, and `ui.dialog.confirm` runs `effect.setParams`; otherwise confirm runs +`effect.apply`. Choosing an effect that the list already has returns `{"pending": "effectExists"}` and opens the +`effectExists` question: `ui.dialog.confirm` opens the applied effect's dialog, `ui.dialog.set {field: "discard", +value: true}` then confirm opens a fresh one that adds another, `ui.dialog.cancel` drops it. diff --git a/docs/mcp.md b/docs/mcp.md index 345bdde9..f006d6be 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -196,3 +196,12 @@ plain group and keeps the clipping path, unpainted. {"name":"run_command","arguments":{"command":"select.set","params":{"ids":[12,15]}}} {"name":"run_command","arguments":{"command":"object.clippingMask.make","params":{}}} ``` + +## Editing appearance stacks + +`effect.move {from, to, fromItem?, toItem?, copy?}` reorders an effect or moves it between the object's effects +(`null`) and a fill's or stroke's (an item index), as dragging its row in the Appearance panel does; `copy: true` copies +it (Alt-drag). `appearance.duplicateItem {index, to?}` / `{indices}` and `appearance.removeItem {index | indices}` +act on several fills/strokes at once, and `appearance.showAllHidden` makes every hidden fill, stroke and effect +visible again. In remote mode, `effect.dialog {effect, index, item}` opens an applied effect's dialog prefilled +(`ui.dialog.confirm` runs `effect.setParams`); see the control protocol for the "already applied" question.