mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
+78
-8
@@ -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<NodeId>,
|
||||
/// 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<Hit> {
|
||||
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<Hit> {
|
||||
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<NodeId
|
||||
return None;
|
||||
}
|
||||
for c in children.iter().rev() {
|
||||
if !c.visible || c.locked {
|
||||
// Hidden, locked and template sublayers block clicks like top-level layers do.
|
||||
if !c.visible || c.locked || c.is_template() {
|
||||
continue;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
@@ -117,14 +122,16 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
}
|
||||
chain.push(c.id);
|
||||
let hit = match &c.kind {
|
||||
// A sublayer whose Preview is off is clicked in outline, like an outline view.
|
||||
NodeKind::Layer { preview: false, .. } => 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<NodeId>, leaves: bool) ->
|
||||
let mut out = Vec::new();
|
||||
let tops: Vec<&std::sync::Arc<Node>> = 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<Node>> {
|
||||
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);
|
||||
|
||||
+12
-2
@@ -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<NodeId> {
|
||||
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<NodeId> {
|
||||
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,
|
||||
|
||||
+36
-1
@@ -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<u8>,
|
||||
},
|
||||
/// 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<NodeId> {
|
||||
fn visit(n: &Node, editable: bool, out: &mut Vec<NodeId>) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+544
-97
@@ -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<CommandSpec> {
|
||||
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 `<name> 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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// 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<Vec<NodeId>> {
|
||||
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<NodeId> {
|
||||
let set: HashSet<NodeId> = ids.iter().copied().filter(|i| d.node(*i).is_some()).collect();
|
||||
let keep: Vec<NodeId> = 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<NodeId>, 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<LayerColor, String> {
|
||||
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 `<Kind>` 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<Value> {
|
||||
let cur = s.doc()?.doc.paste_remembers_layers;
|
||||
@@ -100,8 +367,11 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
/// or the clipping mask is released.
|
||||
fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
Ok(json!({ "clip": clip }))
|
||||
}
|
||||
|
||||
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<NodeId>, usize)>) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
|
||||
@@ -242,37 +560,166 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn collect(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("layer.selectAll", "missing id"))?;
|
||||
let ids: Vec<NodeId> = 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<Value> {
|
||||
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<NodeId> = 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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
#[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<Value> {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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()
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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<NodeId>, flag: Flag, v: bool, label: &str) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = 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<NodeId> = 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<HashSet<NodeId>> {
|
||||
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<Value> {
|
||||
let keep = kept_layers(s, p)?;
|
||||
let ids: Vec<NodeId> = 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<Value> {
|
||||
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<Value> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = 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::<Vec<_>>(), "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<NodeId> =
|
||||
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<NodeId, Vec<usize>> = 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<Value> {
|
||||
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<NodeId> = 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<NodeId> = 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<Value> {
|
||||
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<NodeId> = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
let hidden: Vec<NodeId> = 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<Value> {
|
||||
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<NodeId> = 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<Node> = 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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<NodeId>, Vec<usize>)> = 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<Arc<Node>> = 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<Value> {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -97,17 +97,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// 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<NodeId>) -> Vec<NodeId> {
|
||||
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<Valu
|
||||
/// inside accepted ones); Select > Reselect repeats `cmd` with `p`.
|
||||
fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result<Value> {
|
||||
fn visit(d: &Document, n: &Node, f: &impl Fn(&Document, &Node) -> bool, ids: &mut Vec<NodeId>) {
|
||||
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<Value> {
|
||||
|
||||
fn same_layers(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect();
|
||||
let ids: Vec<NodeId> = 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<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_containing(*id)).collect();
|
||||
let ids: Vec<NodeId> = 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()
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"isolation": st.isolation.map(|l| l.0),
|
||||
"selection": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)),
|
||||
|
||||
@@ -102,6 +102,11 @@ pub struct DocState {
|
||||
saved_doc: Arc<Document>,
|
||||
/// The layer new art goes into (the "current layer" in the Layers panel).
|
||||
pub active_layer: Option<NodeId>,
|
||||
/// 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<NodeId>,
|
||||
/// Isolation mode container.
|
||||
pub isolation: Option<NodeId>,
|
||||
pub interaction: Option<Interaction>,
|
||||
@@ -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<NodeId> {
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<NodeId> {
|
||||
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<NodeId> {
|
||||
s.doc().unwrap().doc.parent_of(id)
|
||||
}
|
||||
|
||||
fn children(s: &Session, id: NodeId) -> Vec<NodeId> {
|
||||
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<u64> {
|
||||
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<NodeId> {
|
||||
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(), "<Rectangle>", "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::<Vec<_>>()}));
|
||||
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<_>>(), 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![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<usize> = 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());
|
||||
}
|
||||
@@ -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<f32>,
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user