Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f95e320907 | ||
|
|
65c5953516 | ||
|
|
0648e99fc4 | ||
|
|
ccdb4d89c1 | ||
|
|
f8e3cfbf35 | ||
|
|
7eb3f25f1f | ||
|
|
27a1a48176 | ||
|
|
f9209e8896 | ||
|
|
f16403a851 | ||
|
|
b64dcc02df | ||
|
|
406c27ebd8 | ||
|
|
dfe7f1a311 |
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:
|
||||
|
||||
@@ -158,6 +158,10 @@ Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/cr
|
||||
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
|
||||
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
|
||||
|
||||
On laptops with two graphics processors, VectorCraft uses the power-saving (integrated) one by default.
|
||||
To use the discrete one, choose **Preferences › Performance › Graphics Processor › High Performance**
|
||||
and restart, or start the app with `WGPU_POWER_PREF=high` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
|
||||
|
||||
### Use it from Claude Code and other agents
|
||||
|
||||
Register the MCP server with Claude Code:
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
@@ -42,7 +42,7 @@ since 2026-10-01.
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; Windows/Linux packaging and accessibility are pending |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; hybrid-graphics laptops render on the power-saving GPU by default (#306); Windows/Linux packaging and accessibility are pending |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
@@ -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:**
|
||||
@@ -141,6 +143,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage) and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
|
||||
## Milestones and estimates
|
||||
@@ -193,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 |
|
||||
|
||||
@@ -241,6 +241,27 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The power preference wgpu picks the window's graphics adapter with (#306): the
|
||||
/// `WGPU_POWER_PREF` environment variable (`low`, `high`, `none`) when set, otherwise Preferences ›
|
||||
/// Performance › Graphics Processor (`gpuPreference`). Power saving unless the preference asks for
|
||||
/// high performance: the canvas is rasterized on the CPU and the GPU only composites it, which the
|
||||
/// integrated GPU of a hybrid-graphics laptop does easily, while presenting frames rendered on the
|
||||
/// discrete GPU through the integrated one made the window flicker on some laptops. With a single
|
||||
/// GPU both preferences pick it.
|
||||
fn power_preference(pref: Option<&str>, env: Option<eframe::wgpu::PowerPreference>) -> eframe::wgpu::PowerPreference {
|
||||
use eframe::wgpu::PowerPreference;
|
||||
match (env, pref) {
|
||||
(Some(p), _) => p,
|
||||
(None, Some("highPerformance")) => PowerPreference::HighPerformance,
|
||||
(None, _) => PowerPreference::LowPower,
|
||||
}
|
||||
}
|
||||
|
||||
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
|
||||
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
||||
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
||||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
@@ -262,7 +283,9 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let saved = read_prefs();
|
||||
let saved_window = saved.as_ref().and_then(|ui| ui.window);
|
||||
let options = eframe::NativeOptions {
|
||||
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
|
||||
let power = power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
@@ -276,6 +299,9 @@ fn main() -> eframe::Result {
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
..Default::default()
|
||||
};
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.power_preference = power;
|
||||
}
|
||||
eframe::run_native(
|
||||
"VectorCraft",
|
||||
options,
|
||||
@@ -297,6 +323,11 @@ fn main() -> eframe::Result {
|
||||
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
|
||||
app.session.recovery.set_default_folder(recovery);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let summary = adapter_summary(&rs.adapter.get_info());
|
||||
log::info!("vectorcraft: rendering with {summary} (power preference {power:?})");
|
||||
app.graphics_adapter = Some(summary);
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
if let Some(port) = control_port {
|
||||
@@ -316,3 +347,35 @@ fn main() -> eframe::Result {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use eframe::wgpu::PowerPreference;
|
||||
|
||||
#[test]
|
||||
fn power_saving_unless_the_preference_or_the_environment_says_otherwise() {
|
||||
assert_eq!(power_preference(None, None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some("powerSaving"), None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance);
|
||||
// A value this version doesn't know (a newer or damaged preference file) is the default.
|
||||
assert_eq!(power_preference(Some("turbo"), None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some(""), None), PowerPreference::LowPower);
|
||||
// WGPU_POWER_PREF wins over the preference, either way.
|
||||
assert_eq!(power_preference(Some("powerSaving"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance);
|
||||
assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(None, Some(PowerPreference::None)), PowerPreference::None);
|
||||
}
|
||||
|
||||
/// The preference the engine saves is the one the app reads back before the window opens.
|
||||
#[test]
|
||||
fn the_saved_engine_preference_is_found() {
|
||||
let mut prefs = vectorcraft_engine::Prefs::default();
|
||||
let saved = prefs.to_json();
|
||||
assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("powerSaving"));
|
||||
prefs.gpu_preference = "highPerformance".into();
|
||||
let saved = prefs.to_json();
|
||||
let pref = saved.get("gpuPreference").and_then(serde_json::Value::as_str);
|
||||
assert_eq!(power_preference(pref, None), PowerPreference::HighPerformance);
|
||||
}
|
||||
}
|
||||
+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());
|
||||
|
||||
@@ -82,6 +82,8 @@ pub const UNITS: &[(&str, &str)] = &[
|
||||
("feet", "Feet"),
|
||||
];
|
||||
const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
|
||||
/// Performance › Graphics Processor (`gpuPreference`), read by the desktop app at startup.
|
||||
pub const GPU_PREFERENCES: &[(&str, &str)] = &[("powerSaving", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
|
||||
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
|
||||
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
|
||||
|
||||
@@ -265,6 +267,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
// Performance
|
||||
p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool),
|
||||
p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool),
|
||||
p!("gpuPreference", "Performance", "GPU Performance", "Graphics Processor", choice(GPU_PREFERENCES)),
|
||||
p!("historyStates", "Performance", "", "History States", int(5, 1000)),
|
||||
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
|
||||
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
|
||||
|
||||
@@ -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.
|
||||
@@ -397,6 +409,12 @@ pub struct Prefs {
|
||||
// Performance
|
||||
pub gpu_performance: bool,
|
||||
pub animated_zoom: bool,
|
||||
/// Which graphics processor the desktop app asks for at startup (it takes effect after a
|
||||
/// restart): `powerSaving` (the integrated GPU on hybrid-graphics laptops) or `highPerformance`
|
||||
/// (the discrete one). The canvas is rasterized on the CPU and only composited on the GPU, so
|
||||
/// the integrated GPU is plenty; presenting from the discrete GPU through the integrated one
|
||||
/// made some hybrid laptops flicker (#306). Single-GPU machines are unaffected.
|
||||
pub gpu_preference: String,
|
||||
pub history_states: u32,
|
||||
pub real_time_drawing: bool,
|
||||
/// Rasterizer worker threads; -1 = automatic.
|
||||
@@ -598,6 +616,7 @@ impl Default for Prefs {
|
||||
interface_language: s("auto"),
|
||||
gpu_performance: true,
|
||||
animated_zoom: true,
|
||||
gpu_preference: s("powerSaving"),
|
||||
history_states: 500,
|
||||
real_time_drawing: true,
|
||||
render_threads: -1,
|
||||
@@ -1062,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) {
|
||||
@@ -1260,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());
|
||||
}
|
||||
@@ -122,3 +122,33 @@ fn prefs_serde_round_trip_and_tolerates_missing_fields() {
|
||||
assert_eq!(partial.keyboard_increment, 4.0);
|
||||
assert_eq!(partial.corner_radius, 12.0);
|
||||
}
|
||||
|
||||
/// Performance › Graphics Processor (#306): power saving by default, set by value or label, kept
|
||||
/// through a save/load round trip, and a bad value is an error that changes nothing.
|
||||
#[test]
|
||||
fn gpu_preference_defaults_to_power_saving_and_validates() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
assert_eq!(s.execute("prefs.get", &json!({"key": "gpuPreference"})).unwrap(), json!("powerSaving"));
|
||||
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
|
||||
assert_eq!(s.prefs.gpu_preference, "highPerformance");
|
||||
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "Power Saving (integrated)"})).unwrap();
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
for bad in [json!("turbo"), json!(""), json!(1), json!(null), json!(["highPerformance"])] {
|
||||
assert!(s.execute("prefs.set", &json!({"key": "gpuPreference", "value": bad})).is_err(), "{bad}");
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
}
|
||||
let p = Prefs { gpu_preference: "highPerformance".into(), ..Default::default() };
|
||||
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
|
||||
assert_eq!(back.gpu_preference, "highPerformance");
|
||||
// Preference files written before the preference existed get the default.
|
||||
let old: Prefs = serde_json::from_value(json!({"gpuPerformance": true})).unwrap();
|
||||
assert_eq!(old.gpu_preference, "powerSaving");
|
||||
let l = s.execute("prefs.list", &json!({})).unwrap();
|
||||
let row = l.as_array().unwrap().iter().find(|e| e["key"] == "gpuPreference").unwrap().clone();
|
||||
assert_eq!(row["category"], "Performance");
|
||||
assert_eq!(row["options"], json!(["powerSaving", "highPerformance"]));
|
||||
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
|
||||
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -83,6 +83,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
|
||||
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::<Vec<_>>(),
|
||||
"activeDocument": app.session.active_index(),
|
||||
"perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps},
|
||||
"graphicsAdapter": app.graphics_adapter,
|
||||
// Saves and exports still being written in the background (Background Save / Export).
|
||||
"background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::<Vec<_>>(),
|
||||
})
|
||||
|
||||
@@ -37,6 +37,11 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
))
|
||||
.size(11.0),
|
||||
);
|
||||
// The GPU the window renders with, for bug reports (Preferences › Performance picks it).
|
||||
if let Some(adapter) = &app.graphics_adapter {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Graphics: {adapter}"), &[("adapter", adapter.as_str())])).size(11.0));
|
||||
}
|
||||
});
|
||||
app.ui.about = open;
|
||||
}
|
||||
@@ -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.
|
@@ -148,6 +148,7 @@
|
||||
Appearance Panel 外觀面板
|
||||
Appearance panel 外觀面板
|
||||
Applies when the linked file is relinked or updated. 重新連結或更新連結檔案時套用。
|
||||
Applies the next time VectorCraft starts. 下次啟動 VectorCraft 時生效。
|
||||
Apply Brush 套用筆刷
|
||||
Apply Character Style 套用字元樣式
|
||||
Apply Effect 套用效果
|
||||
@@ -361,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 收集至新圖層
|
||||
@@ -599,6 +602,7 @@
|
||||
Difference 差異化
|
||||
Differences from {preset}: 與{preset}的差異:
|
||||
Dim Copies to: 將拷貝調暗至:
|
||||
Dim Images to: 影像變暗至:
|
||||
Dimensions 尺寸
|
||||
Direct Selection Tool 直接選取工具
|
||||
Direction Handles 控制把手
|
||||
@@ -694,6 +698,7 @@
|
||||
Duplicate Layer 製作圖層複本
|
||||
Duplicate Paragraph Style 製作段落樣式複本
|
||||
Duplicate Selected Item 製作所選項目的複本
|
||||
Duplicate Selection 製作選取範圍複本
|
||||
Duplicate Slice 製作切片複本
|
||||
Duplicate Swatch 製作色票複本
|
||||
Duplicate Symbol 製作符號複本
|
||||
@@ -814,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: 展開:
|
||||
@@ -920,6 +927,7 @@
|
||||
Flare Tool Options 光暈工具選項
|
||||
Flat Image 平面影像
|
||||
Flatness: 平滑度:
|
||||
Flatten Artwork 將圖稿平面化
|
||||
Flatten Transparency 透明度平面化
|
||||
Flatten Transparency… 透明度平面化…
|
||||
Flattener preset: 平面化預設集:
|
||||
@@ -1017,6 +1025,8 @@
|
||||
Graphic Styles 繪圖樣式
|
||||
Graphic Styles Options 繪圖樣式選項
|
||||
Graphics 圖形
|
||||
Graphics Processor 圖形處理器
|
||||
Graphics: {adapter} 圖形:{adapter}
|
||||
Gravity 重力
|
||||
Grays 灰色
|
||||
Grays and Neutrals 灰色與中性色
|
||||
@@ -1064,6 +1074,7 @@
|
||||
Hide Hidden Characters 隱藏隱藏字元
|
||||
Hide Note 隱藏註解
|
||||
Hide Options 隱藏選項
|
||||
Hide Others 隱藏其他
|
||||
Hide Print Tiling 隱藏列印分頁
|
||||
Hide Rulers 隱藏尺標
|
||||
Hide Slices 隱藏切片
|
||||
@@ -1073,8 +1084,10 @@
|
||||
Hide Transparency Grid 隱藏透明度格點
|
||||
High 高
|
||||
High (300 ppi) 高 (300 ppi)
|
||||
High Performance (discrete) 高效能 (獨立顯示卡)
|
||||
Highlight Alternate Glyphs 醒目提示替代字符
|
||||
Highlight anchors on mouse over 滑鼠移過時醒目提示錨點
|
||||
Highlight Rows 反白顯示列
|
||||
Highlight Substituted Fonts 醒目提示被取代的字體
|
||||
Highlight Substituted Glyphs 醒目提示被取代的字符
|
||||
History 步驟記錄
|
||||
@@ -1215,8 +1228,10 @@
|
||||
Latin-1 Supplement 拉丁文-1 補充
|
||||
Layer 圖層
|
||||
Layer Names 圖層名稱
|
||||
Layer Options 圖層選項
|
||||
Layer Options… 圖層選項…
|
||||
Layers 圖層
|
||||
Layers Panel Options 圖層面板選項
|
||||
Layout 版面配置
|
||||
Layout: 版面配置:
|
||||
Leader: 前導字元:
|
||||
@@ -1300,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)
|
||||
@@ -1364,6 +1380,7 @@
|
||||
Merge Graphic Styles 合併繪圖樣式
|
||||
Merge Graphic Styles… 合併繪圖樣式…
|
||||
Merge Layers 合併圖層
|
||||
Merge Selected 合併選取的項目
|
||||
Merge Swatches 合併色票
|
||||
Mesh Tool 網格工具
|
||||
Message: 訊息:
|
||||
@@ -1416,6 +1433,7 @@
|
||||
Move Mesh Point 移動網格點
|
||||
Move Plane 移動平面
|
||||
Move Plane to Match Object 移動平面以符合物件
|
||||
Move Rows 移動列
|
||||
Move Slices 移動切片
|
||||
Move Spine Point 移動脊線點
|
||||
Move Swatch 移動色票
|
||||
@@ -1452,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 新的不透明度遮色片為反轉
|
||||
@@ -1620,6 +1639,7 @@
|
||||
Optimize for fast web view 最佳化以便快速網頁檢視
|
||||
Optimized 最佳化
|
||||
Options 選項
|
||||
Options for Selection… 選取範圍的選項…
|
||||
Options: 選項:
|
||||
Options… 選項…
|
||||
or more spaces with a tab 個以上的空格取代為定位字元
|
||||
@@ -1639,6 +1659,7 @@
|
||||
Out Tangent Handles 外切線控制把手
|
||||
Outer Glow… 外光暈…
|
||||
Outline 外框
|
||||
Outline Others 其他以外框檢視
|
||||
Outline Stroke 外框筆畫
|
||||
Outline Text 外框文字
|
||||
Outlines and Rules 外框與規則線
|
||||
@@ -1775,6 +1796,7 @@
|
||||
Polygon Properties: 多邊形內容:
|
||||
Polygon Tool 多邊形工具
|
||||
Portrait 直向
|
||||
Power Saving (integrated) 節能 (內建顯示晶片)
|
||||
Portuguese 葡萄牙文
|
||||
Position 位置
|
||||
Postcard 明信片
|
||||
@@ -1807,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 預覽模式
|
||||
@@ -1954,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 將連結檔案重新連結至文件
|
||||
@@ -2065,6 +2090,7 @@
|
||||
Rounded Rectangle 圓角矩形
|
||||
Rounded Rectangle Tool 圓角矩形工具
|
||||
Rounded Rectangle… 圓角矩形…
|
||||
Row Size: 列尺寸:
|
||||
Rows: 列數:
|
||||
Rule: 規則線:
|
||||
Rulers 尺標
|
||||
@@ -2198,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. 選取圖稿,然後使用「新增筆刷」來製作。
|
||||
@@ -2272,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 顯示所有色票
|
||||
@@ -2300,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 顯示網格
|
||||
@@ -2549,6 +2578,7 @@
|
||||
Three Point Perspective Preset 5 三點透視預設集 5
|
||||
Threshold 臨界值
|
||||
Thumbnail View 縮圖檢視
|
||||
Thumbnails: 縮圖:
|
||||
Tile 並排顯示
|
||||
Tile Edge Color 拼貼邊緣顏色
|
||||
Tile Edge Color… 拼貼邊緣顏色…
|
||||
@@ -2664,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.
|
@@ -293,6 +293,9 @@ pub struct VectorcraftApp {
|
||||
/// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
|
||||
/// double-click to maximize, caption buttons) and invisible edge zones resize the window.
|
||||
pub custom_titlebar: bool,
|
||||
/// The graphics adapter the window renders with ("name (backend)"), as the host reports it:
|
||||
/// shown in Help › About and `ui.inspect` for GPU bug reports. `None` when unknown.
|
||||
pub graphics_adapter: Option<String>,
|
||||
/// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details.
|
||||
pub place: place::PlaceState,
|
||||
/// The Paste commands can paste from the system clipboard alone: it holds something to paste
|
||||
@@ -367,6 +370,7 @@ impl VectorcraftApp {
|
||||
canvas_rect: None,
|
||||
hover_doc: None,
|
||||
custom_titlebar: false,
|
||||
graphics_adapter: None,
|
||||
place: Default::default(),
|
||||
system_paste: false,
|
||||
system_paste_at: f64::NEG_INFINITY,
|
||||
|
||||
+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
@@ -279,6 +279,10 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
|
||||
d.fields.insert(sp.key.into(), json!(opts[i].0));
|
||||
}
|
||||
});
|
||||
if sp.key == "gpuPreference" {
|
||||
// The window's graphics device is chosen once, when the app starts.
|
||||
ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0));
|
||||
}
|
||||
}
|
||||
PrefKind::Color => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
@@ -393,6 +397,38 @@ mod tests {
|
||||
assert_eq!(a.session.prefs.ui_brightness, "light");
|
||||
}
|
||||
|
||||
/// Performance › Graphics Processor (#306): shown with its restart note, applied by OK and
|
||||
/// saved with the UI state under the key the desktop app reads before the window opens.
|
||||
#[test]
|
||||
fn graphics_processor_preference_shows_and_persists() {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut a = app();
|
||||
open(&mut a, Some("Performance"));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
// The window sizes itself on the first frame and draws on the second.
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let mut shown = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
assert!(shown.iter().any(|t| t.starts_with("Graphics Processor")), "{shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "Power Saving (integrated)"), "{shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}");
|
||||
a.ui.dialog.as_mut().unwrap().fields.insert("gpuPreference".into(), json!("highPerformance"));
|
||||
confirm(&mut a).unwrap();
|
||||
assert_eq!(a.session.prefs.gpu_preference, "highPerformance");
|
||||
let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap();
|
||||
assert_eq!(saved["engine_prefs"]["gpuPreference"], json!("highPerformance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_category_renders_fields() {
|
||||
for c in PREF_CATEGORIES {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -24,6 +24,14 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
|
||||
- **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`.
|
||||
- Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter).
|
||||
|
||||
## Desktop graphics processor
|
||||
|
||||
The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (wgpu, through eframe) only composites the canvas texture and draws the UI. The desktop app (`apps/vectorcraft/src/main.rs`) therefore asks wgpu for the **power-saving** adapter by default, which is the integrated GPU on hybrid-graphics laptops. Presenting frames rendered on a discrete GPU through the integrated one made the window flicker on some laptops (#306). A machine with one GPU gets that GPU either way.
|
||||
|
||||
- **Preferences › Performance › Graphics Processor** (`gpuPreference`: `powerSaving` or `highPerformance`) picks the other one. The adapter is chosen when the window opens, so a change applies after a restart.
|
||||
- The `WGPU_POWER_PREF` environment variable (`low`, `high` or `none`) overrides the preference.
|
||||
- Help › About and the control channel's `ui.inspect` (`graphicsAdapter`) show the adapter in use, and the app logs it at startup.
|
||||
|
||||
## Fonts: craft-fonts (optional build input)
|
||||
|
||||
Font files are never committed to this repository. Fonts shared by the Crafting Apps live in
|
||||
|
||||
+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