From 27a1a48176901fe37c6657a6bbad92ced445d160 Mon Sep 17 00:00:00 2001 From: BFlat Date: Wed, 7 Oct 2026 22:29:08 +0700 Subject: [PATCH 1/5] Layers: sublayers are layers, not objects, and every row has a command Art in a sublayer is now selected as objects (clicks, marquee, Select All, the Select menu), sublayers select in their own colour, and hidden, locked or template sublayers block clicks. Layer Options gain Preview (per-layer outline) and Dim Images. New and fixed layer.* commands for the Layers panel: setCurrent and highlight (rows at any depth), selectAll, move (reparent with cycle and nesting checks, Alt copies), delete/duplicate rows, merge, flatten, collect, release to layers, reverse, template, hide/outline/lock others and their show/preview/unlock all, locate. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/hit.rs | 86 +++- crates/doc/src/lib.rs | 14 +- crates/doc/src/node.rs | 37 +- crates/doc/src/selection.rs | 6 +- crates/engine/src/cmd/layer.rs | 641 +++++++++++++++++++++++---- crates/engine/src/cmd/layerpanel.rs | 429 ++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/select.rs | 30 +- crates/engine/src/inspect.rs | 11 + crates/engine/src/lib.rs | 26 +- crates/engine/src/tests_layerclip.rs | 4 +- crates/engine/src/tests_layers.rs | 454 +++++++++++++++++++ crates/render/src/lib.rs | 46 ++ crates/render/src/tests_layeropts.rs | 68 +++ 14 files changed, 1722 insertions(+), 132 deletions(-) create mode 100644 crates/engine/src/cmd/layerpanel.rs create mode 100644 crates/engine/src/tests_layers.rs create mode 100644 crates/render/src/tests_layeropts.rs diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 1c46747f..b1b9e60a 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -23,6 +23,8 @@ pub struct Hit { /// The innermost envelope in the ancestry whose contents are being edited (Edit Contents): /// its content, not the envelope, is the object clicked. pub contents_of: Option, + /// How many entries at the start of `ancestry` are layers (the layer and its sublayers). + pub layers: usize, } impl Hit { @@ -34,8 +36,8 @@ impl Hit { if let Some(i) = at(scope).max(at(self.contents_of)) { return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf); } - // Skip layers and sublayers. - self.ancestry.get(1).copied().unwrap_or(self.leaf) + // Skip layers and sublayers: they are not objects. + self.ancestry.get(self.layers.max(1)).copied().unwrap_or(self.leaf) } } @@ -63,7 +65,7 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { if !layer.visible || layer.locked { continue; } - if let NodeKind::Layer { template: true, .. } = layer.kind { + if layer.is_template() { continue; } // Pattern editing mode: only the tile art is editable. @@ -71,8 +73,10 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { continue; } chain.push(layer.id); + let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt }; if let Some(mut h) = hit_children(layer, p, opt, &mut chain) { h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); + h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count(); return Some(h); } chain.pop(); @@ -105,7 +109,8 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec hit_children(c, p, HitOptions { outline: true, ..opt }, chain), NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain), // Edit Contents: the envelope's content hits, undistorted. NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, chain), // A blend's key objects first (Direct and Group Selection pick them); its steps hit // as the blend. NodeKind::Blend { .. } => hit_children(c, p, opt, chain) - .or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None })), - _ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None }), + .or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })), + _ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }), }; if hit.is_some() { return hit; @@ -192,13 +199,28 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> let mut out = Vec::new(); let tops: Vec<&std::sync::Arc> = match scope.and_then(|s| doc.node(s)) { Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(), + // The objects of the layers, looking through sublayers (hidden, locked and template ones + // are left out). None => doc .layers .iter() - .filter(|l| l.visible && !l.locked && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id)) - .flat_map(|l| l.children().into_iter().flatten()) + .filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id)) + .flat_map(|l| layer_objects(l)) .collect(), }; + fn layer_objects(l: &Node) -> Vec<&std::sync::Arc> { + let mut out = vec![]; + for c in l.children().into_iter().flatten() { + if c.is_layer() { + if c.visible && !c.locked && !c.is_template() { + out.extend(layer_objects(c)); + } + } else { + out.push(c); + } + } + out + } fn touches(n: &Node, r: Rect) -> bool { match &n.kind { NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r), @@ -296,6 +318,54 @@ mod tests { assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty()); } + /// Art in a sublayer is an object of its own: a click or a marquee takes it, not the + /// sublayer; hidden, locked and template sublayers block both. + #[test] + fn sublayers_are_not_objects() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap(); + let inner = d.alloc_id(); + d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap(); + let a = d.alloc_id(); + d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + let g = d.alloc_id(); + let b = d.alloc_id(); + let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art()); + d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap(); + let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap(); + assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3)); + let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap(); + assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b)); + let all = Rect::new(-5.0, -5.0, 200.0, 200.0); + assert_eq!(marquee(&d, all, None, false), vec![a, g]); + assert_eq!(d.layer_containing(a), Some(inner)); + assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour"); + assert_eq!(d.selectable_art(), vec![a, g]); + for f in [ + |n: &mut Node| n.visible = false, + |n: &mut Node| n.locked = true, + |n: &mut Node| { + if let NodeKind::Layer { template, .. } = &mut n.kind { + *template = true; + } + }, + ] { + let mut d2 = d.clone(); + f(d2.node_mut(inner).unwrap()); + assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none()); + assert_eq!(marquee(&d2, all, None, false), vec![g]); + assert_eq!(d2.selectable_art(), vec![g]); + } + // A sublayer whose Preview is off is clicked in outline: its fill no longer hits. + if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind { + *preview = false; + } + assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none()); + assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some()); + } + #[test] fn locked_and_hidden_skip() { let mut d = Document::new(200.0, 200.0); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3fad4703..fada7e98 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -837,9 +837,19 @@ impl Document { pub fn is_visible(&self, id: NodeId) -> bool { self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible))) } - /// Colour of the layer containing `id` (selection highlight colour). + /// The innermost layer or sublayer containing `id` (`id` itself when it is a layer). + pub fn layer_containing(&self, id: NodeId) -> Option { + let a = self.ancestry(id)?; + a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer)) + } + /// Every art object the Selection tool can reach (Select All): the visible, unlocked objects + /// of the visible, unlocked, non-template layers, looking through sublayers (bottom first). + pub fn selectable_art(&self) -> Vec { + self.layers.iter().filter(|l| l.visible && !l.locked && !l.is_template()).flat_map(|l| l.layer_art(true)).collect() + } + /// Colour of the innermost layer or sublayer containing `id` (selection highlight colour). pub fn layer_color(&self, id: NodeId) -> [u8; 3] { - let l = self.layer_of(id).and_then(|l| self.node(l)); + let l = self.layer_containing(id).and_then(|l| self.node(l)); match l.map(|n| &n.kind) { Some(NodeKind::Layer { color, .. }) => color.rgb(), _ => LAYER_COLORS[0].1, diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index b7e6573b..3d856e13 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -259,6 +259,14 @@ pub enum NodeKind { /// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group. #[serde(default, skip_serializing_if = "crate::skip::is_default")] clip: bool, + /// Layer Options → Preview: off, the layer's art is drawn (and clicked) in outline on screen + /// (Ctrl-click its eye in the Layers panel). + #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] + preview: bool, + /// Layer Options → Dim Images to: on screen, images on the layer show faded to this + /// percentage (0–100); `None`: not dimmed. + #[serde(default, skip_serializing_if = "Option::is_none")] + dim_images: Option, }, /// A group. With `clip`, the first (bottom-most) child is the clipping path. Group { @@ -448,7 +456,10 @@ impl Node { Self::new(id, NodeKind::Group { children, clip: false }) } pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self { - let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false }); + let mut n = Self::new( + id, + NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false, preview: true, dim_images: None }, + ); n.name = Some(name.to_string()); n } @@ -477,6 +488,30 @@ impl Node { pub fn is_layer(&self) -> bool { matches!(self.kind, NodeKind::Layer { .. }) } + /// A template layer (Layer Options → Template). + pub fn is_template(&self) -> bool { + matches!(self.kind, NodeKind::Layer { template: true, .. }) + } + /// The art objects of this container: its children, looking through sublayers, which are not + /// objects (bottom first). With `editable`, only the visible, unlocked objects in visible, + /// unlocked, non-template sublayers. + pub fn layer_art(&self, editable: bool) -> Vec { + fn visit(n: &Node, editable: bool, out: &mut Vec) { + for c in n.children().into_iter().flatten() { + if editable && (!c.visible || c.locked || c.is_template()) { + continue; + } + if c.is_layer() { + visit(c, editable, out); + } else { + out.push(c.id); + } + } + } + let mut out = vec![]; + visit(self, editable, &mut out); + out + } pub fn is_container(&self) -> bool { self.children().is_some() } diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 70ce0fe3..ef1fe10a 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -98,11 +98,11 @@ impl Selection { } self.slices.retain(|id| doc.is_slice(*id)); } - /// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected, - /// anything else is selected itself. + /// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected + /// (the art of its sublayers too), 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)), + Some(n) if n.is_layer() => self.set(n.layer_art(true)), _ => self.set([id]), } self.target = Some(id); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f7f60ea0..7139d008 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -1,31 +1,125 @@ //! Layers panel, artboards. +//! +//! The Layers panel works on rows: layers, sublayers, groups and objects. Clicking a row makes it +//! the highlighted row (`layer.setCurrent`; Shift and Ctrl change several, `layer.highlight`) and +//! its layer the current one, where new art goes. Panel commands without `ids` act on the +//! highlighted rows, else on the current layer. More panel-menu operations live in +//! [`super::layerpanel`]. + +use std::collections::HashSet; +use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Artboard, LayerColor, NodeId, NodeKind}; +use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; use super::*; use crate::EngineError; +/// Most rows one panel command takes (ids are untrusted input). +pub(crate) const MAX_ROWS: usize = 100_000; + pub fn specs() -> Vec { vec![ - cmd!("layer.new", "New Layer…", ["Window", "Layers"], Some("Cmd+L"), "{name?} → {id} (above the current layer)", has_doc, new_layer), - cmd!("layer.newSublayer", "New Sublayer…", ["Window", "Layers"], None, "{parent?: id, name?} → {id}", has_doc, new_sublayer), - cmd!("layer.delete", "Delete Layer", ["Window", "Layers"], None, "{id?} (default: current layer)", has_doc, delete_layer), - cmd!("layer.duplicate", "Duplicate Layer", ["Window", "Layers"], None, "{id?}", has_doc, duplicate_layer), - cmd!("layer.setCurrent", "Set Current Layer", [], None, "{id}", has_doc, set_current), + cmd!( + "layer.new", + "New Layer…", + ["Window", "Layers"], + Some("Cmd+L"), + "{name?, top?: bool, color?, template?, locked?, visible?, printable?, preview?, dimImages?} a layer directly above the current layer, at its level (a sublayer beside a current sublayer); `top`: above all layers (Ctrl-click the button). Options as layer.setProps → {id}", + has_doc, + new_layer + ), + cmd!( + "layer.newSublayer", + "New Sublayer…", + ["Window", "Layers"], + None, + "{parent?: layer id (default: the highlighted row's layer, else the current layer), name?, options as layer.new} a sublayer on top of the parent's contents → {id}", + has_doc, + new_sublayer + ), + cmd!( + "layer.delete", + "Delete Selection", + ["Window", "Layers"], + None, + "{ids?|id?} delete rows: layers, sublayers, groups or objects with what they hold (default: the highlighted rows, else the current layer). The last layer stays → {deleted}", + has_doc, + delete_rows + ), + cmd!( + "layer.duplicate", + "Duplicate Selection", + ["Window", "Layers"], + None, + "{ids?|id?} copy rows (default: the highlighted rows, else the current layer), each just above its original; a layer's copy is named ` copy`. The copies are highlighted → {ids}", + has_doc, + duplicate_rows + ), + cmd!( + "layer.setCurrent", + "Set Current Layer", + [], + None, + "{id} click a row: it alone is highlighted, and the layer it is (or the layer it is in) becomes the current layer, where new art goes. Not an undo step → {id, layer}", + has_doc, + set_current + ), + cmd!( + "layer.highlight", + "Highlight Rows", + [], + None, + "{ids, mode?: \"set\"|\"add\"|\"toggle\"} highlight Layers panel rows (Shift-click: a range, Ctrl-click: toggle one); the last one's layer becomes current. Not an undo step → {rows}", + has_doc, + highlight + ), cmd!( "layer.setProps", "Layer Options…", ["Window", "Layers"], None, - "{id, name?, visible?, locked?, template?, printable?, color?: index 0..26}", + "{ids?|id? (default: the highlighted rows, else the current layer), name?, visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name} Layer Options; rows that are not layers take name, visible and locked. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step", has_doc, set_props ), - cmd!("layer.collectInNew", "Collect in New Layer", ["Window", "Layers"], None, "{}", has_selection, collect), - cmd!("layer.selectAll", "Select All Art on Layer", [], None, "{id}", has_doc, select_all_on), - cmd!("node.move", "Reorder", [], None, "{id, parent?: id (null = top level), index} drag-reorder in the Layers panel", has_doc, move_node), + cmd!( + "layer.collectInNew", + "Collect in New Layer", + ["Window", "Layers"], + None, + "{ids?} move rows (default: the highlighted rows, else the selected objects) into a new layer where the topmost of them was (a sublayer when they are in a layer) → {id}", + has_doc, + collect + ), + cmd!( + "layer.selectAll", + "Select Art", + [], + None, + "{id, add?: bool} the Layers panel's selection column: select the visible, unlocked art of a layer (its sublayers' too), or the object itself; `add` (Shift-click) adds it to the selection, or removes it when it is all selected already → {selected: [..]}", + has_doc, + select_art + ), + cmd!( + "layer.move", + "Move Rows", + [], + None, + "{ids, target, place?: \"above\"|\"below\"|\"inside\" (default inside), copy?: bool} drag rows in the Layers panel: above or below the target row (beside it, in its layer or group), or inside a layer or group (on top of its contents). Layers go only in layers or at the top level, objects in layers and groups; an object placed beside a top-level layer goes inside it. A row never goes into itself; a locked layer or group takes nothing. `copy` (Alt-drag) moves copies. One undo step → {ids}", + has_doc, + move_rows + ), + cmd!( + "node.move", + "Reorder", + [], + None, + "{id, parent?: id (null = top level), index} put a row at `index` (0 = bottom) of `parent` (layers only at the top level or in layers)", + has_doc, + move_node + ), cmd!( "artboard.new", "New Artboard", @@ -52,7 +146,7 @@ pub fn specs() -> Vec { "Make/Release Clipping Mask", ["Window", "Layers"], None, - "{id?: layer or group (default: the one selected group, else the current layer)} make: its top object (a path, compound path or text, which loses its paint) clips the rest and moves to the bottom (new art added on top is clipped); release: it stops clipping, the clipping path stays unpainted → {clip}", + "{id?: layer or group (default: the one highlighted layer or group row, else the one selected group, else the current layer)} make: its top object (a path, compound path or text, which loses its paint) clips the rest and moves to the bottom (new art added on top is clipped); release: it stops clipping, the clipping path stays unpainted → {clip}", has_doc, clip_toggle ), @@ -61,7 +155,7 @@ pub fn specs() -> Vec { "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: [..]}", + "{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art (its sublayers' too) 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 ), @@ -77,6 +171,179 @@ pub fn specs() -> Vec { ] } +/// The rows a panel command acts on: `ids` or `id`, else the highlighted rows, else the current +/// layer (capped at [`MAX_ROWS`]). +pub(crate) fn rows_param(s: &Session, p: &Value) -> Result> { + let mut ids = match (ids_param(p, "ids"), id_param(p, "id")) { + (Some(ids), _) => ids, + (None, Some(id)) => vec![id], + (None, None) => { + let st = s.doc()?; + let rows = st.highlighted_rows(); + if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows } + } + }; + ids.truncate(MAX_ROWS); + Ok(ids) +} + +/// `ids` that exist, without repeats or rows inside another listed row, in paint order (bottom +/// first). +pub(crate) fn row_roots(d: &Document, ids: &[NodeId]) -> Vec { + let set: HashSet = ids.iter().copied().filter(|i| d.node(*i).is_some()).collect(); + let keep: Vec = set + .iter() + .copied() + .filter(|id| d.ancestry(*id).is_some_and(|a| a.split_last().is_none_or(|(_, above)| !above.iter().any(|x| set.contains(x))))) + .collect(); + d.paint_order(keep) +} + +/// Whether `parent` (None: the top level) can hold `n`: layers go at the top level or in layers, +/// anything else in layers and groups. +pub(crate) fn accepts(d: &Document, parent: Option, n: &Node) -> bool { + match parent { + None => n.is_layer(), + Some(p) => match d.node(p).map(|x| &x.kind) { + Some(NodeKind::Layer { .. }) => true, + Some(NodeKind::Group { .. }) => !n.is_layer(), + _ => false, + }, + } +} + +/// Whether `id` or a container around it is locked. +fn locked_within(d: &Document, id: NodeId) -> bool { + d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked)) +} + +/// Every layer and sublayer, depth first. +fn all_layers(d: &Document) -> Vec<&Node> { + let mut out = vec![]; + d.walk(|n| { + if n.is_layer() { + out.push(n); + } + }); + out +} + +/// The next unused `Layer N` name, counting sublayers too. +pub(crate) fn next_layer_name(d: &Document) -> String { + let layers = all_layers(d); + let taken: HashSet<&str> = layers.iter().filter_map(|l| l.name.as_deref()).collect(); + let mut i = layers.len() + 1; + loop { + let name = format!("Layer {i}"); + if !taken.contains(name.as_str()) { + return name; + } + i += 1; + } +} + +/// A new layer named `name` (default: the next `Layer N`) in the next layer colour. +pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node { + let n = all_layers(d).len(); + let name = name.map(str::to_string).unwrap_or_else(|| next_layer_name(d)); + let id = d.alloc_id(); + Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8)) +} + +/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case). +fn layer_color_param(v: &Value) -> std::result::Result { + match v { + Value::Number(n) => match n.as_u64() { + Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)), + _ => Err(format!("a colour index is 0 to {}, not {n}", LAYER_COLORS.len() - 1)), + }, + Value::String(s) => { + if let Some(i) = LAYER_COLORS.iter().position(|(name, _)| name.eq_ignore_ascii_case(s.trim())) { + return Ok(LayerColor::Preset(i as u8)); + } + let c = vectorcraft_color::Color::from_hex(s).ok_or_else(|| format!("`{s}` is not a colour (#rrggbb or a preset name)"))?; + let [r, g, b, _] = c.to_rgba8(1.0); + Ok(LayerColor::Custom([r, g, b])) + } + _ => Err(format!("a colour is an index, #rrggbb or a preset name, not {v}")), + } +} + +/// Layer Options in `p` applied to layer or row `n` (validated first by [`check_options`]). +fn apply_options(n: &mut Node, p: &Value) { + if let Some(v) = str_param(p, "name") { + if v.trim().is_empty() { + // A layer keeps a name; an object goes back to its generated `` one. + if !n.is_layer() { + n.name = None; + } + } else { + n.name = Some(v.chars().take(512).collect()); + } + } + if let Some(v) = p.get("visible").and_then(Value::as_bool) { + n.visible = v; + } + let locked = p.get("locked").and_then(Value::as_bool); + if let Some(v) = locked { + n.locked = v; + } + let mut lock = None; + if let NodeKind::Layer { template, printable, color, preview, dim_images, .. } = &mut n.kind { + let dim_given = p.get("dimImages").is_some(); + if let Some(v) = p.get("template").and_then(Value::as_bool) + && v != *template + { + *template = v; + // A template is locked and its images dimmed; it stops being either with it. + lock = Some(v); + if !dim_given { + *dim_images = v.then_some(50); + } + } + if let Some(v) = p.get("printable").and_then(Value::as_bool) { + *printable = v; + } + if let Some(v) = p.get("preview").and_then(Value::as_bool) { + *preview = v; + } + match p.get("dimImages") { + Some(Value::Bool(false) | Value::Null) => *dim_images = None, + Some(Value::Bool(true)) => *dim_images = Some(50), + Some(v) => { + if let Some(x) = v.as_f64().filter(|x| x.is_finite()) { + *dim_images = Some(x.clamp(0.0, 100.0).round() as u8); + } + } + None => {} + } + if let Some(c) = p.get("color").and_then(|v| layer_color_param(v).ok()) { + *color = c; + } + } + if let (Some(v), None) = (lock, locked) { + n.locked = v; + } +} + +/// Bad values in Layer Options params, as an error for `cmd`. +fn check_options(cmd: &str, p: &Value) -> Result<()> { + if let Some(v) = p.get("color") { + layer_color_param(v).map_err(|e| bad(cmd, e))?; + } + for k in ["visible", "locked", "template", "printable", "preview"] { + if p.get(k).is_some_and(|v| !v.is_boolean()) { + return Err(bad(cmd, format!("`{k}` is true or false"))); + } + } + if let Some(v) = p.get("dimImages") + && !(v.is_boolean() || v.is_null() || v.as_f64().is_some_and(f64::is_finite)) + { + return Err(bad(cmd, "`dimImages` is a percentage or false")); + } + Ok(()) +} + /// Layers panel → Paste Remembers Layers. fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result { let cur = s.doc()?.doc.paste_remembers_layers; @@ -100,8 +367,11 @@ fn target(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "the object is hidden or locked")); } let layer = n.is_layer(); + let rows = st.layer_rows.clone(); s.select(|d, sel| sel.set_target(d, id))?; let st = s.doc_mut()?; + // Targeting doesn't change which rows are highlighted. + st.layer_rows = rows; if layer { st.active_layer = Some(id); } @@ -112,11 +382,16 @@ fn target(s: &mut Session, p: &Value) -> Result { /// or the clipping mask is released. fn clip_toggle(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; + let container = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })); + let row = match st.highlighted_rows().as_slice() { + [id] if container(id) => Some(*id), + _ => None, + }; let group = match st.selection.objects.as_slice() { [id] if matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Group { .. })) => Some(*id), _ => None, }; - let id = id_param(p, "id").or(group).or(st.current_layer()).ok_or_else(|| bad("layer.clippingMask.toggle", "no layer"))?; + let id = id_param(p, "id").or(row).or(group).or(st.current_layer()).ok_or_else(|| bad("layer.clippingMask.toggle", "no layer"))?; let clip = s.edit("Make/Release Clipping Mask", |d, _| { let n = d.node(id).ok_or(EngineError::NoNode(id))?; if !matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. }) { @@ -138,99 +413,142 @@ fn clip_toggle(s: &mut Session, p: &Value) -> Result { Ok(json!({ "clip": clip })) } -fn new_layer(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").map(str::to_string); - let st = s.doc()?; - let cur = st.active_layer.and_then(|l| st.doc.position(l)).filter(|(par, _, _)| par.is_none()).map(|(_, i, _)| i); - let id = s.edit("New Layer", |d, _| { - let id = d.add_layer(name.as_deref()); - if let Some(i) = cur { - d.move_node(id, None, i + 1)?; - } - Ok(id) +/// A new layer with the options in `p`, at `parent`/`index`; it becomes current and highlighted. +fn add_layer(s: &mut Session, p: &Value, cmd: &str, label: &str, place: impl FnOnce(&Document) -> Result<(Option, usize)>) -> Result { + check_options(cmd, p)?; + let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string); + let id = s.edit(label, |d, _| { + let (parent, index) = place(d)?; + let mut n = make_layer(d, name.as_deref()); + apply_options(&mut n, p); + Ok(d.insert(parent, index, n)?) })?; - s.doc_mut()?.active_layer = Some(id); + let st = s.doc_mut()?; + st.active_layer = Some(id); + st.layer_rows = vec![id]; Ok(json!({ "id": id.0 })) } +fn new_layer(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + // Above the current layer, at its level; on top of everything with `top`. + let cur = st.current_layer().filter(|_| !bool_or(p, "top", false)); + add_layer(s, p, "layer.new", "New Layer", |d| match cur.and_then(|c| d.position(c)) { + Some((parent, i, _)) => Ok((parent, i + 1)), + None => Ok((None, usize::MAX)), + }) +} + fn new_sublayer(s: &mut Session, p: &Value) -> Result { - let parent = id_param(p, "parent").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.newSublayer", "no parent layer"))?; - let name = str_param(p, "name").map(str::to_string); - let id = s.edit("New Sublayer", |d, _| { - let n = d.node(parent).and_then(|n| n.children()).map(|c| c.iter().filter(|c| c.is_layer()).count()).unwrap_or(0); - let id = d.alloc_id(); - let color = LayerColor::Preset(((d.layers.len() + n + 1) % 27) as u8); - let nm = name.unwrap_or_else(|| format!("Layer {}", d.node_count())); - d.insert(Some(parent), usize::MAX, vectorcraft_doc::Node::layer(id, &nm, color))?; - Ok(id) - })?; - Ok(json!({ "id": id.0 })) + const C: &str = "layer.newSublayer"; + let st = s.doc()?; + let row = st.highlighted_rows().last().and_then(|r| st.doc.layer_containing(*r)); + let parent = id_param(p, "parent").or(row).or(st.current_layer()).ok_or_else(|| bad(C, "no parent layer"))?; + if !st.doc.node(parent).is_some_and(Node::is_layer) { + return Err(bad(C, "the parent must be a layer or sublayer")); + } + add_layer(s, p, C, "New Sublayer", |_| Ok((Some(parent), usize::MAX))) } -fn delete_layer(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.delete", "no layer"))?; +fn delete_rows(s: &mut Session, p: &Value) -> Result { + let ids = rows_param(s, p)?; let st = s.doc()?; - if st.doc.layers.len() == 1 && st.doc.layers[0].id == id { + let ids = row_roots(&st.doc, &ids); + if ids.is_empty() { + return Err(bad("layer.delete", "no such rows")); + } + if st.doc.layers.iter().all(|l| ids.contains(&l.id)) { return Err(EngineError::Other("a document needs at least one layer".into())); } - s.edit("Delete Layer", |d, sel| { - d.remove(id)?; + s.edit("Delete Selection", |d, sel| { + for id in &ids { + d.remove(*id)?; + } sel.prune(d); Ok(()) })?; let st = s.doc_mut()?; - if st.active_layer == Some(id) || st.active_layer.is_some_and(|l| st.doc.node(l).is_none()) { + st.layer_rows.clear(); + if st.current_layer().is_none() { st.active_layer = st.doc.default_layer(); } - ok() + Ok(json!({ "deleted": ids.len() })) } -fn duplicate_layer(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.duplicate", "no layer"))?; - let nid = s.edit("Duplicate Layer", |d, _| { - let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; - let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; - let mut c = d.reid(&n); - c.name = Some(format!("{} copy", n.display_name())); - Ok(d.insert(par, idx + 1, c)?) +fn duplicate_rows(s: &mut Session, p: &Value) -> Result { + let ids = rows_param(s, p)?; + let ids = row_roots(&s.doc()?.doc, &ids); + if ids.is_empty() { + return Err(bad("layer.duplicate", "no such rows")); + } + let copies = s.edit("Duplicate Selection", |d, _| { + let mut out = vec![]; + for id in &ids { + let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; + let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; + let mut c = d.reid(&n); + if n.is_layer() { + c.name = Some(format!("{} copy", n.display_name())); + } + out.push(d.insert(par, idx + 1, c)?); + } + Ok(out) })?; - Ok(json!({ "id": nid.0 })) + s.doc_mut()?.layer_rows = copies.clone(); + Ok(json!({ "ids": copies.iter().map(|i| i.0).collect::>() })) } fn set_current(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("layer.setCurrent", "missing id"))?; + const C: &str = "layer.setCurrent"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; let st = s.doc_mut()?; - if !st.doc.node(id).is_some_and(|n| n.is_layer()) { - return Err(bad("layer.setCurrent", "not a layer")); - } - st.active_layer = Some(id); + let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?; + st.active_layer = Some(layer); + st.layer_rows = vec![id]; st.revision += 1; - ok() + Ok(json!({ "id": id.0, "layer": layer.0 })) +} + +fn highlight(s: &mut Session, p: &Value) -> Result { + const C: &str = "layer.highlight"; + let mut ids = ids_param(p, "ids").ok_or_else(|| bad(C, "missing ids"))?; + ids.truncate(MAX_ROWS); + let mode = str_param(p, "mode").unwrap_or("set"); + let st = s.doc_mut()?; + ids.retain(|id| st.doc.node(*id).is_some()); + let mut rows = st.highlighted_rows(); + match mode { + "set" => rows.clear(), + "add" | "toggle" => {} + other => return Err(bad(C, format!("mode is set, add or toggle, not `{other}`"))), + } + let mut last = None; + for id in ids { + if mode == "toggle" && rows.contains(&id) { + rows.retain(|r| *r != id); + } else if !rows.contains(&id) { + rows.push(id); + last = Some(id); + } + } + if let Some(layer) = last.or(rows.last().copied()).and_then(|id| st.doc.layer_containing(id)) { + st.active_layer = Some(layer); + } + st.layer_rows = rows; + st.revision += 1; + Ok(json!({ "rows": st.layer_rows.iter().map(|i| i.0).collect::>() })) } fn set_props(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("layer.setProps", "missing id"))?; + const C: &str = "layer.setProps"; + check_options(C, p)?; + let ids = rows_param(s, p)?; + if ids.is_empty() { + return Err(bad(C, "no rows")); + } s.edit("Layer Options", |d, sel| { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - if let Some(v) = str_param(p, "name") { - n.name = Some(v.to_string()); - } - if let Some(v) = p.get("visible").and_then(Value::as_bool) { - n.visible = v; - } - if let Some(v) = p.get("locked").and_then(Value::as_bool) { - n.locked = v; - } - if let NodeKind::Layer { template, printable, color, .. } = &mut n.kind { - if let Some(v) = p.get("template").and_then(Value::as_bool) { - *template = v; - } - if let Some(v) = p.get("printable").and_then(Value::as_bool) { - *printable = v; - } - if let Some(v) = p.get("color").and_then(Value::as_u64) { - *color = LayerColor::Preset((v % 27) as u8); - } + for id in &ids { + apply_options(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, p); } // Hidden or locked content can't stay selected. let hidden: Vec = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect(); @@ -242,37 +560,166 @@ fn set_props(s: &mut Session, p: &Value) -> Result { ok() } -fn collect(s: &mut Session, _: &Value) -> Result { - let ids = super::edit::selected_roots(s)?; - let lid = s.edit("Collect in New Layer", |d, _| { - let lid = d.add_layer(None); +fn collect(s: &mut Session, p: &Value) -> Result { + const C: &str = "layer.collectInNew"; + let ids = match ids_param(p, "ids") { + Some(ids) => ids, + None => { + let rows = s.doc()?.highlighted_rows(); + if rows.is_empty() { super::edit::selected_roots(s)? } else { rows } + } + }; + let ids = row_roots(&s.doc()?.doc, &ids); + let Some(top) = ids.last().copied() else { return Err(bad(C, "select rows or objects to collect")) }; + let lid = s.edit("Collect in New Layer", |d, sel| { + // Where the topmost of them is: beside it in its layer, or on top of the layer holding + // its group. + let (parent, index) = match d.position(top) { + Some((par, i, _)) if par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer)) => (par, i + 1), + _ => (d.layer_containing(top), usize::MAX), + }; + let layer = make_layer(d, None); + let lid = d.insert(parent, index, layer)?; for id in &ids { d.move_node(*id, Some(lid), usize::MAX)?; } + sel.prune(d); Ok(lid) })?; + let st = s.doc_mut()?; + st.active_layer = Some(lid); + st.layer_rows = vec![lid]; Ok(json!({ "id": lid.0 })) } -fn select_all_on(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("layer.selectAll", "missing id"))?; - let ids: Vec = s - .doc()? - .doc - .node(id) - .and_then(|n| n.children()) - .map(|c| c.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect()) - .unwrap_or_default(); - s.select(|_, sel| sel.set(ids.iter().copied()))?; - ok() +/// The selection column: select a row's art. +fn select_art(s: &mut Session, p: &Value) -> Result { + const C: &str = "layer.selectAll"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let add = bool_or(p, "add", false); + let st = s.doc()?; + let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; + let ids: Vec = if !st.doc.is_editable(id) { + vec![] + } else if n.is_layer() { + n.layer_art(true) + } else { + vec![id] + }; + let all_selected = !ids.is_empty() && ids.iter().all(|i| st.selection.contains(*i)); + let rows = st.layer_rows.clone(); + s.select(|_, sel| { + if !add { + sel.set(ids.iter().copied()); + } else if all_selected { + for i in &ids { + sel.remove(*i); + } + } else { + for i in &ids { + sel.add(*i); + } + } + })?; + let st = s.doc_mut()?; + // The selection column leaves the highlighted rows as they are. + st.layer_rows = rows; + Ok(json!({ "selected": st.selection.objects.iter().map(|i| i.0).collect::>() })) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Place { + Above, + Below, + Inside, +} + +/// Drag rows in the Layers panel. +fn move_rows(s: &mut Session, p: &Value) -> Result { + const C: &str = "layer.move"; + let mut ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?; + ids.truncate(MAX_ROWS); + let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?; + let place = match str_param(p, "place").unwrap_or("inside") { + "above" => Place::Above, + "below" => Place::Below, + "inside" | "into" => Place::Inside, + other => return Err(bad(C, format!("place is above, below or inside, not `{other}`"))), + }; + let copy = bool_or(p, "copy", false); + let st = s.doc()?; + let d = &st.doc; + d.node(target).ok_or(EngineError::NoNode(target))?; + let ids = row_roots(d, &ids); + if ids.is_empty() { + return Err(bad(C, "no such rows")); + } + // Never into itself or a row inside it. + let around = d.ancestry(target).unwrap_or_default(); + if ids.iter().any(|i| around.contains(i)) { + return Err(bad(C, "a row can't go into itself")); + } + let nodes: Vec<&Node> = ids.iter().filter_map(|i| d.node(*i)).collect(); + let (parent, anchor) = match place { + Place::Inside => (Some(target), None), + _ => match d.parent_of(target) { + // Objects can't be at the top level: beside a top-level layer means in it. + None if nodes.iter().any(|n| !n.is_layer()) => (Some(target), None), + parent => (parent, Some(target)), + }, + }; + if let Some(n) = nodes.iter().find(|n| !accepts(d, parent, n)) { + let into = parent.and_then(|p| d.node(p)).map_or("the top level", |p| if p.is_layer() { "a layer" } else { p.kind_label() }); + return Err(bad(C, format!("{} can't go into {into}", n.display_name()))); + } + if parent.is_some_and(|p| locked_within(d, p)) { + return Err(EngineError::Other("the layer or group is locked".into())); + } + let label = if copy { "Duplicate Rows" } else { "Move Rows" }; + let moved = s.edit(label, |d, sel| { + let mut nodes = vec![]; + for id in &ids { + nodes.push(if copy { + let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; + d.reid(&n) + } else { + Arc::unwrap_or_clone(d.remove(*id)?) + }); + } + let count = d.children(parent).map_or(0, Vec::len); + let mut index = match anchor { + None => count, + Some(a) => { + let (_, i, _) = d.position(a).ok_or(EngineError::NoNode(a))?; + if place == Place::Above { i + 1 } else { i } + } + }; + // The clipping path stays at the bottom of a clip group or a clipping layer. + if count > 0 && parent.and_then(|p| d.node(p)).is_some_and(Node::clips) { + index = index.max(1); + } + let mut out = vec![]; + for n in nodes { + out.push(d.insert(parent, index, n)?); + index += 1; + } + sel.prune(d); + Ok(out) + })?; + let st = s.doc_mut()?; + st.layer_rows = moved.clone(); + Ok(json!({ "ids": moved.iter().map(|i| i.0).collect::>() })) } fn move_node(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("node.move", "missing id"))?; + const C: &str = "node.move"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; let parent = id_param(p, "parent"); - let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX) as usize; - if parent.is_none() && !s.doc()?.doc.node(id).is_some_and(|n| n.is_layer()) { - return Err(bad("node.move", "only layers can be at the top level")); + let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX).min(usize::MAX as u64) as usize; + let d = &s.doc()?.doc; + let n = d.node(id).ok_or(EngineError::NoNode(id))?; + if !accepts(d, parent, n) { + return Err(bad(C, "layers go only at the top level or in layers, objects in layers and groups")); } s.edit("Reorder", |d, _| Ok(d.move_node(id, parent, index)?))?; ok() diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs new file mode 100644 index 00000000..a655da4b --- /dev/null +++ b/crates/engine/src/cmd/layerpanel.rs @@ -0,0 +1,429 @@ +//! The Layers panel menu's operations on rows: Merge Selected, Flatten Artwork, Release to Layers, +//! Reverse Order, Template, Hide/Outline/Lock Others and their Show/Preview/Unlock All +//! counterparts, Locate Object. Rows default to the highlighted ones, else the current layer (see +//! [`super::layer`]). + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; + +use super::layer::{make_layer, row_roots, rows_param}; +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!( + "layer.merge", + "Merge Selected", + ["Window", "Layers"], + None, + "{ids?} (default: the highlighted rows) merge two or more rows into the layer listed (highlighted) last: the other layers' contents and the other rows move into it, keeping their stacking order, and the emptied layers go. One undo step → {id}", + has_doc, + merge + ), + cmd!( + "layer.flatten", + "Flatten Artwork", + ["Window", "Layers"], + None, + "{id?} (default: the highlighted row's top-level layer, else the current one's) move the art of every other visible top-level layer into this one, keeping the stacking order; hidden layers and their art are deleted, template layers stay. One undo step → {id, discarded}", + has_doc, + flatten + ), + cmd!( + "layer.releaseToLayers", + "Release to Layers (Sequence)", + ["Window", "Layers"], + None, + "{id?: layer or group (default: the highlighted row, else the current layer), build?: bool} each object in it goes into a new sublayer of its own, in stacking order (a group's objects go into sublayers of its layer, where the group was). `build`: Release to Layers (Build), each new sublayer holds copies of the objects up to its own, so the layers add up. One undo step → {ids}", + has_doc, + release + ), + cmd!( + "layer.releaseToLayersBuild", + "Release to Layers (Build)", + ["Window", "Layers"], + None, + "{id?} layer.releaseToLayers with build: true → {ids}", + has_doc, + |s, p| { + let mut p = p.clone(); + if let Some(m) = p.as_object_mut() { + m.insert("build".into(), json!(true)); + } else { + p = json!({"build": true}); + } + release(s, &p) + } + ), + cmd!( + "layer.reverse", + "Reverse Order", + ["Window", "Layers"], + None, + "{ids?} (default: the highlighted rows) reverse the stacking order of two or more rows that share a layer or group; each keeps the places the others held. One undo step", + has_doc, + reverse + ), + cmd!( + "layer.template", + "Template", + ["Window", "Layers"], + None, + "{ids?, on?: bool (default: toggle the first)} make layers templates (locked, dimmed, left out of print and export) or ordinary layers again (layer.setProps template) → {on}", + has_doc, + template + ), + cmd!( + "layer.hideOthers", + "Hide Others", + ["Window", "Layers"], + None, + "{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer). One undo step → {count}", + has_doc, + |s, p| others(s, p, Flag::Visible, false) + ), + cmd!("layer.showAll", "Show All Layers", ["Window", "Layers"], None, "{} show every layer and sublayer → {count}", has_doc, |s, _| all( + s, + Flag::Visible, + true + )), + cmd!( + "layer.outlineOthers", + "Outline Others", + ["Window", "Layers"], + None, + "{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer) → {count}", + has_doc, + |s, p| others(s, p, Flag::Preview, false) + ), + cmd!( + "layer.previewAll", + "Preview All Layers", + ["Window", "Layers"], + None, + "{} turn Preview on for every layer and sublayer → {count}", + has_doc, + |s, _| all(s, Flag::Preview, true) + ), + cmd!( + "layer.lockOthers", + "Lock Others", + ["Window", "Layers"], + None, + "{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer) → {count}", + has_doc, + |s, p| others(s, p, Flag::Locked, true) + ), + cmd!( + "layer.unlockAll", + "Unlock All Layers", + ["Window", "Layers"], + None, + "{} unlock every layer and sublayer → {count}", + has_doc, + |s, _| all(s, Flag::Locked, false) + ), + cmd!( + "layer.locate", + "Locate Object", + ["Window", "Layers"], + None, + "{id?} (default: the last selected object) highlight the object's row; the Layers panel opens the layers around it and scrolls to it → {id, ancestry: [layer…, id]}", + has_doc, + locate + ), + ] +} + +/// A layer flag the panel menu sets on many layers. +#[derive(Clone, Copy)] +enum Flag { + Visible, + Locked, + Preview, +} + +impl Flag { + fn get(self, n: &Node) -> bool { + match (self, &n.kind) { + (Flag::Visible, _) => n.visible, + (Flag::Locked, _) => n.locked, + (Flag::Preview, NodeKind::Layer { preview, .. }) => *preview, + (Flag::Preview, _) => true, + } + } + fn set(self, n: &mut Node, v: bool) { + match (self, &mut n.kind) { + (Flag::Visible, _) => n.visible = v, + (Flag::Locked, _) => n.locked = v, + (Flag::Preview, NodeKind::Layer { preview, .. }) => *preview = v, + (Flag::Preview, _) => {} + } + } + fn label(self, on: bool) -> &'static str { + match (self, on) { + (Flag::Visible, true) => "Show All Layers", + (Flag::Visible, false) => "Hide Others", + (Flag::Locked, true) => "Lock Others", + (Flag::Locked, false) => "Unlock All Layers", + (Flag::Preview, true) => "Preview All Layers", + (Flag::Preview, false) => "Outline Others", + } + } +} + +/// Set `flag` to `v` on `ids` (layers that differ) in one undo step labelled `label` → how many +/// changed. +fn set_flag(s: &mut Session, ids: Vec, flag: Flag, v: bool, label: &str) -> Result { + let d = &s.doc()?.doc; + let ids: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| flag.get(n) != v)).collect(); + if !ids.is_empty() { + s.edit(label, |d, sel| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + flag.set(n, v); + } + } + // Hidden or locked art can't stay selected. + let out: Vec = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect(); + for o in out { + sel.remove(o); + } + Ok(()) + })?; + } + Ok(json!({ "count": ids.len() })) +} + +/// The top-level layers holding the rows a command acts on. +fn kept_layers(s: &Session, p: &Value) -> Result> { + let ids = rows_param(s, p)?; + let d = &s.doc()?.doc; + Ok(ids.iter().filter_map(|id| d.layer_of(*id)).collect()) +} + +fn others(s: &mut Session, p: &Value, flag: Flag, v: bool) -> Result { + let keep = kept_layers(s, p)?; + let ids: Vec = s.doc()?.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect(); + set_flag(s, ids, flag, v, flag.label(v)) +} + +fn all(s: &mut Session, flag: Flag, v: bool) -> Result { + let mut ids = vec![]; + s.doc()?.doc.walk(|n| { + if n.is_layer() { + ids.push(n.id); + } + }); + set_flag(s, ids, flag, v, flag.label(v)) +} + +fn template(s: &mut Session, p: &Value) -> Result { + let ids = rows_param(s, p)?; + let d = &s.doc()?.doc; + let ids: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(Node::is_layer)).collect(); + let Some(first) = ids.first().and_then(|id| d.node(*id)) else { return Err(bad("layer.template", "select a layer")) }; + let on = bool_or(p, "on", !first.is_template()); + s.execute("layer.setProps", &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "template": on}))?; + Ok(json!({ "on": on })) +} + +/// Move the contents of `others` (layers give their children, anything else itself; none inside +/// another) into layer `into`, keeping the document's stacking order, then delete the emptied +/// layers. +fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { + let own: Vec = + d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !others.contains(c)).collect()).unwrap_or_default(); + let mut moved = vec![]; + for id in others { + let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; + if n.is_layer() { + moved.extend(n.children().into_iter().flatten().map(|c| c.id)); + } else { + moved.push(*id); + } + } + // Where everything is now: the merged layer stacks its contents in this order. + let keys: HashMap> = own.iter().chain(&moved).filter_map(|id| Some((*id, d.index_path(*id)?))).collect(); + let mut taken = vec![]; + for id in &moved { + taken.push(d.remove(*id)?); + } + for id in others { + if d.node(*id).is_some() { + d.remove(*id)?; + } + } + let ch = d.children_mut(Some(into))?; + let mut all = std::mem::take(ch); + all.extend(taken); + all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id))); + *ch = all; + Ok(()) +} + +fn merge(s: &mut Session, p: &Value) -> Result { + const C: &str = "layer.merge"; + let ids = rows_param(s, p)?; + let st = s.doc()?; + let d = &st.doc; + let mut seen = HashSet::new(); + let ids: Vec = ids.into_iter().filter(|id| d.node(*id).is_some() && seen.insert(*id)).collect(); + if ids.len() < 2 { + return Err(bad(C, "highlight two or more rows to merge")); + } + let into = + ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?; + let around = d.ancestry(into).unwrap_or_default(); + // (Rows inside the target layer merge too: only rows inside other listed rows go with those.) + let rest: Vec = ids.iter().copied().filter(|id| *id != into).collect(); + let others = row_roots(d, &rest); + if others.iter().any(|o| around.contains(o)) { + return Err(bad(C, "a layer can't be merged into its own sublayer")); + } + s.edit("Merge Selected", |d, sel| { + merge_into(d, into, &others)?; + sel.prune(d); + Ok(()) + })?; + let st = s.doc_mut()?; + st.active_layer = Some(into); + st.layer_rows = vec![into]; + Ok(json!({ "id": into.0 })) +} + +fn flatten(s: &mut Session, p: &Value) -> Result { + const C: &str = "layer.flatten"; + let st = s.doc()?; + let d = &st.doc; + let from = id_param(p, "id").or_else(|| st.highlighted_rows().last().copied()).or(st.current_layer()); + let into = from.and_then(|id| d.layer_of(id)).ok_or_else(|| bad(C, "no layer"))?; + if !d.node(into).is_some_and(|n| n.visible) { + return Err(EngineError::Other("the layer to flatten into is hidden".into())); + } + let others: Vec = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect(); + let hidden: Vec = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect(); + s.edit("Flatten Artwork", |d, sel| { + for h in &hidden { + d.remove(*h)?; + } + merge_into(d, into, &others)?; + sel.prune(d); + Ok(()) + })?; + let st = s.doc_mut()?; + st.active_layer = Some(into); + st.layer_rows = vec![into]; + Ok(json!({ "id": into.0, "discarded": hidden.len() })) +} + +fn release(s: &mut Session, p: &Value) -> Result { + const C: &str = "layer.releaseToLayers"; + let build = bool_or(p, "build", false); + let st = s.doc()?; + let id = match id_param(p, "id") { + Some(id) => id, + None => match st.highlighted_rows().as_slice() { + [id] => *id, + _ => st.current_layer().ok_or_else(|| bad(C, "no layer"))?, + }, + }; + let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; + let group = match n.kind { + NodeKind::Layer { .. } => false, + NodeKind::Group { .. } => true, + _ => return Err(bad(C, "release a layer or a group")), + }; + // Its objects, bottom first (sublayers stay as they are). + let objects: Vec = n.children().into_iter().flatten().filter(|c| !c.is_layer()).map(|c| c.id).collect(); + if objects.is_empty() { + return Err(EngineError::Other("there are no objects to release".into())); + } + let new = s.edit(if build { "Release to Layers (Build)" } else { "Release to Layers (Sequence)" }, |d, sel| { + // A group's objects go into sublayers of the layer holding it, where it was. + let (parent, mut index) = if group { + let layer = d.layer_containing(id).ok_or(EngineError::NoNode(id))?; + let (par, i, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + if par == Some(layer) { (layer, i) } else { (layer, usize::MAX) } + } else { + (id, 0) + }; + let mut originals = vec![]; + for o in &objects { + originals.push(Arc::unwrap_or_clone(d.remove(*o)?)); + } + if group { + d.remove(id)?; + } + let mut new = vec![]; + let last = originals.len().saturating_sub(1); + for k in 0..originals.len() { + let mut layer = make_layer(d, None); + let lid = layer.id; + let held: Vec = if build { + // Copies of the objects below and itself; the top layer keeps the originals. + (0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { d.reid(o) }).collect() + } else { + originals.get(k).cloned().into_iter().collect() + }; + if let Some(ch) = layer.children_mut() { + ch.extend(held.into_iter().map(Arc::new)); + } + let at = if group { index } else { index.min(d.children(Some(parent)).map_or(0, Vec::len)) }; + d.insert(Some(parent), at, layer)?; + index = at.saturating_add(1); + new.push(lid); + } + sel.prune(d); + Ok(new) + })?; + s.doc_mut()?.layer_rows = new.clone(); + Ok(json!({ "ids": new.iter().map(|i| i.0).collect::>() })) +} + +fn reverse(s: &mut Session, p: &Value) -> Result { + const C: &str = "layer.reverse"; + let ids = rows_param(s, p)?; + let d = &s.doc()?.doc; + let ids = row_roots(d, &ids); + // Rows grouped by the layer or group holding them (None: top-level layers). + let mut by_parent: Vec<(Option, Vec)> = vec![]; + for id in &ids { + let Some((par, i, _)) = d.position(*id) else { continue }; + match by_parent.iter_mut().find(|(p, _)| *p == par) { + Some((_, v)) => v.push(i), + None => by_parent.push((par, vec![i])), + } + } + by_parent.retain(|(_, v)| v.len() >= 2); + if by_parent.is_empty() { + return Err(bad(C, "highlight two or more rows in one layer or group")); + } + s.edit("Reverse Order", |d, _| { + for (par, mut slots) in by_parent { + slots.sort_unstable(); + let ch = d.children_mut(par)?; + let nodes: Vec> = slots.iter().filter_map(|i| ch.get(*i).cloned()).collect(); + for (slot, n) in slots.iter().zip(nodes.into_iter().rev()) { + if let Some(c) = ch.get_mut(*slot) { + *c = n; + } + } + } + Ok(()) + })?; + ok() +} + +fn locate(s: &mut Session, p: &Value) -> Result { + let st = s.doc_mut()?; + let id = id_param(p, "id").or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?; + let ancestry = st.doc.ancestry(id).ok_or(EngineError::NoNode(id))?; + st.layer_rows = vec![id]; + st.revision += 1; + Ok(json!({ "id": id.0, "ancestry": ancestry.iter().map(|i| i.0).collect::>() })) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d61365d6..623f757f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -30,6 +30,7 @@ pub(crate) mod graph; mod halftone; pub mod help; mod layer; +mod layerpanel; pub mod links; mod live; pub(crate) mod maskedit; @@ -191,6 +192,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(gradient::specs()); v.extend(opacitymask::specs()); v.extend(layer::specs()); + v.extend(layerpanel::specs()); v.extend(draw2::specs()); v.extend(xform::specs()); v.extend(effectcmd::specs()); diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index c906c68a..106a94c0 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -97,17 +97,12 @@ pub fn specs() -> Vec { } /// Selectable objects: children of visible, unlocked layers (or of the isolation container). +/// The objects Select All takes: those of the isolated container, else of every layer (looking +/// through sublayers, which are not objects). fn selectable(d: &Document, iso: Option) -> Vec { match iso.and_then(|i| d.node(i)) { - Some(c) => c.children().map(|v| v.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect()).unwrap_or_default(), - None => d - .layers - .iter() - .filter(|l| l.visible && !l.locked) - .flat_map(|l| l.children().into_iter().flatten()) - .filter(|n| n.visible && !n.locked) - .map(|n| n.id) - .collect(), + Some(c) => c.layer_art(true), + None => d.selectable_art(), } } @@ -250,7 +245,11 @@ fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result Reselect repeats `cmd` with `p`. fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result { fn visit(d: &Document, n: &Node, f: &impl Fn(&Document, &Node) -> bool, ids: &mut Vec) { - if n.visible && !n.locked && !n.is_layer() && f(d, n) { + // Hidden or locked objects and sublayers (and what they hold) are out of reach. + if !n.visible || n.locked || n.is_template() { + return; + } + if !n.is_layer() && f(d, n) { return ids.push(n.id); } for c in n.children().into_iter().flatten() { @@ -307,14 +306,9 @@ fn same_attribute(s: &mut Session, p: &Value) -> Result { fn same_layers(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; - let layers: BTreeSet = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect(); - let ids: Vec = layers - .iter() - .filter_map(|l| st.doc.node(*l)) - .flat_map(|l| l.children().into_iter().flatten()) - .filter(|n| n.visible && !n.locked) - .map(|n| n.id) - .collect(); + // The layers or sublayers the selected objects are on (art in their sublayers too). + let layers: BTreeSet = st.selection.objects.iter().filter_map(|id| st.doc.layer_containing(*id)).collect(); + let ids: Vec = layers.iter().filter_map(|l| st.doc.node(*l)).flat_map(|l| l.layer_art(true)).collect(); s.select(|_, sel| sel.set(ids.iter().copied()))?; ok() } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 55713279..c48339ff 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -52,6 +52,15 @@ pub fn node_summary(n: &Node) -> Value { v["strokeOptions"] = stroke_options(&st); } match &n.kind { + NodeKind::Layer { color, template, printable, preview, dim_images, clip, .. } => { + let [r, g, b] = color.rgb(); + v["color"] = json!(format!("#{r:02x}{g:02x}{b:02x}")); + v["template"] = json!(template); + v["printable"] = json!(printable); + v["preview"] = json!(preview); + v["dimImages"] = json!(dim_images); + v["clip"] = json!(clip); + } NodeKind::Path { path, .. } => { v["anchors"] = json!(path.anchor_count()); v["closed"] = json!(path.is_closed()); @@ -79,6 +88,8 @@ pub fn document(s: &Session) -> Value { // Top of the stack first, like the Layers panel. "layers": d.layers.iter().rev().map(|l| node_summary(l)).collect::>(), "currentLayer": st.active_layer.map(|l| l.0), + // The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`). + "layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::>(), "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)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cf9ae1bc..59da0fde 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -102,6 +102,11 @@ pub struct DocState { saved_doc: Arc, /// The layer new art goes into (the "current layer" in the Layers panel). pub active_layer: Option, + /// The rows highlighted in the Layers panel (layers, sublayers, groups or objects, in the order + /// they were clicked): what the panel's Duplicate, Delete, Merge, Options… and similar act on. + /// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`, + /// `layer.highlight`). + pub layer_rows: Vec, /// Isolation mode container. pub isolation: Option, pub interaction: Option, @@ -156,6 +161,7 @@ impl DocState { path, revision: 1, active_layer, + layer_rows: vec![], isolation: None, interaction: None, last_transform: None, @@ -236,7 +242,13 @@ impl DocState { { return Some(i); } - self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer() && !n.locked)).or_else(|| self.doc.default_layer()) + // A sublayer takes new art only while it and the layers around it are shown and unlocked. + self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer()) + } + /// The highlighted Layers panel rows that still exist (ids are reused after undo, so a + /// remembered row must still be in the document). + pub fn highlighted_rows(&self) -> Vec { + self.layer_rows.iter().copied().filter(|id| self.doc.node(*id).is_some()).collect() } /// The object whose opacity mask View Opacity Mask shows ([`DocState::mask_view`]): only while /// its mask is being edited, so leaving editing by any route (undo, deleting the object) ends it. @@ -1069,8 +1081,18 @@ impl Session { pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> { self.active_appearance_item = None; let st = self.doc_mut()?; + let before = st.selection.objects.clone(); f(&st.doc, &mut st.selection); st.selection.prune(&st.doc); + // Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of + // the reference app: new art then goes beside it. + if st.selection.objects != before + && let Some(layer) = st.selection.objects.last().and_then(|id| st.doc.layer_containing(*id)).filter(|l| st.doc.is_editable(*l)) + && st.active_layer != Some(layer) + { + st.active_layer = Some(layer); + st.layer_rows.clear(); + } st.revision += 1; // Puppet Warp pins belong to the art they were placed on: another selection starts afresh. if st.doc.puppet.as_ref().is_some_and(|p| p.ids != st.selection.objects) { @@ -1267,6 +1289,8 @@ mod tests_labspots; #[cfg(test)] mod tests_layerclip; #[cfg(test)] +mod tests_layers; +#[cfg(test)] mod tests_linked_stops; #[cfg(test)] mod tests_links; diff --git a/crates/engine/src/tests_layerclip.rs b/crates/engine/src/tests_layerclip.rs index 074be859..fd79992b 100644 --- a/crates/engine/src/tests_layerclip.rs +++ b/crates/engine/src/tests_layerclip.rs @@ -115,8 +115,8 @@ fn the_top_object_must_be_able_to_clip() { assert_eq!(layer(&s), before); // An explicit id picks the layer even with a group selected. let l = before.id; - s.execute("layer.newSublayer", &json!({"parent": l.0})).unwrap(); - let sub = layer(&s).children().unwrap().last().unwrap().id; + let sub = id_of(&s.execute("layer.newSublayer", &json!({"parent": l.0})).unwrap()); + assert_eq!(s.doc().unwrap().doc.node(l).unwrap().children().unwrap().last().unwrap().id, sub, "on top of the layer's contents"); let err = s.execute("layer.clippingMask.toggle", &json!({"id": sub.0})); assert!(err.is_err(), "an empty sublayer has nothing to clip by"); } diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs new file mode 100644 index 00000000..26ea99f4 --- /dev/null +++ b/crates/engine/src/tests_layers.rs @@ -0,0 +1,454 @@ +//! The Layers panel's commands: rows at every depth (layers, sublayers, groups, objects) are +//! clickable, highlighted and targeted; their art is selected through the selection column; rows +//! move, copy and reparent with validation; and the panel menu's operations (merge, flatten, +//! release to layers, reverse, template, others/all) are single undo steps. + +use serde_json::{Value, json}; +use vectorcraft_doc::{LayerColor, Node, NodeId, NodeKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + id(&s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap()) +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn parent(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().doc.parent_of(id) +} + +fn children(s: &Session, id: NodeId) -> Vec { + node(s, id).children().unwrap().iter().map(|c| c.id).collect() +} + +fn undos(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Layer 1 holding a sublayer `sub` with a rectangle `a` and a group `g` of rectangle `b`, plus a +/// rectangle `c` on Layer 1 itself → (layer, sub, a, g, b, c). Nothing selected. +fn tree(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId, NodeId, NodeId) { + let layer = s.doc().unwrap().doc.layers[0].id; + let c = rect(s, 300.0); + let sub = id(&run(s, "layer.newSublayer", json!({"name": "Sub"}))); + let a = rect(s, 0.0); + let b = rect(s, 100.0); + run(s, "select.set", json!({"ids": [b.0]})); + let g = id(&run(s, "object.group", json!({}))); + run(s, "select.none", json!({})); + (layer, sub, a, g, b, c) +} + +#[test] +fn art_in_a_sublayer_is_selected_as_objects_not_the_sublayer() { + let mut s = session(); + let (_, sub, a, g, _, c) = tree(&mut s); + assert_eq!(parent(&s, a), Some(sub), "new art went into the current sublayer"); + let n = run(&mut s, "select.all", json!({}))["count"].as_u64().unwrap(); + assert_eq!(n, 3); + let sel = s.doc().unwrap().selection.objects.clone(); + assert!(sel.contains(&a) && sel.contains(&g) && sel.contains(&c) && !sel.contains(&sub), "{sel:?}"); + // The Selection tool's click takes the object in the sublayer. + let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, vectorcraft_geom::Point::new(10.0, 10.0), Default::default()).unwrap(); + assert_eq!(h.top_object(None), a); + // Deleting it works (layers were skipped by Clear before). + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "edit.clear", json!({})); + assert!(s.doc().unwrap().doc.node(a).is_none()); +} + +#[test] +fn clicking_rows_at_any_depth_highlights_them_and_sets_the_current_layer() { + let mut s = session(); + let (layer, sub, a, g, b, c) = tree(&mut s); + let before = undos(&s); + for (row, current) in [(layer, layer), (sub, sub), (g, sub), (b, sub), (a, sub), (c, layer)] { + let r = run(&mut s, "layer.setCurrent", json!({"id": row.0})); + assert_eq!(NodeId(r["layer"].as_u64().unwrap()), current); + let st = s.doc().unwrap(); + assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![row], Some(current))); + } + assert_eq!(undos(&s), before, "clicking rows is not an undo step"); + // New art goes into the current sublayer. + run(&mut s, "layer.setCurrent", json!({"id": a.0})); + let n = rect(&mut s, 50.0); + assert_eq!(parent(&s, n), Some(sub)); + assert!(s.execute("layer.setCurrent", &json!({"id": 9999})).is_err()); + // Shift/Ctrl: several rows. + run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0], "mode": "set"})); + run(&mut s, "layer.highlight", json!({"ids": [c.0], "mode": "add"})); + run(&mut s, "layer.highlight", json!({"ids": [a.0], "mode": "toggle"})); + let st = s.doc().unwrap(); + assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![g, c], Some(layer))); + assert!(s.execute("layer.highlight", &json!({"ids": [a.0], "mode": "nope"})).is_err()); + assert_eq!(document_rows(&s), vec![g.0, c.0], "document.inspect lists the rows"); +} + +fn document_rows(s: &Session) -> Vec { + crate::inspect::document(s)["layerRows"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() +} + +#[test] +fn selecting_art_makes_its_layer_current() { + let mut s = session(); + let (layer, sub, a, _, _, c) = tree(&mut s); + run(&mut s, "select.set", json!({"ids": [c.0]})); + assert_eq!(s.doc().unwrap().current_layer(), Some(layer)); + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert_eq!(s.doc().unwrap().current_layer(), Some(sub)); + let n = rect(&mut s, 200.0); + assert_eq!(parent(&s, n), Some(sub)); + // A locked sublayer takes no new art: it goes to the top layer. + run(&mut s, "select.none", json!({})); + run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": true})); + run(&mut s, "layer.setCurrent", json!({"id": sub.0})); + let n = rect(&mut s, 200.0); + assert_eq!(parent(&s, n), Some(layer)); +} + +#[test] +fn the_selection_column_selects_a_rows_art_and_shift_adds_or_removes_it() { + let mut s = session(); + let (layer, sub, a, g, _, c) = tree(&mut s); + run(&mut s, "layer.setCurrent", json!({"id": sub.0})); + let sel = |s: &Session| s.doc().unwrap().selection.objects.clone(); + run(&mut s, "layer.selectAll", json!({"id": layer.0})); + assert_eq!(sel(&s), vec![c, a, g], "the layer's art, its sublayer's too"); + run(&mut s, "layer.selectAll", json!({"id": sub.0})); + assert_eq!(sel(&s), vec![a, g]); + run(&mut s, "layer.selectAll", json!({"id": c.0, "add": true})); + assert_eq!(sel(&s), vec![a, g, c]); + run(&mut s, "layer.selectAll", json!({"id": sub.0, "add": true})); + assert_eq!(sel(&s), vec![c], "all selected already: removed"); + assert_eq!(s.doc().unwrap().highlighted_rows(), vec![sub], "the highlighted row stays"); + // Locked rows select nothing. + run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": true})); + run(&mut s, "layer.selectAll", json!({"id": a.0})); + assert!(sel(&s).is_empty()); + // Targeting a layer selects its sublayers' art too. + run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": false})); + assert_eq!(ids_sel(&run(&mut s, "layer.target", json!({"id": layer.0}))), vec![c, a, g]); +} + +fn ids_sel(v: &Value) -> Vec { + v["selected"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +#[test] +fn visibility_and_lock_per_row_are_undoable() { + let mut s = session(); + let (_, sub, a, g, b, _) = tree(&mut s); + run(&mut s, "select.set", json!({"ids": [a.0, g.0]})); + let before = undos(&s); + run(&mut s, "layer.setProps", json!({"ids": [b.0, sub.0], "visible": false})); + assert!(!node(&s, b).visible && !node(&s, sub).visible); + assert!(s.doc().unwrap().selection.is_empty(), "hidden art is deselected"); + assert_eq!(undos(&s), before + 1, "one step for several rows"); + run(&mut s, "edit.undo", json!({})); + assert!(node(&s, b).visible && node(&s, sub).visible); + run(&mut s, "layer.setProps", json!({"id": a.0, "locked": true, "name": "Box"})); + assert!(node(&s, a).locked); + assert_eq!(node(&s, a).display_name(), "Box"); + run(&mut s, "layer.setProps", json!({"id": a.0, "name": ""})); + assert_eq!(node(&s, a).display_name(), "", "an empty name goes back to the generated one"); + run(&mut s, "layer.setProps", json!({"id": sub.0, "name": ""})); + assert_eq!(node(&s, sub).display_name(), "Sub", "a layer keeps its name"); +} + +#[test] +fn layer_options_validate_and_template_locks_and_dims() { + let mut s = session(); + let (layer, ..) = tree(&mut s); + for bad in [json!({"color": 27}), json!({"color": "#zz"}), json!({"color": [1]}), json!({"locked": "yes"}), json!({"dimImages": "half"})] { + let mut p = bad.clone(); + p["id"] = json!(layer.0); + assert!(s.execute("layer.setProps", &p).is_err(), "{bad}"); + } + let color = |s: &Session| match node(s, layer).kind { + NodeKind::Layer { color, .. } => color, + _ => unreachable!(), + }; + run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "red"})); + assert_eq!(color(&s), LayerColor::Preset(1)); + run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "#123456"})); + assert_eq!(color(&s), LayerColor::Custom([0x12, 0x34, 0x56])); + run(&mut s, "layer.setProps", json!({"id": layer.0, "color": 4})); + assert_eq!(color(&s), LayerColor::Preset(4)); + run(&mut s, "layer.template", json!({"ids": [layer.0]})); + let n = node(&s, layer); + assert!(n.is_template() && n.locked); + assert!(matches!(n.kind, NodeKind::Layer { dim_images: Some(50), .. })); + run(&mut s, "layer.template", json!({"ids": [layer.0]})); + let n = node(&s, layer); + assert!(!n.is_template() && !n.locked); + assert!(matches!(n.kind, NodeKind::Layer { dim_images: None, .. })); + run(&mut s, "layer.setProps", json!({"id": layer.0, "preview": false, "dimImages": 30, "printable": false})); + assert!(matches!(node(&s, layer).kind, NodeKind::Layer { preview: false, dim_images: Some(30), printable: false, .. })); + let summary = &crate::inspect::document(&s)["layers"][0]; + assert_eq!((summary["preview"].clone(), summary["dimImages"].clone()), (json!(false), json!(30))); +} + +#[test] +fn new_layers_go_above_the_current_one_at_its_level() { + let mut s = session(); + let (layer, sub, _, g, ..) = tree(&mut s); + run(&mut s, "layer.setCurrent", json!({"id": sub.0})); + let n = id(&run(&mut s, "layer.new", json!({}))); + assert_eq!(parent(&s, n), Some(layer), "a sublayer beside the current sublayer"); + assert_eq!(children(&s, layer).iter().position(|c| *c == n), children(&s, layer).iter().position(|c| *c == sub).map(|i| i + 1)); + assert_eq!(s.doc().unwrap().highlighted_rows(), vec![n]); + let top = id(&run(&mut s, "layer.new", json!({"top": true, "name": "Top", "color": "green", "locked": true}))); + let st = s.doc().unwrap(); + assert_eq!(st.doc.layers.last().unwrap().id, top); + assert!(st.doc.layers.last().unwrap().locked); + // Sublayers only go into layers. + assert!(s.execute("layer.newSublayer", &json!({"parent": g.0})).is_err()); + let inner = id(&run(&mut s, "layer.newSublayer", json!({"parent": sub.0}))); + assert_eq!(parent(&s, inner), Some(sub)); + // Names count sublayers: no two layers share one. + let mut names = vec![]; + s.doc().unwrap().doc.walk(|n| { + if n.is_layer() { + names.push(n.display_name()); + } + }); + let unique: std::collections::HashSet<&String> = names.iter().collect(); + assert_eq!(unique.len(), names.len(), "{names:?}"); +} + +#[test] +fn rows_move_above_below_and_into_layers_and_groups() { + let mut s = session(); + let (layer, sub, a, g, b, c) = tree(&mut s); + let before = undos(&s); + // Into the group: on top of its contents. + run(&mut s, "layer.move", json!({"ids": [a.0], "target": g.0, "place": "inside"})); + assert_eq!(children(&s, g), vec![b, a]); + // Below b, in the group. + run(&mut s, "layer.move", json!({"ids": [a.0], "target": b.0, "place": "below"})); + assert_eq!(children(&s, g), vec![a, b]); + // Above the sublayer, out into Layer 1. + run(&mut s, "layer.move", json!({"ids": [a.0], "target": sub.0, "place": "above"})); + assert_eq!(parent(&s, a), Some(layer)); + assert_eq!(undos(&s), before + 3); + // Out of the layer into a new top-level layer: an object beside a top-level layer goes in it. + let l2 = id(&run(&mut s, "layer.new", json!({"top": true}))); + run(&mut s, "layer.move", json!({"ids": [c.0], "target": l2.0, "place": "above"})); + assert_eq!(parent(&s, c), Some(l2)); + // A sublayer becomes a top-level layer, and back. + run(&mut s, "layer.move", json!({"ids": [sub.0], "target": l2.0, "place": "above"})); + assert_eq!(parent(&s, sub), None); + assert_eq!(s.doc().unwrap().doc.layers.last().unwrap().id, sub); + run(&mut s, "layer.move", json!({"ids": [sub.0], "target": layer.0})); + assert_eq!(parent(&s, sub), Some(layer)); + // Several rows keep their stacking order. + run(&mut s, "layer.move", json!({"ids": [g.0, a.0], "target": l2.0})); + assert_eq!(children(&s, l2), vec![c, a, g]); + // Undo walks it back. + for _ in 0..4 { + run(&mut s, "edit.undo", json!({})); + } + assert_eq!(parent(&s, sub), Some(layer)); +} + +#[test] +fn moves_are_validated() { + let mut s = session(); + let (layer, sub, a, g, b, _) = tree(&mut s); + let doc_before = s.doc().unwrap().doc.clone(); + let bad = [ + json!({"ids": [sub.0], "target": g.0, "place": "inside"}), + json!({"ids": [layer.0], "target": a.0, "place": "inside"}), + json!({"ids": [layer.0], "target": sub.0, "place": "above"}), + json!({"ids": [sub.0], "target": sub.0}), + json!({"ids": [g.0], "target": b.0, "place": "inside"}), + json!({"ids": [a.0], "target": b.0, "place": "inside"}), + json!({"ids": [a.0], "target": 9999}), + json!({"ids": [9999], "target": a.0}), + json!({"ids": [a.0], "target": g.0, "place": "sideways"}), + json!({"ids": [], "target": g.0}), + json!({"target": g.0}), + ]; + for p in bad { + assert!(s.execute("layer.move", &p).is_err(), "{p}"); + } + // A locked group takes nothing. + run(&mut s, "layer.setProps", json!({"id": g.0, "locked": true})); + assert!(s.execute("layer.move", &json!({"ids": [a.0], "target": g.0})).is_err()); + run(&mut s, "edit.undo", json!({})); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &doc_before), "nothing changed"); + // node.move: layers only at the top level or in layers. + assert!(s.execute("node.move", &json!({"id": sub.0, "parent": g.0, "index": 0})).is_err()); + assert!(s.execute("node.move", &json!({"id": a.0, "index": 0})).is_err()); + assert!(s.execute("node.move", &json!({"id": layer.0, "parent": sub.0, "index": 0})).is_err()); + run(&mut s, "node.move", json!({"id": sub.0, "index": 5})); + assert_eq!(parent(&s, sub), None); +} + +#[test] +fn alt_drag_copies_rows_and_the_selected_art_moves_between_layers() { + let mut s = session(); + let (layer, sub, a, g, _, c) = tree(&mut s); + let count = s.doc().unwrap().doc.node_count(); + let copies = ids(&run(&mut s, "layer.move", json!({"ids": [sub.0], "target": layer.0, "place": "above", "copy": true}))); + assert_eq!(copies.len(), 1); + assert_eq!(s.doc().unwrap().doc.node_count(), count + 4, "the sublayer and its art (a, the group, b), copied"); + assert_eq!(parent(&s, copies[0]), None, "a copy of the sublayer at the top level"); + // Dragging the selected-art square: the selection moves into another layer. + run(&mut s, "select.set", json!({"ids": [a.0, g.0]})); + run(&mut s, "layer.move", json!({"ids": [a.0, g.0], "target": layer.0})); + assert_eq!(children(&s, layer), vec![c, sub, a, g]); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, g], "it stays selected"); +} + +#[test] +fn delete_and_duplicate_rows() { + let mut s = session(); + let (layer, sub, a, g, _, c) = tree(&mut s); + run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0]})); + let copies = ids(&run(&mut s, "layer.duplicate", json!({}))); + assert_eq!(copies.len(), 2); + assert_eq!(children(&s, sub).len(), 4); + assert_eq!(s.doc().unwrap().highlighted_rows(), copies); + run(&mut s, "layer.duplicate", json!({"id": sub.0})); + assert!(children(&s, layer).iter().any(|l| node(&s, *l).display_name() == "Sub copy")); + run(&mut s, "layer.highlight", json!({"ids": copies.iter().map(|c| c.0).collect::>()})); + assert_eq!(run(&mut s, "layer.delete", json!({}))["deleted"], 2); + assert_eq!(children(&s, sub), vec![a, g]); + run(&mut s, "layer.delete", json!({"ids": [c.0, a.0]})); + assert!(s.doc().unwrap().doc.node(c).is_none() && s.doc().unwrap().doc.node(a).is_none()); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().doc.node(c).is_some()); + assert!(s.execute("layer.delete", &json!({"ids": [layer.0]})).is_err(), "the last layer stays"); + run(&mut s, "layer.setCurrent", json!({"id": sub.0})); + run(&mut s, "layer.delete", json!({})); + assert!(s.doc().unwrap().doc.node(sub).is_none()); + assert_eq!(s.doc().unwrap().current_layer(), Some(layer), "the current layer moves on"); +} + +#[test] +fn collect_merge_flatten_and_reverse() { + let mut s = session(); + let (layer, sub, a, g, _, c) = tree(&mut s); + // Collect two rows of the sublayer in a new sublayer of it. + run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0]})); + let col = id(&run(&mut s, "layer.collectInNew", json!({}))); + assert_eq!((parent(&s, col), children(&s, col)), (Some(sub), vec![a, g])); + // Reverse their order. + run(&mut s, "layer.reverse", json!({"ids": [a.0, g.0]})); + assert_eq!(children(&s, col), vec![g, a]); + assert!(s.execute("layer.reverse", &json!({"ids": [a.0]})).is_err()); + run(&mut s, "edit.undo", json!({})); + // Merge the sublayer into Layer 1 (listed last). + run(&mut s, "layer.merge", json!({"ids": [col.0, layer.0]})); + assert!(s.doc().unwrap().doc.node(col).is_none()); + assert_eq!(children(&s, layer), vec![c, sub, a, g]); + assert!(s.execute("layer.merge", &json!({"ids": [layer.0, sub.0]})).is_err(), "not into its own sublayer"); + assert!(s.execute("layer.merge", &json!({"ids": [layer.0]})).is_err()); + // Flatten: other visible layers merge in, hidden ones go, templates stay. + let l2 = id(&run(&mut s, "layer.new", json!({"top": true}))); + let d = rect(&mut s, 0.0); + let l3 = id(&run(&mut s, "layer.new", json!({"top": true, "visible": false}))); + let l4 = id(&run(&mut s, "layer.new", json!({"top": true, "template": true}))); + let before = undos(&s); + let r = run(&mut s, "layer.flatten", json!({"id": layer.0})); + assert_eq!(r["discarded"], 1); + let st = s.doc().unwrap(); + assert_eq!(st.doc.layers.iter().map(|l| l.id).collect::>(), vec![layer, l4]); + assert!(st.doc.node(l2).is_none() && st.doc.node(l3).is_none()); + assert_eq!(parent(&s, d), Some(layer)); + assert_eq!(undos(&s), before + 1); +} + +#[test] +fn release_to_layers_sequence_and_build() { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id; + let (a, b, c) = (rect(&mut s, 0.0), rect(&mut s, 50.0), rect(&mut s, 100.0)); + let new = ids(&run(&mut s, "layer.releaseToLayers", json!({"id": layer.0}))); + assert_eq!(new.len(), 3); + assert_eq!(children(&s, layer), new); + assert_eq!(new.iter().map(|l| children(&s, *l)).collect::>(), vec![vec![a], vec![b], vec![c]]); + run(&mut s, "edit.undo", json!({})); + let new = ids(&run(&mut s, "layer.releaseToLayers", json!({"id": layer.0, "build": true}))); + let counts: Vec = new.iter().map(|l| children(&s, *l).len()).collect(); + assert_eq!(counts, vec![1, 2, 3], "the layers add up"); + assert_eq!(children(&s, new[2]), vec![a, b, c], "the top one keeps the originals"); + run(&mut s, "edit.undo", json!({})); + // A group's objects go into sublayers where the group was. + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let g = id(&run(&mut s, "object.group", json!({}))); + let new = ids(&run(&mut s, "layer.releaseToLayersBuild", json!({"id": g.0}))); + assert_eq!(new.len(), 2); + assert!(s.doc().unwrap().doc.node(g).is_none()); + assert_eq!(children(&s, layer), vec![new[0], new[1], c]); + assert!(s.execute("layer.releaseToLayers", &json!({"id": c.0})).is_err()); +} + +#[test] +fn hide_lock_and_outline_others_and_their_all_counterparts() { + let mut s = session(); + let l1 = s.doc().unwrap().doc.layers[0].id; + let l2 = id(&run(&mut s, "layer.new", json!({}))); + let l3 = id(&run(&mut s, "layer.new", json!({}))); + run(&mut s, "layer.setCurrent", json!({"id": l2.0})); + let flags = |s: &Session, f: fn(&Node) -> bool| [l1, l2, l3].map(|l| f(&node(s, l))); + assert_eq!(run(&mut s, "layer.hideOthers", json!({}))["count"], 2); + assert_eq!(flags(&s, |n| n.visible), [false, true, false]); + run(&mut s, "layer.showAll", json!({})); + assert_eq!(flags(&s, |n| n.visible), [true; 3]); + run(&mut s, "layer.lockOthers", json!({})); + assert_eq!(flags(&s, |n| n.locked), [true, false, true]); + run(&mut s, "layer.unlockAll", json!({})); + assert_eq!(flags(&s, |n| n.locked), [false; 3]); + run(&mut s, "layer.outlineOthers", json!({})); + let preview = |n: &Node| matches!(n.kind, NodeKind::Layer { preview: true, .. }); + assert_eq!(flags(&s, preview), [false, true, false]); + run(&mut s, "layer.previewAll", json!({})); + assert_eq!(flags(&s, preview), [true; 3]); + assert_eq!(run(&mut s, "layer.previewAll", json!({}))["count"], 0, "nothing to do: no undo step"); +} + +#[test] +fn locate_object_highlights_its_row() { + let mut s = session(); + let (layer, sub, _, g, b, _) = tree(&mut s); + run(&mut s, "select.set", json!({"ids": [g.0]})); + let r = run(&mut s, "layer.locate", json!({})); + assert_eq!(r["ancestry"], json!([layer.0, sub.0, g.0])); + assert_eq!(s.doc().unwrap().highlighted_rows(), vec![g]); + assert_eq!(run(&mut s, "layer.locate", json!({"id": b.0}))["id"], b.0); + run(&mut s, "select.none", json!({})); + assert!(s.execute("layer.locate", &json!({})).is_err()); +} + +#[test] +fn the_clipping_mask_button_acts_on_the_highlighted_group() { + let mut s = session(); + let (_, _, a, g, _, _) = tree(&mut s); + run(&mut s, "layer.move", json!({"ids": [a.0], "target": g.0})); + run(&mut s, "layer.setCurrent", json!({"id": g.0})); + assert_eq!(run(&mut s, "layer.clippingMask.toggle", json!({}))["clip"], true); + assert!(node(&s, g).clips()); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index baae4cd1..d365e8ae 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -330,6 +330,9 @@ pub struct Renderer { /// The keys and sizes of the recoloured images colour adjustments made in `images`, oldest /// first (see [`Self::adjusted_image`]). adjusted: std::collections::VecDeque<(String, usize)>, + /// Layer Options → Dim Images to, of the layer being drawn (screen views only): images show + /// faded to this opacity over white. + dim_images: Option, } #[derive(Clone, Copy, Debug, Default)] @@ -406,6 +409,7 @@ impl Renderer { ink_images: Default::default(), stroke_slices: PtrMap::default(), adjusted: Default::default(), + dim_images: None, } } @@ -794,6 +798,31 @@ impl Renderer { } else if !n.is_container() { return; } + // Layer Options on screen: a layer whose Preview is off draws in outline, and Dim Images + // fades its images (exports and thumbnails, which leave templates out, ignore both). + // (A template is dimmed as a whole already.) + if let NodeKind::Layer { preview, dim_images, template, .. } = &n.kind + && f.opts.dim_templates + && !f.opts.skip_templates + { + if !preview && !f.opts.outline { + let opts = RenderOptions { outline: true, ..f.opts.clone() }; + return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force); + } + if let Some(p) = dim_images.filter(|_| !template) { + let outer = self.dim_images; + let dim = f32::from(p).clamp(0.0, 100.0) / 100.0; + self.dim_images = Some(outer.map_or(dim, |o| o.min(dim))); + self.draw_layer_node(ctx, f, n); + self.dim_images = outer; + return; + } + } + self.draw_layer_node(ctx, f, n); + } + + /// [`Self::draw_node`] after culling and Layer Options. + fn draw_layer_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) { if fx::has_object_fx(n) { return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false); } @@ -1309,6 +1338,21 @@ impl Renderer { } } ctx.reset_paint_transform(); + // A dimmed layer's images: white over them, so they show at that opacity on paper. + if let Some(dim) = self.dim_images.filter(|d| *d < 1.0 && !outline) { + ctx.set_paint(f.ink.fixed([255, 255, 255, ((1.0 - dim) * 255.0).round() as u8])); + match area { + Some((bp, rule)) => { + ctx.set_transform(f.view); + ctx.set_fill_rule(fill_rule(*rule)); + ctx.fill_path(bp); + } + None => { + ctx.set_transform(f.view * im.xf); + ctx.fill_rect(&rect); + } + } + } } if outline { let mut p = rect.to_path(0.1); @@ -1592,6 +1636,8 @@ mod tests_isolation; #[cfg(test)] mod tests_knockout; #[cfg(test)] +mod tests_layeropts; +#[cfg(test)] mod tests_objectfx; #[cfg(test)] mod tests_setup; diff --git a/crates/render/src/tests_layeropts.rs b/crates/render/src/tests_layeropts.rs new file mode 100644 index 00000000..53e75758 --- /dev/null +++ b/crates/render/src/tests_layeropts.rs @@ -0,0 +1,68 @@ +//! Layer Options on screen: Preview off draws a layer in outline, Dim Images fades its images; +//! exports (which leave templates out) draw both as they are. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, ImageBlob, ImageObject, Node, NodeId, NodeKind}; +use vectorcraft_geom::shapes; + +use super::*; + +/// A red square (10..40) and a 30×30 red image at (50, 10), on one layer set up by `layer`. +fn doc(layer: impl FnOnce(&mut NodeKind)) -> Document { + let mut d = Document::new(100.0, 100.0); + let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("red".into(), ImageBlob::new("image/png", png)); + let l = d.layers[0].id; + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let sq = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), red); + d.insert(Some(l), 0, sq).unwrap(); + let mut im = Node::new( + NodeId(0), + NodeKind::Image(ImageObject { + key: "red".into(), + width: 30, + height: 30, + xf: Affine::translate((50.0, 10.0)), + link: None, + placement: Default::default(), + }), + ); + im.id = d.alloc_id(); + d.insert(Some(l), 9, im).unwrap(); + layer(&mut d.node_mut(l).unwrap().kind); + d +} + +fn render(d: &Document, export: bool) -> Rendered { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), skip_templates: export, ..Default::default() }; + Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts) +} + +#[test] +fn preview_off_draws_the_layer_in_outline_on_screen_only() { + let d = doc(|k| { + if let NodeKind::Layer { preview, .. } = k { + *preview = false; + } + }); + assert_eq!(render(&d, false).pixel(25, 25), [255, 255, 255, 255], "no fill in outline"); + assert_eq!(render(&d, true).pixel(25, 25), [255, 0, 0, 255], "exports draw it as it is"); +} + +#[test] +fn dim_images_fades_only_the_images() { + let plain = render(&doc(|_| {}), false); + assert_eq!(plain.pixel(65, 25), [255, 0, 0, 255]); + let d = doc(|k| { + if let NodeKind::Layer { dim_images, .. } = k { + *dim_images = Some(50); + } + }); + let img = render(&d, false); + let [r, g, b, _] = img.pixel(65, 25); + assert!(r == 255 && (120..=135).contains(&g) && g == b, "half way to white: {:?}", img.pixel(65, 25)); + assert_eq!(img.pixel(25, 25), [255, 0, 0, 255], "vector art stays"); + assert_eq!(render(&d, true).pixel(65, 25), [255, 0, 0, 255], "exports are not dimmed"); +} From 7eb3f25f1f3824c90b8db0ae6b12dbbb82ea5560 Mon Sep 17 00:00:00 2001 From: BFlat Date: Wed, 7 Oct 2026 22:29:08 +0700 Subject: [PATCH 2/5] Layers: sublayers, groups and objects are clickable rows in the panel Rows at every depth highlight on click (Shift range, Ctrl toggle) and make their layer current; selection squares select a row's art and drag the selection to another row; eye and lock on every row with drag-down-the-column in one undo step and Ctrl-click for Outline; Alt-click triangles; drag and drop rows above, below or into layers and groups with a drop line (Alt copies); Delete key; the full panel menu, Layer Options and Panel Options dialogs. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/layer_options.rs | 283 +++++ .../src/dialogs/layers_panel_options.rs | 117 ++ crates/ui-egui/src/dialogs/mod.rs | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 23 + crates/ui-egui/src/menus.rs | 28 + crates/ui-egui/src/panels/layers.rs | 1040 ++++++++++++++--- crates/ui-egui/src/state.rs | 8 + 7 files changed, 1314 insertions(+), 189 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/layer_options.rs create mode 100644 crates/ui-egui/src/dialogs/layers_panel_options.rs diff --git a/crates/ui-egui/src/dialogs/layer_options.rs b/crates/ui-egui/src/dialogs/layer_options.rs new file mode 100644 index 00000000..e659f4f5 --- /dev/null +++ b/crates/ui-egui/src/dialogs/layer_options.rs @@ -0,0 +1,283 @@ +//! Layer Options (double-click a layer row, or ≡ › Options for Selection…) and the same dialog for +//! a new layer or sublayer (≡ › New Layer…, Alt-click Create New Layer). Objects' rows get Name, +//! Show and Lock only. +//! +//! Fields: `ids` (the rows edited; none for a new layer), `mode` (`edit`, `new` or `newSublayer`), +//! `layer` (the rows are layers), `name`, `color` (a preset's name such as "Light Blue", or +//! `#rrggbb`), `template`, `locked`, `visible`, `printable`, `preview`, `dimImages` and +//! `dimPercent`. OK runs `layer.setProps`, `layer.new` or `layer.newSublayer`. + +use serde_json::{Value, json}; +use vectorcraft_doc::{LAYER_COLORS, LayerColor, Node, NodeId, NodeKind}; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Layer Options. +pub const KIND: &str = "layerOptions"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 320.0, ..DialogSpec::FORM }; + +fn heading(d: &Dialog) -> String { + if d.bool("layer") || d.str("mode") != "edit" { tl!("Layer Options").into() } else { tl!("Options").into() } +} + +/// A layer colour as the dialog shows it: a preset's name, else `#rrggbb`. +fn color_field(c: LayerColor) -> String { + match c { + LayerColor::Preset(i) => LAYER_COLORS.get(i as usize).map_or("Light Blue", |(n, _)| *n).to_string(), + LayerColor::Custom([r, g, b]) => format!("#{r:02x}{g:02x}{b:02x}"), + } +} + +/// The fields for `n` (a layer or any other row). +fn fields_of(n: &Node) -> serde_json::Map { + let mut f = serde_json::Map::new(); + f.insert("name".into(), json!(n.display_name())); + f.insert("visible".into(), json!(n.visible)); + f.insert("locked".into(), json!(n.locked)); + f.insert("layer".into(), json!(n.is_layer())); + if let NodeKind::Layer { color, template, printable, preview, dim_images, .. } = &n.kind { + f.insert("color".into(), json!(color_field(*color))); + f.insert("template".into(), json!(template)); + f.insert("printable".into(), json!(printable)); + f.insert("preview".into(), json!(preview)); + f.insert("dimImages".into(), json!(dim_images.is_some())); + f.insert("dimPercent".into(), json!(dim_images.unwrap_or(50))); + } + f +} + +/// `ui.layerOptions {ids?|id?}`: open Layer Options for rows (default: the highlighted rows, else +/// the current layer), filled in from the first. +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let st = app.session.active().ok_or("no document open")?; + let ids: Vec = match (p.get("ids").and_then(Value::as_array), p.get("id").and_then(Value::as_u64)) { + (Some(a), _) => a.iter().filter_map(Value::as_u64).map(NodeId).collect(), + (None, Some(id)) => vec![NodeId(id)], + (None, None) => { + let rows = st.highlighted_rows(); + if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows } + } + }; + let first = ids.first().and_then(|id| st.doc.node(*id)).ok_or("no such layer or object")?; + let mut f = fields_of(first); + f.insert("ids".into(), json!(ids.iter().map(|i| i.0).collect::>())); + f.insert("mode".into(), json!("edit")); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f }); + Ok(Value::Null) +} + +/// `ui.newLayer {sublayer?}`: Layer Options for a new layer (or sublayer), with the name and +/// colour it would get. +pub fn open_new(app: &mut VectorcraftApp, p: &Value) -> Result { + let st = app.session.active().ok_or("no document open")?; + let mut layers = vec![]; + st.doc.walk(|n| { + if n.is_layer() { + layers.push(n.display_name()); + } + }); + let mut i = layers.len() + 1; + while layers.contains(&format!("Layer {i}")) { + i += 1; + } + let sublayer = p.get("sublayer").and_then(Value::as_bool).unwrap_or(false); + let color = LayerColor::Preset((layers.len() % LAYER_COLORS.len()) as u8); + let fields = json!({ + "mode": if sublayer { "newSublayer" } else { "new" }, "layer": true, "name": format!("Layer {i}"), "color": color_field(color), + "template": false, "locked": false, "visible": true, "printable": true, "preview": true, "dimImages": false, "dimPercent": 50, + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str, enabled: bool) -> bool { + let v = d.bool(key); + let changed = widgets::check(ui, text, v, enabled); + if changed { + d.fields.insert(key.into(), json!(!v)); + } + changed +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let layer = d.bool("layer"); + let many = d.fields.get("ids").and_then(Value::as_array).is_some_and(|a| a.len() > 1); + grid(ui, |ui| { + if !many { + label(ui, tl!("Name:")); + form::text(ui, d, "name", 190.0); + ui.end_row(); + } + if layer { + label(ui, tl!("Color:")); + ui.horizontal(|ui| { + let cur = d.str("color"); + let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect(); + let shown = if LAYER_COLORS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&cur)) { cur.clone() } else { "Custom".into() }; + if let Some((n, _)) = widgets::dropdown(ui, "layer-color", &shown, &names, 140.0).and_then(|i| LAYER_COLORS.get(i)) { + d.fields.insert("color".into(), json!(n)); + } + let [r, g, b] = LAYER_COLORS + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(&cur)) + .map(|(_, c)| *c) + .or_else(|| { + vectorcraft_color::Color::from_hex(&cur).map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }) + }) + .unwrap_or(LAYER_COLORS[0].1); + let mut rgb = [r, g, b]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + d.fields.insert("color".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + } + }); + ui.end_row(); + } + }); + ui.add_space(8.0); + if layer { + ui.horizontal(|ui| { + ui.vertical(|ui| { + // A template is locked and dims its images; turning it off frees both. + if toggle(ui, d, "template", tl!("Template"), true) { + let on = d.bool("template"); + d.fields.insert("locked".into(), json!(on)); + d.fields.insert("dimImages".into(), json!(on)); + } + toggle(ui, d, "visible", tl!("Show"), true); + toggle(ui, d, "preview", tl!("Preview"), true); + ui.horizontal(|ui| { + toggle(ui, d, "dimImages", tl!("Dim Images to:"), true); + let mut v = d.f64("dimPercent", 50.0); + if ui.add_enabled(d.bool("dimImages"), egui::DragValue::new(&mut v).range(0.0..=100.0).suffix("%")).changed() { + d.fields.insert("dimPercent".into(), json!(v.round())); + } + }); + }); + ui.add_space(24.0); + ui.vertical(|ui| { + let template = d.bool("template"); + toggle(ui, d, "locked", tl!("Lock"), !template); + toggle(ui, d, "printable", tl!("Print"), !template); + }); + }); + } else { + toggle(ui, d, "visible", tl!("Show"), true); + toggle(ui, d, "locked", tl!("Lock"), true); + } + false +} + +/// The command and params OK runs. +pub(crate) fn params(d: &Dialog) -> (&'static str, Value) { + let mut p = serde_json::Map::new(); + let many = d.fields.get("ids").and_then(Value::as_array).is_some_and(|a| a.len() > 1); + if !many { + p.insert("name".into(), json!(d.str("name"))); + } + p.insert("visible".into(), json!(d.bool("visible"))); + p.insert("locked".into(), json!(d.bool("locked"))); + if d.bool("layer") { + p.insert("color".into(), json!(d.str("color"))); + p.insert("template".into(), json!(d.bool("template"))); + p.insert("printable".into(), json!(d.bool("printable"))); + p.insert("preview".into(), json!(d.bool("preview"))); + p.insert("dimImages".into(), if d.bool("dimImages") { json!(d.f64("dimPercent", 50.0).clamp(0.0, 100.0)) } else { json!(false) }); + } + match d.str("mode").as_str() { + "new" => ("layer.new", Value::Object(p)), + "newSublayer" => ("layer.newSublayer", Value::Object(p)), + _ => { + p.insert("ids".into(), d.fields.get("ids").cloned().unwrap_or(json!([]))); + ("layer.setProps", Value::Object(p)) + } + } +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (cmd, p) = params(d); + run_and_close(app, cmd, p) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app + } + + fn draw(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn set(app: &mut VectorcraftApp, k: &str, v: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + } + + #[test] + fn layer_options_edit_the_layer_and_ok_is_one_undo_step() { + let mut app = app(); + let layer = app.session.doc().unwrap().doc.layers[0].id; + app.run("ui.layerOptions", json!({})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.kind.as_str(), d.str("name"), d.str("color")), (KIND, "Layer 1".to_string(), "Light Blue".to_string())); + draw(&mut app); + set(&mut app, "name", json!("Sky")); + set(&mut app, "color", json!("#00ff00")); + set(&mut app, "dimImages", json!(true)); + set(&mut app, "dimPercent", json!(30)); + set(&mut app, "preview", json!(false)); + let undo = app.session.doc().unwrap().history.undo.len(); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let n = app.session.doc().unwrap().doc.node(layer).unwrap().clone(); + assert_eq!(n.display_name(), "Sky"); + assert!(matches!(n.kind, NodeKind::Layer { color: LayerColor::Custom([0, 255, 0]), dim_images: Some(30), preview: false, .. })); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + } + + #[test] + fn new_layer_options_make_a_layer_and_objects_get_name_show_and_lock() { + let mut app = app(); + app.run("ui.newLayer", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Layer 2"); + set(&mut app, "name", json!("Ink")); + set(&mut app, "template", json!(true)); + set(&mut app, "locked", json!(true)); + draw(&mut app); + super::super::confirm(&mut app).unwrap(); + let d = &app.session.doc().unwrap().doc; + let top = d.layers.last().unwrap(); + assert_eq!(top.display_name(), "Ink"); + assert!(top.is_template() && top.locked); + // An object's row: Name, Show and Lock. + let r = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})); + assert!(r.is_ok()); + app.session.execute("layer.setCurrent", &json!({"id": app.session.doc().unwrap().doc.layers[0].id.0})).unwrap(); + let rect = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + app.run("ui.layerOptions", json!({"id": rect})).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().bool("layer")); + draw(&mut app); + set(&mut app, "name", json!("Box")); + set(&mut app, "locked", json!(true)); + let (cmd, p) = params(app.ui.dialog.as_ref().unwrap()); + assert_eq!(cmd, "layer.setProps"); + assert!(p.get("template").is_none()); + super::super::confirm(&mut app).unwrap(); + let n = app.session.doc().unwrap().doc.node(NodeId(rect)).unwrap().clone(); + assert_eq!((n.display_name(), n.locked), ("Box".to_string(), true)); + } +} diff --git a/crates/ui-egui/src/dialogs/layers_panel_options.rs b/crates/ui-egui/src/dialogs/layers_panel_options.rs new file mode 100644 index 00000000..424d063a --- /dev/null +++ b/crates/ui-egui/src/dialogs/layers_panel_options.rs @@ -0,0 +1,117 @@ +//! Layers panel › Panel Options…: Show Layers Only, the row size and which rows show thumbnails. +//! +//! Fields: `layersOnly`, `rowSize` (`small`, `medium`, `large` or `other`), `otherSize` (points, +//! 12–100, for `other`), `thumbLayers`, `thumbGroups` and `thumbObjects`. OK applies them to the +//! panel (kept with the UI state). + +use serde_json::{Value, json}; + +use super::DialogSpec; +use super::swatch_options::{grid, label}; +use crate::panels::layers::{PanelOptions, ROW_LARGE, ROW_MEDIUM, ROW_SMALL}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of the Layers Panel Options. +pub const KIND: &str = "layersPanelOptions"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Layers Panel Options").into(), body, confirm, min_width: 300.0, ..DialogSpec::FORM }; + +const SIZES: [(&str, &str, f32); 3] = [("small", "Small", ROW_SMALL), ("medium", "Medium", ROW_MEDIUM), ("large", "Large", ROW_LARGE)]; + +/// `ui.layersPanelOptions`: open the dialog on the panel's options. +pub fn open(app: &mut VectorcraftApp) -> Result { + let o = &app.ui.layers_panel; + let size = SIZES.iter().find(|s| (s.2 - o.row()).abs() < 0.01).map_or("other", |s| s.0); + let fields = json!({ + "layersOnly": o.layers_only, "rowSize": size, "otherSize": o.row(), + "thumbLayers": o.thumb_layers, "thumbGroups": o.thumb_groups, "thumbObjects": o.thumb_objects, + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str) { + let v = d.bool(key); + if widgets::check(ui, text, v, true) { + d.fields.insert(key.into(), json!(!v)); + } +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + check(ui, d, "layersOnly", tl!("Show Layers Only")); + ui.add_space(8.0); + grid(ui, |ui| { + label(ui, tl!("Row Size:")); + ui.horizontal(|ui| { + let cur = d.str("rowSize"); + let labels: Vec<&str> = SIZES.iter().map(|s| s.1).chain(["Other"]).collect(); + let shown = SIZES.iter().find(|s| s.0 == cur).map_or("Other", |s| s.1); + if let Some(i) = widgets::dropdown(ui, "layers-row-size", shown, &labels, 110.0) { + d.fields.insert("rowSize".into(), json!(SIZES.get(i).map_or("other", |s| s.0))); + } + let mut v = d.f64("otherSize", f64::from(ROW_MEDIUM)); + if ui.add_enabled(d.str("rowSize") == "other", egui::DragValue::new(&mut v).range(12.0..=100.0).suffix(" pt")).changed() { + d.fields.insert("otherSize".into(), json!(v.round())); + } + }); + ui.end_row(); + }); + ui.add_space(8.0); + label(ui, tl!("Thumbnails:")); + check(ui, d, "thumbLayers", tl!("Layers")); + check(ui, d, "thumbGroups", tl!("Group")); + check(ui, d, "thumbObjects", tl!("Object")); + false +} + +/// The options the fields describe. +pub(crate) fn options(d: &Dialog) -> PanelOptions { + let size = d.str("rowSize"); + let row_size = SIZES.iter().find(|s| s.0 == size).map_or_else(|| (d.f64("otherSize", f64::from(ROW_MEDIUM)) as f32).clamp(12.0, 100.0), |s| s.2); + PanelOptions { + layers_only: d.bool("layersOnly"), + row_size: if row_size.is_finite() { row_size } else { ROW_MEDIUM }, + thumb_layers: d.bool("thumbLayers"), + thumb_groups: d.bool("thumbGroups"), + thumb_objects: d.bool("thumbObjects"), + } +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.layers_panel = options(d); + app.ui.dialog = None; + Ok(serde_json::to_value(&app.ui.layers_panel).unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + #[test] + fn panel_options_round_trip_and_apply() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.run("ui.layersPanelOptions", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("rowSize"), "medium"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("layersOnly".into(), json!(true)); + d.fields.insert("rowSize".into(), json!("other")); + d.fields.insert("otherSize".into(), json!(500)); + d.fields.insert("thumbObjects".into(), json!(false)); + super::super::confirm(&mut app).unwrap(); + let o = &app.ui.layers_panel; + assert!(o.layers_only && !o.thumb_objects && o.thumb_layers); + assert_eq!(o.row(), 100.0, "kept to 100 pt"); + app.run("ui.layersPanelOptions", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("rowSize".into(), json!("large")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.ui.layers_panel.row(), ROW_LARGE); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 66ef5113..b7e292c9 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -33,6 +33,8 @@ mod gradient_stop; pub mod graphic_style_options; pub mod halftone; pub mod import_pdf; +pub mod layer_options; +pub mod layers_panel_options; pub mod liquify; pub mod missing_links; pub mod new_color_group; @@ -267,6 +269,8 @@ registry! { PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC, BlendOptions: [blend_options::KIND] => blend_options::SPEC, PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC, + LayerOptions: [layer_options::KIND] => layer_options::SPEC, + LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC, } /// The button labels the shared dialog frame can show (OK, discard and the fixed Cancel/Close), diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 555af1f2..80efcb44 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -602,6 +602,7 @@ Difference 差異化 Differences from {preset}: 與{preset}的差異: Dim Copies to: 將拷貝調暗至: + Dim Images to: 影像變暗至: Dimensions 尺寸 Direct Selection Tool 直接選取工具 Direction Handles 控制把手 @@ -697,6 +698,7 @@ Duplicate Layer 製作圖層複本 Duplicate Paragraph Style 製作段落樣式複本 Duplicate Selected Item 製作所選項目的複本 + Duplicate Selection 製作選取範圍複本 Duplicate Slice 製作切片複本 Duplicate Swatch 製作色票複本 Duplicate Symbol 製作符號複本 @@ -817,6 +819,7 @@ Expand Compound Shape 展開複合形狀 Expand Gradient Mesh 展開漸層網格 Expand Gradient To 展開漸層為 + Expand Layers Panel Rows 展開圖層面板列 Expand Options 展開選項 Expand Panels 展開面板 Expand Repeat 展開重複 @@ -924,6 +927,7 @@ Flare Tool Options 光暈工具選項 Flat Image 平面影像 Flatness: 平滑度: + Flatten Artwork 將圖稿平面化 Flatten Transparency 透明度平面化 Flatten Transparency… 透明度平面化… Flattener preset: 平面化預設集: @@ -1070,6 +1074,7 @@ Hide Hidden Characters 隱藏隱藏字元 Hide Note 隱藏註解 Hide Options 隱藏選項 + Hide Others 隱藏其他 Hide Print Tiling 隱藏列印分頁 Hide Rulers 隱藏尺標 Hide Slices 隱藏切片 @@ -1082,6 +1087,7 @@ High Performance (discrete) 高效能 (獨立顯示卡) Highlight Alternate Glyphs 醒目提示替代字符 Highlight anchors on mouse over 滑鼠移過時醒目提示錨點 + Highlight Rows 反白顯示列 Highlight Substituted Fonts 醒目提示被取代的字體 Highlight Substituted Glyphs 醒目提示被取代的字符 History 步驟記錄 @@ -1222,8 +1228,10 @@ Latin-1 Supplement 拉丁文-1 補充 Layer 圖層 Layer Names 圖層名稱 + Layer Options 圖層選項 Layer Options… 圖層選項… Layers 圖層 + Layers Panel Options 圖層面板選項 Layout 版面配置 Layout: 版面配置: Leader: 前導字元: @@ -1307,6 +1315,7 @@ Lock (⌘2) 鎖定 (⌘2) Lock Grid 鎖定格點 Lock Guides 鎖定參考線 + Lock Others 鎖定其他 Lock Slices 鎖定切片 Lock Station Point 鎖定基準點 Lossless (lossy WebP isn't available yet) 無失真 (尚未提供有失真的 WebP) @@ -1371,6 +1380,7 @@ Merge Graphic Styles 合併繪圖樣式 Merge Graphic Styles… 合併繪圖樣式… Merge Layers 合併圖層 + Merge Selected 合併選取的項目 Merge Swatches 合併色票 Mesh Tool 網格工具 Message: 訊息: @@ -1423,6 +1433,7 @@ Move Mesh Point 移動網格點 Move Plane 移動平面 Move Plane to Match Object 移動平面以符合物件 + Move Rows 移動列 Move Slices 移動切片 Move Spine Point 移動脊線點 Move Swatch 移動色票 @@ -1459,6 +1470,7 @@ New Graphic Style 新增繪圖樣式 New Graphic Style (Alt-click to name it) 新增繪圖樣式 (Alt+點擊可命名) New Graphic Style… 新增繪圖樣式… + New Layer Options… 新圖層選項… New Layer… 新增圖層… New Opacity Masks Are Clipping 新的不透明度遮色片為剪裁 New Opacity Masks Are Inverted 新的不透明度遮色片為反轉 @@ -1627,6 +1639,7 @@ Optimize for fast web view 最佳化以便快速網頁檢視 Optimized 最佳化 Options 選項 + Options for Selection… 選取範圍的選項… Options: 選項: Options… 選項… or more spaces with a tab 個以上的空格取代為定位字元 @@ -1646,6 +1659,7 @@ Out Tangent Handles 外切線控制把手 Outer Glow… 外光暈… Outline 外框 + Outline Others 其他以外框檢視 Outline Stroke 外框筆畫 Outline Text 外框文字 Outlines and Rules 外框與規則線 @@ -1815,6 +1829,7 @@ Presets: 預設集: Press 按下 Preview 預覽 + Preview All Layers 預覽所有圖層 Preview in Browser 在瀏覽器中預覽 Preview in Browser needs the desktop app (the web downloads files instead) 在瀏覽器中預覽需要桌面版應用程式 (網頁版會改為下載檔案) Preview Mode 預覽模式 @@ -1962,6 +1977,8 @@ Release Guides 釋放參考線 Release Opacity Mask 釋放不透明度遮色片 Release Selection 釋放選取範圍 + Release to Layers (Build) 釋放至圖層 (累加) + Release to Layers (Sequence) 釋放至圖層 (順序) Release with Perspective 釋放透視 Relink 重新連結 Relink Linked Files to the Document 將連結檔案重新連結至文件 @@ -2073,6 +2090,7 @@ Rounded Rectangle 圓角矩形 Rounded Rectangle Tool 圓角矩形工具 Rounded Rectangle… 圓角矩形… + Row Size: 列尺寸: Rows: 列數: Rule: 規則線: Rulers 尺標 @@ -2206,6 +2224,7 @@ Select an image to trace 選取要描圖的影像 Select Anchors 選取錨點 Select Appearance Item 選取外觀項目 + Select Art 選取圖稿 Select art and choose Object › Pattern › Make. 選取圖稿,然後選擇「物件 › 圖樣 › 製作」。 Select art and click New Symbol to make one. 選取圖稿並按一下「新增符號」即可建立。 Select art and use New Brush to make one. 選取圖稿,然後使用「新增筆刷」來製作。 @@ -2280,6 +2299,7 @@ Show Active Plane Widget 顯示作用中平面 Widget Show All 全部顯示 Show All Hidden Attributes 顯示所有隱藏屬性 + Show All Layers 顯示所有圖層 Show All Plates 顯示所有色版 Show all plates 顯示所有色版 Show All Swatches 顯示所有色票 @@ -2308,6 +2328,7 @@ Show Images in Outline Mode 在外框模式中顯示影像 Show in Folder 在資料夾中顯示 Show Indic Options 顯示印度文字選項 + Show Layers Only 僅顯示圖層 Show Link Info 顯示連結資訊 Show List View 顯示清單檢視 Show Mesh 顯示網格 @@ -2557,6 +2578,7 @@ Three Point Perspective Preset 5 三點透視預設集 5 Threshold 臨界值 Thumbnail View 縮圖檢視 + Thumbnails: 縮圖: Tile 並排顯示 Tile Edge Color 拼貼邊緣顏色 Tile Edge Color… 拼貼邊緣顏色… @@ -2672,6 +2694,7 @@ Unlink the mask 取消連結遮色片 Unlock 解除鎖定 Unlock All 全部解除鎖定 + Unlock All Layers 解除鎖定所有圖層 Unlock Grid 解除鎖定格點 Unlock Guides 解除鎖定參考線 Unnamed Objects 未命名物件 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ed5c0268..f3c15af2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -305,6 +305,30 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{names: [two or more styles]} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`) to name the style OK merges from them (graphicStyle.merge)", ), + ( + "ui.layerOptions", + "Options for Selection…", + "", + "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step)", + ), + ( + "ui.newLayer", + "New Layer Options…", + "", + "{sublayer?: bool} open Layer Options for a new layer (or a sublayer of the current layer), named and coloured as it would be: dialog `layerOptions` with mode new|newSublayer; OK runs layer.new or layer.newSublayer with the fields", + ), + ( + "ui.layersPanelOptions", + "Panel Options…", + "", + "{} open the Layers panel's Panel Options: dialog `layersPanelOptions`, fields layersOnly, rowSize (small|medium|large|other), otherSize (12–100 pt), thumbLayers, thumbGroups, thumbObjects; OK applies them (kept with the UI state)", + ), + ( + "ui.layersExpand", + "Expand Layers Panel Rows", + "", + "{ids?: [id…] (default: every row that holds others), open?: bool (default true)} open or close rows of the Layers panel, as clicking their triangles does → {count}", + ), ( "ui.tileEdgeColor", "Tile Edge Color…", @@ -1022,6 +1046,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::tile_edge_color::open(app), + "ui.layerOptions" => crate::dialogs::layer_options::open(app, p), + "ui.newLayer" => crate::dialogs::layer_options::open_new(app, p), + "ui.layersPanelOptions" => crate::dialogs::layers_panel_options::open(app), + "ui.layersExpand" => crate::panels::layers::expand(app, p), "ui.flattenTransparencyDialog" => { crate::dialogs::flatten::open(app); Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 3a08447c..97e9c727 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1,25 +1,128 @@ -//! The Layers panel: tree with visibility/lock columns, layer colour bars, thumbnails, target -//! circles and selection squares; bottom bar with new/delete. +//! The Layers panel: a row for every layer, sublayer, group and object, nested and indented, with +//! the eye (visibility) and lock columns, the layer colour bar, a disclosure triangle, a thumbnail +//! and the name, then the target circle and the selection column; a bottom bar with the panel's +//! buttons and the ≡ menu. //! -//! 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. +//! - Clicking a row highlights it and makes its layer current (`layer.setCurrent`); Shift-click +//! highlights the range of rows down to it, Ctrl/Cmd-click toggles one (`layer.highlight`). Panel +//! commands (Duplicate, Delete, Merge, Options…) act on the highlighted rows. +//! - The selection column selects a row's art (`layer.selectAll`, Shift adds or removes); a row +//! whose art is selected shows a square in its layer's colour, and dragging that square onto +//! another row moves the selected art there (Alt copies it). +//! - The eye and lock columns toggle a row (`layer.setProps`); dragging down a column sets every +//! row it passes to the same state, in one undo step. Ctrl/Cmd-click an eye switches the layer +//! between Preview and Outline. +//! - Dragging rows moves them above, below or into a layer or group (`layer.move`, a drop line or +//! a box shows where); Alt copies them; dropped on the trash they are deleted. +//! - The triangle opens or closes a row; Alt-click does the same to everything inside it. +//! - Double-clicking a name renames it; double-clicking elsewhere on a row opens its options. +//! - 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; use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2}; -use serde_json::json; -use vectorcraft_doc::{Node, NodeId, NodeKind}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use crate::theme::Tokens; -use crate::widgets::PanelDrag; +use crate::widgets::{Live, PanelDrag}; use crate::{VectorcraftApp, icons, widgets}; -const ROW: f32 = 26.0; +/// Width of the eye and the lock columns. +const COLUMN: f32 = 25.0; +/// Indent per nesting level. +const INDENT: f32 = 14.0; +/// Right edge offsets of the target circle and the selection square. +const TARGET_X: f32 = 28.0; +const SQUARE_X: f32 = 11.0; -fn expanded_id() -> egui::Id { - egui::Id::new("layers-expanded") +/// Layers panel options (≡ › Panel Options…), kept with the UI state. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PanelOptions { + /// Show Layers Only: no rows for groups and objects. + pub layers_only: bool, + /// Row height in points: [`ROW_SMALL`], [`ROW_MEDIUM`], [`ROW_LARGE`] or another (12–100). + pub row_size: f32, + /// Thumbnails on layer, group and object rows. + pub thumb_layers: bool, + pub thumb_groups: bool, + pub thumb_objects: bool, +} + +pub const ROW_SMALL: f32 = 20.0; +pub const ROW_MEDIUM: f32 = 26.0; +pub const ROW_LARGE: f32 = 40.0; + +impl Default for PanelOptions { + fn default() -> Self { + Self { layers_only: false, row_size: ROW_MEDIUM, thumb_layers: true, thumb_groups: true, thumb_objects: true } + } +} + +impl PanelOptions { + /// The row height, kept to 12–100 points. + pub fn row(&self) -> f32 { + if self.row_size.is_finite() { self.row_size.clamp(12.0, 100.0) } else { ROW_MEDIUM } + } + /// Whether `n`'s row shows a thumbnail. + fn thumb(&self, n: &Node) -> bool { + self.row() >= 18.0 + && match n.kind { + NodeKind::Layer { .. } => self.thumb_layers, + NodeKind::Group { .. } => self.thumb_groups, + _ => self.thumb_objects, + } + } +} + +/// What a drag in the panel carries. +#[derive(Clone, Debug)] +enum LayersDrag { + /// Rows dragged to move (or, with Alt, copy) them. + Rows(Vec), + /// The selected-art square: the selected objects go to the row it is dropped on. + Art, +} + +/// A drag down the eye or lock column: every row it passes takes `value`. +#[derive(Clone, Debug, Default)] +struct ColumnDrag { + /// The eye column (visibility), else the lock column. + eye: bool, + value: bool, + ids: Vec, +} + +fn key(name: &str) -> egui::Id { + egui::Id::new(("layers-panel", name)) +} + +fn rename_id() -> egui::Id { + egui::Id::new("layers-rename") +} + +/// Where a dragged row goes relative to the row it is dropped on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Place { + Above, + Below, + Inside, +} + +impl Place { + fn id(self) -> &'static str { + match self { + Place::Above => "above", + Place::Below => "below", + Place::Inside => "inside", + } + } } /// The name painted for a row in `lang`: a generated `` name is translated, anything else is @@ -33,6 +136,50 @@ fn painted_name(n: &Node, name: &str, lang: crate::i18n::Lang) -> String { name.to_string() } +/// Whether `n` gets rows for what it holds (a compound path's paths are one object). +fn opens(n: &Node, opts: &PanelOptions) -> bool { + match n.kind { + NodeKind::Compound { .. } => false, + _ => n.children().is_some_and(|c| c.iter().any(|c| !opts.layers_only || c.is_layer())), + } +} + +/// Whether `n` or anything in it has a name containing `q` (lowercase). +fn matches(n: &Node, q: &str) -> bool { + n.display_name().to_lowercase().contains(q) || n.children().is_some_and(|c| c.iter().any(|c| matches(c, q))) +} + +/// Everything one frame of the panel reads. +struct View<'a> { + doc: &'a Document, + sel: HashSet, + target: Option, + current: Option, + rows: Vec, + opts: PanelOptions, + t: Tokens, + h: f32, + /// The search field's text, lowercase (empty: every row). + query: String, + column: Option, + /// The row to scroll into view. + reveal: Option, +} + +/// What one frame of the panel decided. +#[derive(Default)] +struct Out { + actions: Vec<(String, Value)>, + /// The rows shown, top down. + order: Vec, + /// A row clicked with these modifiers (resolved once every row is listed). + click: Option<(NodeId, egui::Modifiers)>, + /// The column drag after this frame (`Some(None)`: it ended). + column: Option>, + /// Rows to open or close, and whether to open them. + toggle: Vec<(NodeId, bool)>, +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -43,34 +190,107 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if before != Some(st.uid) { ui.data_mut(|d| { if before.is_some() { - d.remove::<(u64, String)>(egui::Id::new("layers-rename")); + d.remove::<(u64, String)>(rename_id()); } d.insert_temp(shown_doc, st.uid); }); } + let uid = st.uid; let doc = st.doc.clone(); - let sel: HashSet = st.selection.objects.iter().copied().collect(); - let target = st.selection.target; - let current = st.active_layer; + let rows = st.highlighted_rows(); + // Rows highlighted anew (a click, Locate Object, a new layer): open the layers around them. + let seen: Vec = ui.data(|d| d.get_temp(key("seen"))).unwrap_or_default(); + let mut reveal = None; + let expanded = app.ui.layers_expanded.entry(uid).or_insert_with(|| doc.layers.iter().map(|l| l.id.0).collect()); + if rows != seen { + for r in &rows { + for a in doc.ancestry(*r).unwrap_or_default().split_last().map(|(_, a)| a.to_vec()).unwrap_or_default() { + expanded.insert(a.0); + } + } + reveal = rows.first().copied(); + ui.data_mut(|d| d.insert_temp(key("seen"), rows.clone())); + } + let query = crate::widgets::search_field(ui, egui::Id::new("layers-search"), tl!("Search All")).trim().to_lowercase(); + let view = View { + doc: &doc, + sel: st.selection.objects.iter().copied().collect(), + target: st.selection.target, + current: st.current_layer(), + rows, + opts: app.ui.layers_panel.clone(), + t, + h: app.ui.layers_panel.row(), + query, + column: ui.data(|d| d.get_temp(key("column"))), + reveal, + }; // 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"). - crate::widgets::search_field(ui, egui::Id::new("layers-search"), tl!("Search All")); + let mut out = Out::default(); ui.add_space(6.0); - let h = ui.available_height() - 34.0; - egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| { + let list_h = ui.available_height() - 34.0; + let list = egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; + let expanded = app.ui.layers_expanded.entry(uid).or_default(); 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); + row(ui, &view, l, 0, false, expanded, &mut out); } + // Room below the last row: a click there leaves the rows as they are. + ui.allocate_space(vec2(ui.available_width(), (ui.available_height() - 2.0).max(0.0))); }); - ui.data_mut(|d| d.insert_temp(expanded_id(), expanded)); - if ui.input(|i| i.pointer.any_released()) { + let list_rect = list.inner_rect; + resolve_click(ui, &view, &mut out); + let expanded = app.ui.layers_expanded.entry(uid).or_default(); + for (id, open) in out.toggle.drain(..) { + if open { + expanded.insert(id.0); + } else { + expanded.remove(&id.0); + } + } + // The column drag: live while it lasts, one undo step when the button is released. + let released = ui.input(|i| i.pointer.any_released()); + let changed = out.column.is_some(); + let column = out.column.take().unwrap_or_else(|| view.column.clone()); + if let Some(c) = column.as_ref().filter(|_| changed || released) { + let (prop, label) = if c.eye { ("visible", "Visibility") } else { ("locked", "Lock") }; + let phase = if released { Live::Released } else { Live::Dragging }; + let params = json!({"ids": c.ids, prop: c.value}); + super::live_run(app, label, "layer.setProps", params, phase); + } + ui.data_mut(|d| match column.filter(|_| !released) { + Some(c) => { + d.insert_temp(key("column"), c); + } + None => d.remove::(key("column")), + }); + if released { ui.data_mut(|d| d.remove::(egui::Id::new("layers-drag"))); } - // Bottom bar. + // Keyboard: Delete removes the highlighted rows while the panel has the keyboard (a row was + // clicked last, not the canvas). + let pressed_outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !list_rect.contains(p))); + if pressed_outside { + ui.data_mut(|d| d.insert_temp(key("focus"), false)); + } + let focus: bool = ui.data(|d| d.get_temp(key("focus"))).unwrap_or(false); + if focus + && !view.rows.is_empty() + && ui.ctx().memory(|m| m.focused().is_none()) + && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace)) + { + out.actions.push(("layer.delete".into(), json!({}))); + } + bottom_bar(app, ui, &view, &doc, &mut out.actions); + for (c, p) in out.actions { + app.run(&c, p).ok(); + } +} + +/// The bottom bar: the layer count and the panel's buttons. +fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc: &Document, actions: &mut Vec<(String, Value)>) { + let t = &view.t; let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider)); let n = doc.layers.len(); @@ -81,146 +301,161 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::FontId::proportional(11.5), t.text_dim, ); + let m = ui.input(|i| i.modifiers); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); let trash = widgets::icon_button(&mut child, "trash-2", tl!("Delete Selection"), false, 24.0); - // A target circle dropped on the trash clears that appearance. + // A target circle dropped on the trash clears that appearance; dragged rows are deleted. if let Some(d) = trash.dnd_release_payload::() && let PanelDrag::Appearance(id) = *d { actions.push(("appearance.clear".into(), json!({"ids": [id.0]}))); + } else if let Some(d) = trash.dnd_release_payload::() + && let LayersDrag::Rows(ids) = &*d + { + actions.push(("layer.delete".into(), json!({"ids": ids.iter().map(|i| i.0).collect::>()}))); } else if trash.clicked() { - if app.session.active().is_some_and(|d| !d.selection.is_empty()) { + if view.rows.is_empty() && app.session.active().is_some_and(|d| !d.selection.is_empty()) { actions.push(("edit.clear".into(), json!({}))); } else { actions.push(("layer.delete".into(), json!({}))); } } + // Create New Layer: Alt asks for its options first, Ctrl/Cmd puts it on top of every layer. if widgets::icon_button(&mut child, "file-plus", tl!("Create New Layer"), false, 24.0).clicked() { - actions.push(("layer.new".into(), json!({}))); + if m.alt { + actions.push(("ui.newLayer".into(), json!({}))); + } else { + actions.push(("layer.new".into(), json!({"top": m.command}))); + } } if widgets::icon_button(&mut child, "plus", tl!("Create New Sublayer"), false, 24.0).clicked() { - actions.push(("layer.newSublayer".into(), json!({}))); + actions.push(if m.alt { ("ui.newLayer".into(), json!({"sublayer": true})) } else { ("layer.newSublayer".into(), json!({})) }); } if widgets::icon_button(&mut child, "frame", tl!("Make/Release Clipping Mask"), false, 24.0).clicked() { actions.push(("layer.clippingMask.toggle".into(), json!({}))); } if widgets::icon_button(&mut child, "search", tl!("Locate Object"), false, 24.0).clicked() { - // Expand ancestors of the selection. - if let Some(st) = app.session.active() { - let mut ex: HashSet = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_default(); - for id in &st.selection.objects { - for a in st.doc.ancestry(*id).unwrap_or_default() { - ex.insert(a.0); - } - } - ui.data_mut(|d| d.insert_temp(expanded_id(), ex)); - } + actions.push(("layer.locate".into(), json!({}))); } - for (c, p) in actions { - app.run(&c, p).ok(); +} + +/// Resolve the row clicked this frame: plain (highlight it), Ctrl/Cmd (toggle it) or Shift +/// (highlight the rows from the last one clicked down to it). +fn resolve_click(ui: &Ui, view: &View, out: &mut Out) { + let Some((id, m)) = out.click.take() else { return }; + let anchor: Option = ui.data(|d| d.get_temp(key("anchor"))); + ui.data_mut(|d| d.insert_temp(key("focus"), true)); + if m.shift + && let Some(a) = anchor.or(view.rows.last().copied()) + && let (Some(i), Some(j)) = (out.order.iter().position(|r| *r == a), out.order.iter().position(|r| *r == id)) + { + let range: Vec = out.order.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().map(|r| r.0).collect(); + out.actions.push(("layer.highlight".into(), json!({"ids": range, "mode": if m.command { "add" } else { "set" }}))); + return; + } + ui.data_mut(|d| d.insert_temp(key("anchor"), id)); + if m.command { + out.actions.push(("layer.highlight".into(), json!({"ids": [id.0], "mode": "toggle"}))); + } else { + out.actions.push(("layer.setCurrent".into(), json!({"id": id.0}))); + } +} + +/// Whether row `n` can take `drag` inside it. +fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { + match n.kind { + NodeKind::Layer { .. } => true, + NodeKind::Group { .. } => match drag { + LayersDrag::Rows(ids) => !ids.iter().any(|i| doc.node(*i).is_some_and(Node::is_layer)), + LayersDrag::Art => true, + }, + _ => false, } } #[allow(clippy::too_many_arguments)] -fn row( - ui: &mut Ui, - doc: &vectorcraft_doc::Document, - n: &Node, - depth: usize, - clip_path: bool, - sel: &HashSet, - target: Option, - current: Option, - expanded: &mut HashSet, - actions: &mut Vec<(String, serde_json::Value)>, - t: &Tokens, -) { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click_and_drag()); - let is_sel = sel.contains(&n.id); +fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expanded: &mut HashSet, out: &mut Out) { + let doc = view.doc; + let t = &view.t; + if !view.query.is_empty() && !matches(n, &view.query) { + return; + } + if view.opts.layers_only && !n.is_layer() { + return; + } + let h = view.h; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); + out.order.push(n.id); + if view.reveal == Some(n.id) { + ui.scroll_to_rect(r, None); + } + let is_sel = view.sel.contains(&n.id); let child_sel = !is_sel && n.children().is_some_and(|_| { let mut any = false; - n.walk(&mut |c| any |= c.id != n.id && sel.contains(&c.id)); + n.walk(&mut |c| any |= c.id != n.id && view.sel.contains(&c.id)); any }); let color = { let c = doc.layer_color(n.id); Color32::from_rgb(c[0], c[1], c[2]) }; - if n.is_layer() && Some(n.id) == current { + // Highlighted rows (else the current layer's). + let highlighted = if view.rows.is_empty() { n.is_layer() && Some(n.id) == view.current } else { view.rows.contains(&n.id) }; + if highlighted { ui.painter().rect_filled(r, 0.0, t.row_selected); } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); } ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); - if n.is_layer() && Some(n.id) == current { - // Current-layer marker: small triangle in the top-right corner. + if n.is_layer() && Some(n.id) == view.current { + // Current-layer marker: a small triangle in the top-right corner. let c = r.right_top(); - ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-6.0, 0.0), c + vec2(0.0, 6.0)], Color32::from_gray(0xcc), Stroke::NONE)); + ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-6.0, 0.0), c + vec2(0.0, 6.0)], t.text, Stroke::NONE)); } - // Eye and lock columns. - let eye = egui::Rect::from_min_size(r.min, vec2(25.0, ROW)); - let lock = egui::Rect::from_min_size(r.min + vec2(25.0, 0.0), vec2(25.0, ROW)); - ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.input_border)); - ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.input_border)); - let er = ui.interact(eye, ui.id().with(("eye", n.id.0)), Sense::click()); - if n.visible { - icons::paint(ui, "eye", egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0)), t.icon); - } - if er.clicked() { - let cmd = if n.is_layer() { "layer.setProps" } else { "object.setProps" }; - let key = if n.is_layer() { "id" } else { "ids" }; - let idv = if n.is_layer() { json!(n.id.0) } else { json!([n.id.0]) }; - actions.push((cmd.into(), json!({key: idv, "visible": !n.visible}))); - } - let lr = ui.interact(lock, ui.id().with(("lock", n.id.0)), Sense::click()); - if n.locked { - icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.icon); - } else if lr.hovered() { - icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.text_disabled); - } - if lr.clicked() { - let cmd = if n.is_layer() { "layer.setProps" } else { "object.setProps" }; - let key = if n.is_layer() { "id" } else { "ids" }; - let idv = if n.is_layer() { json!(n.id.0) } else { json!([n.id.0]) }; - actions.push((cmd.into(), json!({key: idv, "locked": !n.locked}))); - } - // Layer colour bar. - let mut x = lock.right() + 2.0; - if n.is_layer() { - ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(4.0, ROW)), 0.0, color); - } - x += 6.0 + depth as f32 * 14.0; - // Disclosure. - let has_children = n.children().is_some_and(|c| !c.is_empty()) && !matches!(n.kind, NodeKind::Compound { .. }); + eye_and_lock(ui, view, n, r, out); + // Layer colour bar (every row shows the colour of its layer). + let mut x = r.left() + 2.0 * COLUMN + 2.0; + ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(if n.is_layer() { 4.0 } else { 2.0 }, h)), 0.0, color); + x += 6.0 + depth as f32 * INDENT; + // Disclosure triangle; Alt opens or closes everything inside too. + let searching = !view.query.is_empty(); + let has_children = opens(n, &view.opts); + let open = has_children && (searching || expanded.contains(&n.id.0)); if has_children { - let open = expanded.contains(&n.id.0); - let dr = egui::Rect::from_min_size(egui::pos2(x, r.top() + 5.0), vec2(14.0, 16.0)); + let dr = egui::Rect::from_min_size(egui::pos2(x, r.center().y - 8.0), vec2(14.0, 16.0)); let dresp = ui.interact(dr, ui.id().with(("disc", n.id.0)), Sense::click()); icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim); if dresp.clicked() { - if open { - expanded.remove(&n.id.0); - } else { - expanded.insert(n.id.0); + out.toggle.push((n.id, !open)); + if ui.input(|i| i.modifiers.alt) { + n.walk(&mut |c| { + if c.id != n.id && opens(c, &view.opts) { + out.toggle.push((c.id, !open)); + } + }); } } } x += 16.0; // Thumbnail. - let th = egui::Rect::from_min_size(egui::pos2(x, r.top() + 2.0), vec2(22.0, 22.0)); - ui.painter().rect_filled(th, 0.0, Color32::WHITE); - ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside); - if !real_thumb(ui, doc, n, th) { - thumb(ui, n, th); - } - x += 26.0; + let thumb_rect = view.opts.thumb(n).then(|| { + let s = (h - 4.0).min(40.0); + let th = egui::Rect::from_min_size(egui::pos2(x, r.center().y - s / 2.0), vec2(s, s)); + ui.painter().rect_filled(th, 0.0, Color32::WHITE); + ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + if !real_thumb(ui, doc, n, th) { + thumb(ui, n, th); + } + x += s + 4.0; + th + }); // 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)); - let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.top() + 3.0), egui::pos2(r.right() - 44.0, r.bottom() - 3.0)); + let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id())); + let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0)); + let name_end; match renaming { Some((rid, mut buf)) if rid == n.id.0 => { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); @@ -231,16 +466,14 @@ fn row( te.request_focus(); } if te.lost_focus() { - ui.data_mut(|d| d.remove::<(u64, String)>(rename_id)); + ui.data_mut(|d| d.remove::<(u64, String)>(rename_id())); if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name { - let cmd = if n.is_layer() { "layer.setProps" } else { "object.setProps" }; - let key = if n.is_layer() { "id" } else { "ids" }; - let idv = if n.is_layer() { json!(n.id.0) } else { json!([n.id.0]) }; - actions.push((cmd.into(), json!({key: idv, "name": buf}))); + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf}))); } } else { - ui.data_mut(|d| d.insert_temp(rename_id, (n.id.0, buf))); + ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, buf))); } + name_end = name_rect.right(); } _ => { let painter = ui.painter().with_clip_rect(name_rect); @@ -250,7 +483,9 @@ fn row( } else { painted_name(n, &name, crate::i18n::current()) }; - let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, t.text); + let dim = !doc.is_visible(n.id) || n.is_template(); + let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, if dim { t.text_dim } else { t.text }); + name_end = text.right(); // 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)); @@ -262,80 +497,165 @@ fn row( // Target circle and selection square. 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 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); + let tc = egui::pos2(r.right() - TARGET_X, r.center().y); + let tresp = target_circle(ui, n, tc, h, view.target.map_or(is_sel, |id| id == n.id), &mut out.actions, t); + let sq = egui::pos2(r.right() - SQUARE_X, r.center().y); if is_sel { let q = egui::Rect::from_center_size(sq, vec2(7.0, 7.0)); ui.painter().rect_filled(q, 0.0, color); - ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); + ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside); } else if child_sel { 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()); + let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, h)), ui.id().with(("selsq", n.id.0)), Sense::click_and_drag()); + // Dragging the selected-art square carries the selection to another row. + if (is_sel || child_sel) && sq_resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Art); + } + let m = ui.input(|i| i.modifiers); if tresp.clicked() { - actions.push(("layer.target".into(), json!({"id": n.id.0}))); + out.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) { - actions.push(("select.toggle".into(), json!({"id": n.id.0}))); - } else { - actions.push(("select.set".into(), json!({"ids": [n.id.0]}))); - } + out.actions.push(("layer.selectAll".into(), json!({"id": n.id.0, "add": m.shift}))); } else if resp.clicked() { - if n.is_layer() { - actions.push(("layer.setCurrent".into(), json!({"id": n.id.0}))); - } else { - actions.push(("select.set".into(), json!({"ids": [n.id.0]}))); - } + out.click = Some((n.id, m)); } if resp.double_clicked() { - ui.data_mut(|d| d.insert_temp(egui::Id::new("layers-rename"), (n.id.0, n.display_name()))); - } - // Drag to reorder: drop onto a row moves the dragged node above it (into its parent). - let drag_id = egui::Id::new("layers-drag"); - if resp.drag_started() { - ui.data_mut(|d| d.insert_temp(drag_id, n.id.0)); - } - let dragging: Option = ui.data(|d| d.get_temp(drag_id)); - if let Some(src) = dragging - && src != n.id.0 - && ui.rect_contains_pointer(r) - { - let above = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.y < r.center().y); - let y = if above { r.top() } else { r.bottom() }; - ui.painter().line_segment([egui::pos2(r.left() + 46.0, y), egui::pos2(r.right(), y)], Stroke::new(2.0, t.accent)); - if ui.input(|i| i.pointer.any_released()) { - let (parent, index) = match doc.position(n.id) { - Some((par, idx, _)) => (par, if above { idx + 1 } else { idx }), - None => (None, 0), - }; - // A layer dropped on a non-layer goes into that row's container; top level only for layers. - let src_is_layer = doc.node(vectorcraft_doc::NodeId(src)).is_some_and(|x| x.is_layer()); - if parent.is_some() || src_is_layer { - actions.push(("node.move".into(), json!({"id": src, "parent": parent.map(|p| p.0), "index": index}))); - } - ui.data_mut(|d| d.remove::(drag_id)); + let on_name = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x <= name_end.max(name_rect.left() + 20.0) && p.x >= name_rect.left()); + let on_thumb = thumb_rect.is_some_and(|th| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| th.contains(p))); + if on_name && !on_thumb { + ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, n.display_name()))); + } else { + out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]}))); } } - - if has_children - && expanded.contains(&n.id.0) - && let Some(children) = n.children() - { + // Dragging rows: the highlighted rows when this is one of them, else this row. + if resp.drag_started() && view.column.is_none() { + let ids = if view.rows.contains(&n.id) { view.rows.clone() } else { vec![n.id] }; + egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Rows(ids)); + } + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + drop_target(ui, view, n, r, x - 16.0, open, &resp, out); + if open && let Some(children) = n.children() { for (i, c) in children.iter().enumerate().rev() { - row(ui, doc, c, depth + 1, i == 0 && n.clips(), sel, target, current, expanded, actions, t); + row(ui, view, c, depth + 1, i == 0 && n.clips(), expanded, out); } } } +/// The eye and lock columns of row `n` at `r`. +fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out) { + let t = &view.t; + let h = view.h; + let eye = egui::Rect::from_min_size(r.min, vec2(COLUMN, h)); + let lock = egui::Rect::from_min_size(r.min + vec2(COLUMN, 0.0), vec2(COLUMN, h)); + ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.input_border)); + ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.input_border)); + let preview = !matches!(n.kind, NodeKind::Layer { preview: false, .. }); + let er = ui.interact(eye, ui.id().with(("eye", n.id.0)), Sense::click_and_drag()); + if n.visible { + let icon = egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0)); + if n.is_template() { + // A template layer shows the template mark instead of the eye. + icons::paint(ui, "shapes", icon, t.icon); + } else if preview { + icons::paint(ui, "eye", icon, t.icon); + } else { + // Outline view: a hollow eye. + icons::paint(ui, "eye", icon, t.text_dim); + ui.painter().circle_stroke(eye.center(), 7.5, Stroke::new(1.0, t.text_dim)); + } + } + let lr = ui.interact(lock, ui.id().with(("lock", n.id.0)), Sense::click_and_drag()); + if n.locked { + icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.icon); + } else if lr.hovered() { + icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.text_disabled); + } + let m = ui.input(|i| i.modifiers); + if er.clicked() { + if m.command && n.is_layer() { + // Ctrl/Cmd-click: Preview ↔ Outline for this layer. + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "preview": !preview}))); + } else { + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "visible": !n.visible}))); + } + } + if lr.clicked() { + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "locked": !n.locked}))); + } + // Dragging down a column gives every row it passes this row's new state. + if er.drag_started() { + out.column = Some(Some(ColumnDrag { eye: true, value: !n.visible, ids: vec![n.id.0] })); + } else if lr.drag_started() { + out.column = Some(Some(ColumnDrag { eye: false, value: !n.locked, ids: vec![n.id.0] })); + } else if let Some(c) = out.column.clone().unwrap_or_else(|| view.column.clone()) + && !c.ids.contains(&n.id.0) + && ui.input(|i| i.pointer.primary_down() && i.pointer.interact_pos().is_some_and(|p| p.y >= r.top() && p.y < r.bottom())) + { + let mut c = c; + c.ids.push(n.id.0); + out.column = Some(Some(c)); + } +} + +/// Row `n` at `r` as a drop target for dragged rows or the selected-art square: shows where they +/// would go (a line above or below it, or a box around it for inside) and moves them on release +/// (Alt copies). `indent` is where the row's content starts. +#[allow(clippy::too_many_arguments)] +fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: bool, resp: &egui::Response, out: &mut Out) { + let Some(drag) = resp.dnd_hover_payload::() else { return }; + let doc = view.doc; + let moving: Vec = match &*drag { + LayersDrag::Rows(ids) => ids.clone(), + LayersDrag::Art => view.sel.iter().copied().collect(), + }; + // Never into itself or a row inside it. + let around = doc.ancestry(n.id).unwrap_or_default(); + if moving.is_empty() || moving.iter().any(|m| around.contains(m)) { + return; + } + let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return }; + let rel = (p.y - r.top()) / r.height().max(1.0); + let inside = takes(n, &drag, doc); + let place = match &*drag { + LayersDrag::Art if inside => Place::Inside, + LayersDrag::Art => Place::Above, + LayersDrag::Rows(_) if inside && rel >= 0.25 && (rel <= 0.75 || open) => Place::Inside, + LayersDrag::Rows(_) if rel < 0.5 => Place::Above, + LayersDrag::Rows(_) => Place::Below, + }; + let stroke = Stroke::new(2.0, view.t.accent); + match place { + Place::Above => { + ui.painter().line_segment([egui::pos2(indent, r.top()), egui::pos2(r.right(), r.top())], stroke); + } + Place::Below => { + ui.painter().line_segment([egui::pos2(indent, r.bottom()), egui::pos2(r.right(), r.bottom())], stroke); + } + Place::Inside => { + ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside); + } + } + if resp.dnd_release_payload::().is_some() { + let copy = ui.input(|i| i.modifiers.alt); + let ids: Vec = match &*drag { + // The selection's top objects, as Arrange and Group take them. + LayersDrag::Art => doc.paint_order(moving.iter().copied()).iter().map(|i| i.0).collect(), + LayersDrag::Rows(ids) => ids.iter().map(|i| i.0).collect(), + }; + out.actions.push(("layer.move".into(), json!({"ids": ids, "target": n.id.0, "place": place.id(), "copy": copy}))); + } +} + /// 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()); +fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, h: f32, targeted: bool, actions: &mut Vec<(String, Value)>, t: &Tokens) -> egui::Response { + let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, h)), 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::() @@ -432,27 +752,127 @@ fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { /// The Layers panel's (≡) menu. pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - const ITEMS: [(&str, &str); 6] = [ - ("New Layer…", "layer.new"), - ("New Sublayer…", "layer.newSublayer"), - ("Duplicate Layer", "layer.duplicate"), - ("Delete Layer", "layer.delete"), - ("Make/Release Clipping Mask", "layer.clippingMask.toggle"), - ("Collect in New Layer", "layer.collectInNew"), + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let rows = st.highlighted_rows(); + let current = st.current_layer(); + let has_sel = !st.selection.is_empty(); + let isolated = st.isolation.is_some(); + let targets: Vec = if rows.is_empty() { current.into_iter().collect() } else { rows.clone() }; + let one_container = match targets.as_slice() { + [id] => doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. })), + _ => false, + }; + let layers_in: Vec<&Node> = targets.iter().filter_map(|id| doc.node(*id)).filter(|n| n.is_layer()).collect(); + let template = layers_in.first().is_some_and(|l| l.is_template()); + // The top-level layers holding the rows, and the others. + let kept: HashSet = targets.iter().filter_map(|id| doc.layer_of(*id)).collect(); + let others: Vec<&Node> = doc.layers.iter().map(|l| &**l).filter(|l| !kept.contains(&l.id)).collect(); + let mut all_layers = vec![]; + doc.walk(|n| { + if n.is_layer() { + all_layers.push(n); + } + }); + let mut items: Vec> = vec![ + Some(("New Layer…", "ui.newLayer", json!({}), true, false)), + Some(("New Sublayer…", "ui.newLayer", json!({"sublayer": true}), current.is_some(), false)), + Some(("Duplicate Selection", "layer.duplicate", json!({}), !targets.is_empty(), false)), + Some(("Delete Selection", "layer.delete", json!({}), !targets.is_empty(), false)), + Some(("Options for Selection…", "ui.layerOptions", json!({}), !targets.is_empty(), false)), + None, + Some(("Make/Release Clipping Mask", "layer.clippingMask.toggle", json!({}), true, false)), + None, ]; - for (i, (label, cmd)) in ITEMS.into_iter().enumerate() { - if i == 4 { - ui.separator(); - } - if widgets::menu_item(ui, tl!(label), crate::menus::enabled(app, cmd), false) { - app.run(cmd, json!({})).ok(); + items.push(if isolated { + Some(("Exit Isolation Mode", "object.exitIsolation", json!({}), true, false)) + } else { + let id = targets.first().map(|i| i.0); + Some(("Enter Isolation Mode", "object.isolate", json!({ "id": id }), one_container, false)) + }); + items.extend([ + None, + Some(("Locate Object", "layer.locate", json!({}), has_sel, false)), + None, + Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)), + Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)), + Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)), + Some(("Release to Layers (Sequence)", "layer.releaseToLayers", json!({}), one_container, false)), + Some(("Release to Layers (Build)", "layer.releaseToLayersBuild", json!({}), one_container, false)), + Some(("Reverse Order", "layer.reverse", json!({}), rows.len() >= 2, false)), + None, + Some(("Template", "layer.template", json!({}), !layers_in.is_empty(), template)), + ]); + // Each pair reads by state: Hide Others while another layer shows, else Show All Layers. + let any_other = |f: &dyn Fn(&Node) -> bool| others.iter().any(|l| f(l)); + items.push(Some(if any_other(&|l| l.visible) { + ("Hide Others", "layer.hideOthers", json!({}), true, false) + } else { + ("Show All Layers", "layer.showAll", json!({}), all_layers.iter().any(|l| !l.visible), false) + })); + items.push(Some(if any_other(&|l| !matches!(l.kind, NodeKind::Layer { preview: false, .. })) { + ("Outline Others", "layer.outlineOthers", json!({}), true, false) + } else { + ( + "Preview All Layers", + "layer.previewAll", + json!({}), + all_layers.iter().any(|l| matches!(l.kind, NodeKind::Layer { preview: false, .. })), + false, + ) + })); + items.push(Some(if any_other(&|l| !l.locked) { + ("Lock Others", "layer.lockOthers", json!({}), true, false) + } else { + ("Unlock All Layers", "layer.unlockAll", json!({}), all_layers.iter().any(|l| l.locked), false) + })); + items.push(None); + let remembers = doc.paste_remembers_layers; + items.push(Some(("Paste Remembers Layers", "layer.pasteRemembersLayers", json!({"on": !remembers}), true, remembers))); + items.push(None); + items.push(Some(("Panel Options…", "ui.layersPanelOptions", json!({}), true, false))); + for item in items { + match item { + None => { + ui.separator(); + } + Some((label, cmd, p, enabled, checked)) => { + if widgets::menu_item(ui, label, enabled, checked) { + app.run(cmd, p).ok(); + } + } } } - ui.separator(); - let remembers = app.session.active().is_some_and(|d| d.doc.paste_remembers_layers); - if widgets::menu_item(ui, tl!("Paste Remembers Layers"), app.session.active().is_some(), remembers) { - app.run("layer.pasteRemembersLayers", json!({"on": !remembers})).ok(); +} + +/// `ui.layersExpand {ids?, open?}`: open (or close) rows of the active document's Layers panel; +/// without `ids`, every row that holds others. +pub fn expand(app: &mut VectorcraftApp, p: &Value) -> Result { + let st = app.session.active().ok_or("no document open")?; + let open = p.get("open").and_then(Value::as_bool).unwrap_or(true); + let ids: Vec = match p.get("ids").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_u64).filter(|i| st.doc.node(NodeId(*i)).is_some()).collect(), + None => { + let mut v = vec![]; + st.doc.walk(|n| { + if n.is_container() { + v.push(n.id.0); + } + }); + v + } + }; + let uid = st.uid; + let layers: Vec = st.doc.layers.iter().map(|l| l.id.0).collect(); + let set = app.ui.layers_expanded.entry(uid).or_insert_with(|| layers.into_iter().collect()); + for i in &ids { + if open { + set.insert(*i); + } else { + set.remove(i); + } } + Ok(json!({ "count": ids.len() })) } #[cfg(test)] @@ -708,4 +1128,246 @@ mod tests { assert_eq!(c.len(), 3); assert!(after > before + 1, "a dashed underline: {before} → {after}"); } + + // ---- rows at every depth (layers, sublayers, groups, objects) ---- + + /// Layer 1 holding rectangle `c` and a sublayer holding rectangle `a` and a group of + /// rectangle `b`, every row open, nothing selected → (app, [layer, sub, g, b, a, c]): the rows + /// top down. + fn nested() -> (VectorcraftApp, [NodeId; 6]) { + 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(); + let id = |v: serde_json::Value| NodeId(v["id"].as_u64().unwrap()); + run(&mut app, "file.new", json!({"width": 400, "height": 400})); + let layer = app.session.active().unwrap().doc.layers[0].id; + let c = id(run(&mut app, "shape.rectangle", json!({"x": 300, "y": 0, "width": 20, "height": 20}))); + let sub = id(run(&mut app, "layer.newSublayer", json!({"name": "Sub"}))); + let a = id(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))); + let b = id(run(&mut app, "shape.rectangle", json!({"x": 100, "y": 0, "width": 20, "height": 20}))); + run(&mut app, "select.set", json!({"ids": [b.0]})); + let g = id(run(&mut app, "object.group", json!({}))); + run(&mut app, "select.none", json!({})); + expand(&mut app, &json!({})).unwrap(); + (app, [layer, sub, g, b, a, c]) + } + + /// Press and release at `at` with `mods` held, over three frames. + fn click_with(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2, mods: egui::Modifiers) { + for e in [egui::Event::PointerMoved(at), button(at, true), button(at, false)] { + let mut out = + ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(mods), e], ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + } + } + + fn rows_of(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().highlighted_rows() + } + + #[test] + fn rows_at_every_depth_are_clickable() { + let (mut app, [layer, sub, g, b, a, c]) = nested(); + let ctx = egui::Context::default(); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 6, "a row for the layer, the sublayer, the group, both rectangles in it and the one on the layer"); + let name = |i: usize| egui::pos2(150.0, circles[i].y); + for (i, row, current) in [(1, sub, sub), (2, g, sub), (3, b, sub), (4, a, sub), (5, c, layer), (0, layer, layer)] { + click_with(&mut app, &ctx, name(i), egui::Modifiers::NONE); + let st = app.session.active().unwrap(); + assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![row], Some(current)), "row {i}"); + } + // New art goes into the current sublayer once its row is clicked. + click_with(&mut app, &ctx, name(1), egui::Modifiers::NONE); + let n = app.session.execute("shape.ellipse", &json!({"x": 0, "y": 100, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(app.session.active().unwrap().doc.parent_of(NodeId(n)), Some(sub)); + app.session.execute("edit.undo", &json!({})).unwrap(); + // Shift-click highlights the range down to the row; Ctrl/Cmd-click toggles one. + click_with(&mut app, &ctx, name(4), egui::Modifiers::SHIFT); + assert_eq!(rows_of(&app), vec![sub, g, b, a]); + click_with(&mut app, &ctx, name(2), egui::Modifiers::COMMAND); + assert_eq!(rows_of(&app), vec![sub, b, a]); + click_with(&mut app, &ctx, name(5), egui::Modifiers::COMMAND); + assert_eq!(rows_of(&app), vec![sub, b, a, c]); + // Delete with the panel's keyboard: the highlighted rows go. + let del = egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, vec![del], false); + let d = &app.session.active().unwrap().doc; + assert!(d.node(sub).is_none() && d.node(c).is_none()); + assert_eq!(d.layers.len(), 1); + } + + /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows + /// holding selected art). + fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + let size = |w: f32| { + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if (r.rect.width() - w).abs() < 0.01 && (r.rect.height() - w).abs() < 0.01 && r.fill != Color32::TRANSPARENT)).count() + }; + (size(7.0), size(4.0)) + } + + #[test] + fn selection_squares_show_and_select_a_rows_art() { + let (mut app, [layer, sub, g, b, a, c]) = nested(); + let ctx = egui::Context::default(); + assert_eq!(squares(&mut app, &ctx), (0, 0)); + app.session.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + assert_eq!(squares(&mut app, &ctx), (1, 2), "the group's square, and small ones on the sublayer and the layer"); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let square = |i: usize| circles[i] + vec2(TARGET_X - SQUARE_X, 0.0); + let sel = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone(); + click_with(&mut app, &ctx, square(4), egui::Modifiers::NONE); + assert_eq!(sel(&app), vec![a]); + click_with(&mut app, &ctx, square(5), egui::Modifiers::SHIFT); + assert_eq!(sel(&app), vec![a, c]); + click_with(&mut app, &ctx, square(1), egui::Modifiers::NONE); + assert_eq!(sel(&app), vec![a, g], "a sublayer's square selects its art"); + click_with(&mut app, &ctx, square(0), egui::Modifiers::NONE); + assert_eq!(sel(&app), vec![c, a, g], "a layer's, its sublayers' too"); + let _ = (layer, sub, b); + } + + #[test] + fn eye_and_lock_toggle_nested_rows_and_drag_down_the_column() { + let (mut app, [_, sub, g, b, a, _]) = nested(); + let ctx = egui::Context::default(); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let (eye, lock) = (|i: usize| egui::pos2(12.0, circles[i].y), |i: usize| egui::pos2(37.0, circles[i].y)); + let node = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().clone(); + click_with(&mut app, &ctx, eye(3), egui::Modifiers::NONE); + assert!(!node(&app, b).visible, "an object in a group in a sublayer hides"); + click_with(&mut app, &ctx, eye(3), egui::Modifiers::NONE); + click_with(&mut app, &ctx, lock(2), egui::Modifiers::NONE); + assert!(node(&app, g).locked, "a group locks"); + click_with(&mut app, &ctx, lock(2), egui::Modifiers::NONE); + // Ctrl/Cmd-click a layer's eye: Outline, and back. + click_with(&mut app, &ctx, eye(1), egui::Modifiers::COMMAND); + assert!(matches!(node(&app, sub).kind, NodeKind::Layer { preview: false, .. })); + click_with(&mut app, &ctx, eye(1), egui::Modifiers::COMMAND); + assert!(matches!(node(&app, sub).kind, NodeKind::Layer { preview: true, .. })); + // Drag down the eye column from the group to the rectangle below: all three hide, one step. + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let before = undo(&app); + let steps = [ + vec![egui::Event::PointerMoved(eye(2))], + vec![button(eye(2), true)], + vec![egui::Event::PointerMoved(eye(2) + vec2(0.0, 6.0))], + vec![egui::Event::PointerMoved(eye(3))], + vec![egui::Event::PointerMoved(eye(4))], + vec![button(eye(4), false)], + vec![], + ]; + for e in steps { + frame(&mut app, &ctx, e, false); + } + assert!(!node(&app, g).visible && !node(&app, b).visible && !node(&app, a).visible); + assert!(node(&app, sub).visible, "rows it didn't pass stay"); + assert_eq!(undo(&app), before + 1, "one undo step"); + assert!(!app.session.in_interaction()); + app.session.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&app, g).visible && node(&app, b).visible && node(&app, a).visible); + } + + #[test] + fn triangles_open_and_close_rows_and_alt_does_everything_inside() { + let (mut app, [layer, sub, ..]) = nested(); + let ctx = egui::Context::default(); + let count = |app: &mut VectorcraftApp| frame(app, &ctx, vec![], false).0.len(); + assert_eq!(count(&mut app), 6); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let triangle = |depth: f32, y: f32| egui::pos2(2.0 * COLUMN + 8.0 + depth * INDENT + 7.0, y); + // Close the sublayer: its group, the group's rectangle and its own rectangle go. + click_with(&mut app, &ctx, triangle(1.0, circles[1].y), egui::Modifiers::NONE); + assert_eq!(count(&mut app), 3); + // Alt-click the layer's: everything inside closes, and opens again. + click_with(&mut app, &ctx, triangle(0.0, circles[0].y), egui::Modifiers::ALT); + assert_eq!(count(&mut app), 1); + click_with(&mut app, &ctx, triangle(0.0, circles[0].y), egui::Modifiers::ALT); + assert_eq!(count(&mut app), 6); + // Show Layers Only (Panel Options). + app.ui.layers_panel.layers_only = true; + assert_eq!(count(&mut app), 2); + app.ui.layers_panel = PanelOptions { row_size: ROW_LARGE, ..Default::default() }; + let (c, _) = frame(&mut app, &ctx, vec![], false); + assert!((c[1].y - c[0].y - ROW_LARGE).abs() < 0.5, "large rows"); + let _ = (layer, sub); + } + + #[test] + fn dragging_rows_moves_them_into_other_layers_and_alt_copies() { + let (mut app, [layer, sub, g, _, a, c]) = nested(); + let ctx = egui::Context::default(); + let l2 = NodeId(app.session.execute("layer.new", &json!({"top": true, "name": "Top"})).unwrap()["id"].as_u64().unwrap()); + // Rows: Top, Layer 1, Sub, g, b, a, c. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 7); + let name = |i: usize| egui::pos2(150.0, circles[i].y); + let parent = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.parent_of(id); + // Rectangle a onto the middle of the Top layer's row: into it. + drag(&mut app, &ctx, name(5), name(0), false); + assert_eq!(parent(&app, a), Some(l2)); + // Rows: Top, a, Layer 1, Sub, g, b, c. The sublayer onto the upper edge of Layer 1's row: + // above it, a top-level layer now. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 7); + drag(&mut app, &ctx, egui::pos2(150.0, circles[3].y), egui::pos2(150.0, circles[2].y - ROW_MEDIUM * 0.4), false); + assert_eq!(parent(&app, sub), None); + let d = &app.session.active().unwrap().doc; + assert_eq!(d.layers.iter().map(|l| l.id).collect::>(), vec![layer, sub, l2]); + // Rows: Top, a, Sub, g, b, Layer 1, c. Alt-drag the group into Layer 1: a copy goes there, + // the group stays. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let count = app.session.active().unwrap().doc.node_count(); + drag(&mut app, &ctx, egui::pos2(150.0, circles[3].y), egui::pos2(150.0, circles[5].y), true); + assert_eq!(parent(&app, g), Some(sub)); + assert_eq!(app.session.active().unwrap().doc.node_count(), count + 2, "the group and its rectangle, copied"); + let _ = (layer, c); + } + + #[test] + fn the_selected_art_square_drags_the_selection_to_another_layer() { + let (mut app, [_, sub, g, _, a, c]) = nested(); + let ctx = egui::Context::default(); + app.session.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap(); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let square = |i: usize| circles[i] + vec2(TARGET_X - SQUARE_X, 0.0); + // From the sublayer's (small) square onto Layer 1's row. + drag(&mut app, &ctx, square(1), egui::pos2(150.0, circles[0].y), false); + let d = &app.session.active().unwrap().doc; + assert_eq!(d.node(d.layers[0].id).unwrap().children().unwrap().iter().map(|n| n.id).collect::>(), vec![c, sub, a, g]); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 2, "still selected"); + } + + #[test] + fn the_panel_menu_lists_every_operation() { + let (mut app, _) = nested(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + for label in [ + "New Layer…", + "New Sublayer…", + "Duplicate Selection", + "Delete Selection", + "Options for Selection…", + "Make/Release Clipping Mask", + "Enter Isolation Mode", + "Locate Object", + "Merge Selected", + "Flatten Artwork", + "Collect in New Layer", + "Release to Layers (Sequence)", + "Release to Layers (Build)", + "Reverse Order", + "Template", + "Show All Layers", + "Preview All Layers", + "Unlock All Layers", + "Paste Remembers Layers", + "Panel Options…", + ] { + assert!(texts.iter().any(|(t, _)| t.trim() == label), "{label} missing from {texts:?}"); + } + } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 874c6e0b..756f3bdf 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -339,6 +339,12 @@ pub struct UiState { /// becoming active, leaves it. #[serde(skip)] pub home: Option<(Option, usize)>, + /// Layers panel › Panel Options… (row size, thumbnails, Show Layers Only). + #[serde(default)] + pub layers_panel: crate::panels::layers::PanelOptions, + /// The Layers panel's open rows, per open document (`DocState::uid` → node ids). + #[serde(skip)] + pub layers_expanded: std::collections::HashMap>, /// The desktop window's size, position and maximized state, saved when the app quits and /// restored at the next launch (the desktop host reads and writes it; none on the web). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -422,6 +428,8 @@ impl Default for UiState { eps_options: Value::Null, dxf_import: Value::Null, home: None, + layers_panel: Default::default(), + layers_expanded: Default::default(), window: None, } } From f8e3cfbf3566f32fed460cb3aa5730337a8106ff Mon Sep 17 00:00:00 2001 From: BFlat Date: Wed, 7 Oct 2026 22:29:09 +0700 Subject: [PATCH 3/5] Layers: document the Layers panel commands and update the ROADMAP Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 5 ++-- docs/control-protocol.md | 9 +++++-- docs/mcp.md | 53 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 61c4bb9b..b9517f82 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, and the 2026-10-07 issue fixes; see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes and the Layers panel rework; see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -115,6 +115,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, and a symbol dragged onto the canvas places an instance where it is dropped. - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, Gradient, Eyedropper, Blend and the Liquify tools their panels or options. +- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies, the trash deletes), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. - **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, and Draw Normal / Behind / Inside modes. - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. - **Geometry and effects:** @@ -195,7 +196,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | | Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 | -| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 | +| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth, ruler-drag modifiers (Shift snaps to ticks, Alt swaps orientation) and dragging existing guides | 4–8 | | File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against real files (only reduced repros so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d8e7a9eb..86c80e1e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -288,8 +288,13 @@ Paste placement: through the app, `edit.paste` and `edit.pasteWithoutFormatting` paste at the centre of the view. The Paste menu items are enabled while the system clipboard holds something to paste (SVG, PDF, text or a bitmap on the desktop; SVG on the web), even with nothing copied in the app (looked at up to four times a second; `ui.menu.list` shows it). The Layers panel menu (≡ on -the dock's tab strip while Layers shows) lists the layer commands and Paste Remembers Layers -(`layer.pasteRemembersLayers`, checked when on). +the dock's tab strip while Layers shows) lists the layer commands (New Layer…, Duplicate and Delete Selection, +Options for Selection…, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New +Layer, Release to Layers, Reverse Order, Template, Hide/Outline/Lock Others or Show/Preview/Unlock All Layers), Paste +Remembers Layers (`layer.pasteRemembersLayers`, checked when on) and Panel Options…. Layer Options is the +`layerOptions` dialog (`ui.layerOptions {ids?}`, `ui.newLayer {sublayer?}`), Panel Options the `layersPanelOptions` +dialog (`ui.layersPanelOptions`), and `ui.layersExpand {ids?, open?}` opens or closes rows; see the Layers panel +section of `docs/mcp.md` for the row commands (`layer.setCurrent`, `layer.highlight`, `layer.move`…). File Info: File → File Info… (`file.info` from the menu or Cmd+Alt+Shift+I; `ui.fileInfoDialog` for agents) opens the `fileInfo` dialog. Its fields are what `file.info` reports (`title`, `author`, `authorTitle`, `description`, diff --git a/docs/mcp.md b/docs/mcp.md index 0b9a65a0..f1fe2f96 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -512,7 +512,7 @@ paints behind the clipped art and its stroke over it, not clipped, on screen and ``` `layer.clippingMask.toggle {id?}` is the Layers panel's clipping mask button: the top object of the layer `id` (default: -the one selected group, else the current layer) becomes its clipping path (unpainted, moved to the bottom of the layer, +the one highlighted layer or group row, else the one selected group, else the current layer) becomes its clipping path (unpainted, moved to the bottom of the layer, so art added later is clipped too); called again it releases the mask. It returns `{clip}`, and the Layers panel underlines clipping-path names. @@ -719,9 +719,58 @@ branch sets all of it. `paint.sampleColor {color}` puts a sampled colour (in its {"name":"run_command","arguments":{"command":"appearance.copyFrom","params":{"source":12,"ids":[7,8]}}} ``` +## The Layers panel: rows, layers and sublayers + +Every layer, sublayer, group and object is a row of the Layers panel. Sublayers are layers inside layers: they are not +objects, so clicking or marquee-selecting art in a sublayer, Select All and the other Select commands take the art +itself, and each sublayer has its own colour for the selection highlight. `document.inspect` lists the tree (layers +report `color`, `template`, `printable`, `preview`, `dimImages` and `clip`), the `currentLayer` (where new art goes) +and `layerRows`, the rows highlighted in the panel. + +- **Clicking rows** (not undo steps): `layer.setCurrent {id}` is a plain click on any row: it alone is highlighted + and its layer (the row itself when it is a layer or sublayer) becomes current. `layer.highlight {ids, mode?: + set|add|toggle}` is Shift-click (a range) and Ctrl/Cmd-click (toggle). Selecting art makes its layer current. + Without `ids`, the panel commands below act on the highlighted rows, else on the current layer. +- **Selection column**: `layer.selectAll {id, add?}` selects a row's visible, unlocked art (a layer's sublayers' + too); `add` (Shift) adds it, or removes it when it's all selected. +- **Eye and lock**: `layer.setProps {ids?, visible?, locked?, name?}` for any rows, plus Layer Options for layers: + `template` (also locks the layer and dims its images to 50% unless given), `printable`, `preview` (off: the layer + draws and is clicked in outline, Ctrl/Cmd-click its eye), `dimImages` (0–100, or false) and `color` (an index 0–26, + `#rrggbb` or a preset name). Several rows change in one undo step (a drag down the eye or lock column). +- **Dragging rows**: `layer.move {ids, target, place?: above|below|inside, copy?}` moves rows beside a row or into a + layer or group (on top of its contents), keeping their stacking order; `copy` (Alt-drag) moves copies. Layers go + only in layers or at the top level and objects in layers and groups (an object placed beside a top-level layer goes + inside it); a row never goes into itself or a row inside it, and a locked layer or group takes nothing. Dragging the + selected-art square is `layer.move` with the selected objects. `node.move {id, parent?, index}` is the low-level + form with the same rules. +- **Buttons**: `layer.new {name?, top?, …options}` (above the current layer at its level; `top` with Ctrl/Cmd), + `layer.newSublayer {parent?, name?, …options}` (on top of the parent's contents), `layer.delete {ids?}` (rows with + what they hold; the last layer stays), `layer.locate {id?}` (highlights the selected object's row; the panel opens + the rows around it) and `layer.clippingMask.toggle`. +- **Panel menu**: `layer.duplicate {ids?}`, `layer.merge {ids?}` (into the layer highlighted last, keeping the + stacking order), `layer.flatten {id?}` (every other visible layer's art into one layer; hidden layers are deleted, + templates stay), `layer.collectInNew {ids?}`, `layer.releaseToLayers {id?, build?}` and + `layer.releaseToLayersBuild` (each object of a layer or group in a sublayer of its own; Build adds up copies), + `layer.reverse {ids?}`, `layer.template {ids?, on?}`, `layer.hideOthers` / `layer.showAll`, + `layer.outlineOthers` / `layer.previewAll`, `layer.lockOthers` / `layer.unlockAll`, `layer.pasteRemembersLayers`, + `object.isolate {id}` / `object.exitIsolation`. +- **Dialogs and panel state** (control channel and the app's MCP): `ui.layerOptions {ids?}` and `ui.newLayer + {sublayer?}` open Layer Options (dialog `layerOptions`: name, color, template, locked, visible, printable, preview, + dimImages, dimPercent; OK is one undo step), `ui.layersPanelOptions` opens Panel Options (`layersPanelOptions`: + layersOnly, rowSize small|medium|large|other, otherSize, thumbLayers, thumbGroups, thumbObjects), and + `ui.layersExpand {ids?, open?}` opens or closes rows as their triangles do (Alt-click: everything inside). + +```json +{"name":"run_command","arguments":{"command":"layer.newSublayer","params":{"name":"Shadows"}}} +{"name":"run_command","arguments":{"command":"layer.move","params":{"ids":[12,15],"target":7,"place":"inside"}}} +{"name":"run_command","arguments":{"command":"layer.setProps","params":{"ids":[7],"preview":false,"color":"Orange"}}} +{"name":"run_command","arguments":{"command":"layer.highlight","params":{"ids":[3,7]}}} +{"name":"run_command","arguments":{"command":"layer.merge","params":{}}} +``` + ## 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 +`layer.target {id}` is the Layers panel's target circle: a layer gets its visible, unlocked art (its sublayers' too) 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. From ccdb4d89c17bfed65706564eefe6a6f641e7770e Mon Sep 17 00:00:00 2001 From: BFlat Date: Wed, 7 Oct 2026 22:54:01 +0700 Subject: [PATCH 4/5] Layers: the panel's rows work in the collapsed dock's flyout Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/layers.rs | 42 +++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 97e9c727..ac014d12 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -236,8 +236,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { row(ui, &view, l, 0, false, expanded, &mut out); } - // Room below the last row: a click there leaves the rows as they are. - ui.allocate_space(vec2(ui.available_width(), (ui.available_height() - 2.0).max(0.0))); }); let list_rect = list.inner_rect; resolve_click(ui, &view, &mut out); @@ -1370,4 +1368,44 @@ mod tests { assert!(texts.iter().any(|(t, _)| t.trim() == label), "{label} missing from {texts:?}"); } } + + /// With the dock collapsed, Layers shows in a floating flyout: its rows work there too. + #[test] + fn rows_work_in_the_floating_flyout() { + let (mut app, [_, sub, ..]) = nested(); + app.ui.dock_collapsed = true; + app.ui.open_panel = Some("layers".into()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0)); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let input = egui::RawInput { time: Some(time), events, screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| crate::dock::floating_panel(app, ui.ctx())); + out.textures_delta.clear(); + let mut c: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Circle(cs) if cs.radius == 5.0 => Some(cs.center), + _ => None, + }) + .collect(); + c.sort_by(|a, b| a.y.total_cmp(&b.y)); + c + }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + let circles = frame(&mut app, vec![]); + assert_eq!(circles.len(), 6, "every row shows in the flyout"); + assert!(circles.iter().all(|c| screen.contains(*c))); + let at = circles[1] - vec2(120.0, 0.0); + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true)]); + frame(&mut app, vec![button(at, false)]); + frame(&mut app, vec![]); + assert_eq!(rows_of(&app), vec![sub]); + assert_eq!(app.session.active().unwrap().current_layer(), Some(sub)); + } } From 0648e99fc4c647c3b09931c7f8ae38d0d47c241d Mon Sep 17 00:00:00 2001 From: BFlat Date: Wed, 7 Oct 2026 23:06:31 +0700 Subject: [PATCH 5/5] Layers: keep clippy happy (menu item type, no unreachable in tests) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_layers.rs | 2 +- crates/ui-egui/src/panels/layers.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 26ea99f4..6ad69cf3 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -187,7 +187,7 @@ fn layer_options_validate_and_template_locks_and_dims() { } let color = |s: &Session| match node(s, layer).kind { NodeKind::Layer { color, .. } => color, - _ => unreachable!(), + _ => panic!("not a layer"), }; run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "red"})); assert_eq!(color(&s), LayerColor::Preset(1)); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index ac014d12..3e6b8ee4 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -748,6 +748,9 @@ fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { }); } +/// A panel menu item: label, command, params, enabled, checked. +type MenuItem<'a> = (&'a str, &'a str, Value, bool, bool); + /// The Layers panel's (≡) menu. pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; @@ -772,7 +775,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { all_layers.push(n); } }); - let mut items: Vec> = vec![ + let mut items: Vec> = vec![ Some(("New Layer…", "ui.newLayer", json!({}), true, false)), Some(("New Sublayer…", "ui.newLayer", json!({"sublayer": true}), current.is_some(), false)), Some(("Duplicate Selection", "layer.duplicate", json!({}), !targets.is_empty(), false)),