M3.69: target circles target layers, groups and objects; drag them to move appearances

Clicking a Layers panel target circle runs `layer.target {id}`: a layer gets its art
selected and becomes the target (`Selection.target`) of the appearance, effect,
transparency and opacity-mask commands, which then edit the layer itself; the
Appearance panel lists it. Dragging a circle onto another runs
`appearance.transfer {source, target, copy?}` (Alt copies), dropping it on the trash
clears it. Filled dots also mark groups and layers with fills of their own, masked
objects get a dashed underline, and while an opacity mask is edited the panel lists
only an `<Opacity Mask>` entry and the tab says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 02:15:41 +07:00
co-authored by Claude Opus 5.5
parent a2adf729a7
commit b768b4a53b
14 changed files with 529 additions and 51 deletions
+45
View File
@@ -19,6 +19,11 @@ pub struct Selection {
/// Key object for Align.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<NodeId>,
/// 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<NodeId>,
}
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<Item = NodeId>) {
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<NodeId> {
@@ -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);
}
}
+67 -12
View File
@@ -148,6 +148,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<usize> {
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<bool> {
}
}
/// 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<Vec<NodeId>> {
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<Vec<NodeId>> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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 { .. })
+27
View File
@@ -56,9 +56,36 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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::<Vec<_>>() }))
}
/// 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<Value> {
+2 -2
View File
@@ -75,7 +75,7 @@ fn enter(s: &mut Session, p: &Value) -> Result<Value> {
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();
}
+11 -7
View File
@@ -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<CommandSpec> {
}
/// 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<Vec<NodeId>> {
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<Value> {
}
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<Value> {
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))
})?;
+1 -1
View File
@@ -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<usize>)> {
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));
+1 -1
View File
@@ -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)))
}
+2
View File
@@ -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::<Vec<_>>(),
"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::<Vec<_>>(),
"redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::<Vec<_>>(),
"objects": d.node_count(),
+2
View File
@@ -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;
+117
View File
@@ -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());
}
+32 -3
View File
@@ -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>/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>/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>/Opacity Mask)"), "{}", title(&s));
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
assert!(title(&s).ends_with("(RGB/Preview)"));
}
}
+14 -7
View File
@@ -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::<Vec<usize>>()));
}
@@ -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<Node> {
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) {
+191 -18
View File
@@ -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 `<Opacity Mask>` 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<NodeId> = 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<u64> = 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::<PanelDrag>()
&& 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<NodeId>,
target: Option<NodeId>,
current: Option<NodeId>,
expanded: &mut HashSet<u64>,
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) { "<Opacity Mask>".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::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id));
if let Some(d) = resp.dnd_release_payload::<PanelDrag>()
&& 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<egui::Event>, alt: bool) -> (Vec<egui::Pos2>, 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, `<Opacity Mask>`.
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 == "<Opacity Mask>"), "{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}");
}
}
+17
View File
@@ -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 `<Opacity Mask>` entry and the document tab says `(<Opacity Mask>/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}}}
```