mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-15 03:36:51 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f95e320907 | ||
|
|
65c5953516 | ||
|
|
0648e99fc4 | ||
|
|
ccdb4d89c1 | ||
|
|
f8e3cfbf35 | ||
|
|
7eb3f25f1f | ||
|
|
27a1a48176 | ||
|
|
f9209e8896 | ||
|
|
f16403a851 | ||
|
|
b64dcc02df |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -95,6 +95,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, and Czech and Japanese cover every menu label, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
@@ -114,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:**
|
||||
@@ -194,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
@@ -23,6 +23,8 @@ pub struct Hit {
|
||||
/// The innermost envelope in the ancestry whose contents are being edited (Edit Contents):
|
||||
/// its content, not the envelope, is the object clicked.
|
||||
pub contents_of: Option<NodeId>,
|
||||
/// How many entries at the start of `ancestry` are layers (the layer and its sublayers).
|
||||
pub layers: usize,
|
||||
}
|
||||
|
||||
impl Hit {
|
||||
@@ -34,8 +36,8 @@ impl Hit {
|
||||
if let Some(i) = at(scope).max(at(self.contents_of)) {
|
||||
return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf);
|
||||
}
|
||||
// Skip layers and sublayers.
|
||||
self.ancestry.get(1).copied().unwrap_or(self.leaf)
|
||||
// Skip layers and sublayers: they are not objects.
|
||||
self.ancestry.get(self.layers.max(1)).copied().unwrap_or(self.leaf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +65,7 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
if !layer.visible || layer.locked {
|
||||
continue;
|
||||
}
|
||||
if let NodeKind::Layer { template: true, .. } = layer.kind {
|
||||
if layer.is_template() {
|
||||
continue;
|
||||
}
|
||||
// Pattern editing mode: only the tile art is editable.
|
||||
@@ -71,8 +73,10 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
continue;
|
||||
}
|
||||
chain.push(layer.id);
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
if let Some(mut h) = hit_children(layer, p, opt, &mut chain) {
|
||||
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
|
||||
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
|
||||
return Some(h);
|
||||
}
|
||||
chain.pop();
|
||||
@@ -105,7 +109,8 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
return None;
|
||||
}
|
||||
for c in children.iter().rev() {
|
||||
if !c.visible || c.locked {
|
||||
// Hidden, locked and template sublayers block clicks like top-level layers do.
|
||||
if !c.visible || c.locked || c.is_template() {
|
||||
continue;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
@@ -117,14 +122,16 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
}
|
||||
chain.push(c.id);
|
||||
let hit = match &c.kind {
|
||||
// A sublayer whose Preview is off is clicked in outline, like an outline view.
|
||||
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, chain),
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain),
|
||||
// Edit Contents: the envelope's content hits, undistorted.
|
||||
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, chain),
|
||||
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
|
||||
// as the blend.
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, chain)
|
||||
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None })),
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None }),
|
||||
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
|
||||
};
|
||||
if hit.is_some() {
|
||||
return hit;
|
||||
@@ -192,13 +199,28 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
let mut out = Vec::new();
|
||||
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
|
||||
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
|
||||
// The objects of the layers, looking through sublayers (hidden, locked and template ones
|
||||
// are left out).
|
||||
None => doc
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.visible && !l.locked && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
|
||||
.flat_map(|l| layer_objects(l))
|
||||
.collect(),
|
||||
};
|
||||
fn layer_objects(l: &Node) -> Vec<&std::sync::Arc<Node>> {
|
||||
let mut out = vec![];
|
||||
for c in l.children().into_iter().flatten() {
|
||||
if c.is_layer() {
|
||||
if c.visible && !c.locked && !c.is_template() {
|
||||
out.extend(layer_objects(c));
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn touches(n: &Node, r: Rect) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
|
||||
@@ -296,6 +318,54 @@ mod tests {
|
||||
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
|
||||
}
|
||||
|
||||
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
|
||||
/// sublayer; hidden, locked and template sublayers block both.
|
||||
#[test]
|
||||
fn sublayers_are_not_objects() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let sub = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap();
|
||||
let inner = d.alloc_id();
|
||||
d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap();
|
||||
let a = d.alloc_id();
|
||||
d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||||
let g = d.alloc_id();
|
||||
let b = d.alloc_id();
|
||||
let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art());
|
||||
d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap();
|
||||
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
|
||||
assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3));
|
||||
let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap();
|
||||
assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b));
|
||||
let all = Rect::new(-5.0, -5.0, 200.0, 200.0);
|
||||
assert_eq!(marquee(&d, all, None, false), vec![a, g]);
|
||||
assert_eq!(d.layer_containing(a), Some(inner));
|
||||
assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour");
|
||||
assert_eq!(d.selectable_art(), vec![a, g]);
|
||||
for f in [
|
||||
|n: &mut Node| n.visible = false,
|
||||
|n: &mut Node| n.locked = true,
|
||||
|n: &mut Node| {
|
||||
if let NodeKind::Layer { template, .. } = &mut n.kind {
|
||||
*template = true;
|
||||
}
|
||||
},
|
||||
] {
|
||||
let mut d2 = d.clone();
|
||||
f(d2.node_mut(inner).unwrap());
|
||||
assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none());
|
||||
assert_eq!(marquee(&d2, all, None, false), vec![g]);
|
||||
assert_eq!(d2.selectable_art(), vec![g]);
|
||||
}
|
||||
// A sublayer whose Preview is off is clicked in outline: its fill no longer hits.
|
||||
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind {
|
||||
*preview = false;
|
||||
}
|
||||
assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none());
|
||||
assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_and_hidden_skip() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
+12
-2
@@ -837,9 +837,19 @@ impl Document {
|
||||
pub fn is_visible(&self, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible)))
|
||||
}
|
||||
/// Colour of the layer containing `id` (selection highlight colour).
|
||||
/// The innermost layer or sublayer containing `id` (`id` itself when it is a layer).
|
||||
pub fn layer_containing(&self, id: NodeId) -> Option<NodeId> {
|
||||
let a = self.ancestry(id)?;
|
||||
a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer))
|
||||
}
|
||||
/// Every art object the Selection tool can reach (Select All): the visible, unlocked objects
|
||||
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first).
|
||||
pub fn selectable_art(&self) -> Vec<NodeId> {
|
||||
self.layers.iter().filter(|l| l.visible && !l.locked && !l.is_template()).flat_map(|l| l.layer_art(true)).collect()
|
||||
}
|
||||
/// Colour of the innermost layer or sublayer containing `id` (selection highlight colour).
|
||||
pub fn layer_color(&self, id: NodeId) -> [u8; 3] {
|
||||
let l = self.layer_of(id).and_then(|l| self.node(l));
|
||||
let l = self.layer_containing(id).and_then(|l| self.node(l));
|
||||
match l.map(|n| &n.kind) {
|
||||
Some(NodeKind::Layer { color, .. }) => color.rgb(),
|
||||
_ => LAYER_COLORS[0].1,
|
||||
|
||||
+36
-1
@@ -259,6 +259,14 @@ pub enum NodeKind {
|
||||
/// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
clip: bool,
|
||||
/// Layer Options → Preview: off, the layer's art is drawn (and clicked) in outline on screen
|
||||
/// (Ctrl-click its eye in the Layers panel).
|
||||
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
|
||||
preview: bool,
|
||||
/// Layer Options → Dim Images to: on screen, images on the layer show faded to this
|
||||
/// percentage (0–100); `None`: not dimmed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
dim_images: Option<u8>,
|
||||
},
|
||||
/// A group. With `clip`, the first (bottom-most) child is the clipping path.
|
||||
Group {
|
||||
@@ -448,7 +456,10 @@ impl Node {
|
||||
Self::new(id, NodeKind::Group { children, clip: false })
|
||||
}
|
||||
pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self {
|
||||
let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false });
|
||||
let mut n = Self::new(
|
||||
id,
|
||||
NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false, preview: true, dim_images: None },
|
||||
);
|
||||
n.name = Some(name.to_string());
|
||||
n
|
||||
}
|
||||
@@ -477,6 +488,30 @@ impl Node {
|
||||
pub fn is_layer(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Layer { .. })
|
||||
}
|
||||
/// A template layer (Layer Options → Template).
|
||||
pub fn is_template(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Layer { template: true, .. })
|
||||
}
|
||||
/// The art objects of this container: its children, looking through sublayers, which are not
|
||||
/// objects (bottom first). With `editable`, only the visible, unlocked objects in visible,
|
||||
/// unlocked, non-template sublayers.
|
||||
pub fn layer_art(&self, editable: bool) -> Vec<NodeId> {
|
||||
fn visit(n: &Node, editable: bool, out: &mut Vec<NodeId>) {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if editable && (!c.visible || c.locked || c.is_template()) {
|
||||
continue;
|
||||
}
|
||||
if c.is_layer() {
|
||||
visit(c, editable, out);
|
||||
} else {
|
||||
out.push(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
visit(self, editable, &mut out);
|
||||
out
|
||||
}
|
||||
pub fn is_container(&self) -> bool {
|
||||
self.children().is_some()
|
||||
}
|
||||
|
||||
@@ -98,11 +98,11 @@ impl Selection {
|
||||
}
|
||||
self.slices.retain(|id| doc.is_slice(*id));
|
||||
}
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected,
|
||||
/// anything else is selected itself.
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
|
||||
/// (the art of its sublayers too), anything else is selected itself.
|
||||
pub fn set_target(&mut self, doc: &Document, id: NodeId) {
|
||||
match doc.node(id) {
|
||||
Some(n) if n.is_layer() => self.set(n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id)),
|
||||
Some(n) if n.is_layer() => self.set(n.layer_art(true)),
|
||||
_ => self.set([id]),
|
||||
}
|
||||
self.target = Some(id);
|
||||
|
||||
+544
-97
@@ -1,31 +1,125 @@
|
||||
//! Layers panel, artboards.
|
||||
//!
|
||||
//! The Layers panel works on rows: layers, sublayers, groups and objects. Clicking a row makes it
|
||||
//! the highlighted row (`layer.setCurrent`; Shift and Ctrl change several, `layer.highlight`) and
|
||||
//! its layer the current one, where new art goes. Panel commands without `ids` act on the
|
||||
//! highlighted rows, else on the current layer. More panel-menu operations live in
|
||||
//! [`super::layerpanel`].
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Artboard, LayerColor, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
/// Most rows one panel command takes (ids are untrusted input).
|
||||
pub(crate) const MAX_ROWS: usize = 100_000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("layer.new", "New Layer…", ["Window", "Layers"], Some("Cmd+L"), "{name?} → {id} (above the current layer)", has_doc, new_layer),
|
||||
cmd!("layer.newSublayer", "New Sublayer…", ["Window", "Layers"], None, "{parent?: id, name?} → {id}", has_doc, new_sublayer),
|
||||
cmd!("layer.delete", "Delete Layer", ["Window", "Layers"], None, "{id?} (default: current layer)", has_doc, delete_layer),
|
||||
cmd!("layer.duplicate", "Duplicate Layer", ["Window", "Layers"], None, "{id?}", has_doc, duplicate_layer),
|
||||
cmd!("layer.setCurrent", "Set Current Layer", [], None, "{id}", has_doc, set_current),
|
||||
cmd!(
|
||||
"layer.new",
|
||||
"New Layer…",
|
||||
["Window", "Layers"],
|
||||
Some("Cmd+L"),
|
||||
"{name?, top?: bool, color?, template?, locked?, visible?, printable?, preview?, dimImages?} a layer directly above the current layer, at its level (a sublayer beside a current sublayer); `top`: above all layers (Ctrl-click the button). Options as layer.setProps → {id}",
|
||||
has_doc,
|
||||
new_layer
|
||||
),
|
||||
cmd!(
|
||||
"layer.newSublayer",
|
||||
"New Sublayer…",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{parent?: layer id (default: the highlighted row's layer, else the current layer), name?, options as layer.new} a sublayer on top of the parent's contents → {id}",
|
||||
has_doc,
|
||||
new_sublayer
|
||||
),
|
||||
cmd!(
|
||||
"layer.delete",
|
||||
"Delete Selection",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?|id?} delete rows: layers, sublayers, groups or objects with what they hold (default: the highlighted rows, else the current layer). The last layer stays → {deleted}",
|
||||
has_doc,
|
||||
delete_rows
|
||||
),
|
||||
cmd!(
|
||||
"layer.duplicate",
|
||||
"Duplicate Selection",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?|id?} copy rows (default: the highlighted rows, else the current layer), each just above its original; a layer's copy is named `<name> copy`. The copies are highlighted → {ids}",
|
||||
has_doc,
|
||||
duplicate_rows
|
||||
),
|
||||
cmd!(
|
||||
"layer.setCurrent",
|
||||
"Set Current Layer",
|
||||
[],
|
||||
None,
|
||||
"{id} click a row: it alone is highlighted, and the layer it is (or the layer it is in) becomes the current layer, where new art goes. Not an undo step → {id, layer}",
|
||||
has_doc,
|
||||
set_current
|
||||
),
|
||||
cmd!(
|
||||
"layer.highlight",
|
||||
"Highlight Rows",
|
||||
[],
|
||||
None,
|
||||
"{ids, mode?: \"set\"|\"add\"|\"toggle\"} highlight Layers panel rows (Shift-click: a range, Ctrl-click: toggle one); the last one's layer becomes current. Not an undo step → {rows}",
|
||||
has_doc,
|
||||
highlight
|
||||
),
|
||||
cmd!(
|
||||
"layer.setProps",
|
||||
"Layer Options…",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id, name?, visible?, locked?, template?, printable?, color?: index 0..26}",
|
||||
"{ids?|id? (default: the highlighted rows, else the current layer), name?, visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name} Layer Options; rows that are not layers take name, visible and locked. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step",
|
||||
has_doc,
|
||||
set_props
|
||||
),
|
||||
cmd!("layer.collectInNew", "Collect in New Layer", ["Window", "Layers"], None, "{}", has_selection, collect),
|
||||
cmd!("layer.selectAll", "Select All Art on Layer", [], None, "{id}", has_doc, select_all_on),
|
||||
cmd!("node.move", "Reorder", [], None, "{id, parent?: id (null = top level), index} drag-reorder in the Layers panel", has_doc, move_node),
|
||||
cmd!(
|
||||
"layer.collectInNew",
|
||||
"Collect in New Layer",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} move rows (default: the highlighted rows, else the selected objects) into a new layer where the topmost of them was (a sublayer when they are in a layer) → {id}",
|
||||
has_doc,
|
||||
collect
|
||||
),
|
||||
cmd!(
|
||||
"layer.selectAll",
|
||||
"Select Art",
|
||||
[],
|
||||
None,
|
||||
"{id, add?: bool} the Layers panel's selection column: select the visible, unlocked art of a layer (its sublayers' too), or the object itself; `add` (Shift-click) adds it to the selection, or removes it when it is all selected already → {selected: [..]}",
|
||||
has_doc,
|
||||
select_art
|
||||
),
|
||||
cmd!(
|
||||
"layer.move",
|
||||
"Move Rows",
|
||||
[],
|
||||
None,
|
||||
"{ids, target, place?: \"above\"|\"below\"|\"inside\" (default inside), copy?: bool} drag rows in the Layers panel: above or below the target row (beside it, in its layer or group), or inside a layer or group (on top of its contents). Layers go only in layers or at the top level, objects in layers and groups; an object placed beside a top-level layer goes inside it. A row never goes into itself; a locked layer or group takes nothing. `copy` (Alt-drag) moves copies. One undo step → {ids}",
|
||||
has_doc,
|
||||
move_rows
|
||||
),
|
||||
cmd!(
|
||||
"node.move",
|
||||
"Reorder",
|
||||
[],
|
||||
None,
|
||||
"{id, parent?: id (null = top level), index} put a row at `index` (0 = bottom) of `parent` (layers only at the top level or in layers)",
|
||||
has_doc,
|
||||
move_node
|
||||
),
|
||||
cmd!(
|
||||
"artboard.new",
|
||||
"New Artboard",
|
||||
@@ -52,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make/Release Clipping Mask",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?: layer or group (default: the one selected group, else the current layer)} make: its top object (a path, compound path or text, which loses its paint) clips the rest and moves to the bottom (new art added on top is clipped); release: it stops clipping, the clipping path stays unpainted → {clip}",
|
||||
"{id?: layer or group (default: the one highlighted layer or group row, else the one selected group, else the current layer)} make: its top object (a path, compound path or text, which loses its paint) clips the rest and moves to the bottom (new art added on top is clipped); release: it stops clipping, the clipping path stays unpainted → {clip}",
|
||||
has_doc,
|
||||
clip_toggle
|
||||
),
|
||||
@@ -61,7 +155,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Target",
|
||||
[],
|
||||
None,
|
||||
"{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
|
||||
"{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art (its sublayers' too) selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
|
||||
has_doc,
|
||||
target
|
||||
),
|
||||
@@ -77,6 +171,179 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The rows a panel command acts on: `ids` or `id`, else the highlighted rows, else the current
|
||||
/// layer (capped at [`MAX_ROWS`]).
|
||||
pub(crate) fn rows_param(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let mut ids = match (ids_param(p, "ids"), id_param(p, "id")) {
|
||||
(Some(ids), _) => ids,
|
||||
(None, Some(id)) => vec![id],
|
||||
(None, None) => {
|
||||
let st = s.doc()?;
|
||||
let rows = st.highlighted_rows();
|
||||
if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows }
|
||||
}
|
||||
};
|
||||
ids.truncate(MAX_ROWS);
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// `ids` that exist, without repeats or rows inside another listed row, in paint order (bottom
|
||||
/// first).
|
||||
pub(crate) fn row_roots(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let set: HashSet<NodeId> = ids.iter().copied().filter(|i| d.node(*i).is_some()).collect();
|
||||
let keep: Vec<NodeId> = set
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| d.ancestry(*id).is_some_and(|a| a.split_last().is_none_or(|(_, above)| !above.iter().any(|x| set.contains(x)))))
|
||||
.collect();
|
||||
d.paint_order(keep)
|
||||
}
|
||||
|
||||
/// Whether `parent` (None: the top level) can hold `n`: layers go at the top level or in layers,
|
||||
/// anything else in layers and groups.
|
||||
pub(crate) fn accepts(d: &Document, parent: Option<NodeId>, n: &Node) -> bool {
|
||||
match parent {
|
||||
None => n.is_layer(),
|
||||
Some(p) => match d.node(p).map(|x| &x.kind) {
|
||||
Some(NodeKind::Layer { .. }) => true,
|
||||
Some(NodeKind::Group { .. }) => !n.is_layer(),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` or a container around it is locked.
|
||||
fn locked_within(d: &Document, id: NodeId) -> bool {
|
||||
d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked))
|
||||
}
|
||||
|
||||
/// Every layer and sublayer, depth first.
|
||||
fn all_layers(d: &Document) -> Vec<&Node> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if n.is_layer() {
|
||||
out.push(n);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The next unused `Layer N` name, counting sublayers too.
|
||||
pub(crate) fn next_layer_name(d: &Document) -> String {
|
||||
let layers = all_layers(d);
|
||||
let taken: HashSet<&str> = layers.iter().filter_map(|l| l.name.as_deref()).collect();
|
||||
let mut i = layers.len() + 1;
|
||||
loop {
|
||||
let name = format!("Layer {i}");
|
||||
if !taken.contains(name.as_str()) {
|
||||
return name;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// A new layer named `name` (default: the next `Layer N`) in the next layer colour.
|
||||
pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
|
||||
let n = all_layers(d).len();
|
||||
let name = name.map(str::to_string).unwrap_or_else(|| next_layer_name(d));
|
||||
let id = d.alloc_id();
|
||||
Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8))
|
||||
}
|
||||
|
||||
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
|
||||
fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
|
||||
match v {
|
||||
Value::Number(n) => match n.as_u64() {
|
||||
Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
|
||||
_ => Err(format!("a colour index is 0 to {}, not {n}", LAYER_COLORS.len() - 1)),
|
||||
},
|
||||
Value::String(s) => {
|
||||
if let Some(i) = LAYER_COLORS.iter().position(|(name, _)| name.eq_ignore_ascii_case(s.trim())) {
|
||||
return Ok(LayerColor::Preset(i as u8));
|
||||
}
|
||||
let c = vectorcraft_color::Color::from_hex(s).ok_or_else(|| format!("`{s}` is not a colour (#rrggbb or a preset name)"))?;
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
Ok(LayerColor::Custom([r, g, b]))
|
||||
}
|
||||
_ => Err(format!("a colour is an index, #rrggbb or a preset name, not {v}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer Options in `p` applied to layer or row `n` (validated first by [`check_options`]).
|
||||
fn apply_options(n: &mut Node, p: &Value) {
|
||||
if let Some(v) = str_param(p, "name") {
|
||||
if v.trim().is_empty() {
|
||||
// A layer keeps a name; an object goes back to its generated `<Kind>` one.
|
||||
if !n.is_layer() {
|
||||
n.name = None;
|
||||
}
|
||||
} else {
|
||||
n.name = Some(v.chars().take(512).collect());
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
n.visible = v;
|
||||
}
|
||||
let locked = p.get("locked").and_then(Value::as_bool);
|
||||
if let Some(v) = locked {
|
||||
n.locked = v;
|
||||
}
|
||||
let mut lock = None;
|
||||
if let NodeKind::Layer { template, printable, color, preview, dim_images, .. } = &mut n.kind {
|
||||
let dim_given = p.get("dimImages").is_some();
|
||||
if let Some(v) = p.get("template").and_then(Value::as_bool)
|
||||
&& v != *template
|
||||
{
|
||||
*template = v;
|
||||
// A template is locked and its images dimmed; it stops being either with it.
|
||||
lock = Some(v);
|
||||
if !dim_given {
|
||||
*dim_images = v.then_some(50);
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("printable").and_then(Value::as_bool) {
|
||||
*printable = v;
|
||||
}
|
||||
if let Some(v) = p.get("preview").and_then(Value::as_bool) {
|
||||
*preview = v;
|
||||
}
|
||||
match p.get("dimImages") {
|
||||
Some(Value::Bool(false) | Value::Null) => *dim_images = None,
|
||||
Some(Value::Bool(true)) => *dim_images = Some(50),
|
||||
Some(v) => {
|
||||
if let Some(x) = v.as_f64().filter(|x| x.is_finite()) {
|
||||
*dim_images = Some(x.clamp(0.0, 100.0).round() as u8);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(c) = p.get("color").and_then(|v| layer_color_param(v).ok()) {
|
||||
*color = c;
|
||||
}
|
||||
}
|
||||
if let (Some(v), None) = (lock, locked) {
|
||||
n.locked = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bad values in Layer Options params, as an error for `cmd`.
|
||||
fn check_options(cmd: &str, p: &Value) -> Result<()> {
|
||||
if let Some(v) = p.get("color") {
|
||||
layer_color_param(v).map_err(|e| bad(cmd, e))?;
|
||||
}
|
||||
for k in ["visible", "locked", "template", "printable", "preview"] {
|
||||
if p.get(k).is_some_and(|v| !v.is_boolean()) {
|
||||
return Err(bad(cmd, format!("`{k}` is true or false")));
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("dimImages")
|
||||
&& !(v.is_boolean() || v.is_null() || v.as_f64().is_some_and(f64::is_finite))
|
||||
{
|
||||
return Err(bad(cmd, "`dimImages` is a percentage or false"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Layers panel → Paste Remembers Layers.
|
||||
fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = s.doc()?.doc.paste_remembers_layers;
|
||||
@@ -100,8 +367,11 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "the object is hidden or locked"));
|
||||
}
|
||||
let layer = n.is_layer();
|
||||
let rows = st.layer_rows.clone();
|
||||
s.select(|d, sel| sel.set_target(d, id))?;
|
||||
let st = s.doc_mut()?;
|
||||
// Targeting doesn't change which rows are highlighted.
|
||||
st.layer_rows = rows;
|
||||
if layer {
|
||||
st.active_layer = Some(id);
|
||||
}
|
||||
@@ -112,11 +382,16 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// or the clipping mask is released.
|
||||
fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let container = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }));
|
||||
let row = match st.highlighted_rows().as_slice() {
|
||||
[id] if container(id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let group = match st.selection.objects.as_slice() {
|
||||
[id] if matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Group { .. })) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let id = id_param(p, "id").or(group).or(st.current_layer()).ok_or_else(|| bad("layer.clippingMask.toggle", "no layer"))?;
|
||||
let id = id_param(p, "id").or(row).or(group).or(st.current_layer()).ok_or_else(|| bad("layer.clippingMask.toggle", "no layer"))?;
|
||||
let clip = s.edit("Make/Release Clipping Mask", |d, _| {
|
||||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
if !matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. }) {
|
||||
@@ -138,99 +413,142 @@ fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "clip": clip }))
|
||||
}
|
||||
|
||||
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
let st = s.doc()?;
|
||||
let cur = st.active_layer.and_then(|l| st.doc.position(l)).filter(|(par, _, _)| par.is_none()).map(|(_, i, _)| i);
|
||||
let id = s.edit("New Layer", |d, _| {
|
||||
let id = d.add_layer(name.as_deref());
|
||||
if let Some(i) = cur {
|
||||
d.move_node(id, None, i + 1)?;
|
||||
}
|
||||
Ok(id)
|
||||
/// A new layer with the options in `p`, at `parent`/`index`; it becomes current and highlighted.
|
||||
fn add_layer(s: &mut Session, p: &Value, cmd: &str, label: &str, place: impl FnOnce(&Document) -> Result<(Option<NodeId>, usize)>) -> Result<Value> {
|
||||
check_options(cmd, p)?;
|
||||
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
|
||||
let id = s.edit(label, |d, _| {
|
||||
let (parent, index) = place(d)?;
|
||||
let mut n = make_layer(d, name.as_deref());
|
||||
apply_options(&mut n, p);
|
||||
Ok(d.insert(parent, index, n)?)
|
||||
})?;
|
||||
s.doc_mut()?.active_layer = Some(id);
|
||||
let st = s.doc_mut()?;
|
||||
st.active_layer = Some(id);
|
||||
st.layer_rows = vec![id];
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
// Above the current layer, at its level; on top of everything with `top`.
|
||||
let cur = st.current_layer().filter(|_| !bool_or(p, "top", false));
|
||||
add_layer(s, p, "layer.new", "New Layer", |d| match cur.and_then(|c| d.position(c)) {
|
||||
Some((parent, i, _)) => Ok((parent, i + 1)),
|
||||
None => Ok((None, usize::MAX)),
|
||||
})
|
||||
}
|
||||
|
||||
fn new_sublayer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let parent = id_param(p, "parent").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.newSublayer", "no parent layer"))?;
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
let id = s.edit("New Sublayer", |d, _| {
|
||||
let n = d.node(parent).and_then(|n| n.children()).map(|c| c.iter().filter(|c| c.is_layer()).count()).unwrap_or(0);
|
||||
let id = d.alloc_id();
|
||||
let color = LayerColor::Preset(((d.layers.len() + n + 1) % 27) as u8);
|
||||
let nm = name.unwrap_or_else(|| format!("Layer {}", d.node_count()));
|
||||
d.insert(Some(parent), usize::MAX, vectorcraft_doc::Node::layer(id, &nm, color))?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
const C: &str = "layer.newSublayer";
|
||||
let st = s.doc()?;
|
||||
let row = st.highlighted_rows().last().and_then(|r| st.doc.layer_containing(*r));
|
||||
let parent = id_param(p, "parent").or(row).or(st.current_layer()).ok_or_else(|| bad(C, "no parent layer"))?;
|
||||
if !st.doc.node(parent).is_some_and(Node::is_layer) {
|
||||
return Err(bad(C, "the parent must be a layer or sublayer"));
|
||||
}
|
||||
add_layer(s, p, C, "New Sublayer", |_| Ok((Some(parent), usize::MAX)))
|
||||
}
|
||||
|
||||
fn delete_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.delete", "no layer"))?;
|
||||
fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let st = s.doc()?;
|
||||
if st.doc.layers.len() == 1 && st.doc.layers[0].id == id {
|
||||
let ids = row_roots(&st.doc, &ids);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.delete", "no such rows"));
|
||||
}
|
||||
if st.doc.layers.iter().all(|l| ids.contains(&l.id)) {
|
||||
return Err(EngineError::Other("a document needs at least one layer".into()));
|
||||
}
|
||||
s.edit("Delete Layer", |d, sel| {
|
||||
d.remove(id)?;
|
||||
s.edit("Delete Selection", |d, sel| {
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
if st.active_layer == Some(id) || st.active_layer.is_some_and(|l| st.doc.node(l).is_none()) {
|
||||
st.layer_rows.clear();
|
||||
if st.current_layer().is_none() {
|
||||
st.active_layer = st.doc.default_layer();
|
||||
}
|
||||
ok()
|
||||
Ok(json!({ "deleted": ids.len() }))
|
||||
}
|
||||
|
||||
fn duplicate_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.duplicate", "no layer"))?;
|
||||
let nid = s.edit("Duplicate Layer", |d, _| {
|
||||
let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
let mut c = d.reid(&n);
|
||||
c.name = Some(format!("{} copy", n.display_name()));
|
||||
Ok(d.insert(par, idx + 1, c)?)
|
||||
fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let ids = row_roots(&s.doc()?.doc, &ids);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.duplicate", "no such rows"));
|
||||
}
|
||||
let copies = s.edit("Duplicate Selection", |d, _| {
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
let mut c = d.reid(&n);
|
||||
if n.is_layer() {
|
||||
c.name = Some(format!("{} copy", n.display_name()));
|
||||
}
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "id": nid.0 }))
|
||||
s.doc_mut()?.layer_rows = copies.clone();
|
||||
Ok(json!({ "ids": copies.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("layer.setCurrent", "missing id"))?;
|
||||
const C: &str = "layer.setCurrent";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let st = s.doc_mut()?;
|
||||
if !st.doc.node(id).is_some_and(|n| n.is_layer()) {
|
||||
return Err(bad("layer.setCurrent", "not a layer"));
|
||||
}
|
||||
st.active_layer = Some(id);
|
||||
let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?;
|
||||
st.active_layer = Some(layer);
|
||||
st.layer_rows = vec![id];
|
||||
st.revision += 1;
|
||||
ok()
|
||||
Ok(json!({ "id": id.0, "layer": layer.0 }))
|
||||
}
|
||||
|
||||
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.highlight";
|
||||
let mut ids = ids_param(p, "ids").ok_or_else(|| bad(C, "missing ids"))?;
|
||||
ids.truncate(MAX_ROWS);
|
||||
let mode = str_param(p, "mode").unwrap_or("set");
|
||||
let st = s.doc_mut()?;
|
||||
ids.retain(|id| st.doc.node(*id).is_some());
|
||||
let mut rows = st.highlighted_rows();
|
||||
match mode {
|
||||
"set" => rows.clear(),
|
||||
"add" | "toggle" => {}
|
||||
other => return Err(bad(C, format!("mode is set, add or toggle, not `{other}`"))),
|
||||
}
|
||||
let mut last = None;
|
||||
for id in ids {
|
||||
if mode == "toggle" && rows.contains(&id) {
|
||||
rows.retain(|r| *r != id);
|
||||
} else if !rows.contains(&id) {
|
||||
rows.push(id);
|
||||
last = Some(id);
|
||||
}
|
||||
}
|
||||
if let Some(layer) = last.or(rows.last().copied()).and_then(|id| st.doc.layer_containing(id)) {
|
||||
st.active_layer = Some(layer);
|
||||
}
|
||||
st.layer_rows = rows;
|
||||
st.revision += 1;
|
||||
Ok(json!({ "rows": st.layer_rows.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("layer.setProps", "missing id"))?;
|
||||
const C: &str = "layer.setProps";
|
||||
check_options(C, p)?;
|
||||
let ids = rows_param(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no rows"));
|
||||
}
|
||||
s.edit("Layer Options", |d, sel| {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
if let Some(v) = str_param(p, "name") {
|
||||
n.name = Some(v.to_string());
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
n.visible = v;
|
||||
}
|
||||
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
|
||||
n.locked = v;
|
||||
}
|
||||
if let NodeKind::Layer { template, printable, color, .. } = &mut n.kind {
|
||||
if let Some(v) = p.get("template").and_then(Value::as_bool) {
|
||||
*template = v;
|
||||
}
|
||||
if let Some(v) = p.get("printable").and_then(Value::as_bool) {
|
||||
*printable = v;
|
||||
}
|
||||
if let Some(v) = p.get("color").and_then(Value::as_u64) {
|
||||
*color = LayerColor::Preset((v % 27) as u8);
|
||||
}
|
||||
for id in &ids {
|
||||
apply_options(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, p);
|
||||
}
|
||||
// Hidden or locked content can't stay selected.
|
||||
let hidden: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
|
||||
@@ -242,37 +560,166 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn collect(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = super::edit::selected_roots(s)?;
|
||||
let lid = s.edit("Collect in New Layer", |d, _| {
|
||||
let lid = d.add_layer(None);
|
||||
fn collect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.collectInNew";
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => {
|
||||
let rows = s.doc()?.highlighted_rows();
|
||||
if rows.is_empty() { super::edit::selected_roots(s)? } else { rows }
|
||||
}
|
||||
};
|
||||
let ids = row_roots(&s.doc()?.doc, &ids);
|
||||
let Some(top) = ids.last().copied() else { return Err(bad(C, "select rows or objects to collect")) };
|
||||
let lid = s.edit("Collect in New Layer", |d, sel| {
|
||||
// Where the topmost of them is: beside it in its layer, or on top of the layer holding
|
||||
// its group.
|
||||
let (parent, index) = match d.position(top) {
|
||||
Some((par, i, _)) if par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer)) => (par, i + 1),
|
||||
_ => (d.layer_containing(top), usize::MAX),
|
||||
};
|
||||
let layer = make_layer(d, None);
|
||||
let lid = d.insert(parent, index, layer)?;
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(lid), usize::MAX)?;
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(lid)
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.active_layer = Some(lid);
|
||||
st.layer_rows = vec![lid];
|
||||
Ok(json!({ "id": lid.0 }))
|
||||
}
|
||||
|
||||
fn select_all_on(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("layer.selectAll", "missing id"))?;
|
||||
let ids: Vec<NodeId> = s
|
||||
.doc()?
|
||||
.doc
|
||||
.node(id)
|
||||
.and_then(|n| n.children())
|
||||
.map(|c| c.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect())
|
||||
.unwrap_or_default();
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
ok()
|
||||
/// The selection column: select a row's art.
|
||||
fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.selectAll";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let add = bool_or(p, "add", false);
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let ids: Vec<NodeId> = if !st.doc.is_editable(id) {
|
||||
vec![]
|
||||
} else if n.is_layer() {
|
||||
n.layer_art(true)
|
||||
} else {
|
||||
vec![id]
|
||||
};
|
||||
let all_selected = !ids.is_empty() && ids.iter().all(|i| st.selection.contains(*i));
|
||||
let rows = st.layer_rows.clone();
|
||||
s.select(|_, sel| {
|
||||
if !add {
|
||||
sel.set(ids.iter().copied());
|
||||
} else if all_selected {
|
||||
for i in &ids {
|
||||
sel.remove(*i);
|
||||
}
|
||||
} else {
|
||||
for i in &ids {
|
||||
sel.add(*i);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
// The selection column leaves the highlighted rows as they are.
|
||||
st.layer_rows = rows;
|
||||
Ok(json!({ "selected": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Place {
|
||||
Above,
|
||||
Below,
|
||||
Inside,
|
||||
}
|
||||
|
||||
/// Drag rows in the Layers panel.
|
||||
fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.move";
|
||||
let mut ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?;
|
||||
ids.truncate(MAX_ROWS);
|
||||
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?;
|
||||
let place = match str_param(p, "place").unwrap_or("inside") {
|
||||
"above" => Place::Above,
|
||||
"below" => Place::Below,
|
||||
"inside" | "into" => Place::Inside,
|
||||
other => return Err(bad(C, format!("place is above, below or inside, not `{other}`"))),
|
||||
};
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
d.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
let ids = row_roots(d, &ids);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no such rows"));
|
||||
}
|
||||
// Never into itself or a row inside it.
|
||||
let around = d.ancestry(target).unwrap_or_default();
|
||||
if ids.iter().any(|i| around.contains(i)) {
|
||||
return Err(bad(C, "a row can't go into itself"));
|
||||
}
|
||||
let nodes: Vec<&Node> = ids.iter().filter_map(|i| d.node(*i)).collect();
|
||||
let (parent, anchor) = match place {
|
||||
Place::Inside => (Some(target), None),
|
||||
_ => match d.parent_of(target) {
|
||||
// Objects can't be at the top level: beside a top-level layer means in it.
|
||||
None if nodes.iter().any(|n| !n.is_layer()) => (Some(target), None),
|
||||
parent => (parent, Some(target)),
|
||||
},
|
||||
};
|
||||
if let Some(n) = nodes.iter().find(|n| !accepts(d, parent, n)) {
|
||||
let into = parent.and_then(|p| d.node(p)).map_or("the top level", |p| if p.is_layer() { "a layer" } else { p.kind_label() });
|
||||
return Err(bad(C, format!("{} can't go into {into}", n.display_name())));
|
||||
}
|
||||
if parent.is_some_and(|p| locked_within(d, p)) {
|
||||
return Err(EngineError::Other("the layer or group is locked".into()));
|
||||
}
|
||||
let label = if copy { "Duplicate Rows" } else { "Move Rows" };
|
||||
let moved = s.edit(label, |d, sel| {
|
||||
let mut nodes = vec![];
|
||||
for id in &ids {
|
||||
nodes.push(if copy {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
d.reid(&n)
|
||||
} else {
|
||||
Arc::unwrap_or_clone(d.remove(*id)?)
|
||||
});
|
||||
}
|
||||
let count = d.children(parent).map_or(0, Vec::len);
|
||||
let mut index = match anchor {
|
||||
None => count,
|
||||
Some(a) => {
|
||||
let (_, i, _) = d.position(a).ok_or(EngineError::NoNode(a))?;
|
||||
if place == Place::Above { i + 1 } else { i }
|
||||
}
|
||||
};
|
||||
// The clipping path stays at the bottom of a clip group or a clipping layer.
|
||||
if count > 0 && parent.and_then(|p| d.node(p)).is_some_and(Node::clips) {
|
||||
index = index.max(1);
|
||||
}
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
out.push(d.insert(parent, index, n)?);
|
||||
index += 1;
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(out)
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.layer_rows = moved.clone();
|
||||
Ok(json!({ "ids": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("node.move", "missing id"))?;
|
||||
const C: &str = "node.move";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let parent = id_param(p, "parent");
|
||||
let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX) as usize;
|
||||
if parent.is_none() && !s.doc()?.doc.node(id).is_some_and(|n| n.is_layer()) {
|
||||
return Err(bad("node.move", "only layers can be at the top level"));
|
||||
let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX).min(usize::MAX as u64) as usize;
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
if !accepts(d, parent, n) {
|
||||
return Err(bad(C, "layers go only at the top level or in layers, objects in layers and groups"));
|
||||
}
|
||||
s.edit("Reorder", |d, _| Ok(d.move_node(id, parent, index)?))?;
|
||||
ok()
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
//! The Layers panel menu's operations on rows: Merge Selected, Flatten Artwork, Release to Layers,
|
||||
//! Reverse Order, Template, Hide/Outline/Lock Others and their Show/Preview/Unlock All
|
||||
//! counterparts, Locate Object. Rows default to the highlighted ones, else the current layer (see
|
||||
//! [`super::layer`]).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::layer::{make_layer, row_roots, rows_param};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"layer.merge",
|
||||
"Merge Selected",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} (default: the highlighted rows) merge two or more rows into the layer listed (highlighted) last: the other layers' contents and the other rows move into it, keeping their stacking order, and the emptied layers go. One undo step → {id}",
|
||||
has_doc,
|
||||
merge
|
||||
),
|
||||
cmd!(
|
||||
"layer.flatten",
|
||||
"Flatten Artwork",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?} (default: the highlighted row's top-level layer, else the current one's) move the art of every other visible top-level layer into this one, keeping the stacking order; hidden layers and their art are deleted, template layers stay. One undo step → {id, discarded}",
|
||||
has_doc,
|
||||
flatten
|
||||
),
|
||||
cmd!(
|
||||
"layer.releaseToLayers",
|
||||
"Release to Layers (Sequence)",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?: layer or group (default: the highlighted row, else the current layer), build?: bool} each object in it goes into a new sublayer of its own, in stacking order (a group's objects go into sublayers of its layer, where the group was). `build`: Release to Layers (Build), each new sublayer holds copies of the objects up to its own, so the layers add up. One undo step → {ids}",
|
||||
has_doc,
|
||||
release
|
||||
),
|
||||
cmd!(
|
||||
"layer.releaseToLayersBuild",
|
||||
"Release to Layers (Build)",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?} layer.releaseToLayers with build: true → {ids}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let mut p = p.clone();
|
||||
if let Some(m) = p.as_object_mut() {
|
||||
m.insert("build".into(), json!(true));
|
||||
} else {
|
||||
p = json!({"build": true});
|
||||
}
|
||||
release(s, &p)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"layer.reverse",
|
||||
"Reverse Order",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} (default: the highlighted rows) reverse the stacking order of two or more rows that share a layer or group; each keeps the places the others held. One undo step",
|
||||
has_doc,
|
||||
reverse
|
||||
),
|
||||
cmd!(
|
||||
"layer.template",
|
||||
"Template",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?, on?: bool (default: toggle the first)} make layers templates (locked, dimmed, left out of print and export) or ordinary layers again (layer.setProps template) → {on}",
|
||||
has_doc,
|
||||
template
|
||||
),
|
||||
cmd!(
|
||||
"layer.hideOthers",
|
||||
"Hide Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer). One undo step → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Visible, false)
|
||||
),
|
||||
cmd!("layer.showAll", "Show All Layers", ["Window", "Layers"], None, "{} show every layer and sublayer → {count}", has_doc, |s, _| all(
|
||||
s,
|
||||
Flag::Visible,
|
||||
true
|
||||
)),
|
||||
cmd!(
|
||||
"layer.outlineOthers",
|
||||
"Outline Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Preview, false)
|
||||
),
|
||||
cmd!(
|
||||
"layer.previewAll",
|
||||
"Preview All Layers",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{} turn Preview on for every layer and sublayer → {count}",
|
||||
has_doc,
|
||||
|s, _| all(s, Flag::Preview, true)
|
||||
),
|
||||
cmd!(
|
||||
"layer.lockOthers",
|
||||
"Lock Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Locked, true)
|
||||
),
|
||||
cmd!(
|
||||
"layer.unlockAll",
|
||||
"Unlock All Layers",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{} unlock every layer and sublayer → {count}",
|
||||
has_doc,
|
||||
|s, _| all(s, Flag::Locked, false)
|
||||
),
|
||||
cmd!(
|
||||
"layer.locate",
|
||||
"Locate Object",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?} (default: the last selected object) highlight the object's row; the Layers panel opens the layers around it and scrolls to it → {id, ancestry: [layer…, id]}",
|
||||
has_doc,
|
||||
locate
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// A layer flag the panel menu sets on many layers.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Flag {
|
||||
Visible,
|
||||
Locked,
|
||||
Preview,
|
||||
}
|
||||
|
||||
impl Flag {
|
||||
fn get(self, n: &Node) -> bool {
|
||||
match (self, &n.kind) {
|
||||
(Flag::Visible, _) => n.visible,
|
||||
(Flag::Locked, _) => n.locked,
|
||||
(Flag::Preview, NodeKind::Layer { preview, .. }) => *preview,
|
||||
(Flag::Preview, _) => true,
|
||||
}
|
||||
}
|
||||
fn set(self, n: &mut Node, v: bool) {
|
||||
match (self, &mut n.kind) {
|
||||
(Flag::Visible, _) => n.visible = v,
|
||||
(Flag::Locked, _) => n.locked = v,
|
||||
(Flag::Preview, NodeKind::Layer { preview, .. }) => *preview = v,
|
||||
(Flag::Preview, _) => {}
|
||||
}
|
||||
}
|
||||
fn label(self, on: bool) -> &'static str {
|
||||
match (self, on) {
|
||||
(Flag::Visible, true) => "Show All Layers",
|
||||
(Flag::Visible, false) => "Hide Others",
|
||||
(Flag::Locked, true) => "Lock Others",
|
||||
(Flag::Locked, false) => "Unlock All Layers",
|
||||
(Flag::Preview, true) => "Preview All Layers",
|
||||
(Flag::Preview, false) => "Outline Others",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `flag` to `v` on `ids` (layers that differ) in one undo step labelled `label` → how many
|
||||
/// changed.
|
||||
fn set_flag(s: &mut Session, ids: Vec<NodeId>, flag: Flag, v: bool, label: &str) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| flag.get(n) != v)).collect();
|
||||
if !ids.is_empty() {
|
||||
s.edit(label, |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
flag.set(n, v);
|
||||
}
|
||||
}
|
||||
// Hidden or locked art can't stay selected.
|
||||
let out: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
|
||||
for o in out {
|
||||
sel.remove(o);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
}
|
||||
|
||||
/// The top-level layers holding the rows a command acts on.
|
||||
fn kept_layers(s: &Session, p: &Value) -> Result<HashSet<NodeId>> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(ids.iter().filter_map(|id| d.layer_of(*id)).collect())
|
||||
}
|
||||
|
||||
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool) -> Result<Value> {
|
||||
let keep = kept_layers(s, p)?;
|
||||
let ids: Vec<NodeId> = s.doc()?.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect();
|
||||
set_flag(s, ids, flag, v, flag.label(v))
|
||||
}
|
||||
|
||||
fn all(s: &mut Session, flag: Flag, v: bool) -> Result<Value> {
|
||||
let mut ids = vec![];
|
||||
s.doc()?.doc.walk(|n| {
|
||||
if n.is_layer() {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
set_flag(s, ids, flag, v, flag.label(v))
|
||||
}
|
||||
|
||||
fn template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(Node::is_layer)).collect();
|
||||
let Some(first) = ids.first().and_then(|id| d.node(*id)) else { return Err(bad("layer.template", "select a layer")) };
|
||||
let on = bool_or(p, "on", !first.is_template());
|
||||
s.execute("layer.setProps", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "template": on}))?;
|
||||
Ok(json!({ "on": on }))
|
||||
}
|
||||
|
||||
/// Move the contents of `others` (layers give their children, anything else itself; none inside
|
||||
/// another) into layer `into`, keeping the document's stacking order, then delete the emptied
|
||||
/// layers.
|
||||
fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
|
||||
let own: Vec<NodeId> =
|
||||
d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !others.contains(c)).collect()).unwrap_or_default();
|
||||
let mut moved = vec![];
|
||||
for id in others {
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if n.is_layer() {
|
||||
moved.extend(n.children().into_iter().flatten().map(|c| c.id));
|
||||
} else {
|
||||
moved.push(*id);
|
||||
}
|
||||
}
|
||||
// Where everything is now: the merged layer stacks its contents in this order.
|
||||
let keys: HashMap<NodeId, Vec<usize>> = own.iter().chain(&moved).filter_map(|id| Some((*id, d.index_path(*id)?))).collect();
|
||||
let mut taken = vec![];
|
||||
for id in &moved {
|
||||
taken.push(d.remove(*id)?);
|
||||
}
|
||||
for id in others {
|
||||
if d.node(*id).is_some() {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
}
|
||||
let ch = d.children_mut(Some(into))?;
|
||||
let mut all = std::mem::take(ch);
|
||||
all.extend(taken);
|
||||
all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id)));
|
||||
*ch = all;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.merge";
|
||||
let ids = rows_param(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let mut seen = HashSet::new();
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some() && seen.insert(*id)).collect();
|
||||
if ids.len() < 2 {
|
||||
return Err(bad(C, "highlight two or more rows to merge"));
|
||||
}
|
||||
let into =
|
||||
ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
|
||||
let around = d.ancestry(into).unwrap_or_default();
|
||||
// (Rows inside the target layer merge too: only rows inside other listed rows go with those.)
|
||||
let rest: Vec<NodeId> = ids.iter().copied().filter(|id| *id != into).collect();
|
||||
let others = row_roots(d, &rest);
|
||||
if others.iter().any(|o| around.contains(o)) {
|
||||
return Err(bad(C, "a layer can't be merged into its own sublayer"));
|
||||
}
|
||||
s.edit("Merge Selected", |d, sel| {
|
||||
merge_into(d, into, &others)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.active_layer = Some(into);
|
||||
st.layer_rows = vec![into];
|
||||
Ok(json!({ "id": into.0 }))
|
||||
}
|
||||
|
||||
fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.flatten";
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let from = id_param(p, "id").or_else(|| st.highlighted_rows().last().copied()).or(st.current_layer());
|
||||
let into = from.and_then(|id| d.layer_of(id)).ok_or_else(|| bad(C, "no layer"))?;
|
||||
if !d.node(into).is_some_and(|n| n.visible) {
|
||||
return Err(EngineError::Other("the layer to flatten into is hidden".into()));
|
||||
}
|
||||
let others: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
let hidden: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
s.edit("Flatten Artwork", |d, sel| {
|
||||
for h in &hidden {
|
||||
d.remove(*h)?;
|
||||
}
|
||||
merge_into(d, into, &others)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.active_layer = Some(into);
|
||||
st.layer_rows = vec![into];
|
||||
Ok(json!({ "id": into.0, "discarded": hidden.len() }))
|
||||
}
|
||||
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.releaseToLayers";
|
||||
let build = bool_or(p, "build", false);
|
||||
let st = s.doc()?;
|
||||
let id = match id_param(p, "id") {
|
||||
Some(id) => id,
|
||||
None => match st.highlighted_rows().as_slice() {
|
||||
[id] => *id,
|
||||
_ => st.current_layer().ok_or_else(|| bad(C, "no layer"))?,
|
||||
},
|
||||
};
|
||||
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let group = match n.kind {
|
||||
NodeKind::Layer { .. } => false,
|
||||
NodeKind::Group { .. } => true,
|
||||
_ => return Err(bad(C, "release a layer or a group")),
|
||||
};
|
||||
// Its objects, bottom first (sublayers stay as they are).
|
||||
let objects: Vec<NodeId> = n.children().into_iter().flatten().filter(|c| !c.is_layer()).map(|c| c.id).collect();
|
||||
if objects.is_empty() {
|
||||
return Err(EngineError::Other("there are no objects to release".into()));
|
||||
}
|
||||
let new = s.edit(if build { "Release to Layers (Build)" } else { "Release to Layers (Sequence)" }, |d, sel| {
|
||||
// A group's objects go into sublayers of the layer holding it, where it was.
|
||||
let (parent, mut index) = if group {
|
||||
let layer = d.layer_containing(id).ok_or(EngineError::NoNode(id))?;
|
||||
let (par, i, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
if par == Some(layer) { (layer, i) } else { (layer, usize::MAX) }
|
||||
} else {
|
||||
(id, 0)
|
||||
};
|
||||
let mut originals = vec![];
|
||||
for o in &objects {
|
||||
originals.push(Arc::unwrap_or_clone(d.remove(*o)?));
|
||||
}
|
||||
if group {
|
||||
d.remove(id)?;
|
||||
}
|
||||
let mut new = vec![];
|
||||
let last = originals.len().saturating_sub(1);
|
||||
for k in 0..originals.len() {
|
||||
let mut layer = make_layer(d, None);
|
||||
let lid = layer.id;
|
||||
let held: Vec<Node> = if build {
|
||||
// Copies of the objects below and itself; the top layer keeps the originals.
|
||||
(0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { d.reid(o) }).collect()
|
||||
} else {
|
||||
originals.get(k).cloned().into_iter().collect()
|
||||
};
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
ch.extend(held.into_iter().map(Arc::new));
|
||||
}
|
||||
let at = if group { index } else { index.min(d.children(Some(parent)).map_or(0, Vec::len)) };
|
||||
d.insert(Some(parent), at, layer)?;
|
||||
index = at.saturating_add(1);
|
||||
new.push(lid);
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(new)
|
||||
})?;
|
||||
s.doc_mut()?.layer_rows = new.clone();
|
||||
Ok(json!({ "ids": new.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.reverse";
|
||||
let ids = rows_param(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let ids = row_roots(d, &ids);
|
||||
// Rows grouped by the layer or group holding them (None: top-level layers).
|
||||
let mut by_parent: Vec<(Option<NodeId>, Vec<usize>)> = vec![];
|
||||
for id in &ids {
|
||||
let Some((par, i, _)) = d.position(*id) else { continue };
|
||||
match by_parent.iter_mut().find(|(p, _)| *p == par) {
|
||||
Some((_, v)) => v.push(i),
|
||||
None => by_parent.push((par, vec![i])),
|
||||
}
|
||||
}
|
||||
by_parent.retain(|(_, v)| v.len() >= 2);
|
||||
if by_parent.is_empty() {
|
||||
return Err(bad(C, "highlight two or more rows in one layer or group"));
|
||||
}
|
||||
s.edit("Reverse Order", |d, _| {
|
||||
for (par, mut slots) in by_parent {
|
||||
slots.sort_unstable();
|
||||
let ch = d.children_mut(par)?;
|
||||
let nodes: Vec<Arc<Node>> = slots.iter().filter_map(|i| ch.get(*i).cloned()).collect();
|
||||
for (slot, n) in slots.iter().zip(nodes.into_iter().rev()) {
|
||||
if let Some(c) = ch.get_mut(*slot) {
|
||||
*c = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn locate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
let id = id_param(p, "id").or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?;
|
||||
let ancestry = st.doc.ancestry(id).ok_or(EngineError::NoNode(id))?;
|
||||
st.layer_rows = vec![id];
|
||||
st.revision += 1;
|
||||
Ok(json!({ "id": id.0, "ancestry": ancestry.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
@@ -30,6 +30,7 @@ pub(crate) mod graph;
|
||||
mod halftone;
|
||||
pub mod help;
|
||||
mod layer;
|
||||
mod layerpanel;
|
||||
pub mod links;
|
||||
mod live;
|
||||
pub(crate) mod maskedit;
|
||||
@@ -191,6 +192,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(gradient::specs());
|
||||
v.extend(opacitymask::specs());
|
||||
v.extend(layer::specs());
|
||||
v.extend(layerpanel::specs());
|
||||
v.extend(draw2::specs());
|
||||
v.extend(xform::specs());
|
||||
v.extend(effectcmd::specs());
|
||||
|
||||
@@ -97,17 +97,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// Selectable objects: children of visible, unlocked layers (or of the isolation container).
|
||||
/// The objects Select All takes: those of the isolated container, else of every layer (looking
|
||||
/// through sublayers, which are not objects).
|
||||
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
|
||||
match iso.and_then(|i| d.node(i)) {
|
||||
Some(c) => c.children().map(|v| v.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect()).unwrap_or_default(),
|
||||
None => d
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.visible && !l.locked)
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|n| n.visible && !n.locked)
|
||||
.map(|n| n.id)
|
||||
.collect(),
|
||||
Some(c) => c.layer_art(true),
|
||||
None => d.selectable_art(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +245,11 @@ fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Valu
|
||||
/// inside accepted ones); Select > Reselect repeats `cmd` with `p`.
|
||||
fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result<Value> {
|
||||
fn visit(d: &Document, n: &Node, f: &impl Fn(&Document, &Node) -> bool, ids: &mut Vec<NodeId>) {
|
||||
if n.visible && !n.locked && !n.is_layer() && f(d, n) {
|
||||
// Hidden or locked objects and sublayers (and what they hold) are out of reach.
|
||||
if !n.visible || n.locked || n.is_template() {
|
||||
return;
|
||||
}
|
||||
if !n.is_layer() && f(d, n) {
|
||||
return ids.push(n.id);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
@@ -307,14 +306,9 @@ fn same_attribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn same_layers(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect();
|
||||
let ids: Vec<NodeId> = layers
|
||||
.iter()
|
||||
.filter_map(|l| st.doc.node(*l))
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|n| n.visible && !n.locked)
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
// The layers or sublayers the selected objects are on (art in their sublayers too).
|
||||
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_containing(*id)).collect();
|
||||
let ids: Vec<NodeId> = layers.iter().filter_map(|l| st.doc.node(*l)).flat_map(|l| l.layer_art(true)).collect();
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -52,6 +52,15 @@ pub fn node_summary(n: &Node) -> Value {
|
||||
v["strokeOptions"] = stroke_options(&st);
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Layer { color, template, printable, preview, dim_images, clip, .. } => {
|
||||
let [r, g, b] = color.rgb();
|
||||
v["color"] = json!(format!("#{r:02x}{g:02x}{b:02x}"));
|
||||
v["template"] = json!(template);
|
||||
v["printable"] = json!(printable);
|
||||
v["preview"] = json!(preview);
|
||||
v["dimImages"] = json!(dim_images);
|
||||
v["clip"] = json!(clip);
|
||||
}
|
||||
NodeKind::Path { path, .. } => {
|
||||
v["anchors"] = json!(path.anchor_count());
|
||||
v["closed"] = json!(path.is_closed());
|
||||
@@ -79,6 +88,8 @@ pub fn document(s: &Session) -> Value {
|
||||
// Top of the stack first, like the Layers panel.
|
||||
"layers": d.layers.iter().rev().map(|l| node_summary(l)).collect::<Vec<_>>(),
|
||||
"currentLayer": st.active_layer.map(|l| l.0),
|
||||
// The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`).
|
||||
"layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"isolation": st.isolation.map(|l| l.0),
|
||||
"selection": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)),
|
||||
|
||||
@@ -102,6 +102,11 @@ pub struct DocState {
|
||||
saved_doc: Arc<Document>,
|
||||
/// The layer new art goes into (the "current layer" in the Layers panel).
|
||||
pub active_layer: Option<NodeId>,
|
||||
/// The rows highlighted in the Layers panel (layers, sublayers, groups or objects, in the order
|
||||
/// they were clicked): what the panel's Duplicate, Delete, Merge, Options… and similar act on.
|
||||
/// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`,
|
||||
/// `layer.highlight`).
|
||||
pub layer_rows: Vec<NodeId>,
|
||||
/// Isolation mode container.
|
||||
pub isolation: Option<NodeId>,
|
||||
pub interaction: Option<Interaction>,
|
||||
@@ -156,6 +161,7 @@ impl DocState {
|
||||
path,
|
||||
revision: 1,
|
||||
active_layer,
|
||||
layer_rows: vec![],
|
||||
isolation: None,
|
||||
interaction: None,
|
||||
last_transform: None,
|
||||
@@ -236,7 +242,13 @@ impl DocState {
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer() && !n.locked)).or_else(|| self.doc.default_layer())
|
||||
// A sublayer takes new art only while it and the layers around it are shown and unlocked.
|
||||
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())
|
||||
}
|
||||
/// The highlighted Layers panel rows that still exist (ids are reused after undo, so a
|
||||
/// remembered row must still be in the document).
|
||||
pub fn highlighted_rows(&self) -> Vec<NodeId> {
|
||||
self.layer_rows.iter().copied().filter(|id| self.doc.node(*id).is_some()).collect()
|
||||
}
|
||||
/// The object whose opacity mask View Opacity Mask shows ([`DocState::mask_view`]): only while
|
||||
/// its mask is being edited, so leaving editing by any route (undo, deleting the object) ends it.
|
||||
@@ -1069,8 +1081,18 @@ impl Session {
|
||||
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
|
||||
self.active_appearance_item = None;
|
||||
let st = self.doc_mut()?;
|
||||
let before = st.selection.objects.clone();
|
||||
f(&st.doc, &mut st.selection);
|
||||
st.selection.prune(&st.doc);
|
||||
// Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of
|
||||
// the reference app: new art then goes beside it.
|
||||
if st.selection.objects != before
|
||||
&& let Some(layer) = st.selection.objects.last().and_then(|id| st.doc.layer_containing(*id)).filter(|l| st.doc.is_editable(*l))
|
||||
&& st.active_layer != Some(layer)
|
||||
{
|
||||
st.active_layer = Some(layer);
|
||||
st.layer_rows.clear();
|
||||
}
|
||||
st.revision += 1;
|
||||
// Puppet Warp pins belong to the art they were placed on: another selection starts afresh.
|
||||
if st.doc.puppet.as_ref().is_some_and(|p| p.ids != st.selection.objects) {
|
||||
@@ -1267,6 +1289,8 @@ mod tests_labspots;
|
||||
#[cfg(test)]
|
||||
mod tests_layerclip;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_linked_stops;
|
||||
#[cfg(test)]
|
||||
mod tests_links;
|
||||
|
||||
@@ -115,8 +115,8 @@ fn the_top_object_must_be_able_to_clip() {
|
||||
assert_eq!(layer(&s), before);
|
||||
// An explicit id picks the layer even with a group selected.
|
||||
let l = before.id;
|
||||
s.execute("layer.newSublayer", &json!({"parent": l.0})).unwrap();
|
||||
let sub = layer(&s).children().unwrap().last().unwrap().id;
|
||||
let sub = id_of(&s.execute("layer.newSublayer", &json!({"parent": l.0})).unwrap());
|
||||
assert_eq!(s.doc().unwrap().doc.node(l).unwrap().children().unwrap().last().unwrap().id, sub, "on top of the layer's contents");
|
||||
let err = s.execute("layer.clippingMask.toggle", &json!({"id": sub.0}));
|
||||
assert!(err.is_err(), "an empty sublayer has nothing to clip by");
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
//! The Layers panel's commands: rows at every depth (layers, sublayers, groups, objects) are
|
||||
//! clickable, highlighted and targeted; their art is selected through the selection column; rows
|
||||
//! move, copy and reparent with validation; and the panel menu's operations (merge, flatten,
|
||||
//! release to layers, reverse, template, others/all) are single undo steps.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{LayerColor, Node, NodeId, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
id(&s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap())
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn parent(s: &Session, id: NodeId) -> Option<NodeId> {
|
||||
s.doc().unwrap().doc.parent_of(id)
|
||||
}
|
||||
|
||||
fn children(s: &Session, id: NodeId) -> Vec<NodeId> {
|
||||
node(s, id).children().unwrap().iter().map(|c| c.id).collect()
|
||||
}
|
||||
|
||||
fn undos(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// Layer 1 holding a sublayer `sub` with a rectangle `a` and a group `g` of rectangle `b`, plus a
|
||||
/// rectangle `c` on Layer 1 itself → (layer, sub, a, g, b, c). Nothing selected.
|
||||
fn tree(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId, NodeId, NodeId) {
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let c = rect(s, 300.0);
|
||||
let sub = id(&run(s, "layer.newSublayer", json!({"name": "Sub"})));
|
||||
let a = rect(s, 0.0);
|
||||
let b = rect(s, 100.0);
|
||||
run(s, "select.set", json!({"ids": [b.0]}));
|
||||
let g = id(&run(s, "object.group", json!({})));
|
||||
run(s, "select.none", json!({}));
|
||||
(layer, sub, a, g, b, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_in_a_sublayer_is_selected_as_objects_not_the_sublayer() {
|
||||
let mut s = session();
|
||||
let (_, sub, a, g, _, c) = tree(&mut s);
|
||||
assert_eq!(parent(&s, a), Some(sub), "new art went into the current sublayer");
|
||||
let n = run(&mut s, "select.all", json!({}))["count"].as_u64().unwrap();
|
||||
assert_eq!(n, 3);
|
||||
let sel = s.doc().unwrap().selection.objects.clone();
|
||||
assert!(sel.contains(&a) && sel.contains(&g) && sel.contains(&c) && !sel.contains(&sub), "{sel:?}");
|
||||
// The Selection tool's click takes the object in the sublayer.
|
||||
let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, vectorcraft_geom::Point::new(10.0, 10.0), Default::default()).unwrap();
|
||||
assert_eq!(h.top_object(None), a);
|
||||
// Deleting it works (layers were skipped by Clear before).
|
||||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||||
run(&mut s, "edit.clear", json!({}));
|
||||
assert!(s.doc().unwrap().doc.node(a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_rows_at_any_depth_highlights_them_and_sets_the_current_layer() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, b, c) = tree(&mut s);
|
||||
let before = undos(&s);
|
||||
for (row, current) in [(layer, layer), (sub, sub), (g, sub), (b, sub), (a, sub), (c, layer)] {
|
||||
let r = run(&mut s, "layer.setCurrent", json!({"id": row.0}));
|
||||
assert_eq!(NodeId(r["layer"].as_u64().unwrap()), current);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![row], Some(current)));
|
||||
}
|
||||
assert_eq!(undos(&s), before, "clicking rows is not an undo step");
|
||||
// New art goes into the current sublayer.
|
||||
run(&mut s, "layer.setCurrent", json!({"id": a.0}));
|
||||
let n = rect(&mut s, 50.0);
|
||||
assert_eq!(parent(&s, n), Some(sub));
|
||||
assert!(s.execute("layer.setCurrent", &json!({"id": 9999})).is_err());
|
||||
// Shift/Ctrl: several rows.
|
||||
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0], "mode": "set"}));
|
||||
run(&mut s, "layer.highlight", json!({"ids": [c.0], "mode": "add"}));
|
||||
run(&mut s, "layer.highlight", json!({"ids": [a.0], "mode": "toggle"}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![g, c], Some(layer)));
|
||||
assert!(s.execute("layer.highlight", &json!({"ids": [a.0], "mode": "nope"})).is_err());
|
||||
assert_eq!(document_rows(&s), vec![g.0, c.0], "document.inspect lists the rows");
|
||||
}
|
||||
|
||||
fn document_rows(s: &Session) -> Vec<u64> {
|
||||
crate::inspect::document(s)["layerRows"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_art_makes_its_layer_current() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, _, _, c) = tree(&mut s);
|
||||
run(&mut s, "select.set", json!({"ids": [c.0]}));
|
||||
assert_eq!(s.doc().unwrap().current_layer(), Some(layer));
|
||||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||||
assert_eq!(s.doc().unwrap().current_layer(), Some(sub));
|
||||
let n = rect(&mut s, 200.0);
|
||||
assert_eq!(parent(&s, n), Some(sub));
|
||||
// A locked sublayer takes no new art: it goes to the top layer.
|
||||
run(&mut s, "select.none", json!({}));
|
||||
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": true}));
|
||||
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
|
||||
let n = rect(&mut s, 200.0);
|
||||
assert_eq!(parent(&s, n), Some(layer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_column_selects_a_rows_art_and_shift_adds_or_removes_it() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, _, c) = tree(&mut s);
|
||||
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
|
||||
let sel = |s: &Session| s.doc().unwrap().selection.objects.clone();
|
||||
run(&mut s, "layer.selectAll", json!({"id": layer.0}));
|
||||
assert_eq!(sel(&s), vec![c, a, g], "the layer's art, its sublayer's too");
|
||||
run(&mut s, "layer.selectAll", json!({"id": sub.0}));
|
||||
assert_eq!(sel(&s), vec![a, g]);
|
||||
run(&mut s, "layer.selectAll", json!({"id": c.0, "add": true}));
|
||||
assert_eq!(sel(&s), vec![a, g, c]);
|
||||
run(&mut s, "layer.selectAll", json!({"id": sub.0, "add": true}));
|
||||
assert_eq!(sel(&s), vec![c], "all selected already: removed");
|
||||
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![sub], "the highlighted row stays");
|
||||
// Locked rows select nothing.
|
||||
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": true}));
|
||||
run(&mut s, "layer.selectAll", json!({"id": a.0}));
|
||||
assert!(sel(&s).is_empty());
|
||||
// Targeting a layer selects its sublayers' art too.
|
||||
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": false}));
|
||||
assert_eq!(ids_sel(&run(&mut s, "layer.target", json!({"id": layer.0}))), vec![c, a, g]);
|
||||
}
|
||||
|
||||
fn ids_sel(v: &Value) -> Vec<NodeId> {
|
||||
v["selected"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_and_lock_per_row_are_undoable() {
|
||||
let mut s = session();
|
||||
let (_, sub, a, g, b, _) = tree(&mut s);
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, g.0]}));
|
||||
let before = undos(&s);
|
||||
run(&mut s, "layer.setProps", json!({"ids": [b.0, sub.0], "visible": false}));
|
||||
assert!(!node(&s, b).visible && !node(&s, sub).visible);
|
||||
assert!(s.doc().unwrap().selection.is_empty(), "hidden art is deselected");
|
||||
assert_eq!(undos(&s), before + 1, "one step for several rows");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(node(&s, b).visible && node(&s, sub).visible);
|
||||
run(&mut s, "layer.setProps", json!({"id": a.0, "locked": true, "name": "Box"}));
|
||||
assert!(node(&s, a).locked);
|
||||
assert_eq!(node(&s, a).display_name(), "Box");
|
||||
run(&mut s, "layer.setProps", json!({"id": a.0, "name": ""}));
|
||||
assert_eq!(node(&s, a).display_name(), "<Rectangle>", "an empty name goes back to the generated one");
|
||||
run(&mut s, "layer.setProps", json!({"id": sub.0, "name": ""}));
|
||||
assert_eq!(node(&s, sub).display_name(), "Sub", "a layer keeps its name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_options_validate_and_template_locks_and_dims() {
|
||||
let mut s = session();
|
||||
let (layer, ..) = tree(&mut s);
|
||||
for bad in [json!({"color": 27}), json!({"color": "#zz"}), json!({"color": [1]}), json!({"locked": "yes"}), json!({"dimImages": "half"})] {
|
||||
let mut p = bad.clone();
|
||||
p["id"] = json!(layer.0);
|
||||
assert!(s.execute("layer.setProps", &p).is_err(), "{bad}");
|
||||
}
|
||||
let color = |s: &Session| match node(s, layer).kind {
|
||||
NodeKind::Layer { color, .. } => color,
|
||||
_ => 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());
|
||||
}
|
||||
@@ -330,6 +330,9 @@ pub struct Renderer {
|
||||
/// The keys and sizes of the recoloured images colour adjustments made in `images`, oldest
|
||||
/// first (see [`Self::adjusted_image`]).
|
||||
adjusted: std::collections::VecDeque<(String, usize)>,
|
||||
/// Layer Options → Dim Images to, of the layer being drawn (screen views only): images show
|
||||
/// faded to this opacity over white.
|
||||
dim_images: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -406,6 +409,7 @@ impl Renderer {
|
||||
ink_images: Default::default(),
|
||||
stroke_slices: PtrMap::default(),
|
||||
adjusted: Default::default(),
|
||||
dim_images: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,6 +798,31 @@ impl Renderer {
|
||||
} else if !n.is_container() {
|
||||
return;
|
||||
}
|
||||
// Layer Options on screen: a layer whose Preview is off draws in outline, and Dim Images
|
||||
// fades its images (exports and thumbnails, which leave templates out, ignore both).
|
||||
// (A template is dimmed as a whole already.)
|
||||
if let NodeKind::Layer { preview, dim_images, template, .. } = &n.kind
|
||||
&& f.opts.dim_templates
|
||||
&& !f.opts.skip_templates
|
||||
{
|
||||
if !preview && !f.opts.outline {
|
||||
let opts = RenderOptions { outline: true, ..f.opts.clone() };
|
||||
return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force);
|
||||
}
|
||||
if let Some(p) = dim_images.filter(|_| !template) {
|
||||
let outer = self.dim_images;
|
||||
let dim = f32::from(p).clamp(0.0, 100.0) / 100.0;
|
||||
self.dim_images = Some(outer.map_or(dim, |o| o.min(dim)));
|
||||
self.draw_layer_node(ctx, f, n);
|
||||
self.dim_images = outer;
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.draw_layer_node(ctx, f, n);
|
||||
}
|
||||
|
||||
/// [`Self::draw_node`] after culling and Layer Options.
|
||||
fn draw_layer_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) {
|
||||
if fx::has_object_fx(n) {
|
||||
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
|
||||
}
|
||||
@@ -1309,6 +1338,21 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
ctx.reset_paint_transform();
|
||||
// A dimmed layer's images: white over them, so they show at that opacity on paper.
|
||||
if let Some(dim) = self.dim_images.filter(|d| *d < 1.0 && !outline) {
|
||||
ctx.set_paint(f.ink.fixed([255, 255, 255, ((1.0 - dim) * 255.0).round() as u8]));
|
||||
match area {
|
||||
Some((bp, rule)) => {
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_fill_rule(fill_rule(*rule));
|
||||
ctx.fill_path(bp);
|
||||
}
|
||||
None => {
|
||||
ctx.set_transform(f.view * im.xf);
|
||||
ctx.fill_rect(&rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if outline {
|
||||
let mut p = rect.to_path(0.1);
|
||||
@@ -1592,6 +1636,8 @@ mod tests_isolation;
|
||||
#[cfg(test)]
|
||||
mod tests_knockout;
|
||||
#[cfg(test)]
|
||||
mod tests_layeropts;
|
||||
#[cfg(test)]
|
||||
mod tests_objectfx;
|
||||
#[cfg(test)]
|
||||
mod tests_setup;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Layer Options on screen: Preview off draws a layer in outline, Dim Images fades its images;
|
||||
//! exports (which leave templates out) draw both as they are.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, ImageBlob, ImageObject, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A red square (10..40) and a 30×30 red image at (50, 10), on one layer set up by `layer`.
|
||||
fn doc(layer: impl FnOnce(&mut NodeKind)) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("red".into(), ImageBlob::new("image/png", png));
|
||||
let l = d.layers[0].id;
|
||||
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let sq = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), red);
|
||||
d.insert(Some(l), 0, sq).unwrap();
|
||||
let mut im = Node::new(
|
||||
NodeId(0),
|
||||
NodeKind::Image(ImageObject {
|
||||
key: "red".into(),
|
||||
width: 30,
|
||||
height: 30,
|
||||
xf: Affine::translate((50.0, 10.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
}),
|
||||
);
|
||||
im.id = d.alloc_id();
|
||||
d.insert(Some(l), 9, im).unwrap();
|
||||
layer(&mut d.node_mut(l).unwrap().kind);
|
||||
d
|
||||
}
|
||||
|
||||
fn render(d: &Document, export: bool) -> Rendered {
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), skip_templates: export, ..Default::default() };
|
||||
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_off_draws_the_layer_in_outline_on_screen_only() {
|
||||
let d = doc(|k| {
|
||||
if let NodeKind::Layer { preview, .. } = k {
|
||||
*preview = false;
|
||||
}
|
||||
});
|
||||
assert_eq!(render(&d, false).pixel(25, 25), [255, 255, 255, 255], "no fill in outline");
|
||||
assert_eq!(render(&d, true).pixel(25, 25), [255, 0, 0, 255], "exports draw it as it is");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dim_images_fades_only_the_images() {
|
||||
let plain = render(&doc(|_| {}), false);
|
||||
assert_eq!(plain.pixel(65, 25), [255, 0, 0, 255]);
|
||||
let d = doc(|k| {
|
||||
if let NodeKind::Layer { dim_images, .. } = k {
|
||||
*dim_images = Some(50);
|
||||
}
|
||||
});
|
||||
let img = render(&d, false);
|
||||
let [r, g, b, _] = img.pixel(65, 25);
|
||||
assert!(r == 255 && (120..=135).contains(&g) && g == b, "half way to white: {:?}", img.pixel(65, 25));
|
||||
assert_eq!(img.pixel(25, 25), [255, 0, 0, 255], "vector art stays");
|
||||
assert_eq!(render(&d, true).pixel(65, 25), [255, 0, 0, 255], "exports are not dimmed");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
+336
-74
@@ -1,109 +1,200 @@
|
||||
//! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column
|
||||
//! whose panels pop out to the left.
|
||||
//! whose panels pop out to the left. The double arrow at the top of the dock collapses the tabbed
|
||||
//! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column
|
||||
//! then expands it again.
|
||||
|
||||
use egui::{CornerRadius, Sense, Stroke, Ui, vec2};
|
||||
use egui::{CornerRadius, Rect, Sense, Stroke, Ui, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::state::{DockTab, ICON_PANEL_GROUPS, ICON_PANELS};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{VectorcraftApp, icons, panels, widgets};
|
||||
|
||||
const ICON_COL: f32 = 38.0;
|
||||
/// Height of the strip along the top of a dock column that carries its double arrow.
|
||||
const HEADER: f32 = 14.0;
|
||||
/// The tabbed group's width until the dock has been laid out (its default size).
|
||||
const DOCK_WIDTH: f32 = 300.0;
|
||||
/// Top of a popped-out panel.
|
||||
const FLYOUT_TOP: f32 = 110.0;
|
||||
|
||||
/// Where the icon column's left edge was drawn last (egui temp memory, one frame old).
|
||||
fn column_left_id() -> egui::Id {
|
||||
egui::Id::new("dock-icon-column-left")
|
||||
}
|
||||
|
||||
/// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while
|
||||
/// one of its panels is popped out shows that panel's tab instead.
|
||||
pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) {
|
||||
app.ui.dock_collapsed = collapsed;
|
||||
app.ui.dock = true;
|
||||
if !collapsed && let Some(tab) = app.ui.open_panel.as_deref().and_then(DockTab::from_id) {
|
||||
app.ui.dock_tab = tab;
|
||||
app.ui.open_panel = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The strip along the top of a dock column with its double arrow: » collapses the dock to icons,
|
||||
/// « expands it. The whole strip is the click target (as on the toolbar). True when clicked.
|
||||
fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (hdr, resp) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER), Sense::click());
|
||||
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
|
||||
let arrow = Rect::from_min_size(hdr.right_top() + vec2(-16.0, 1.0), vec2(12.0, 12.0));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(arrow, CornerRadius::same(2), t.hover);
|
||||
}
|
||||
let icon = if collapsed { "chevrons-left" } else { "chevrons-right" };
|
||||
icons::paint(ui, icon, arrow.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text });
|
||||
resp.on_hover_text(if collapsed { tl!("Expand Panels") } else { tl!("Collapse to Icons") }).clicked()
|
||||
}
|
||||
|
||||
/// Run `window.collapseDock` for a click on a double arrow.
|
||||
fn toggle(app: &mut VectorcraftApp, collapsed: bool) {
|
||||
// It only fails on bad params, which this never sends; show it all the same.
|
||||
if let Err(e) = app.run("window.collapseDock", json!({ "collapsed": collapsed })) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
/// One icon of the column: a click pops its panel out (or puts it away).
|
||||
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, label: &str, icon: &str) {
|
||||
let open = app.ui.open_panel.as_deref() == Some(id);
|
||||
if widgets::icon_button(ui, icon, label, open, 30.0).clicked() {
|
||||
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Main tabbed group.
|
||||
egui::Panel::right("dock")
|
||||
.resizable(true)
|
||||
.default_size(300.0)
|
||||
.size_range(230.0..=520.0)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
|
||||
.show(ui, |ui| {
|
||||
// Tab strip.
|
||||
let (hdr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover());
|
||||
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
|
||||
icons::paint(ui, "chevrons-right", egui::Rect::from_min_size(hdr.right_top() + vec2(-14.0, 2.0), vec2(10.0, 10.0)), t.text);
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
|
||||
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
|
||||
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
|
||||
let mut x = strip.left();
|
||||
for (tab, label) in [(DockTab::Properties, "Properties"), (DockTab::Layers, "Layers"), (DockTab::Libraries, "Libraries")] {
|
||||
let active = app.ui.dock_tab == tab;
|
||||
let galley =
|
||||
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim });
|
||||
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
|
||||
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click());
|
||||
if active {
|
||||
ui.painter().rect_filled(r, 0.0, t.panel);
|
||||
// Main tabbed group, unless it is collapsed to icons.
|
||||
if !app.ui.dock_collapsed {
|
||||
egui::Panel::right("dock")
|
||||
.resizable(true)
|
||||
.default_size(DOCK_WIDTH)
|
||||
.size_range(230.0..=520.0)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
|
||||
.show(ui, |ui| {
|
||||
if collapse_header(ui, false) {
|
||||
toggle(app, true);
|
||||
}
|
||||
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
|
||||
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
|
||||
if resp.clicked() {
|
||||
app.ui.dock_tab = tab;
|
||||
// Tab strip.
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
|
||||
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
|
||||
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
|
||||
let mut x = strip.left();
|
||||
for tab in DockTab::ALL {
|
||||
let (_, label, _) = tab.info();
|
||||
let active = app.ui.dock_tab == tab;
|
||||
let galley =
|
||||
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim });
|
||||
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
|
||||
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click());
|
||||
if active {
|
||||
ui.painter().rect_filled(r, 0.0, t.panel);
|
||||
}
|
||||
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
|
||||
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
|
||||
if resp.clicked() {
|
||||
app.ui.dock_tab = tab;
|
||||
}
|
||||
x = r.right();
|
||||
}
|
||||
x = r.right();
|
||||
}
|
||||
let menu_id = match app.ui.dock_tab {
|
||||
DockTab::Properties => "properties",
|
||||
DockTab::Layers => "layers",
|
||||
DockTab::Libraries => "libraries",
|
||||
};
|
||||
panels::panel_menu(app, ui, menu_id, egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)));
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab {
|
||||
DockTab::Properties => {
|
||||
egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui));
|
||||
}
|
||||
DockTab::Layers => panels::layers::show(app, ui),
|
||||
DockTab::Libraries => panels::libraries(app, ui),
|
||||
let menu_id = app.ui.dock_tab.info().0;
|
||||
panels::panel_menu(app, ui, menu_id, egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)));
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab {
|
||||
DockTab::Properties => {
|
||||
egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui));
|
||||
}
|
||||
DockTab::Layers => panels::layers::show(app, ui),
|
||||
DockTab::Libraries => panels::libraries(app, ui),
|
||||
});
|
||||
});
|
||||
});
|
||||
// Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock).
|
||||
egui::Panel::right("icon_column")
|
||||
}
|
||||
// Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock). A
|
||||
// collapsed group's panels head it, under the « that expands them again.
|
||||
let collapsed = app.ui.dock_collapsed;
|
||||
let column = egui::Panel::right("icon_column")
|
||||
.resizable(false)
|
||||
.exact_size(ICON_COL)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(4, 6)).stroke(Stroke::new(1.5, t.border)))
|
||||
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
// The icons scroll in a window too short for them.
|
||||
widgets::strip_scroll(ui, "icon_column", |ui| {
|
||||
for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() {
|
||||
if gi > 0 {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
|
||||
ui.painter().line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
|
||||
}
|
||||
for id in group.iter() {
|
||||
let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue };
|
||||
let open = app.ui.open_panel.as_deref() == Some(*id);
|
||||
if widgets::icon_button(ui, icon, label, open, 30.0).clicked() {
|
||||
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
|
||||
if collapsed && collapse_header(ui, true) {
|
||||
toggle(app, false);
|
||||
}
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
// The icons scroll in a window too short for them.
|
||||
widgets::strip_scroll(ui, "icon_column", |ui| {
|
||||
if collapsed {
|
||||
for tab in DockTab::ALL {
|
||||
let (id, label, icon) = tab.info();
|
||||
panel_icon(app, ui, id, label, icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() {
|
||||
if gi > 0 || collapsed {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
|
||||
ui.painter()
|
||||
.line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
|
||||
}
|
||||
for id in group.iter() {
|
||||
let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue };
|
||||
panel_icon(app, ui, id, label, icon);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let left = column.response.rect.left();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(column_left_id(), left));
|
||||
}
|
||||
|
||||
/// An icon panel popped out next to the icon column.
|
||||
/// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels
|
||||
/// while the dock is collapsed.
|
||||
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(id) = app.ui.open_panel.clone() else { return };
|
||||
let Some((_, label, _)) = ICON_PANELS.iter().find(|p| p.0 == id) else { return };
|
||||
let tab = DockTab::from_id(&id);
|
||||
let label = match tab {
|
||||
Some(tab) if app.ui.dock_collapsed => tab.info().1,
|
||||
// Expanded, the group shows its panels in the dock itself.
|
||||
Some(_) => return,
|
||||
None => match ICON_PANELS.iter().find(|p| p.0 == id) {
|
||||
Some((_, label, _)) => *label,
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
// Pinned by its right edge, 6 points left of the icon column: a panel wider than its 256
|
||||
// points grows towards the canvas rather than over the panel icons.
|
||||
let right = screen.right() - ICON_COL - 300.0 - 6.0;
|
||||
// Pinned by its right edge, 6 points left of the icon column wherever the dock's width (or its
|
||||
// collapsed state) put it: content wider than the flyout grows it towards the canvas rather
|
||||
// than over the panel icons. Without a dock on screen it keeps to the window's right edge.
|
||||
let column = if app.ui.dock && app.ui.screen_mode < 3 {
|
||||
ctx.data(|d| d.get_temp::<f32>(column_left_id()))
|
||||
.filter(|x| x.is_finite() && *x > screen.left() && *x <= screen.right())
|
||||
.unwrap_or_else(|| screen.right() - ICON_COL - if app.ui.dock_collapsed { 0.0 } else { DOCK_WIDTH })
|
||||
} else {
|
||||
screen.right()
|
||||
};
|
||||
let right = column - 6.0;
|
||||
// The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one
|
||||
// that keeps the flyout on screen.
|
||||
// The tabbed group's panels keep the width the dock gives them; icon panels are 256 points.
|
||||
let width = if tab.is_some() { DOCK_WIDTH } else { 256.0 };
|
||||
let tall = (screen.height() - FLYOUT_TOP - 26.0 - 20.0 - 40.0).clamp(160.0, 560.0);
|
||||
let mut open = true;
|
||||
let area = egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP);
|
||||
area.fixed_pos(egui::pos2(right, 110.0)).show(ctx, |ui| {
|
||||
area.fixed_pos(egui::pos2(right, FLYOUT_TOP)).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
|
||||
ui.set_width(256.0);
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover());
|
||||
ui.set_width(width);
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover());
|
||||
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
|
||||
let tab = egui::Rect::from_min_size(
|
||||
let title = egui::Rect::from_min_size(
|
||||
strip.min,
|
||||
vec2(ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
|
||||
);
|
||||
ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
|
||||
ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text);
|
||||
ui.painter().rect_filled(title, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
|
||||
ui.painter().text(title.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text);
|
||||
let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
|
||||
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
|
||||
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
|
||||
@@ -113,8 +204,26 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let menu_r = egui::Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0));
|
||||
panels::panel_menu(app, ui, &id, menu_r);
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
|
||||
ui.set_width(236.0);
|
||||
panels::show_icon_panel(app, ui, &id);
|
||||
ui.set_width(width - 20.0);
|
||||
match tab {
|
||||
Some(DockTab::Properties) => {
|
||||
// At least `tall` when the sections are taller: the flyout's area keeps the
|
||||
// size its content needs, and egui sizes a new area at most 400 points high,
|
||||
// so a scroll area that only took the room it was given never grew past it.
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("props-flyout")
|
||||
.max_height(tall)
|
||||
.min_scrolled_height(tall)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| panels::properties::show(app, ui));
|
||||
}
|
||||
Some(DockTab::Layers) => {
|
||||
ui.set_max_height(tall);
|
||||
panels::layers::show(app, ui);
|
||||
}
|
||||
Some(DockTab::Libraries) => panels::libraries(app, ui),
|
||||
None => panels::show_icon_panel(app, ui, &id),
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -179,4 +288,157 @@ mod tests {
|
||||
}
|
||||
assert!(icons.last().unwrap().bottom() <= 400.0, "scrolled into view: {:?}", icons.last());
|
||||
}
|
||||
|
||||
const SCREEN: egui::Vec2 = vec2(1600.0, 1000.0);
|
||||
|
||||
/// A frame of the dock and its flyout on a 1600 × 1000 window.
|
||||
struct Harness {
|
||||
app: VectorcraftApp,
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let mut h = Self { app, ctx, time: 0.0 };
|
||||
h.settle();
|
||||
h
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<egui::Event>) {
|
||||
self.time += 0.05;
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, SCREEN);
|
||||
let input = egui::RawInput { time: Some(self.time), events, screen_rect: Some(screen), ..Default::default() };
|
||||
let app = &mut self.app;
|
||||
self.ctx
|
||||
.run_ui(input, |ui| {
|
||||
show(app, ui);
|
||||
floating_panel(app, ui.ctx());
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
}
|
||||
|
||||
fn settle(&mut self) {
|
||||
for _ in 0..3 {
|
||||
self.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
fn click(&mut self, at: egui::Pos2) {
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
self.frame(vec![egui::Event::PointerMoved(at), button(true)]);
|
||||
self.frame(vec![button(false)]);
|
||||
self.settle();
|
||||
}
|
||||
|
||||
/// The icon column's left edge as last drawn.
|
||||
fn column_left(&self) -> f32 {
|
||||
self.ctx.data(|d| d.get_temp::<f32>(column_left_id())).unwrap()
|
||||
}
|
||||
|
||||
/// The panel icons of the column, top to bottom.
|
||||
fn icons(&self) -> Vec<Rect> {
|
||||
widget_rects(&self.ctx, vec2(30.0, 30.0))
|
||||
}
|
||||
|
||||
/// The double arrow at the top right of the window (the dock's, or the collapsed column's).
|
||||
fn arrow(&self) -> egui::Pos2 {
|
||||
egui::pos2(SCREEN.x - 10.0, HEADER / 2.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_panel_count() -> usize {
|
||||
ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_double_arrow_collapses_the_dock_to_icons_and_back() {
|
||||
let mut h = Harness::new();
|
||||
assert!(!h.app.ui.dock_collapsed);
|
||||
// Expanded, the group (at least its 230-point minimum) is right of the column.
|
||||
let expanded = |left: f32| left <= SCREEN.x - ICON_COL - 230.0;
|
||||
assert!(expanded(h.column_left()), "expanded: the group is right of the column: {}", h.column_left());
|
||||
assert_eq!(h.icons().len(), icon_panel_count());
|
||||
h.click(h.arrow());
|
||||
assert!(h.app.ui.dock_collapsed, "the » collapses the dock");
|
||||
assert!(h.column_left() > SCREEN.x - ICON_COL - 2.0, "collapsed: only the icon column is left: {}", h.column_left());
|
||||
let icons = h.icons();
|
||||
assert_eq!(icons.len(), icon_panel_count() + 3, "Properties, Layers and Libraries became icons");
|
||||
assert!(icons[0].top() > HEADER, "the icons sit under the « strip");
|
||||
h.click(h.arrow());
|
||||
assert!(!h.app.ui.dock_collapsed, "the « expands the dock again");
|
||||
assert!(expanded(h.column_left()), "the group is back: {}", h.column_left());
|
||||
assert_eq!(h.icons().len(), icon_panel_count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_collapsed_panel_pops_out_left_of_the_column() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap();
|
||||
h.settle();
|
||||
// Properties, Layers, Libraries head the column.
|
||||
let layers = h.icons()[1];
|
||||
h.click(layers.center());
|
||||
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"));
|
||||
h.settle();
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap();
|
||||
let column = h.column_left();
|
||||
assert!(rect.right() <= column, "the flyout {rect:?} stays left of the column at {column}");
|
||||
assert!(rect.left() >= 0.0 && rect.bottom() <= SCREEN.y, "the flyout {rect:?} is on screen");
|
||||
assert!(rect.height() > 200.0, "Layers gets room for its list: {rect:?}");
|
||||
// Its » puts it away.
|
||||
h.click(egui::pos2(rect.right() - 13.0, rect.top() + 13.0));
|
||||
assert_eq!(h.app.ui.open_panel, None, "the flyout's » closes it");
|
||||
// Expanding with a panel popped out shows its tab.
|
||||
h.click(layers.center());
|
||||
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"));
|
||||
h.click(h.arrow());
|
||||
assert!(!h.app.ui.dock_collapsed);
|
||||
assert_eq!((h.app.ui.open_panel.as_deref(), h.app.ui.dock_tab), (None, DockTab::Layers));
|
||||
// Properties pops out as wide as the dock and shows its sections, not a sliver, also as
|
||||
// the first flyout of a session (a fresh harness: the flyouts share one area, which
|
||||
// would otherwise keep the size the Layers flyout gave it).
|
||||
let mut h = Harness::new();
|
||||
h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap();
|
||||
h.app.run("window.panel", json!({"panel": "properties"})).unwrap();
|
||||
h.settle();
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap();
|
||||
assert!(rect.width() >= DOCK_WIDTH && rect.right() <= h.column_left(), "Properties flyout {rect:?}");
|
||||
// Taller than the 400 points egui sizes a new area at: its sections (Align, Quick
|
||||
// Actions) aren't cut off below Appearance.
|
||||
assert!(rect.height() > 450.0 && rect.bottom() <= SCREEN.y, "Properties flyout {rect:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_collapse_command_sets_or_toggles_the_dock() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
assert_eq!(app.run("window.collapseDock", json!({})).unwrap(), json!(true));
|
||||
assert!(app.ui.dock_collapsed && crate::menus::checked(&app, "window.collapseDock", &json!({})) == Some(true));
|
||||
assert_eq!(app.run("window.collapseDock", json!({"collapsed": true})).unwrap(), json!(true));
|
||||
assert!(app.run("window.collapseDock", json!({"collapsed": "yes"})).is_err());
|
||||
// Window › Layers pops Layers out of its icon while collapsed.
|
||||
app.run("window.panel", json!({"panel": "layers"})).unwrap();
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("layers"));
|
||||
assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(true));
|
||||
assert_eq!(app.run("window.collapseDock", json!({})).unwrap(), json!(false));
|
||||
assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_tab), (None, DockTab::Layers));
|
||||
app.run("window.panel", json!({"panel": "properties"})).unwrap();
|
||||
assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_tab), (None, DockTab::Properties));
|
||||
// Saved with the preferences and with a user workspace; the built-in ones expand it.
|
||||
app.run("window.collapseDock", json!({"collapsed": true})).unwrap();
|
||||
let saved: crate::UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();
|
||||
assert!(saved.sanitized().dock_collapsed);
|
||||
app.run("window.workspace.new", json!({"name": "Icons"})).unwrap();
|
||||
app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
|
||||
assert!(!app.ui.dock_collapsed, "Essentials shows the panels");
|
||||
app.run("window.workspace", json!({"name": "Icons"})).unwrap();
|
||||
assert!(app.ui.dock_collapsed, "the user workspace keeps them collapsed");
|
||||
}
|
||||
}
|
||||
@@ -569,3 +569,6 @@
|
||||
Make Compound Path Vytvořit složenou cestu
|
||||
Release Compound Path Uvolnit složenou cestu
|
||||
Select All Vybrat vše
|
||||
Collapse Panels to Icons Sbalit panely na ikony
|
||||
Collapse to Icons Sbalit na ikony
|
||||
Expand Panels Rozbalit panely
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -569,3 +569,6 @@
|
||||
Make Compound Path 複合パスを作成
|
||||
Release Compound Path 複合パスを解除
|
||||
Select All すべてを選択
|
||||
Collapse Panels to Icons パネルをアイコンに折りたたむ
|
||||
Collapse to Icons アイコンに折りたたむ
|
||||
Expand Panels パネルを展開
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -362,6 +362,8 @@
|
||||
CMYK CMYK
|
||||
CMYK Color CMYK 色彩
|
||||
CMYK colours print with their own values; off, they go through the colour settings too. CMYK 顏色以其本身的數值列印;關閉時,也會經過色彩設定處理。
|
||||
Collapse Panels to Icons 將面板收合為圖示
|
||||
Collapse to Icons 收合為圖示
|
||||
Collate 逐份列印
|
||||
Collect for Export 收集以供匯出
|
||||
Collect in New Layer 收集至新圖層
|
||||
@@ -600,6 +602,7 @@
|
||||
Difference 差異化
|
||||
Differences from {preset}: 與{preset}的差異:
|
||||
Dim Copies to: 將拷貝調暗至:
|
||||
Dim Images to: 影像變暗至:
|
||||
Dimensions 尺寸
|
||||
Direct Selection Tool 直接選取工具
|
||||
Direction Handles 控制把手
|
||||
@@ -695,6 +698,7 @@
|
||||
Duplicate Layer 製作圖層複本
|
||||
Duplicate Paragraph Style 製作段落樣式複本
|
||||
Duplicate Selected Item 製作所選項目的複本
|
||||
Duplicate Selection 製作選取範圍複本
|
||||
Duplicate Slice 製作切片複本
|
||||
Duplicate Swatch 製作色票複本
|
||||
Duplicate Symbol 製作符號複本
|
||||
@@ -815,7 +819,9 @@
|
||||
Expand Compound Shape 展開複合形狀
|
||||
Expand Gradient Mesh 展開漸層網格
|
||||
Expand Gradient To 展開漸層為
|
||||
Expand Layers Panel Rows 展開圖層面板列
|
||||
Expand Options 展開選項
|
||||
Expand Panels 展開面板
|
||||
Expand Repeat 展開重複
|
||||
Expand Shape 展開形狀
|
||||
Expand: 展開:
|
||||
@@ -921,6 +927,7 @@
|
||||
Flare Tool Options 光暈工具選項
|
||||
Flat Image 平面影像
|
||||
Flatness: 平滑度:
|
||||
Flatten Artwork 將圖稿平面化
|
||||
Flatten Transparency 透明度平面化
|
||||
Flatten Transparency… 透明度平面化…
|
||||
Flattener preset: 平面化預設集:
|
||||
@@ -1067,6 +1074,7 @@
|
||||
Hide Hidden Characters 隱藏隱藏字元
|
||||
Hide Note 隱藏註解
|
||||
Hide Options 隱藏選項
|
||||
Hide Others 隱藏其他
|
||||
Hide Print Tiling 隱藏列印分頁
|
||||
Hide Rulers 隱藏尺標
|
||||
Hide Slices 隱藏切片
|
||||
@@ -1079,6 +1087,7 @@
|
||||
High Performance (discrete) 高效能 (獨立顯示卡)
|
||||
Highlight Alternate Glyphs 醒目提示替代字符
|
||||
Highlight anchors on mouse over 滑鼠移過時醒目提示錨點
|
||||
Highlight Rows 反白顯示列
|
||||
Highlight Substituted Fonts 醒目提示被取代的字體
|
||||
Highlight Substituted Glyphs 醒目提示被取代的字符
|
||||
History 步驟記錄
|
||||
@@ -1219,8 +1228,10 @@
|
||||
Latin-1 Supplement 拉丁文-1 補充
|
||||
Layer 圖層
|
||||
Layer Names 圖層名稱
|
||||
Layer Options 圖層選項
|
||||
Layer Options… 圖層選項…
|
||||
Layers 圖層
|
||||
Layers Panel Options 圖層面板選項
|
||||
Layout 版面配置
|
||||
Layout: 版面配置:
|
||||
Leader: 前導字元:
|
||||
@@ -1304,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)
|
||||
@@ -1368,6 +1380,7 @@
|
||||
Merge Graphic Styles 合併繪圖樣式
|
||||
Merge Graphic Styles… 合併繪圖樣式…
|
||||
Merge Layers 合併圖層
|
||||
Merge Selected 合併選取的項目
|
||||
Merge Swatches 合併色票
|
||||
Mesh Tool 網格工具
|
||||
Message: 訊息:
|
||||
@@ -1420,6 +1433,7 @@
|
||||
Move Mesh Point 移動網格點
|
||||
Move Plane 移動平面
|
||||
Move Plane to Match Object 移動平面以符合物件
|
||||
Move Rows 移動列
|
||||
Move Slices 移動切片
|
||||
Move Spine Point 移動脊線點
|
||||
Move Swatch 移動色票
|
||||
@@ -1456,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 新的不透明度遮色片為反轉
|
||||
@@ -1624,6 +1639,7 @@
|
||||
Optimize for fast web view 最佳化以便快速網頁檢視
|
||||
Optimized 最佳化
|
||||
Options 選項
|
||||
Options for Selection… 選取範圍的選項…
|
||||
Options: 選項:
|
||||
Options… 選項…
|
||||
or more spaces with a tab 個以上的空格取代為定位字元
|
||||
@@ -1643,6 +1659,7 @@
|
||||
Out Tangent Handles 外切線控制把手
|
||||
Outer Glow… 外光暈…
|
||||
Outline 外框
|
||||
Outline Others 其他以外框檢視
|
||||
Outline Stroke 外框筆畫
|
||||
Outline Text 外框文字
|
||||
Outlines and Rules 外框與規則線
|
||||
@@ -1812,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 預覽模式
|
||||
@@ -1959,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 將連結檔案重新連結至文件
|
||||
@@ -2070,6 +2090,7 @@
|
||||
Rounded Rectangle 圓角矩形
|
||||
Rounded Rectangle Tool 圓角矩形工具
|
||||
Rounded Rectangle… 圓角矩形…
|
||||
Row Size: 列尺寸:
|
||||
Rows: 列數:
|
||||
Rule: 規則線:
|
||||
Rulers 尺標
|
||||
@@ -2203,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. 選取圖稿,然後使用「新增筆刷」來製作。
|
||||
@@ -2277,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 顯示所有色票
|
||||
@@ -2305,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 顯示網格
|
||||
@@ -2554,6 +2578,7 @@
|
||||
Three Point Perspective Preset 5 三點透視預設集 5
|
||||
Threshold 臨界值
|
||||
Thumbnail View 縮圖檢視
|
||||
Thumbnails: 縮圖:
|
||||
Tile 並排顯示
|
||||
Tile Edge Color 拼貼邊緣顏色
|
||||
Tile Edge Color… 拼貼邊緣顏色…
|
||||
@@ -2669,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.
|
+60
-19
@@ -172,6 +172,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("window.taskBar", "Contextual Task Bar", "", "{}"),
|
||||
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
|
||||
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"),
|
||||
(
|
||||
"window.collapseDock",
|
||||
"Collapse Panels to Icons",
|
||||
"",
|
||||
"{collapsed?: bool} collapse the dock's Properties | Layers | Libraries group to icons (true), expand it (false) or toggle (omitted), as the double arrow at the top of the dock does; returns the new state",
|
||||
),
|
||||
("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"),
|
||||
("window.workspace", "Workspace", "", "{name} switch workspace (Essentials, Essentials Classic, Painting, …)"),
|
||||
("window.workspace.reset", "Reset Essentials", "", "{} reset the current workspace"),
|
||||
@@ -299,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…",
|
||||
@@ -895,27 +925,35 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
app.ui.control_bar = on;
|
||||
Ok(json!(on))
|
||||
}
|
||||
"window.collapseDock" => {
|
||||
let collapsed = match p.get("collapsed") {
|
||||
None | Some(Value::Null) => !app.ui.dock_collapsed,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Some(Err("collapsed must be true or false".into())),
|
||||
};
|
||||
crate::dock::set_collapsed(app, collapsed);
|
||||
Ok(json!(collapsed))
|
||||
}
|
||||
"window.panel" => {
|
||||
let panel = s("panel").unwrap_or_default();
|
||||
match panel.as_str() {
|
||||
"properties" => {
|
||||
app.ui.dock_tab = DockTab::Properties;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"layers" => {
|
||||
app.ui.dock_tab = DockTab::Layers;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"libraries" => {
|
||||
app.ui.dock_tab = DockTab::Libraries;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
p if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => {
|
||||
match (panel.as_str(), DockTab::from_id(&panel)) {
|
||||
// A collapsed dock pops the panel out of its icon, like the icon panels.
|
||||
(p, Some(tab)) if app.ui.dock_collapsed => {
|
||||
app.ui.dock_tab = tab;
|
||||
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
|
||||
app.ui.dock = true;
|
||||
Ok(json!({"open": app.ui.open_panel}))
|
||||
}
|
||||
other => Err(format!("unknown panel `{other}`")),
|
||||
(_, Some(tab)) => {
|
||||
app.ui.dock_tab = tab;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
(p, None) if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => {
|
||||
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
|
||||
app.ui.dock = true;
|
||||
Ok(json!({"open": app.ui.open_panel}))
|
||||
}
|
||||
(other, None) => Err(format!("unknown panel `{other}`")),
|
||||
}
|
||||
}
|
||||
"window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) {
|
||||
@@ -1008,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)
|
||||
@@ -1195,13 +1237,12 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
||||
"window.taskBar" => app.ui.task_bar,
|
||||
"window.panel" => {
|
||||
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
|
||||
match panel {
|
||||
"properties" => app.ui.dock_tab == DockTab::Properties,
|
||||
"layers" => app.ui.dock_tab == DockTab::Layers,
|
||||
"libraries" => app.ui.dock_tab == DockTab::Libraries,
|
||||
match DockTab::from_id(panel) {
|
||||
Some(tab) if !app.ui.dock_collapsed => app.ui.dock_tab == tab,
|
||||
_ => app.ui.open_panel.as_deref() == Some(panel),
|
||||
}
|
||||
}
|
||||
"window.collapseDock" => app.ui.dock_collapsed,
|
||||
"window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()),
|
||||
"window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness),
|
||||
"view.slices.lock" => app.session.slices_locked(),
|
||||
|
||||
+892
-189
File diff suppressed because it is too large.
Load diff
@@ -76,6 +76,24 @@ pub enum DockTab {
|
||||
Libraries,
|
||||
}
|
||||
|
||||
impl DockTab {
|
||||
/// The tab's panel id (`window.panel`), English label and icon (when the dock is collapsed).
|
||||
pub fn info(self) -> (&'static str, &'static str, &'static str) {
|
||||
match self {
|
||||
DockTab::Properties => ("properties", "Properties", "dc-options"),
|
||||
DockTab::Layers => ("layers", "Layers", "layers"),
|
||||
DockTab::Libraries => ("libraries", "Libraries", "library"),
|
||||
}
|
||||
}
|
||||
|
||||
pub const ALL: [DockTab; 3] = [DockTab::Properties, DockTab::Layers, DockTab::Libraries];
|
||||
|
||||
/// The tab whose panel id is `id`.
|
||||
pub fn from_id(id: &str) -> Option<DockTab> {
|
||||
DockTab::ALL.into_iter().find(|t| t.info().0 == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Panels that live as collapsed icons in the dock (Essentials Classic).
|
||||
pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
||||
("color", "Color", "palette"),
|
||||
@@ -219,7 +237,12 @@ pub struct UiState {
|
||||
pub legacy_language: Option<String>,
|
||||
pub brightness: Brightness,
|
||||
pub dock_tab: DockTab,
|
||||
/// Icon panel currently popped out of the collapsed column.
|
||||
/// The dock's tabbed group (Properties | Layers | Libraries) is collapsed to icons at the top of
|
||||
/// the icon column (the dock's double arrow, `window.collapseDock`).
|
||||
#[serde(default)]
|
||||
pub dock_collapsed: bool,
|
||||
/// Icon panel currently popped out of the collapsed column (also `properties`, `layers` or
|
||||
/// `libraries` while the dock is collapsed).
|
||||
pub open_panel: Option<String>,
|
||||
pub control_bar: bool,
|
||||
pub toolbar: bool,
|
||||
@@ -316,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")]
|
||||
@@ -358,6 +387,7 @@ impl Default for UiState {
|
||||
legacy_language: None,
|
||||
brightness: Brightness::MediumDark,
|
||||
dock_tab: DockTab::Properties,
|
||||
dock_collapsed: false,
|
||||
open_panel: None,
|
||||
control_bar: false,
|
||||
toolbar: true,
|
||||
@@ -398,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct Workspace {
|
||||
pub task_bar: bool,
|
||||
pub dock: bool,
|
||||
pub dock_tab: DockTab,
|
||||
/// The dock's tabbed group is collapsed to icons (built-in workspaces: expanded).
|
||||
pub dock_collapsed: bool,
|
||||
pub open_panel: Option<String>,
|
||||
pub status_bar: bool,
|
||||
}
|
||||
@@ -115,6 +117,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
|
||||
task_bar: ui.task_bar,
|
||||
dock: ui.dock,
|
||||
dock_tab: ui.dock_tab,
|
||||
dock_collapsed: ui.dock_collapsed,
|
||||
open_panel: ui.open_panel.clone(),
|
||||
status_bar: ui.status_bar,
|
||||
}
|
||||
@@ -130,6 +133,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
|
||||
ui.task_bar = w.task_bar;
|
||||
ui.dock = w.dock;
|
||||
ui.dock_tab = w.dock_tab;
|
||||
ui.dock_collapsed = w.dock_collapsed;
|
||||
ui.open_panel = w.open_panel.clone();
|
||||
ui.status_bar = w.status_bar;
|
||||
ui.flyout = None;
|
||||
|
||||
@@ -180,6 +180,13 @@ a swatch clicked there runs `paint.setFill` / `paint.setStroke`. Panel keys (Col
|
||||
Appearance Shift+F6, Graphic Styles Shift+F5, Stroke Cmd+F10, Gradient Cmd+F9, Transparency Cmd+Shift+F10) run
|
||||
`window.panel {panel}` and can be pressed with `ui.key`; `ui.menu.list` shows them on the Window menu's items.
|
||||
|
||||
Collapsing the dock: `window.collapseDock {collapsed?}` (the » at the top of the dock; omitted toggles) hides the
|
||||
Properties | Layers | Libraries group and puts its three panels as icons at the top of the icon column, under a «
|
||||
that expands them again. While collapsed, those icons and `window.panel {panel: "properties"|"layers"|"libraries"}`
|
||||
pop the panel out next to the column like the other icon panels (`ui.dock_collapsed`, `ui.open_panel` in
|
||||
`ui.inspect`); expanding with one popped out shows its tab. The state is saved with the preferences and in user
|
||||
workspaces; the built-in workspaces expand the dock.
|
||||
|
||||
Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection
|
||||
(fields `preset`: a preset name, setting it loads that preset's options; the option keys of
|
||||
`object.flattenTransparency`: `balance` 0–100, `lineArtPpi` and `gradientPpi` 1–2400, `textToOutlines`,
|
||||
@@ -281,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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user