Compare commits

...
Author SHA1 Message Date
BFlatandClaude Opus 5.5 f95e320907 Packaging: Windows MSI gets a setup wizard with version and launch box
Double-clicking the MSI used to flash a progress bar and close, so users couldn't tell it had
installed. It now opens a wizard: Welcome, install folder (remembered across upgrades), Ready,
progress, and a Finish page that says where to find the app with a ticked "Launch VectorCraft"
box. Every page names the full version, pre-release tag included. The wizard art is drawn from
the app icon at package time; msiexec /qn still installs silently.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 20:41:53 +07:00
BFlat 65c5953516 Merge pull request #353 from storytold/feat/layers-panel
Layers: the whole Layers panel, with sublayers, groups and objects as clickable rows
2026-10-07 23:49:12 +07:00
BFlatandClaude Opus 5.5 0648e99fc4 Layers: keep clippy happy (menu item type, no unreachable in tests)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 23:06:31 +07:00
BFlatandClaude Opus 5.5 ccdb4d89c1 Layers: the panel's rows work in the collapsed dock's flyout
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:54:34 +07:00
BFlatandClaude Opus 5.5 f8e3cfbf35 Layers: document the Layers panel commands and update the ROADMAP
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:29:15 +07:00
BFlatandClaude Opus 5.5 7eb3f25f1f Layers: sublayers, groups and objects are clickable rows in the panel
Rows at every depth highlight on click (Shift range, Ctrl toggle) and make
their layer current; selection squares select a row's art and drag the
selection to another row; eye and lock on every row with drag-down-the-column
in one undo step and Ctrl-click for Outline; Alt-click triangles; drag and drop
rows above, below or into layers and groups with a drop line (Alt copies);
Delete key; the full panel menu, Layer Options and Panel Options dialogs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:29:15 +07:00
BFlatandClaude Opus 5.5 27a1a48176 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 <[email protected]>
2026-10-07 22:29:15 +07:00
BFlat f9209e8896 Merge pull request #347 from storytold/fix/issue-293
Fix #293: the dock's double arrow collapses its panels to icons
2026-10-07 22:27:32 +07:00
31 changed files with 3247 additions and 336 deletions

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
xmllint --noout packaging/windows/vectorcraft.wxs packaging/windows/vectorcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
+3 -1
View File
@@ -148,7 +148,9 @@ jobs:
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build, sign, package
shell: pwsh
env:
+3 -1
View File
@@ -45,7 +45,9 @@ jobs:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
+3 -2
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, and the 2026-10-07 issue fixes; see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes and the Layers panel rework; see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -115,6 +115,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, and a symbol dragged onto the canvas places an instance where it is dropped.
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, Gradient, Eyedropper, Blend and the Liquify tools their panels or options.
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies, the trash deletes), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
- **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard.
- **Geometry and effects:**
@@ -195,7 +196,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth, ruler-drag modifiers (Shift snaps to ticks, Alt swaps orientation) and dragging existing guides | 4–8 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against real files (only reduced repros so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
+78 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
+3 -3
View File
@@ -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
View File
@@ -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()
+429
View File
@@ -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<_>>() }))
}
+2
View File
@@ -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());
+12 -18
View File
@@ -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()
}
+11
View File
@@ -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)),
+25 -1
View File
@@ -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;
+2 -2
View File
@@ -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");
}
+454
View File
@@ -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,
_ => panic!("not a layer"),
};
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());
}
+46
View File
@@ -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;
+68
View File
@@ -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");
}
+283
View File
@@ -0,0 +1,283 @@
//! Layer Options (double-click a layer row, or ≡ › Options for Selection…) and the same dialog for
//! a new layer or sublayer (≡ › New Layer…, Alt-click Create New Layer). Objects' rows get Name,
//! Show and Lock only.
//!
//! Fields: `ids` (the rows edited; none for a new layer), `mode` (`edit`, `new` or `newSublayer`),
//! `layer` (the rows are layers), `name`, `color` (a preset's name such as "Light Blue", or
//! `#rrggbb`), `template`, `locked`, `visible`, `printable`, `preview`, `dimImages` and
//! `dimPercent`. OK runs `layer.setProps`, `layer.new` or `layer.newSublayer`.
use serde_json::{Value, json};
use vectorcraft_doc::{LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
use super::swatch_options::{grid, label};
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Layer Options.
pub const KIND: &str = "layerOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 320.0, ..DialogSpec::FORM };
fn heading(d: &Dialog) -> String {
if d.bool("layer") || d.str("mode") != "edit" { tl!("Layer Options").into() } else { tl!("Options").into() }
}
/// A layer colour as the dialog shows it: a preset's name, else `#rrggbb`.
fn color_field(c: LayerColor) -> String {
match c {
LayerColor::Preset(i) => LAYER_COLORS.get(i as usize).map_or("Light Blue", |(n, _)| *n).to_string(),
LayerColor::Custom([r, g, b]) => format!("#{r:02x}{g:02x}{b:02x}"),
}
}
/// The fields for `n` (a layer or any other row).
fn fields_of(n: &Node) -> serde_json::Map<String, Value> {
let mut f = serde_json::Map::new();
f.insert("name".into(), json!(n.display_name()));
f.insert("visible".into(), json!(n.visible));
f.insert("locked".into(), json!(n.locked));
f.insert("layer".into(), json!(n.is_layer()));
if let NodeKind::Layer { color, template, printable, preview, dim_images, .. } = &n.kind {
f.insert("color".into(), json!(color_field(*color)));
f.insert("template".into(), json!(template));
f.insert("printable".into(), json!(printable));
f.insert("preview".into(), json!(preview));
f.insert("dimImages".into(), json!(dim_images.is_some()));
f.insert("dimPercent".into(), json!(dim_images.unwrap_or(50)));
}
f
}
/// `ui.layerOptions {ids?|id?}`: open Layer Options for rows (default: the highlighted rows, else
/// the current layer), filled in from the first.
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let ids: Vec<NodeId> = match (p.get("ids").and_then(Value::as_array), p.get("id").and_then(Value::as_u64)) {
(Some(a), _) => a.iter().filter_map(Value::as_u64).map(NodeId).collect(),
(None, Some(id)) => vec![NodeId(id)],
(None, None) => {
let rows = st.highlighted_rows();
if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows }
}
};
let first = ids.first().and_then(|id| st.doc.node(*id)).ok_or("no such layer or object")?;
let mut f = fields_of(first);
f.insert("ids".into(), json!(ids.iter().map(|i| i.0).collect::<Vec<_>>()));
f.insert("mode".into(), json!("edit"));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f });
Ok(Value::Null)
}
/// `ui.newLayer {sublayer?}`: Layer Options for a new layer (or sublayer), with the name and
/// colour it would get.
pub fn open_new(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let mut layers = vec![];
st.doc.walk(|n| {
if n.is_layer() {
layers.push(n.display_name());
}
});
let mut i = layers.len() + 1;
while layers.contains(&format!("Layer {i}")) {
i += 1;
}
let sublayer = p.get("sublayer").and_then(Value::as_bool).unwrap_or(false);
let color = LayerColor::Preset((layers.len() % LAYER_COLORS.len()) as u8);
let fields = json!({
"mode": if sublayer { "newSublayer" } else { "new" }, "layer": true, "name": format!("Layer {i}"), "color": color_field(color),
"template": false, "locked": false, "visible": true, "printable": true, "preview": true, "dimImages": false, "dimPercent": 50,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str, enabled: bool) -> bool {
let v = d.bool(key);
let changed = widgets::check(ui, text, v, enabled);
if changed {
d.fields.insert(key.into(), json!(!v));
}
changed
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let layer = d.bool("layer");
let many = d.fields.get("ids").and_then(Value::as_array).is_some_and(|a| a.len() > 1);
grid(ui, |ui| {
if !many {
label(ui, tl!("Name:"));
form::text(ui, d, "name", 190.0);
ui.end_row();
}
if layer {
label(ui, tl!("Color:"));
ui.horizontal(|ui| {
let cur = d.str("color");
let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
let shown = if LAYER_COLORS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&cur)) { cur.clone() } else { "Custom".into() };
if let Some((n, _)) = widgets::dropdown(ui, "layer-color", &shown, &names, 140.0).and_then(|i| LAYER_COLORS.get(i)) {
d.fields.insert("color".into(), json!(n));
}
let [r, g, b] = LAYER_COLORS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(&cur))
.map(|(_, c)| *c)
.or_else(|| {
vectorcraft_color::Color::from_hex(&cur).map(|c| {
let [r, g, b, _] = c.to_rgba8(1.0);
[r, g, b]
})
})
.unwrap_or(LAYER_COLORS[0].1);
let mut rgb = [r, g, b];
if ui.color_edit_button_srgb(&mut rgb).changed() {
d.fields.insert("color".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
});
ui.end_row();
}
});
ui.add_space(8.0);
if layer {
ui.horizontal(|ui| {
ui.vertical(|ui| {
// A template is locked and dims its images; turning it off frees both.
if toggle(ui, d, "template", tl!("Template"), true) {
let on = d.bool("template");
d.fields.insert("locked".into(), json!(on));
d.fields.insert("dimImages".into(), json!(on));
}
toggle(ui, d, "visible", tl!("Show"), true);
toggle(ui, d, "preview", tl!("Preview"), true);
ui.horizontal(|ui| {
toggle(ui, d, "dimImages", tl!("Dim Images to:"), true);
let mut v = d.f64("dimPercent", 50.0);
if ui.add_enabled(d.bool("dimImages"), egui::DragValue::new(&mut v).range(0.0..=100.0).suffix("%")).changed() {
d.fields.insert("dimPercent".into(), json!(v.round()));
}
});
});
ui.add_space(24.0);
ui.vertical(|ui| {
let template = d.bool("template");
toggle(ui, d, "locked", tl!("Lock"), !template);
toggle(ui, d, "printable", tl!("Print"), !template);
});
});
} else {
toggle(ui, d, "visible", tl!("Show"), true);
toggle(ui, d, "locked", tl!("Lock"), true);
}
false
}
/// The command and params OK runs.
pub(crate) fn params(d: &Dialog) -> (&'static str, Value) {
let mut p = serde_json::Map::new();
let many = d.fields.get("ids").and_then(Value::as_array).is_some_and(|a| a.len() > 1);
if !many {
p.insert("name".into(), json!(d.str("name")));
}
p.insert("visible".into(), json!(d.bool("visible")));
p.insert("locked".into(), json!(d.bool("locked")));
if d.bool("layer") {
p.insert("color".into(), json!(d.str("color")));
p.insert("template".into(), json!(d.bool("template")));
p.insert("printable".into(), json!(d.bool("printable")));
p.insert("preview".into(), json!(d.bool("preview")));
p.insert("dimImages".into(), if d.bool("dimImages") { json!(d.f64("dimPercent", 50.0).clamp(0.0, 100.0)) } else { json!(false) });
}
match d.str("mode").as_str() {
"new" => ("layer.new", Value::Object(p)),
"newSublayer" => ("layer.newSublayer", Value::Object(p)),
_ => {
p.insert("ids".into(), d.fields.get("ids").cloned().unwrap_or(json!([])));
("layer.setProps", Value::Object(p))
}
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (cmd, p) = params(d);
run_and_close(app, cmd, p)
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app
}
fn draw(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
fn set(app: &mut VectorcraftApp, k: &str, v: Value) {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
}
#[test]
fn layer_options_edit_the_layer_and_ok_is_one_undo_step() {
let mut app = app();
let layer = app.session.doc().unwrap().doc.layers[0].id;
app.run("ui.layerOptions", json!({})).unwrap();
let d = app.ui.dialog.clone().unwrap();
assert_eq!((d.kind.as_str(), d.str("name"), d.str("color")), (KIND, "Layer 1".to_string(), "Light Blue".to_string()));
draw(&mut app);
set(&mut app, "name", json!("Sky"));
set(&mut app, "color", json!("#00ff00"));
set(&mut app, "dimImages", json!(true));
set(&mut app, "dimPercent", json!(30));
set(&mut app, "preview", json!(false));
let undo = app.session.doc().unwrap().history.undo.len();
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let n = app.session.doc().unwrap().doc.node(layer).unwrap().clone();
assert_eq!(n.display_name(), "Sky");
assert!(matches!(n.kind, NodeKind::Layer { color: LayerColor::Custom([0, 255, 0]), dim_images: Some(30), preview: false, .. }));
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
}
#[test]
fn new_layer_options_make_a_layer_and_objects_get_name_show_and_lock() {
let mut app = app();
app.run("ui.newLayer", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Layer 2");
set(&mut app, "name", json!("Ink"));
set(&mut app, "template", json!(true));
set(&mut app, "locked", json!(true));
draw(&mut app);
super::super::confirm(&mut app).unwrap();
let d = &app.session.doc().unwrap().doc;
let top = d.layers.last().unwrap();
assert_eq!(top.display_name(), "Ink");
assert!(top.is_template() && top.locked);
// An object's row: Name, Show and Lock.
let r = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
assert!(r.is_ok());
app.session.execute("layer.setCurrent", &json!({"id": app.session.doc().unwrap().doc.layers[0].id.0})).unwrap();
let rect = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
app.run("ui.layerOptions", json!({"id": rect})).unwrap();
assert!(!app.ui.dialog.as_ref().unwrap().bool("layer"));
draw(&mut app);
set(&mut app, "name", json!("Box"));
set(&mut app, "locked", json!(true));
let (cmd, p) = params(app.ui.dialog.as_ref().unwrap());
assert_eq!(cmd, "layer.setProps");
assert!(p.get("template").is_none());
super::super::confirm(&mut app).unwrap();
let n = app.session.doc().unwrap().doc.node(NodeId(rect)).unwrap().clone();
assert_eq!((n.display_name(), n.locked), ("Box".to_string(), true));
}
}
@@ -0,0 +1,117 @@
//! Layers panel › Panel Options…: Show Layers Only, the row size and which rows show thumbnails.
//!
//! Fields: `layersOnly`, `rowSize` (`small`, `medium`, `large` or `other`), `otherSize` (points,
//! 12–100, for `other`), `thumbLayers`, `thumbGroups` and `thumbObjects`. OK applies them to the
//! panel (kept with the UI state).
use serde_json::{Value, json};
use super::DialogSpec;
use super::swatch_options::{grid, label};
use crate::panels::layers::{PanelOptions, ROW_LARGE, ROW_MEDIUM, ROW_SMALL};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of the Layers Panel Options.
pub const KIND: &str = "layersPanelOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Layers Panel Options").into(), body, confirm, min_width: 300.0, ..DialogSpec::FORM };
const SIZES: [(&str, &str, f32); 3] = [("small", "Small", ROW_SMALL), ("medium", "Medium", ROW_MEDIUM), ("large", "Large", ROW_LARGE)];
/// `ui.layersPanelOptions`: open the dialog on the panel's options.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let o = &app.ui.layers_panel;
let size = SIZES.iter().find(|s| (s.2 - o.row()).abs() < 0.01).map_or("other", |s| s.0);
let fields = json!({
"layersOnly": o.layers_only, "rowSize": size, "otherSize": o.row(),
"thumbLayers": o.thumb_layers, "thumbGroups": o.thumb_groups, "thumbObjects": o.thumb_objects,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str) {
let v = d.bool(key);
if widgets::check(ui, text, v, true) {
d.fields.insert(key.into(), json!(!v));
}
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
check(ui, d, "layersOnly", tl!("Show Layers Only"));
ui.add_space(8.0);
grid(ui, |ui| {
label(ui, tl!("Row Size:"));
ui.horizontal(|ui| {
let cur = d.str("rowSize");
let labels: Vec<&str> = SIZES.iter().map(|s| s.1).chain(["Other"]).collect();
let shown = SIZES.iter().find(|s| s.0 == cur).map_or("Other", |s| s.1);
if let Some(i) = widgets::dropdown(ui, "layers-row-size", shown, &labels, 110.0) {
d.fields.insert("rowSize".into(), json!(SIZES.get(i).map_or("other", |s| s.0)));
}
let mut v = d.f64("otherSize", f64::from(ROW_MEDIUM));
if ui.add_enabled(d.str("rowSize") == "other", egui::DragValue::new(&mut v).range(12.0..=100.0).suffix(" pt")).changed() {
d.fields.insert("otherSize".into(), json!(v.round()));
}
});
ui.end_row();
});
ui.add_space(8.0);
label(ui, tl!("Thumbnails:"));
check(ui, d, "thumbLayers", tl!("Layers"));
check(ui, d, "thumbGroups", tl!("Group"));
check(ui, d, "thumbObjects", tl!("Object"));
false
}
/// The options the fields describe.
pub(crate) fn options(d: &Dialog) -> PanelOptions {
let size = d.str("rowSize");
let row_size = SIZES.iter().find(|s| s.0 == size).map_or_else(|| (d.f64("otherSize", f64::from(ROW_MEDIUM)) as f32).clamp(12.0, 100.0), |s| s.2);
PanelOptions {
layers_only: d.bool("layersOnly"),
row_size: if row_size.is_finite() { row_size } else { ROW_MEDIUM },
thumb_layers: d.bool("thumbLayers"),
thumb_groups: d.bool("thumbGroups"),
thumb_objects: d.bool("thumbObjects"),
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
app.ui.layers_panel = options(d);
app.ui.dialog = None;
Ok(serde_json::to_value(&app.ui.layers_panel).unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
#[test]
fn panel_options_round_trip_and_apply() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.run("ui.layersPanelOptions", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("rowSize"), "medium");
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(&mut app, ui.ctx()));
out.textures_delta.clear();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("layersOnly".into(), json!(true));
d.fields.insert("rowSize".into(), json!("other"));
d.fields.insert("otherSize".into(), json!(500));
d.fields.insert("thumbObjects".into(), json!(false));
super::super::confirm(&mut app).unwrap();
let o = &app.ui.layers_panel;
assert!(o.layers_only && !o.thumb_objects && o.thumb_layers);
assert_eq!(o.row(), 100.0, "kept to 100 pt");
app.run("ui.layersPanelOptions", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("rowSize".into(), json!("large"));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.ui.layers_panel.row(), ROW_LARGE);
}
}
+4
View File
@@ -33,6 +33,8 @@ mod gradient_stop;
pub mod graphic_style_options;
pub mod halftone;
pub mod import_pdf;
pub mod layer_options;
pub mod layers_panel_options;
pub mod liquify;
pub mod missing_links;
pub mod new_color_group;
@@ -267,6 +269,8 @@ registry! {
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
LayerOptions: [layer_options::KIND] => layer_options::SPEC,
LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC,
}
/// The button labels the shared dialog frame can show (OK, discard and the fixed Cancel/Close),
+23
View File
@@ -602,6 +602,7 @@
Difference 差異化
Differences from {preset}: 與{preset}的差異:
Dim Copies to: 將拷貝調暗至:
Dim Images to: 影像變暗至:
Dimensions 尺寸
Direct Selection Tool 直接選取工具
Direction Handles 控制把手
@@ -697,6 +698,7 @@
Duplicate Layer 製作圖層複本
Duplicate Paragraph Style 製作段落樣式複本
Duplicate Selected Item 製作所選項目的複本
Duplicate Selection 製作選取範圍複本
Duplicate Slice 製作切片複本
Duplicate Swatch 製作色票複本
Duplicate Symbol 製作符號複本
@@ -817,6 +819,7 @@
Expand Compound Shape 展開複合形狀
Expand Gradient Mesh 展開漸層網格
Expand Gradient To 展開漸層為
Expand Layers Panel Rows 展開圖層面板列
Expand Options 展開選項
Expand Panels 展開面板
Expand Repeat 展開重複
@@ -924,6 +927,7 @@
Flare Tool Options 光暈工具選項
Flat Image 平面影像
Flatness: 平滑度:
Flatten Artwork 將圖稿平面化
Flatten Transparency 透明度平面化
Flatten Transparency… 透明度平面化…
Flattener preset: 平面化預設集:
@@ -1070,6 +1074,7 @@
Hide Hidden Characters 隱藏隱藏字元
Hide Note 隱藏註解
Hide Options 隱藏選項
Hide Others 隱藏其他
Hide Print Tiling 隱藏列印分頁
Hide Rulers 隱藏尺標
Hide Slices 隱藏切片
@@ -1082,6 +1087,7 @@
High Performance (discrete) 高效能 (獨立顯示卡)
Highlight Alternate Glyphs 醒目提示替代字符
Highlight anchors on mouse over 滑鼠移過時醒目提示錨點
Highlight Rows 反白顯示列
Highlight Substituted Fonts 醒目提示被取代的字體
Highlight Substituted Glyphs 醒目提示被取代的字符
History 步驟記錄
@@ -1222,8 +1228,10 @@
Latin-1 Supplement 拉丁文-1 補充
Layer 圖層
Layer Names 圖層名稱
Layer Options 圖層選項
Layer Options… 圖層選項…
Layers 圖層
Layers Panel Options 圖層面板選項
Layout 版面配置
Layout: 版面配置:
Leader: 前導字元:
@@ -1307,6 +1315,7 @@
Lock (⌘2) 鎖定 (⌘2)
Lock Grid 鎖定格點
Lock Guides 鎖定參考線
Lock Others 鎖定其他
Lock Slices 鎖定切片
Lock Station Point 鎖定基準點
Lossless (lossy WebP isn't available yet) 無失真 (尚未提供有失真的 WebP)
@@ -1371,6 +1380,7 @@
Merge Graphic Styles 合併繪圖樣式
Merge Graphic Styles… 合併繪圖樣式…
Merge Layers 合併圖層
Merge Selected 合併選取的項目
Merge Swatches 合併色票
Mesh Tool 網格工具
Message: 訊息:
@@ -1423,6 +1433,7 @@
Move Mesh Point 移動網格點
Move Plane 移動平面
Move Plane to Match Object 移動平面以符合物件
Move Rows 移動列
Move Slices 移動切片
Move Spine Point 移動脊線點
Move Swatch 移動色票
@@ -1459,6 +1470,7 @@
New Graphic Style 新增繪圖樣式
New Graphic Style (Alt-click to name it) 新增繪圖樣式 (Alt+點擊可命名)
New Graphic Style… 新增繪圖樣式…
New Layer Options… 新圖層選項…
New Layer… 新增圖層…
New Opacity Masks Are Clipping 新的不透明度遮色片為剪裁
New Opacity Masks Are Inverted 新的不透明度遮色片為反轉
@@ -1627,6 +1639,7 @@
Optimize for fast web view 最佳化以便快速網頁檢視
Optimized 最佳化
Options 選項
Options for Selection… 選取範圍的選項…
Options: 選項:
Options… 選項…
or more spaces with a tab 個以上的空格取代為定位字元
@@ -1646,6 +1659,7 @@
Out Tangent Handles 外切線控制把手
Outer Glow… 外光暈…
Outline 外框
Outline Others 其他以外框檢視
Outline Stroke 外框筆畫
Outline Text 外框文字
Outlines and Rules 外框與規則線
@@ -1815,6 +1829,7 @@
Presets: 預設集:
Press 按下
Preview 預覽
Preview All Layers 預覽所有圖層
Preview in Browser 在瀏覽器中預覽
Preview in Browser needs the desktop app (the web downloads files instead) 在瀏覽器中預覽需要桌面版應用程式 (網頁版會改為下載檔案)
Preview Mode 預覽模式
@@ -1962,6 +1977,8 @@
Release Guides 釋放參考線
Release Opacity Mask 釋放不透明度遮色片
Release Selection 釋放選取範圍
Release to Layers (Build) 釋放至圖層 (累加)
Release to Layers (Sequence) 釋放至圖層 (順序)
Release with Perspective 釋放透視
Relink 重新連結
Relink Linked Files to the Document 將連結檔案重新連結至文件
@@ -2073,6 +2090,7 @@
Rounded Rectangle 圓角矩形
Rounded Rectangle Tool 圓角矩形工具
Rounded Rectangle… 圓角矩形…
Row Size: 列尺寸:
Rows: 列數:
Rule: 規則線:
Rulers 尺標
@@ -2206,6 +2224,7 @@
Select an image to trace 選取要描圖的影像
Select Anchors 選取錨點
Select Appearance Item 選取外觀項目
Select Art 選取圖稿
Select art and choose Object › Pattern › Make. 選取圖稿,然後選擇「物件 › 圖樣 › 製作」。
Select art and click New Symbol to make one. 選取圖稿並按一下「新增符號」即可建立。
Select art and use New Brush to make one. 選取圖稿,然後使用「新增筆刷」來製作。
@@ -2280,6 +2299,7 @@
Show Active Plane Widget 顯示作用中平面 Widget
Show All 全部顯示
Show All Hidden Attributes 顯示所有隱藏屬性
Show All Layers 顯示所有圖層
Show All Plates 顯示所有色版
Show all plates 顯示所有色版
Show All Swatches 顯示所有色票
@@ -2308,6 +2328,7 @@
Show Images in Outline Mode 在外框模式中顯示影像
Show in Folder 在資料夾中顯示
Show Indic Options 顯示印度文字選項
Show Layers Only 僅顯示圖層
Show Link Info 顯示連結資訊
Show List View 顯示清單檢視
Show Mesh 顯示網格
@@ -2557,6 +2578,7 @@
Three Point Perspective Preset 5 三點透視預設集 5
Threshold 臨界值
Thumbnail View 縮圖檢視
Thumbnails: 縮圖:
Tile 並排顯示
Tile Edge Color 拼貼邊緣顏色
Tile Edge Color… 拼貼邊緣顏色…
@@ -2672,6 +2694,7 @@
Unlink the mask 取消連結遮色片
Unlock 解除鎖定
Unlock All 全部解除鎖定
Unlock All Layers 解除鎖定所有圖層
Unlock Grid 解除鎖定格點
Unlock Guides 解除鎖定參考線
Unnamed Objects 未命名物件
Can't render this file because it contains an unexpected character in line 6 and column 47.
+28
View File
@@ -305,6 +305,30 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{names: [two or more styles]} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`) to name the style OK merges from them (graphicStyle.merge)",
),
(
"ui.layerOptions",
"Options for Selection…",
"",
"{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step)",
),
(
"ui.newLayer",
"New Layer Options…",
"",
"{sublayer?: bool} open Layer Options for a new layer (or a sublayer of the current layer), named and coloured as it would be: dialog `layerOptions` with mode new|newSublayer; OK runs layer.new or layer.newSublayer with the fields",
),
(
"ui.layersPanelOptions",
"Panel Options…",
"",
"{} open the Layers panel's Panel Options: dialog `layersPanelOptions`, fields layersOnly, rowSize (small|medium|large|other), otherSize (12–100 pt), thumbLayers, thumbGroups, thumbObjects; OK applies them (kept with the UI state)",
),
(
"ui.layersExpand",
"Expand Layers Panel Rows",
"",
"{ids?: [id…] (default: every row that holds others), open?: bool (default true)} open or close rows of the Layers panel, as clicking their triangles does → {count}",
),
(
"ui.tileEdgeColor",
"Tile Edge Color…",
@@ -1022,6 +1046,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
crate::dialogs::graphic_style_options::open_merge(app, names.unwrap_or_default())
}
"ui.tileEdgeColor" => crate::dialogs::tile_edge_color::open(app),
"ui.layerOptions" => crate::dialogs::layer_options::open(app, p),
"ui.newLayer" => crate::dialogs::layer_options::open_new(app, p),
"ui.layersPanelOptions" => crate::dialogs::layers_panel_options::open(app),
"ui.layersExpand" => crate::panels::layers::expand(app, p),
"ui.flattenTransparencyDialog" => {
crate::dialogs::flatten::open(app);
Ok(Value::Null)
File diff suppressed because it is too large. Load diff
+8
View File
@@ -339,6 +339,12 @@ pub struct UiState {
/// becoming active, leaves it.
#[serde(skip)]
pub home: Option<(Option<u64>, usize)>,
/// Layers panel › Panel Options… (row size, thumbnails, Show Layers Only).
#[serde(default)]
pub layers_panel: crate::panels::layers::PanelOptions,
/// The Layers panel's open rows, per open document (`DocState::uid` → node ids).
#[serde(skip)]
pub layers_expanded: std::collections::HashMap<u64, std::collections::HashSet<u64>>,
/// The desktop window's size, position and maximized state, saved when the app quits and
/// restored at the next launch (the desktop host reads and writes it; none on the web).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -422,6 +428,8 @@ impl Default for UiState {
eps_options: Value::Null,
dxf_import: Value::Null,
home: None,
layers_panel: Default::default(),
layers_expanded: Default::default(),
window: None,
}
}
+7 -2
View File
@@ -288,8 +288,13 @@ Paste placement: through the app, `edit.paste` and `edit.pasteWithoutFormatting`
paste at the centre of the view. The Paste menu items are enabled while the system clipboard holds something to
paste (SVG, PDF, text or a bitmap on the desktop; SVG on the web), even with nothing copied in the app (looked at up
to four times a second; `ui.menu.list` shows it). The Layers panel menu (≡ on
the dock's tab strip while Layers shows) lists the layer commands and Paste Remembers Layers
(`layer.pasteRemembersLayers`, checked when on).
the dock's tab strip while Layers shows) lists the layer commands (New Layer…, Duplicate and Delete Selection,
Options for Selection…, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New
Layer, Release to Layers, Reverse Order, Template, Hide/Outline/Lock Others or Show/Preview/Unlock All Layers), Paste
Remembers Layers (`layer.pasteRemembersLayers`, checked when on) and Panel Options…. Layer Options is the
`layerOptions` dialog (`ui.layerOptions {ids?}`, `ui.newLayer {sublayer?}`), Panel Options the `layersPanelOptions`
dialog (`ui.layersPanelOptions`), and `ui.layersExpand {ids?, open?}` opens or closes rows; see the Layers panel
section of `docs/mcp.md` for the row commands (`layer.setCurrent`, `layer.highlight`, `layer.move`…).
File Info: File → File Info… (`file.info` from the menu or Cmd+Alt+Shift+I; `ui.fileInfoDialog` for agents) opens the
`fileInfo` dialog. Its fields are what `file.info` reports (`title`, `author`, `authorTitle`, `description`,
+51 -2
View File
@@ -512,7 +512,7 @@ paints behind the clipped art and its stroke over it, not clipped, on screen and
```
`layer.clippingMask.toggle {id?}` is the Layers panel's clipping mask button: the top object of the layer `id` (default:
the one selected group, else the current layer) becomes its clipping path (unpainted, moved to the bottom of the layer,
the one highlighted layer or group row, else the one selected group, else the current layer) becomes its clipping path (unpainted, moved to the bottom of the layer,
so art added later is clipped too); called again it releases the mask. It returns `{clip}`, and the Layers panel
underlines clipping-path names.
@@ -719,9 +719,58 @@ branch sets all of it. `paint.sampleColor {color}` puts a sampled colour (in its
{"name":"run_command","arguments":{"command":"appearance.copyFrom","params":{"source":12,"ids":[7,8]}}}
```
## The Layers panel: rows, layers and sublayers
Every layer, sublayer, group and object is a row of the Layers panel. Sublayers are layers inside layers: they are not
objects, so clicking or marquee-selecting art in a sublayer, Select All and the other Select commands take the art
itself, and each sublayer has its own colour for the selection highlight. `document.inspect` lists the tree (layers
report `color`, `template`, `printable`, `preview`, `dimImages` and `clip`), the `currentLayer` (where new art goes)
and `layerRows`, the rows highlighted in the panel.
- **Clicking rows** (not undo steps): `layer.setCurrent {id}` is a plain click on any row: it alone is highlighted
and its layer (the row itself when it is a layer or sublayer) becomes current. `layer.highlight {ids, mode?:
set|add|toggle}` is Shift-click (a range) and Ctrl/Cmd-click (toggle). Selecting art makes its layer current.
Without `ids`, the panel commands below act on the highlighted rows, else on the current layer.
- **Selection column**: `layer.selectAll {id, add?}` selects a row's visible, unlocked art (a layer's sublayers'
too); `add` (Shift) adds it, or removes it when it's all selected.
- **Eye and lock**: `layer.setProps {ids?, visible?, locked?, name?}` for any rows, plus Layer Options for layers:
`template` (also locks the layer and dims its images to 50% unless given), `printable`, `preview` (off: the layer
draws and is clicked in outline, Ctrl/Cmd-click its eye), `dimImages` (0–100, or false) and `color` (an index 0–26,
`#rrggbb` or a preset name). Several rows change in one undo step (a drag down the eye or lock column).
- **Dragging rows**: `layer.move {ids, target, place?: above|below|inside, copy?}` moves rows beside a row or into a
layer or group (on top of its contents), keeping their stacking order; `copy` (Alt-drag) moves copies. Layers go
only in layers or at the top level and objects in layers and groups (an object placed beside a top-level layer goes
inside it); a row never goes into itself or a row inside it, and a locked layer or group takes nothing. Dragging the
selected-art square is `layer.move` with the selected objects. `node.move {id, parent?, index}` is the low-level
form with the same rules.
- **Buttons**: `layer.new {name?, top?, …options}` (above the current layer at its level; `top` with Ctrl/Cmd),
`layer.newSublayer {parent?, name?, …options}` (on top of the parent's contents), `layer.delete {ids?}` (rows with
what they hold; the last layer stays), `layer.locate {id?}` (highlights the selected object's row; the panel opens
the rows around it) and `layer.clippingMask.toggle`.
- **Panel menu**: `layer.duplicate {ids?}`, `layer.merge {ids?}` (into the layer highlighted last, keeping the
stacking order), `layer.flatten {id?}` (every other visible layer's art into one layer; hidden layers are deleted,
templates stay), `layer.collectInNew {ids?}`, `layer.releaseToLayers {id?, build?}` and
`layer.releaseToLayersBuild` (each object of a layer or group in a sublayer of its own; Build adds up copies),
`layer.reverse {ids?}`, `layer.template {ids?, on?}`, `layer.hideOthers` / `layer.showAll`,
`layer.outlineOthers` / `layer.previewAll`, `layer.lockOthers` / `layer.unlockAll`, `layer.pasteRemembersLayers`,
`object.isolate {id}` / `object.exitIsolation`.
- **Dialogs and panel state** (control channel and the app's MCP): `ui.layerOptions {ids?}` and `ui.newLayer
{sublayer?}` open Layer Options (dialog `layerOptions`: name, color, template, locked, visible, printable, preview,
dimImages, dimPercent; OK is one undo step), `ui.layersPanelOptions` opens Panel Options (`layersPanelOptions`:
layersOnly, rowSize small|medium|large|other, otherSize, thumbLayers, thumbGroups, thumbObjects), and
`ui.layersExpand {ids?, open?}` opens or closes rows as their triangles do (Alt-click: everything inside).
```json
{"name":"run_command","arguments":{"command":"layer.newSublayer","params":{"name":"Shadows"}}}
{"name":"run_command","arguments":{"command":"layer.move","params":{"ids":[12,15],"target":7,"place":"inside"}}}
{"name":"run_command","arguments":{"command":"layer.setProps","params":{"ids":[7],"preview":false,"color":"Orange"}}}
{"name":"run_command","arguments":{"command":"layer.highlight","params":{"ids":[3,7]}}}
{"name":"run_command","arguments":{"command":"layer.merge","params":{}}}
```
## Targeting layers and moving appearances
`layer.target {id}` is the Layers panel's target circle: a layer gets its visible, unlocked art selected and is itself
`layer.target {id}` is the Layers panel's target circle: a layer gets its visible, unlocked art (its sublayers' too) selected and is itself
the target, so `appearance.*`, `effect.*`, `transparency.*` and the opacity-mask commands without `ids` act on the
layer (its opacity, its own fills and effects, an opacity mask on the whole layer); a group or object is simply
selected. `document.inspect` reports it as `target`, and any other selection change ends it.
+7
View File
@@ -99,6 +99,13 @@ to the target triple, so host build scripts aren't affected.
- `vectorcraft.wxs` (WiX) is a per-machine install into Program Files with a Start Menu shortcut
and an App Paths entry (Win+R `vectorcraft`). Same-version upgrades are allowed, so release
candidates replace each other.
- Double-clicking the MSI opens a setup wizard (WixUI_InstallDir without the licence page):
Welcome, install folder (remembered for upgrades in `HKLM\Software\VectorCraft\InstallDir`),
Ready, a progress page, and a Finish page that says where to find the app, with a ticked "Launch
VectorCraft" box. The pages name the full version (`vectorcraft.en-us.wxl`). `package.ps1` draws
the wizard's banner and side bitmaps from the app icon, so no WiX stock art ships. `msiexec /qn`
still installs silently. Building needs the WiX UI and Util extensions:
`wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`.
- The ARM64 build is cross-compiled on the x64 runner. `.github/workflows/windows-arm64.yml`
installs that MSI on a Windows 11 ARM64 runner, checks that both installed programs are ARM64,
runs `vectorcraft-cli --version` natively and uninstalls.
+42 -1
View File
@@ -13,6 +13,7 @@
Needs: Rust (MSVC toolchain + the target), the Windows SDK (rc.exe, signtool.exe),
and WiX v5: dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
.EXAMPLE
pwsh packaging/windows/package.ps1 -Arch x64
@@ -91,10 +92,50 @@ Copy-Item (Join-Path $Bin 'vectorcraft.exe'), (Join-Path $Bin 'vectorcraft-cli.e
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'vectorcraft.exe') (Join-Path $Stage 'vectorcraft-cli.exe')
# ---- MSI ---------------------------------------------------------------------------------------
# The setup wizard's art, drawn here from the app icon so the MSI shows VectorCraft rather than
# WiX's stock bitmaps: a 493x58 banner across the top of the inner pages (the page title is drawn
# over its left side, so that stays white) and a 493x312 backdrop for the Welcome and Finish pages
# (their text sits on the right; the left 164 px are ours). Both must be 24-bit BMPs.
function New-InstallerArt([string] $IconPng, [string] $BannerOut, [string] $DialogOut) {
Add-Type -AssemblyName System.Drawing
$paper = [System.Drawing.ColorTranslator]::FromHtml('#efe9dc')
$accent = [System.Drawing.ColorTranslator]::FromHtml('#e8573f')
$icon = [System.Drawing.Image]::FromFile($IconPng)
try {
foreach ($spec in @(@($BannerOut, 493, 58), @($DialogOut, 493, 312))) {
$bmp = New-Object System.Drawing.Bitmap $spec[1], $spec[2], ([System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
$g = [System.Drawing.Graphics]::FromImage($bmp)
try {
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.Clear([System.Drawing.Color]::White)
if ($spec[2] -eq 58) {
$g.DrawImage($icon, 493 - 46 - 8, 6, 46, 46)
} else {
$panel = New-Object System.Drawing.SolidBrush $paper
$stripe = New-Object System.Drawing.SolidBrush $accent
$g.FillRectangle($panel, 0, 0, 164, 312)
$g.FillRectangle($stripe, 160, 0, 4, 312)
$g.DrawImage($icon, 18, 64, 124, 124)
$panel.Dispose(); $stripe.Dispose()
}
} finally { $g.Dispose() }
$bmp.Save($spec[0], [System.Drawing.Imaging.ImageFormat]::Bmp)
$bmp.Dispose()
}
} finally { $icon.Dispose() }
}
$Banner = Join-Path $Stage 'installer-banner.bmp'
$Dialog = Join-Path $Stage 'installer-dialog.bmp'
New-InstallerArt (Join-Path $Root 'assets\app-icon\hicolor\256x256\apps\ai.storyteller.vectorcraft.png') $Banner $Dialog
$Msi = Join-Path $Dist "vectorcraft-$Version-windows-$Arch.msi"
Invoke-Native 'wix build' {
wix build (Join-Path $PSScriptRoot 'vectorcraft.wxs') -arch $Arch `
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\vectorcraft.ico')" `
-ext WixToolset.UI.wixext -ext WixToolset.Util.wixext `
-culture en-US -loc (Join-Path $PSScriptRoot 'vectorcraft.en-us.wxl') `
-d "Version=$MsiVersion" -d "DisplayVersion=$Version" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\vectorcraft.ico')" `
-d "BannerBmp=$Banner" -d "DialogBmp=$Dialog" `
-o $Msi
}
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Overrides for the setup wizard's WixUI strings, so its pages name the version being installed
([VectorcraftVersion] is the full version, pre-release tag included, set in vectorcraft.wxs).
-->
<WixLocalization Culture="en-US" xmlns="http://wixtoolset.org/schemas/v4/wxl">
<String Id="WelcomeDlgTitle" Value="{\WixUI_Font_Bigger}Welcome to the [ProductName] [VectorcraftVersion] Setup Wizard" />
<String Id="WelcomeDlgDescription" Value="The Setup Wizard will install [ProductName] version [VectorcraftVersion] on your computer. Click Next to continue or Cancel to exit the Setup Wizard." />
<String Id="VerifyReadyDlgInstallTitle" Value="{\WixUI_Font_Title}Ready to install [ProductName] [VectorcraftVersion]" />
<String Id="ExitDialogTitle" Value="{\WixUI_Font_Bigger}Completed the [ProductName] [VectorcraftVersion] Setup Wizard" />
</WixLocalization>
+42 -5
View File
@@ -1,13 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
VectorCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
VectorCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`, plus the UI and Util extensions:
`wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`). Built by package.ps1:
wix build vectorcraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
wix build vectorcraft.wxs -arch x64 -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext
-culture en-US -loc vectorcraft.en-us.wxl -d Version=1.2.3 -d DisplayVersion=1.2.3-rc.1
-d BinDir=... -d IconPath=... -d BannerBmp=... -d DialogBmp=... -o out.msi
Per-machine install into Program Files, a Start Menu shortcut and an App Paths entry (so
`vectorcraft` starts from the Run dialog), plus vectorcraft-cli.
Per-machine install with a setup wizard that names the version: Welcome, install folder (Program Files\VectorCraft by
default, remembered for upgrades), Ready, a progress page, and a Finish page with a ticked
"Launch VectorCraft" box. Installs a Start Menu shortcut and an App Paths entry (so `vectorcraft`
starts from the Run dialog), plus vectorcraft-cli. `msiexec /qn` still installs silently.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package
Name="VectorCraft"
Manufacturer="Learning Machines LLC"
@@ -34,9 +40,39 @@
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<!-- An upgrade installs where the user put the previous version (written by VectorcraftApp). -->
<Property Id="INSTALLFOLDER">
<RegistrySearch Id="PreviousInstallFolder" Root="HKLM" Key="Software\VectorCraft" Name="InstallDir" Type="raw" />
</Property>
<Feature Id="Main" Title="VectorCraft" Level="1">
<ComponentGroupRef Id="VectorcraftFiles" />
</Feature>
<!-- The setup wizard: WixUI_InstallDir without its licence page (the code is MIT OR
Apache-2.0; there is nothing to accept). A later NewDialog on the same button wins. -->
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
<UI>
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg" Order="2" Condition="NOT Installed" />
<Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2" />
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchVectorcraft"
Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed" />
</UI>
<!-- Our own art, generated by package.ps1 from the app icon (not WiX's stock bitmaps). -->
<WixVariable Id="WixUIBannerBmp" Value="$(var.BannerBmp)" />
<WixVariable Id="WixUIDialogBmp" Value="$(var.DialogBmp)" />
<!-- The full version (Version is numeric, so it drops a pre-release tag), shown on the wizard's
pages through vectorcraft.en-us.wxl. -->
<Property Id="VectorcraftVersion" Value="$(var.DisplayVersion)" />
<!-- Finish page: say where to find the app, and offer to start it (ticked by default). -->
<Property Id="WIXUI_EXITDIALOGOPTIONALTEXT" Value="VectorCraft $(var.DisplayVersion) is installed. You can start it any time from the Start menu." />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Launch VectorCraft" />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
<Property Id="WixShellExecTarget" Value="[#VectorcraftExe]" />
<!-- Runs in the wizard's own (unelevated) process, so the app starts as the user, not as admin. -->
<CustomAction Id="LaunchVectorcraft" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)" DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
</Package>
<Fragment>
@@ -49,6 +85,7 @@
</File>
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\vectorcraft.exe" Value="[#VectorcraftExe]" Type="string" />
<RegistryValue Root="HKLM" Key="Software\VectorCraft" Name="InstallDir" Value="[INSTALLFOLDER]" Type="string" />
</Component>
<Component Id="VectorcraftCli">