diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 99bbc73a0..1f820d497 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -19,6 +19,11 @@ pub struct Selection { /// Key object for Align. #[serde(default, skip_serializing_if = "Option::is_none")] pub key: Option, + /// The object, group or layer targeted through its target circle in the Layers panel, which + /// appearance, transparency and opacity-mask commands then act on (a targeted layer has its + /// art selected). Any other selection change clears it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, } impl Selection { @@ -35,6 +40,7 @@ impl Selection { self.objects.clear(); self.anchors.clear(); self.key = None; + self.target = None; } pub fn set(&mut self, ids: impl IntoIterator) { self.clear(); @@ -43,11 +49,13 @@ impl Selection { } } pub fn add(&mut self, id: NodeId) { + self.target = None; if !self.objects.contains(&id) { self.objects.push(id); } } pub fn remove(&mut self, id: NodeId) { + self.target = None; self.objects.retain(|x| *x != id); self.anchors.remove(&id); if self.key == Some(id) { @@ -68,6 +76,26 @@ impl Selection { if self.key.is_some_and(|k| doc.node(k).is_none()) { self.key = None; } + if self.target.is_some_and(|t| doc.node(t).is_none()) { + self.target = None; + } + } + /// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected, + /// anything else is selected itself. + pub fn set_target(&mut self, doc: &Document, id: NodeId) { + match doc.node(id) { + Some(n) if n.is_layer() => self.set(n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id)), + _ => self.set([id]), + } + self.target = Some(id); + } + /// What appearance and transparency edits act on: the targeted object, group or layer, else + /// the selected objects. + pub fn subjects(&self) -> &[NodeId] { + match &self.target { + Some(t) => std::slice::from_ref(t), + None => &self.objects, + } } /// Top-level ordering: selected ids sorted by paint order (bottom first). pub fn in_paint_order(&self, doc: &Document) -> Vec { @@ -93,4 +121,21 @@ mod tests { s.clear(); assert!(s.is_empty()); } + + #[test] + fn selection_changes_clear_the_target() { + let mut s = Selection::default(); + s.set([NodeId(2), NodeId(3)]); + s.target = Some(NodeId(1)); + assert_eq!(s.subjects(), &[NodeId(1)]); + s.add(NodeId(4)); + assert_eq!(s.target, None); + assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]); + s.target = Some(NodeId(1)); + s.remove(NodeId(2)); + assert_eq!(s.target, None); + s.target = Some(NodeId(1)); + s.set([NodeId(3)]); + assert_eq!(s.target, None); + } } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index bc68407b6..cfb42df3b 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -148,6 +148,15 @@ pub fn specs() -> Vec { has_doc, target_contents ), + cmd!( + "appearance.transfer", + "Move Appearance", + [], + None, + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds → {source, target}", + has_doc, + transfer + ), ] } @@ -165,7 +174,7 @@ impl Session { pub fn appearance_item(&self) -> Option { let a = self.active_appearance_item.as_ref()?; let st = self.active()?; - if st.uid != a.doc || st.selection.objects != a.objects { + if st.uid != a.doc || st.selection.subjects() != a.objects.as_slice() { return None; } let n = st.doc.node(*a.objects.first()?)?; @@ -230,7 +239,8 @@ impl ItemTarget { /// leaves (as without an active item) and vice versa. Explicit items are kept (and checked). pub(crate) fn of_kind(self, s: &Session, fill: bool) -> Self { let ItemTarget::Item { index, explicit: false } = self else { return self }; - let fits = s.active().and_then(|st| st.doc.node(*st.selection.objects.first()?)?.appearance.items.get(index).map(|it| it.is_fill() == fill)); + let fits = + s.active().and_then(|st| st.doc.node(*st.selection.subjects().first()?)?.appearance.items.get(index).map(|it| it.is_fill() == fill)); if fits == Some(true) { self } else { ItemTarget::Top } } @@ -274,12 +284,22 @@ fn targets_contents(p: &Value) -> Result { } } -/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too), else the selected -/// objects (a selected compound-path member stands for its compound); with `target: "contents"` -/// the painted objects inside the groups and layers among them ([`leaf_targets`]). +/// The objects appearance edits act on without `ids`: the targeted object, group or layer +/// (`layer.target`), else the selected objects (a selected compound-path member stands for its +/// compound). +pub(crate) fn subject_roots(s: &Session) -> Result> { + match s.doc()?.selection.target { + Some(t) => Ok(vec![t]), + None => selected_roots(s), + } +} + +/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too), else the +/// [`subject_roots`]; with `target: "contents"` the painted objects inside the groups and layers +/// among them ([`leaf_targets`]). pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result> { let roots = if p.get("ids").is_none() && p.get("id").is_none() { - selected_roots(s)? + subject_roots(s)? } else { let d = &s.doc()?.doc; targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect() @@ -347,7 +367,7 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result { None => return Err(bad(C, "missing `index` (an item index or null)")), }; let st = s.doc()?; - let objects = st.selection.objects.clone(); + let objects = st.selection.subjects().to_vec(); let first = objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| EngineError::Other("nothing selected".into()))?; let fill = first.appearance.items.get(index).ok_or_else(|| bad(C, format!("no appearance item {index}")))?.is_fill(); let doc = st.uid; @@ -393,11 +413,7 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { s.edit("Clear Appearance", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { - // No fill, no stroke, and the object's Opacity row back to Default. A group keeps no - // fill or stroke rows of its own. - n.appearance = if is_group(n) { Appearance::default() } else { Appearance::basic(Paint::None, Paint::None, 1.0) }; - n.opacity = 1.0; - n.blend = BlendMode::Normal; + clear(n); } } Ok(()) @@ -406,6 +422,45 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { ok() } +/// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A +/// group keeps no fill or stroke rows of its own. +fn clear(n: &mut Node) { + n.appearance = if is_group(n) { Appearance::default() } else { Appearance::basic(Paint::None, Paint::None, 1.0) }; + n.opacity = 1.0; + n.blend = BlendMode::Normal; +} + +fn transfer(s: &mut Session, p: &Value) -> Result { + const C: &str = "appearance.transfer"; + let source = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; + let target = id_param(p, "target").ok_or_else(|| bad(C, "missing `target` id"))?; + if source == target { + return Err(bad(C, "`source` and `target` are the same object")); + } + let copy = bool_or(p, "copy", false); + s.edit(if copy { "Copy Appearance" } else { "Move Appearance" }, |d, _| { + let src = d.node(source).cloned().ok_or(EngineError::NoNode(source))?; + let to = d.node_mut(target).ok_or(EngineError::NoNode(target))?; + let mut ap = src.appearance.clone(); + if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) { + ap.rebase_gradients(f, t); + } + // Only groups, layers and type have a Contents (Characters) row to keep a slot for. + if to.contents_label().is_none() { + ap.contents_index = None; + } + to.appearance = ap; + (to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape); + if !copy && let Some(n) = d.node_mut(source) { + clear(n); + (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); + } + Ok(()) + })?; + s.active_appearance_item = None; + Ok(json!({ "source": source.0, "target": target.0 })) +} + /// A group or layer (no fill or stroke rows of its own unless added). fn is_group(n: &Node) -> bool { matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f749f192f..2ce070334 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -56,9 +56,36 @@ pub fn specs() -> Vec { has_doc, clip_toggle ), + cmd!( + "layer.target", + "Target", + [], + None, + "{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}", + has_doc, + target + ), ] } +/// The Layers panel's target circle. +fn target(s: &mut Session, p: &Value) -> Result { + const C: &str = "layer.target"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let st = s.doc()?; + let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; + if !st.doc.is_editable(id) { + return Err(bad(C, "the object is hidden or locked")); + } + let layer = n.is_layer(); + s.select(|d, sel| sel.set_target(d, id))?; + let st = s.doc_mut()?; + if layer { + st.active_layer = Some(id); + } + Ok(json!({ "id": id.0, "selected": st.selection.objects.iter().map(|i| i.0).collect::>() })) +} + /// The Layers panel's clipping mask button: the top object of a layer (or group) clips the rest, /// or the clipping mask is released. fn clip_toggle(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index db804aee4..2f679e7c8 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -75,7 +75,7 @@ fn enter(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "already editing an opacity mask")); } let id = id_param(p, "id") - .or_else(|| st.selection.objects.iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.mask.is_some()))) + .or_else(|| st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.mask.is_some()))) .ok_or_else(|| bad(C, "select an object with an opacity mask"))?; let layer = s.edit("Edit Opacity Mask", |d, sel| begin(d, sel, id))?; show_editing(s, Some(layer))?; @@ -117,7 +117,7 @@ pub(crate) fn finish(d: &mut Document, sel: &mut Selection) { sync(d); d.drop_edit_modes(); if d.node(me.object).is_some() { - sel.set([me.object]); + sel.set_target(d, me.object); } else { sel.clear(); } diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 6cec3b24b..52622c79a 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -12,7 +12,6 @@ use serde_json::{Value, json}; use vectorcraft_color::BlendMode; use vectorcraft_doc::{Document, Knockout, Node, NodeId, OpacityMask, Selection}; -use super::edit::selected_roots; use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing}; use super::*; use crate::EngineError; @@ -134,12 +133,17 @@ pub fn specs() -> Vec { } /// The objects the Transparency panel's commands act on: `ids` / `id`, else the object whose -/// opacity mask is being edited (the selection then is its mask art), else the selection. +/// opacity mask is being edited (the selection then is its mask art), else the targeted object, +/// group or layer (`layer.target`), else the selection. pub(crate) fn transparency_targets(s: &Session, p: &Value) -> Result> { - match s.doc()?.doc.mask_edit { - Some(me) if p.get("ids").is_none() && p.get("id").is_none() => Ok(vec![me.object]), - _ => targets(s, p), + let st = s.doc()?; + if p.get("ids").is_some() || p.get("id").is_some() { + return targets(s, p); } + Ok(match st.doc.mask_edit { + Some(me) => vec![me.object], + None => st.selection.subjects().to_vec(), + }) } /// An opacity parameter, in percent (0..100) everywhere, as the model's 0..1. @@ -170,7 +174,7 @@ fn make(s: &mut Session, p: &Value) -> Result { } Selection { objects: ids, ..Default::default() }.in_paint_order(d) } - None => selected_roots(s)?, + None => super::appearance::subject_roots(s)?, }; let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) }; let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped); @@ -202,7 +206,7 @@ fn make(s: &mut Session, p: &Value) -> Result { let mut m = OpacityMask::new(mask_art, clip); m.invert = invert; n.mask = Some(Box::new(m)); - sel.set([target]); + sel.set_target(d, target); let layer = if mask_id.is_none() { Some(begin(d, sel, target)?) } else { None }; Ok((target, layer)) })?; diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 9dd8148fb..727abefe9 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -173,7 +173,7 @@ impl Session { /// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(&Node, Option)> { let st = self.active()?; - let first = st.doc.node(*st.selection.objects.first()?)?; + let first = st.doc.node(*st.selection.subjects().first()?)?; let item = self.appearance_item(); if first.appearance.item_of_kind(item, !stroke).is_some() { return Some((first, item)); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 5c21a831a..a7f826e51 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -302,7 +302,7 @@ impl Session { /// it is still linked (still has that style's look). pub fn selection_graphic_style(&self) -> Option<(&GraphicStyle, bool)> { let st = self.active()?; - let n = st.doc.node(*st.selection.objects.first()?)?; + let n = st.doc.node(*st.selection.subjects().first()?)?; let g = st.doc.graphic_style_by_id(n.graphic_style?)?; Some((g, in_sync(n, g))) } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 90c43273f..f1fe238a5 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -64,6 +64,8 @@ pub fn document(s: &Session) -> Value { "isolation": st.isolation.map(|l| l.0), "selection": st.selection.objects.iter().map(|i| i.0).collect::>(), "selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)), + // The layer, group or object targeted through the Layers panel (`layer.target`). + "target": st.selection.target.map(|t| t.0), "history": st.history.undo.iter().map(|h| h.label.clone()).collect::>(), "redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::>(), "objects": d.node_count(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 536cd62b7..343b28bce 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -839,6 +839,8 @@ mod tests_swatches; #[cfg(test)] mod tests_swatchlib; #[cfg(test)] +mod tests_targeting; +#[cfg(test)] mod tests_textedit; #[cfg(test)] mod tests_tileedge; diff --git a/crates/engine/src/tests_targeting.rs b/crates/engine/src/tests_targeting.rs new file mode 100644 index 000000000..73e41f780 --- /dev/null +++ b/crates/engine/src/tests_targeting.rs @@ -0,0 +1,117 @@ +//! Targeting through the Layers panel's target circles (`layer.target`) and moving or copying +//! appearances between objects (`appearance.transfer`). + +use serde_json::{Value, json}; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::Node; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 20, "width": 40, "height": 40}))["id"].as_u64().unwrap()) +} + +#[test] +fn a_targeted_layer_takes_transparency_appearance_and_effects() { + let mut s = session(); + let a = rect(&mut s, 20.0); + let b = rect(&mut s, 100.0); + let layer = s.doc().unwrap().doc.layers[0].id; + let r = run(&mut s, "layer.target", json!({"id": layer.0})); + assert_eq!(r["selected"], json!([a.0, b.0]), "the layer's art is selected"); + assert_eq!(s.doc().unwrap().selection.target, Some(layer)); + run(&mut s, "transparency.set", json!({"opacity": 40, "blend": "multiply"})); + let l = node(&s, layer); + assert!((l.opacity - 0.4).abs() < 1e-6 && l.blend == BlendMode::Multiply); + assert_eq!(node(&s, a).opacity, 1.0, "the art keeps its own opacity"); + assert_eq!(s.execute("transparency.info", &json!({})).unwrap()["ids"], json!([layer.0])); + run(&mut s, "appearance.addStroke", json!({})); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow"})); + let l = node(&s, layer); + assert_eq!(l.appearance.items.len(), 1); + assert_eq!(l.appearance.effects.len(), 1); + // The Appearance panel's active row is a row of the layer's own stack. + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + assert_eq!(s.appearance_item(), Some(0)); + // Undo is one step per command, and any other selection change ends the targeting. + run(&mut s, "edit.undo", json!({})); + assert!(node(&s, layer).appearance.effects.is_empty()); + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert_eq!(s.doc().unwrap().selection.target, None); + assert_eq!(s.appearance_item(), None); + run(&mut s, "transparency.set", json!({"opacity": 50})); + assert!((node(&s, a).opacity - 0.5).abs() < 1e-6); + assert!((node(&s, layer).opacity - 0.4).abs() < 1e-6); +} + +#[test] +fn targeting_an_object_selects_it_and_locked_layers_cannot_be_targeted() { + let mut s = session(); + let a = rect(&mut s, 20.0); + run(&mut s, "select.none", json!({})); + run(&mut s, "layer.target", json!({"id": a.0})); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + let layer = s.doc().unwrap().doc.layers[0].id; + run(&mut s, "layer.setProps", json!({"id": layer.0, "locked": true})); + assert!(s.execute("layer.target", &json!({"id": layer.0})).is_err()); + assert!(s.execute("layer.target", &json!({"id": 9999})).is_err()); +} + +#[test] +fn a_targeted_layer_gets_an_opacity_mask() { + let mut s = session(); + rect(&mut s, 20.0); + let layer = s.doc().unwrap().doc.layers[0].id; + run(&mut s, "layer.target", json!({"id": layer.0})); + assert_eq!(run(&mut s, "transparency.makeOpacityMask", json!({}))["id"], json!(layer.0)); + run(&mut s, "transparency.stopEditingOpacityMask", json!({})); + assert!(node(&s, layer).mask.is_some()); + assert_eq!(s.doc().unwrap().selection.target, Some(layer), "the masked layer stays targeted"); +} + +#[test] +fn transfer_moves_or_copies_the_appearance() { + let mut s = session(); + let a = rect(&mut s, 20.0); + let b = rect(&mut s, 100.0); + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "effect.apply", json!({"effect": "distort.twist"})); + run(&mut s, "transparency.set", json!({"opacity": 30, "blend": "screen"})); + let src = node(&s, a); + // Copy (Alt-drag): both have it. + run(&mut s, "appearance.transfer", json!({"source": a.0, "target": b.0, "copy": true})); + let (na, nb) = (node(&s, a), node(&s, b)); + assert_eq!(na.appearance, src.appearance); + assert_eq!(nb.appearance, src.appearance); + assert!((nb.opacity - 0.3).abs() < 1e-6 && nb.blend == BlendMode::Screen); + // Move onto the layer: the source is left cleared; one undo step brings it back. + let layer = s.doc().unwrap().doc.layers[0].id; + run(&mut s, "appearance.transfer", json!({"source": a.0, "target": layer.0})); + let (na, nl) = (node(&s, a), node(&s, layer)); + assert_eq!(nl.appearance.items.len(), src.appearance.items.len()); + assert_eq!(nl.appearance.effects.len(), 1); + assert!((nl.opacity - 0.3).abs() < 1e-6); + assert!(na.appearance.effects.is_empty() && na.opacity == 1.0 && na.blend == BlendMode::Normal); + assert_eq!(na.appearance.fill_paint(), Paint::None); + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, a).appearance, src.appearance); + assert!(node(&s, layer).appearance.items.is_empty()); + assert!(s.execute("appearance.transfer", &json!({"source": a.0, "target": a.0})).is_err()); + assert!(s.execute("appearance.transfer", &json!({"source": a.0})).is_err()); +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index a76c8812b..ef049c667 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -195,6 +195,18 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { }); } +/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited +/// "Name* @ 66.67 % (/Opacity Mask)". +fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool) -> String { + let mode = if d.doc.mask_edit.is_some() { + "/Opacity Mask".to_string() + } else { + let color = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" }; + format!("{color}/{}", if outline { "Outline" } else { "Preview" }) + }; + format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %")) +} + /// Document tab strip: "Name* @ 66.67% (RGB/Preview)". pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -207,9 +219,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { let active = app.session.active_index(); for (i, d) in app.session.documents().iter().enumerate() { let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0); - let mode = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" }; - let vm = if app.ui.view.outline { "Outline" } else { "Preview" }; - let title = format!("{}{} @ {} ({mode}/{vm})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %")); + let title = tab_title(d, zoom, app.ui.view.outline); let is_active = Some(i) == active; let galley = ui.painter().layout_no_wrap(title, theme::semibold(12.5), if is_active { t.text_strong } else { t.text_dim }); let w = galley.size().x + 50.0; @@ -467,3 +477,22 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { }); }); } + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + + #[test] + fn the_tab_title_names_the_opacity_mask_while_it_is_edited() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + let title = |s: &Session| super::tab_title(s.active().unwrap(), 1.0, false); + assert!(title(&s).ends_with("(RGB/Preview)"), "{}", title(&s)); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + assert!(title(&s).ends_with("(/Opacity Mask)"), "{}", title(&s)); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + assert!(title(&s).ends_with("(RGB/Preview)")); + } +} diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 7c9d9cd03..32458375b 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId}; -use super::{first_selected, live_run, pstate, set_pstate}; +use super::{live_run, pstate, set_pstate}; use crate::panels::stroke::paint_profile; use crate::theme::Tokens; use crate::widgets::{self, PanelDrag, TransparencyEdit, menu_item}; @@ -86,7 +86,7 @@ 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()); + let owner = app.session.active().and_then(|d| d.selection.subjects().first().copied()); set_pstate(ctx, "ap-sel", (owner, sel)); set_pstate(ctx, "ap-multi", (owner, sel.item().into_iter().collect::>())); } @@ -223,7 +223,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 node = first_subject(app); 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| { @@ -272,11 +272,18 @@ fn thumbnail_drag(ui: &mut Ui, th: Rect, id: NodeId) { } } +/// The object the panel lists: the targeted layer, group or object (`layer.target`), else the +/// first selected one. +fn first_subject(app: &VectorcraftApp) -> Option { + let st = app.session.active()?; + st.selection.subjects().first().and_then(|id| st.doc.node(*id)).cloned() +} + /// The selection as the Control bar and the object row name it: "No Selection", "Mixed Objects" -/// or the object's kind ("Path", "Type", …). +/// or the kind ("Path", "Type", "Layer"…) of the object, or targeted layer, the panel lists. pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str { let Some(st) = app.session.active() else { return "No Selection" }; - match st.selection.objects.as_slice() { + match st.selection.subjects() { [] => "No Selection", [id] => st.doc.node(*id).map_or("No Selection", Node::kind_label), _ => "Mixed Objects", @@ -286,7 +293,7 @@ pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str { /// Do the selected objects differ in appearance (fills, strokes, effects, opacity or blend mode)? fn mixed_appearances(app: &VectorcraftApp) -> bool { let Some(st) = app.session.active() else { return false }; - let mut nodes = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)); + let mut nodes = st.selection.subjects().iter().filter_map(|id| st.doc.node(*id)); let Some(first) = nodes.next() else { return false }; nodes.any(|n| n.appearance != first.appearance || n.opacity != first.opacity || n.blend != first.blend) } @@ -839,7 +846,7 @@ pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - let node = first_selected(app); + let node = first_subject(app); let has = node.is_some(); let sel = current_sel(app, ui.ctx(), node.as_ref()); if menu_item(ui, "Add New Fill", has, false) { diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 8988d335b..3ceb682a5 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1,5 +1,10 @@ //! The Layers panel: tree with visibility/lock columns, layer colour bars, thumbnails, target //! circles and selection squares; bottom bar with new/delete. +//! +//! A target circle targets its layer, group or object (`layer.target`); dragging it onto another +//! row's circle moves the appearance there (Alt copies it, `appearance.transfer`) and dropping it +//! on the trash clears it. While an opacity mask is edited the panel lists only its art, under +//! an `` entry. use std::collections::HashSet; @@ -8,6 +13,7 @@ use serde_json::json; use vectorcraft_doc::{Node, NodeId, NodeKind}; use crate::theme::Tokens; +use crate::widgets::PanelDrag; use crate::{VectorcraftApp, icons, widgets}; const ROW: f32 = 26.0; @@ -21,7 +27,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let sel: HashSet = st.selection.objects.iter().copied().collect(); + let target = st.selection.target; let current = st.active_layer; + // While an opacity mask is edited only its art is listed. + let mask_layer = doc.mask_edit.map(|m| m.layer); let mut expanded: HashSet = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_else(|| doc.layers.iter().map(|l| l.id.0).collect()); let mut actions: Vec<(String, serde_json::Value)> = vec![]; // Search field ("Search All"). @@ -30,8 +39,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let h = ui.available_height() - 34.0; egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; - for l in doc.layers.iter().rev() { - row(ui, &doc, l, 0, false, &sel, current, &mut expanded, &mut actions, &t); + for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { + row(ui, &doc, l, 0, false, &sel, target, current, &mut expanded, &mut actions, &t); } }); ui.data_mut(|d| d.insert_temp(expanded_id(), expanded)); @@ -50,7 +59,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { t.text_dim, ); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); - if widgets::icon_button(&mut child, "trash-2", "Delete Selection", false, 24.0).clicked() { + let trash = widgets::icon_button(&mut child, "trash-2", "Delete Selection", false, 24.0); + // A target circle dropped on the trash clears that appearance. + if let Some(d) = trash.dnd_release_payload::() + && let PanelDrag::Appearance(id) = *d + { + actions.push(("appearance.clear".into(), json!({"ids": [id.0]}))); + } else if trash.clicked() { if app.session.active().is_some_and(|d| !d.selection.is_empty()) { actions.push(("edit.clear".into(), json!({}))); } else { @@ -91,6 +106,7 @@ fn row( depth: usize, clip_path: bool, sel: &HashSet, + target: Option, current: Option, expanded: &mut HashSet, actions: &mut Vec<(String, serde_json::Value)>, @@ -177,7 +193,7 @@ fn row( } x += 26.0; // Name. - let name = n.display_name(); + let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "".to_string() } else { n.display_name() }; let font = egui::FontId::proportional(13.0); let rename_id = egui::Id::new("layers-rename"); let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)); @@ -202,9 +218,11 @@ fn row( _ => { let painter = ui.painter().with_clip_rect(name_rect); let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name.clone(), font, t.text); - // A clipping path's name is underlined. + // A clipping path's name is underlined, a masked object's with a dashed line. if clip_path { painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text)); + } else if n.mask.is_some() { + painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0)); } } } @@ -212,14 +230,7 @@ fn row( let col_x = r.right() - 43.5; ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); let tc = egui::pos2(r.right() - 28.0, r.center().y); - let styled = has_styled_target(n); - ui.painter().circle_stroke(tc, 5.0, Stroke::new(1.0, t.icon)); - if is_sel { - ui.painter().circle_stroke(tc, 2.8, Stroke::new(1.0, t.icon)); - } - if styled { - ui.painter().circle_filled(tc, 3.2, t.icon); - } + let tresp = target_circle(ui, n, tc, target.map_or(is_sel, |id| id == n.id), actions, t); let sq = egui::pos2(r.right() - 11.0, r.center().y); if is_sel { let q = egui::Rect::from_center_size(sq, vec2(7.0, 7.0)); @@ -229,7 +240,9 @@ fn row( ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(4.0, 4.0)), 0.0, color); } let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, ROW)), ui.id().with(("selsq", n.id.0)), Sense::click()); - if sq_resp.clicked() { + if tresp.clicked() { + actions.push(("layer.target".into(), json!({"id": n.id.0}))); + } else if sq_resp.clicked() { if n.is_layer() { actions.push(("layer.selectAll".into(), json!({"id": n.id.0}))); } else if ui.input(|i| i.modifiers.shift) { @@ -276,15 +289,47 @@ fn row( if has_children && expanded.contains(&n.id.0) { for (i, c) in n.children().unwrap().iter().enumerate().rev() { - row(ui, doc, c, depth + 1, i == 0 && n.clips(), sel, current, expanded, actions, t); + row(ui, doc, c, depth + 1, i == 0 && n.clips(), sel, target, current, expanded, actions, t); } } } -/// Whether an object's target circle is filled: its appearance is not basic or its transparency -/// (opacity, blend mode, isolation, knockout or an opacity mask) is not the default. +/// The target circle of `n`'s row at `c`: a ring, doubled while `n` is targeted, filled when `n` +/// has an appearance or transparency of its own. Its response is clicked to target `n`; dragging +/// it carries `n`'s appearance ([`PanelDrag::Appearance`]) onto another row's circle (Alt +/// copies it) or the trash. +fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, targeted: bool, actions: &mut Vec<(String, serde_json::Value)>, t: &Tokens) -> egui::Response { + let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, ROW)), ui.id().with(("target", n.id.0)), Sense::click_and_drag()); + widgets::drag_source(ui, &resp, || PanelDrag::Appearance(n.id)); + let held = resp.dnd_hover_payload::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id)); + if let Some(d) = resp.dnd_release_payload::() + && let PanelDrag::Appearance(source) = *d + && source != n.id + { + let copy = ui.input(|i| i.modifiers.alt); + actions.push(("appearance.transfer".into(), json!({"source": source.0, "target": n.id.0, "copy": copy}))); + } + let ring = if held { t.accent } else { t.icon }; + ui.painter().circle_stroke(c, 5.0, Stroke::new(if held { 1.5 } else { 1.0 }, ring)); + if targeted { + ui.painter().circle_stroke(c, 2.8, Stroke::new(1.0, t.icon)); + } + if has_styled_target(n) { + ui.painter().circle_filled(c, 3.2, t.icon); + } + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + resp +} + +/// Whether an object's target circle is filled: its appearance is not basic (a group or layer: +/// it has fills, strokes or effects of its own) or its transparency (opacity, blend mode, +/// isolation, knockout or an opacity mask) is not the default. fn has_styled_target(n: &Node) -> bool { - !n.appearance.is_basic() || !n.has_default_transparency() + !n.appearance.is_basic() + || (matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.appearance.items.is_empty()) + || !n.has_default_transparency() } /// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc` @@ -403,4 +448,132 @@ mod tests { run(&mut app, "layer.clippingMask.toggle", json!({})); assert_eq!(underlines(&mut app), 0); } + + /// One headless frame of the panel with `events` (Alt held when `alt`): the centres of the + /// target circles, top row first, and the text-coloured line segments (underlines) drawn. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, alt: bool) -> (Vec, usize) { + let mut events = events; + events.insert(0, egui::Event::ModifiersChanged(egui::Modifiers { alt, ..Default::default() })); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let text = Tokens::get(ctx).text; + let mut circles = vec![]; + let mut lines = 0; + for c in &out.shapes { + match &c.shape { + egui::Shape::Circle(cs) if cs.radius == 5.0 => circles.push(cs.center), + egui::Shape::LineSegment { stroke, .. } if stroke.color == text => lines += 1, + _ => {} + } + } + circles.sort_by(|a, b| a.y.total_cmp(&b.y)); + (circles, lines) + } + + fn button(pos: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } + } + + /// Drag from `a` to `b` over a few frames (Alt held when `alt`). + fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, alt: bool) { + let steps = [ + vec![egui::Event::PointerMoved(a)], + vec![button(a, true)], + vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))], + vec![egui::Event::PointerMoved(b)], + vec![button(b, false)], + vec![], + ]; + for e in steps { + frame(app, ctx, e, alt); + } + } + + /// A document with two rectangles on one layer → (app, layer, [bottom, top]). + fn two_rects() -> (VectorcraftApp, NodeId, [NodeId; 2]) { + 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": 200, "height": 200})); + let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + let b = run(&mut app, "shape.rectangle", json!({"x": 60, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut app, "select.none", json!({})); + let layer = app.session.active().unwrap().doc.layers[0].id; + (app, layer, [NodeId(a), NodeId(b)]) + } + + #[test] + fn clicking_a_target_circle_targets_the_layer() { + let (mut app, layer, [a, b]) = two_rects(); + let ctx = egui::Context::default(); + // Rows top down: the layer, the top rectangle, the bottom one. + let (c, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(c.len(), 3); + for e in [egui::Event::PointerMoved(c[0]), button(c[0], true), button(c[0], false)] { + frame(&mut app, &ctx, vec![e], false); + } + let st = app.session.active().unwrap(); + assert_eq!(st.selection.target, Some(layer)); + assert_eq!(st.selection.objects, vec![a, b]); + assert_eq!(super::super::appearance::object_label(&app), "Layer", "the Appearance panel lists the layer"); + app.session.execute("transparency.set", &json!({"opacity": 25})).unwrap(); + assert!((app.session.active().unwrap().doc.node(layer).unwrap().opacity - 0.25).abs() < 1e-6); + // The layer's circle is filled now; clicking an object's circle targets that object. + assert_eq!(filled_targets(&mut app, &ctx), 1); + for e in [egui::Event::PointerMoved(c[2]), button(c[2], true), button(c[2], false)] { + frame(&mut app, &ctx, vec![e], false); + } + let st = app.session.active().unwrap(); + assert_eq!((st.selection.target, st.selection.objects.clone()), (Some(a), vec![a])); + } + + #[test] + fn dragging_a_target_circle_moves_or_copies_the_appearance_and_the_trash_clears_it() { + let (mut app, layer, [a, b]) = two_rects(); + app.session.execute("transparency.set", &json!({"ids": [a.0], "opacity": 30})).unwrap(); + let ctx = egui::Context::default(); + let (c, _) = frame(&mut app, &ctx, vec![], false); + let opacity = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().opacity; + // Alt-drag the bottom rectangle's circle onto the top one's: both have it. + drag(&mut app, &ctx, c[2], c[1], true); + assert!((opacity(&app, b) - 0.3).abs() < 1e-6 && (opacity(&app, a) - 0.3).abs() < 1e-6); + // A plain drag onto the layer's circle moves it there. + drag(&mut app, &ctx, c[1], c[0], false); + assert!((opacity(&app, layer) - 0.3).abs() < 1e-6 && opacity(&app, b) == 1.0); + // Dropped on the trash, the layer's appearance is cleared (and nothing is deleted). + let st = app.session.active().unwrap(); + let count = st.doc.node_count(); + let trash = { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); + out.textures_delta.clear(); + // The bottom bar's rightmost button, under the right end of its top divider. + let divider = Tokens::get(&ctx).divider; + let corner = out.shapes.iter().rev().find_map(|c| match &c.shape { + egui::Shape::LineSegment { points, stroke } if stroke.color == divider => Some(points[1]), + _ => None, + }); + corner.unwrap() + vec2(-12.0, 15.0) + }; + drag(&mut app, &ctx, c[0], trash, false); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.node(layer).unwrap().opacity, 1.0); + assert_eq!(st.doc.node_count(), count); + } + + #[test] + fn masks_underline_dashed_and_mask_editing_lists_only_the_mask() { + let (mut app, _, [a, _]) = two_rects(); + let ctx = egui::Context::default(); + let (_, before) = frame(&mut app, &ctx, vec![], false); + app.session.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + app.session.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + // Editing the new (empty) mask: one row, ``. + let (c, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(c.len(), 1); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + assert!(texts.iter().any(|(t, _)| t == ""), "{texts:?}"); + app.session.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + let (c, after) = frame(&mut app, &ctx, vec![], false); + assert_eq!(c.len(), 3); + assert!(after > before + 1, "a dashed underline: {before} → {after}"); + } } diff --git a/docs/mcp.md b/docs/mcp.md index 7c975c496..bd9d1bcc1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -396,3 +396,20 @@ branch sets all of it. `paint.sampleColor {color}` puts a sampled colour (in its {"name":"run_command","arguments":{"command":"eyedropper.setOptions","params":{"pickUp":{"appearance":{"stroke":{"weight":false}}}}}} {"name":"run_command","arguments":{"command":"appearance.copyFrom","params":{"source":12,"ids":[7,8]}}} ``` + +## Targeting layers and moving appearances + +`layer.target {id}` is the Layers panel's target circle: a layer gets its visible, unlocked art selected and is itself +the target, so `appearance.*`, `effect.*`, `transparency.*` and the opacity-mask commands without `ids` act on the +layer (its opacity, its own fills and effects, an opacity mask on the whole layer); a group or object is simply +selected. `document.inspect` reports it as `target`, and any other selection change ends it. +`appearance.transfer {source, target, copy?}` is dragging a target circle onto another: the target gets the source's +fills, strokes, effects and transparency, and the source is cleared unless `copy` (Alt-drag). Dropping a circle on the +panel's trash is `appearance.clear {ids: [id]}`. Masked objects' names have a dashed underline; while an opacity mask +is edited the panel lists only an `` entry and the document tab says `(/Opacity Mask)`. + +```json +{"name":"run_command","arguments":{"command":"layer.target","params":{"id":2}}} +{"name":"run_command","arguments":{"command":"transparency.set","params":{"opacity":50}}} +{"name":"run_command","arguments":{"command":"appearance.transfer","params":{"source":12,"target":2,"copy":true}}} +```