diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 1c46747f9..b1b9e60a0 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 3fad4703b..fada7e981 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 b7e6573b6..3d856e138 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 70ce0fe36..ef1fe10aa 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 f7f60ea04..7139d0085 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 000000000..a655da4b0 --- /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 d61365d61..623f757fe 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 c906c68a3..106a94c0e 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 557132794..c48339ffd 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 cf9ae1bce..59da0fdea 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 074be8592..fd79992bd 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 000000000..26ea99f49 --- /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 baae4cd13..d365e8aeb 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 000000000..53e757584 --- /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"); +}