Compare commits

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:29:15 +07:00
BFlatandClaude Opus 5.5 27a1a48176 Layers: sublayers are layers, not objects, and every row has a command
Art in a sublayer is now selected as objects (clicks, marquee, Select All, the
Select menu), sublayers select in their own colour, and hidden, locked or
template sublayers block clicks. Layer Options gain Preview (per-layer outline)
and Dim Images. New and fixed layer.* commands for the Layers panel: setCurrent
and highlight (rows at any depth), selectAll, move (reparent with cycle and
nesting checks, Alt copies), delete/duplicate rows, merge, flatten, collect,
release to layers, reverse, template, hide/outline/lock others and their
show/preview/unlock all, locate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:29:15 +07:00
BFlat f9209e8896 Merge pull request #347 from storytold/fix/issue-293
Fix #293: the dock's double arrow collapses its panels to icons
2026-10-07 22:27:32 +07:00
BFlatandClaude Opus 5.5 f16403a851 Fix #293: the dock's double arrow collapses its panels to icons
The » at the top of the right dock was only painted. Its header strip is
now a click target (hover highlight, "Collapse to Icons" tooltip) that
runs the new window.collapseDock UI command: the Properties | Layers |
Libraries group hides and its three panels become icons at the top of
the icon column, under a « strip ("Expand Panels") that expands them
again. While collapsed, those icons and window.panel pop the panel out
next to the column like the other icon panels (dock-width flyout, Layers
and Properties given a height that keeps them on screen); expanding with
one popped out shows its tab.

The flyout now follows the icon column's real left edge (recorded each
frame) instead of assuming a 300-point dock, so it also tracks a resized
or collapsed dock. The collapsed state is kept with the UI preferences
and in user workspaces; the built-in workspaces expand the dock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:21:59 +07:00
BFlat b64dcc02df Merge pull request #329 from storytold/fix/issue-306
Fix #306: render on the power-saving GPU by default, with a preference for the discrete one
2026-10-07 21:21:15 +07:00
BFlatandClaude Opus 5.5 406c27ebd8 Fix #306: render on the power-saving GPU by default, with a preference for the discrete one
On hybrid-graphics laptops (an integrated GPU driving the display plus a
discrete one), egui-wgpu's default power preference is HighPerformance, so
VectorCraft rendered on the discrete GPU and its frames were copied to the
display through the integrated one. On an Asus ZenBook with Intel Iris + Arc
the window flickered whenever the mouse moved over it (#306, #305).

The canvas is rasterized on the CPU (vello_cpu) and the GPU only composites
the canvas texture and draws the UI, so the integrated GPU is plenty. The
desktop app now asks wgpu for the power-saving adapter by default.
Single-GPU machines are unaffected.

- Preferences > Performance > Graphics Processor (`gpuPreference`:
  powerSaving, the default, or highPerformance), with a note that it
  applies after a restart. The app reads it from the saved preferences
  before the window opens. WGPU_POWER_PREF still overrides it.
- Help > About and `ui.inspect` (`graphicsAdapter`) name the adapter in
  use, and it is logged at startup, for future GPU bug reports.
- Docs (README, docs/development.md) and ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:10:17 +07:00
BFlat dfe7f1a311 Merge pull request #326 from storytold/fix/issue-296
Fix #296: variable fonts list and draw every named style
2026-10-07 21:09:48 +07:00
44 changed files with 3830 additions and 432 deletions

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
xmllint --noout packaging/windows/vectorcraft.wxs packaging/windows/vectorcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
+3 -1
View File
@@ -148,7 +148,9 @@ jobs:
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build, sign, package
shell: pwsh
env:
+3 -1
View File
@@ -45,7 +45,9 @@ jobs:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
+4
View File
@@ -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
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, and the 2026-10-07 issue fixes; see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes and the Layers panel rework; see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -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 |
+64 -1
View File
@@ -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
View File
@@ -23,6 +23,8 @@ pub struct Hit {
/// The innermost envelope in the ancestry whose contents are being edited (Edit Contents):
/// its content, not the envelope, is the object clicked.
pub contents_of: Option<NodeId>,
/// How many entries at the start of `ancestry` are layers (the layer and its sublayers).
pub layers: usize,
}
impl Hit {
@@ -34,8 +36,8 @@ impl Hit {
if let Some(i) = at(scope).max(at(self.contents_of)) {
return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf);
}
// Skip layers and sublayers.
self.ancestry.get(1).copied().unwrap_or(self.leaf)
// Skip layers and sublayers: they are not objects.
self.ancestry.get(self.layers.max(1)).copied().unwrap_or(self.leaf)
}
}
@@ -63,7 +65,7 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
if !layer.visible || layer.locked {
continue;
}
if let NodeKind::Layer { template: true, .. } = layer.kind {
if layer.is_template() {
continue;
}
// Pattern editing mode: only the tile art is editable.
@@ -71,8 +73,10 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
continue;
}
chain.push(layer.id);
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
if let Some(mut h) = hit_children(layer, p, opt, &mut chain) {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
return Some(h);
}
chain.pop();
@@ -105,7 +109,8 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
return None;
}
for c in children.iter().rev() {
if !c.visible || c.locked {
// Hidden, locked and template sublayers block clicks like top-level layers do.
if !c.visible || c.locked || c.is_template() {
continue;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
@@ -117,14 +122,16 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
}
chain.push(c.id);
let hit = match &c.kind {
// A sublayer whose Preview is off is clicked in outline, like an outline view.
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, chain),
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain),
// Edit Contents: the envelope's content hits, undistorted.
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, chain),
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
// as the blend.
NodeKind::Blend { .. } => hit_children(c, p, opt, chain)
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None })),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None }),
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
};
if hit.is_some() {
return hit;
@@ -192,13 +199,28 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
let mut out = Vec::new();
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
// The objects of the layers, looking through sublayers (hidden, locked and template ones
// are left out).
None => doc
.layers
.iter()
.filter(|l| l.visible && !l.locked && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
.flat_map(|l| l.children().into_iter().flatten())
.filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
.flat_map(|l| layer_objects(l))
.collect(),
};
fn layer_objects(l: &Node) -> Vec<&std::sync::Arc<Node>> {
let mut out = vec![];
for c in l.children().into_iter().flatten() {
if c.is_layer() {
if c.visible && !c.locked && !c.is_template() {
out.extend(layer_objects(c));
}
} else {
out.push(c);
}
}
out
}
fn touches(n: &Node, r: Rect) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
@@ -296,6 +318,54 @@ mod tests {
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
}
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
/// sublayer; hidden, locked and template sublayers block both.
#[test]
fn sublayers_are_not_objects() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let sub = d.alloc_id();
d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap();
let inner = d.alloc_id();
d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap();
let a = d.alloc_id();
d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let g = d.alloc_id();
let b = d.alloc_id();
let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art());
d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap();
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3));
let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap();
assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b));
let all = Rect::new(-5.0, -5.0, 200.0, 200.0);
assert_eq!(marquee(&d, all, None, false), vec![a, g]);
assert_eq!(d.layer_containing(a), Some(inner));
assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour");
assert_eq!(d.selectable_art(), vec![a, g]);
for f in [
|n: &mut Node| n.visible = false,
|n: &mut Node| n.locked = true,
|n: &mut Node| {
if let NodeKind::Layer { template, .. } = &mut n.kind {
*template = true;
}
},
] {
let mut d2 = d.clone();
f(d2.node_mut(inner).unwrap());
assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none());
assert_eq!(marquee(&d2, all, None, false), vec![g]);
assert_eq!(d2.selectable_art(), vec![g]);
}
// A sublayer whose Preview is off is clicked in outline: its fill no longer hits.
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind {
*preview = false;
}
assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none());
assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some());
}
#[test]
fn locked_and_hidden_skip() {
let mut d = Document::new(200.0, 200.0);
+12 -2
View File
@@ -837,9 +837,19 @@ impl Document {
pub fn is_visible(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible)))
}
/// Colour of the layer containing `id` (selection highlight colour).
/// The innermost layer or sublayer containing `id` (`id` itself when it is a layer).
pub fn layer_containing(&self, id: NodeId) -> Option<NodeId> {
let a = self.ancestry(id)?;
a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer))
}
/// Every art object the Selection tool can reach (Select All): the visible, unlocked objects
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first).
pub fn selectable_art(&self) -> Vec<NodeId> {
self.layers.iter().filter(|l| l.visible && !l.locked && !l.is_template()).flat_map(|l| l.layer_art(true)).collect()
}
/// Colour of the innermost layer or sublayer containing `id` (selection highlight colour).
pub fn layer_color(&self, id: NodeId) -> [u8; 3] {
let l = self.layer_of(id).and_then(|l| self.node(l));
let l = self.layer_containing(id).and_then(|l| self.node(l));
match l.map(|n| &n.kind) {
Some(NodeKind::Layer { color, .. }) => color.rgb(),
_ => LAYER_COLORS[0].1,
+36 -1
View File
@@ -259,6 +259,14 @@ pub enum NodeKind {
/// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
clip: bool,
/// Layer Options → Preview: off, the layer's art is drawn (and clicked) in outline on screen
/// (Ctrl-click its eye in the Layers panel).
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
preview: bool,
/// Layer Options → Dim Images to: on screen, images on the layer show faded to this
/// percentage (0–100); `None`: not dimmed.
#[serde(default, skip_serializing_if = "Option::is_none")]
dim_images: Option<u8>,
},
/// A group. With `clip`, the first (bottom-most) child is the clipping path.
Group {
@@ -448,7 +456,10 @@ impl Node {
Self::new(id, NodeKind::Group { children, clip: false })
}
pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self {
let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false });
let mut n = Self::new(
id,
NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false, preview: true, dim_images: None },
);
n.name = Some(name.to_string());
n
}
@@ -477,6 +488,30 @@ impl Node {
pub fn is_layer(&self) -> bool {
matches!(self.kind, NodeKind::Layer { .. })
}
/// A template layer (Layer Options → Template).
pub fn is_template(&self) -> bool {
matches!(self.kind, NodeKind::Layer { template: true, .. })
}
/// The art objects of this container: its children, looking through sublayers, which are not
/// objects (bottom first). With `editable`, only the visible, unlocked objects in visible,
/// unlocked, non-template sublayers.
pub fn layer_art(&self, editable: bool) -> Vec<NodeId> {
fn visit(n: &Node, editable: bool, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten() {
if editable && (!c.visible || c.locked || c.is_template()) {
continue;
}
if c.is_layer() {
visit(c, editable, out);
} else {
out.push(c.id);
}
}
}
let mut out = vec![];
visit(self, editable, &mut out);
out
}
pub fn is_container(&self) -> bool {
self.children().is_some()
}
+3 -3
View File
@@ -98,11 +98,11 @@ impl Selection {
}
self.slices.retain(|id| doc.is_slice(*id));
}
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected,
/// anything else is selected itself.
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
/// (the art of its sublayers too), anything else is selected itself.
pub fn set_target(&mut self, doc: &Document, id: NodeId) {
match doc.node(id) {
Some(n) if n.is_layer() => self.set(n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id)),
Some(n) if n.is_layer() => self.set(n.layer_art(true)),
_ => self.set([id]),
}
self.target = Some(id);
+544 -97
View File
@@ -1,31 +1,125 @@
//! Layers panel, artboards.
//!
//! The Layers panel works on rows: layers, sublayers, groups and objects. Clicking a row makes it
//! the highlighted row (`layer.setCurrent`; Shift and Ctrl change several, `layer.highlight`) and
//! its layer the current one, where new art goes. Panel commands without `ids` act on the
//! highlighted rows, else on the current layer. More panel-menu operations live in
//! [`super::layerpanel`].
use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Artboard, LayerColor, NodeId, NodeKind};
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
use vectorcraft_geom::Rect;
use super::*;
use crate::EngineError;
/// Most rows one panel command takes (ids are untrusted input).
pub(crate) const MAX_ROWS: usize = 100_000;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.new", "New Layer…", ["Window", "Layers"], Some("Cmd+L"), "{name?} → {id} (above the current layer)", has_doc, new_layer),
cmd!("layer.newSublayer", "New Sublayer…", ["Window", "Layers"], None, "{parent?: id, name?} → {id}", has_doc, new_sublayer),
cmd!("layer.delete", "Delete Layer", ["Window", "Layers"], None, "{id?} (default: current layer)", has_doc, delete_layer),
cmd!("layer.duplicate", "Duplicate Layer", ["Window", "Layers"], None, "{id?}", has_doc, duplicate_layer),
cmd!("layer.setCurrent", "Set Current Layer", [], None, "{id}", has_doc, set_current),
cmd!(
"layer.new",
"New Layer…",
["Window", "Layers"],
Some("Cmd+L"),
"{name?, top?: bool, color?, template?, locked?, visible?, printable?, preview?, dimImages?} a layer directly above the current layer, at its level (a sublayer beside a current sublayer); `top`: above all layers (Ctrl-click the button). Options as layer.setProps → {id}",
has_doc,
new_layer
),
cmd!(
"layer.newSublayer",
"New Sublayer…",
["Window", "Layers"],
None,
"{parent?: layer id (default: the highlighted row's layer, else the current layer), name?, options as layer.new} a sublayer on top of the parent's contents → {id}",
has_doc,
new_sublayer
),
cmd!(
"layer.delete",
"Delete Selection",
["Window", "Layers"],
None,
"{ids?|id?} delete rows: layers, sublayers, groups or objects with what they hold (default: the highlighted rows, else the current layer). The last layer stays → {deleted}",
has_doc,
delete_rows
),
cmd!(
"layer.duplicate",
"Duplicate Selection",
["Window", "Layers"],
None,
"{ids?|id?} copy rows (default: the highlighted rows, else the current layer), each just above its original; a layer's copy is named `<name> copy`. The copies are highlighted → {ids}",
has_doc,
duplicate_rows
),
cmd!(
"layer.setCurrent",
"Set Current Layer",
[],
None,
"{id} click a row: it alone is highlighted, and the layer it is (or the layer it is in) becomes the current layer, where new art goes. Not an undo step → {id, layer}",
has_doc,
set_current
),
cmd!(
"layer.highlight",
"Highlight Rows",
[],
None,
"{ids, mode?: \"set\"|\"add\"|\"toggle\"} highlight Layers panel rows (Shift-click: a range, Ctrl-click: toggle one); the last one's layer becomes current. Not an undo step → {rows}",
has_doc,
highlight
),
cmd!(
"layer.setProps",
"Layer Options…",
["Window", "Layers"],
None,
"{id, name?, visible?, locked?, template?, printable?, color?: index 0..26}",
"{ids?|id? (default: the highlighted rows, else the current layer), name?, visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name} Layer Options; rows that are not layers take name, visible and locked. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step",
has_doc,
set_props
),
cmd!("layer.collectInNew", "Collect in New Layer", ["Window", "Layers"], None, "{}", has_selection, collect),
cmd!("layer.selectAll", "Select All Art on Layer", [], None, "{id}", has_doc, select_all_on),
cmd!("node.move", "Reorder", [], None, "{id, parent?: id (null = top level), index} drag-reorder in the Layers panel", has_doc, move_node),
cmd!(
"layer.collectInNew",
"Collect in New Layer",
["Window", "Layers"],
None,
"{ids?} move rows (default: the highlighted rows, else the selected objects) into a new layer where the topmost of them was (a sublayer when they are in a layer) → {id}",
has_doc,
collect
),
cmd!(
"layer.selectAll",
"Select Art",
[],
None,
"{id, add?: bool} the Layers panel's selection column: select the visible, unlocked art of a layer (its sublayers' too), or the object itself; `add` (Shift-click) adds it to the selection, or removes it when it is all selected already → {selected: [..]}",
has_doc,
select_art
),
cmd!(
"layer.move",
"Move Rows",
[],
None,
"{ids, target, place?: \"above\"|\"below\"|\"inside\" (default inside), copy?: bool} drag rows in the Layers panel: above or below the target row (beside it, in its layer or group), or inside a layer or group (on top of its contents). Layers go only in layers or at the top level, objects in layers and groups; an object placed beside a top-level layer goes inside it. A row never goes into itself; a locked layer or group takes nothing. `copy` (Alt-drag) moves copies. One undo step → {ids}",
has_doc,
move_rows
),
cmd!(
"node.move",
"Reorder",
[],
None,
"{id, parent?: id (null = top level), index} put a row at `index` (0 = bottom) of `parent` (layers only at the top level or in layers)",
has_doc,
move_node
),
cmd!(
"artboard.new",
"New Artboard",
@@ -52,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make/Release Clipping Mask",
["Window", "Layers"],
None,
"{id?: layer or group (default: the one selected group, else the current layer)} make: its top object (a path, compound path or text, which loses its paint) clips the rest and moves to the bottom (new art added on top is clipped); release: it stops clipping, the clipping path stays unpainted → {clip}",
"{id?: layer or group (default: the one highlighted layer or group row, else the one selected group, else the current layer)} make: its top object (a path, compound path or text, which loses its paint) clips the rest and moves to the bottom (new art added on top is clipped); release: it stops clipping, the clipping path stays unpainted → {clip}",
has_doc,
clip_toggle
),
@@ -61,7 +155,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Target",
[],
None,
"{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
"{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art (its sublayers' too) selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
has_doc,
target
),
@@ -77,6 +171,179 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// The rows a panel command acts on: `ids` or `id`, else the highlighted rows, else the current
/// layer (capped at [`MAX_ROWS`]).
pub(crate) fn rows_param(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let mut ids = match (ids_param(p, "ids"), id_param(p, "id")) {
(Some(ids), _) => ids,
(None, Some(id)) => vec![id],
(None, None) => {
let st = s.doc()?;
let rows = st.highlighted_rows();
if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows }
}
};
ids.truncate(MAX_ROWS);
Ok(ids)
}
/// `ids` that exist, without repeats or rows inside another listed row, in paint order (bottom
/// first).
pub(crate) fn row_roots(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let set: HashSet<NodeId> = ids.iter().copied().filter(|i| d.node(*i).is_some()).collect();
let keep: Vec<NodeId> = set
.iter()
.copied()
.filter(|id| d.ancestry(*id).is_some_and(|a| a.split_last().is_none_or(|(_, above)| !above.iter().any(|x| set.contains(x)))))
.collect();
d.paint_order(keep)
}
/// Whether `parent` (None: the top level) can hold `n`: layers go at the top level or in layers,
/// anything else in layers and groups.
pub(crate) fn accepts(d: &Document, parent: Option<NodeId>, n: &Node) -> bool {
match parent {
None => n.is_layer(),
Some(p) => match d.node(p).map(|x| &x.kind) {
Some(NodeKind::Layer { .. }) => true,
Some(NodeKind::Group { .. }) => !n.is_layer(),
_ => false,
},
}
}
/// Whether `id` or a container around it is locked.
fn locked_within(d: &Document, id: NodeId) -> bool {
d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked))
}
/// Every layer and sublayer, depth first.
fn all_layers(d: &Document) -> Vec<&Node> {
let mut out = vec![];
d.walk(|n| {
if n.is_layer() {
out.push(n);
}
});
out
}
/// The next unused `Layer N` name, counting sublayers too.
pub(crate) fn next_layer_name(d: &Document) -> String {
let layers = all_layers(d);
let taken: HashSet<&str> = layers.iter().filter_map(|l| l.name.as_deref()).collect();
let mut i = layers.len() + 1;
loop {
let name = format!("Layer {i}");
if !taken.contains(name.as_str()) {
return name;
}
i += 1;
}
}
/// A new layer named `name` (default: the next `Layer N`) in the next layer colour.
pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
let n = all_layers(d).len();
let name = name.map(str::to_string).unwrap_or_else(|| next_layer_name(d));
let id = d.alloc_id();
Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8))
}
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
match v {
Value::Number(n) => match n.as_u64() {
Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
_ => Err(format!("a colour index is 0 to {}, not {n}", LAYER_COLORS.len() - 1)),
},
Value::String(s) => {
if let Some(i) = LAYER_COLORS.iter().position(|(name, _)| name.eq_ignore_ascii_case(s.trim())) {
return Ok(LayerColor::Preset(i as u8));
}
let c = vectorcraft_color::Color::from_hex(s).ok_or_else(|| format!("`{s}` is not a colour (#rrggbb or a preset name)"))?;
let [r, g, b, _] = c.to_rgba8(1.0);
Ok(LayerColor::Custom([r, g, b]))
}
_ => Err(format!("a colour is an index, #rrggbb or a preset name, not {v}")),
}
}
/// Layer Options in `p` applied to layer or row `n` (validated first by [`check_options`]).
fn apply_options(n: &mut Node, p: &Value) {
if let Some(v) = str_param(p, "name") {
if v.trim().is_empty() {
// A layer keeps a name; an object goes back to its generated `<Kind>` one.
if !n.is_layer() {
n.name = None;
}
} else {
n.name = Some(v.chars().take(512).collect());
}
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
n.visible = v;
}
let locked = p.get("locked").and_then(Value::as_bool);
if let Some(v) = locked {
n.locked = v;
}
let mut lock = None;
if let NodeKind::Layer { template, printable, color, preview, dim_images, .. } = &mut n.kind {
let dim_given = p.get("dimImages").is_some();
if let Some(v) = p.get("template").and_then(Value::as_bool)
&& v != *template
{
*template = v;
// A template is locked and its images dimmed; it stops being either with it.
lock = Some(v);
if !dim_given {
*dim_images = v.then_some(50);
}
}
if let Some(v) = p.get("printable").and_then(Value::as_bool) {
*printable = v;
}
if let Some(v) = p.get("preview").and_then(Value::as_bool) {
*preview = v;
}
match p.get("dimImages") {
Some(Value::Bool(false) | Value::Null) => *dim_images = None,
Some(Value::Bool(true)) => *dim_images = Some(50),
Some(v) => {
if let Some(x) = v.as_f64().filter(|x| x.is_finite()) {
*dim_images = Some(x.clamp(0.0, 100.0).round() as u8);
}
}
None => {}
}
if let Some(c) = p.get("color").and_then(|v| layer_color_param(v).ok()) {
*color = c;
}
}
if let (Some(v), None) = (lock, locked) {
n.locked = v;
}
}
/// Bad values in Layer Options params, as an error for `cmd`.
fn check_options(cmd: &str, p: &Value) -> Result<()> {
if let Some(v) = p.get("color") {
layer_color_param(v).map_err(|e| bad(cmd, e))?;
}
for k in ["visible", "locked", "template", "printable", "preview"] {
if p.get(k).is_some_and(|v| !v.is_boolean()) {
return Err(bad(cmd, format!("`{k}` is true or false")));
}
}
if let Some(v) = p.get("dimImages")
&& !(v.is_boolean() || v.is_null() || v.as_f64().is_some_and(f64::is_finite))
{
return Err(bad(cmd, "`dimImages` is a percentage or false"));
}
Ok(())
}
/// Layers panel → Paste Remembers Layers.
fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result<Value> {
let cur = s.doc()?.doc.paste_remembers_layers;
@@ -100,8 +367,11 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "the object is hidden or locked"));
}
let layer = n.is_layer();
let rows = st.layer_rows.clone();
s.select(|d, sel| sel.set_target(d, id))?;
let st = s.doc_mut()?;
// Targeting doesn't change which rows are highlighted.
st.layer_rows = rows;
if layer {
st.active_layer = Some(id);
}
@@ -112,11 +382,16 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
/// or the clipping mask is released.
fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let container = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }));
let row = match st.highlighted_rows().as_slice() {
[id] if container(id) => Some(*id),
_ => None,
};
let group = match st.selection.objects.as_slice() {
[id] if matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Group { .. })) => Some(*id),
_ => None,
};
let id = id_param(p, "id").or(group).or(st.current_layer()).ok_or_else(|| bad("layer.clippingMask.toggle", "no layer"))?;
let id = id_param(p, "id").or(row).or(group).or(st.current_layer()).ok_or_else(|| bad("layer.clippingMask.toggle", "no layer"))?;
let clip = s.edit("Make/Release Clipping Mask", |d, _| {
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
if !matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. }) {
@@ -138,99 +413,142 @@ fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "clip": clip }))
}
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").map(str::to_string);
let st = s.doc()?;
let cur = st.active_layer.and_then(|l| st.doc.position(l)).filter(|(par, _, _)| par.is_none()).map(|(_, i, _)| i);
let id = s.edit("New Layer", |d, _| {
let id = d.add_layer(name.as_deref());
if let Some(i) = cur {
d.move_node(id, None, i + 1)?;
}
Ok(id)
/// A new layer with the options in `p`, at `parent`/`index`; it becomes current and highlighted.
fn add_layer(s: &mut Session, p: &Value, cmd: &str, label: &str, place: impl FnOnce(&Document) -> Result<(Option<NodeId>, usize)>) -> Result<Value> {
check_options(cmd, p)?;
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
let id = s.edit(label, |d, _| {
let (parent, index) = place(d)?;
let mut n = make_layer(d, name.as_deref());
apply_options(&mut n, p);
Ok(d.insert(parent, index, n)?)
})?;
s.doc_mut()?.active_layer = Some(id);
let st = s.doc_mut()?;
st.active_layer = Some(id);
st.layer_rows = vec![id];
Ok(json!({ "id": id.0 }))
}
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
// Above the current layer, at its level; on top of everything with `top`.
let cur = st.current_layer().filter(|_| !bool_or(p, "top", false));
add_layer(s, p, "layer.new", "New Layer", |d| match cur.and_then(|c| d.position(c)) {
Some((parent, i, _)) => Ok((parent, i + 1)),
None => Ok((None, usize::MAX)),
})
}
fn new_sublayer(s: &mut Session, p: &Value) -> Result<Value> {
let parent = id_param(p, "parent").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.newSublayer", "no parent layer"))?;
let name = str_param(p, "name").map(str::to_string);
let id = s.edit("New Sublayer", |d, _| {
let n = d.node(parent).and_then(|n| n.children()).map(|c| c.iter().filter(|c| c.is_layer()).count()).unwrap_or(0);
let id = d.alloc_id();
let color = LayerColor::Preset(((d.layers.len() + n + 1) % 27) as u8);
let nm = name.unwrap_or_else(|| format!("Layer {}", d.node_count()));
d.insert(Some(parent), usize::MAX, vectorcraft_doc::Node::layer(id, &nm, color))?;
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
const C: &str = "layer.newSublayer";
let st = s.doc()?;
let row = st.highlighted_rows().last().and_then(|r| st.doc.layer_containing(*r));
let parent = id_param(p, "parent").or(row).or(st.current_layer()).ok_or_else(|| bad(C, "no parent layer"))?;
if !st.doc.node(parent).is_some_and(Node::is_layer) {
return Err(bad(C, "the parent must be a layer or sublayer"));
}
add_layer(s, p, C, "New Sublayer", |_| Ok((Some(parent), usize::MAX)))
}
fn delete_layer(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.delete", "no layer"))?;
fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
let ids = rows_param(s, p)?;
let st = s.doc()?;
if st.doc.layers.len() == 1 && st.doc.layers[0].id == id {
let ids = row_roots(&st.doc, &ids);
if ids.is_empty() {
return Err(bad("layer.delete", "no such rows"));
}
if st.doc.layers.iter().all(|l| ids.contains(&l.id)) {
return Err(EngineError::Other("a document needs at least one layer".into()));
}
s.edit("Delete Layer", |d, sel| {
d.remove(id)?;
s.edit("Delete Selection", |d, sel| {
for id in &ids {
d.remove(*id)?;
}
sel.prune(d);
Ok(())
})?;
let st = s.doc_mut()?;
if st.active_layer == Some(id) || st.active_layer.is_some_and(|l| st.doc.node(l).is_none()) {
st.layer_rows.clear();
if st.current_layer().is_none() {
st.active_layer = st.doc.default_layer();
}
ok()
Ok(json!({ "deleted": ids.len() }))
}
fn duplicate_layer(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.duplicate", "no layer"))?;
let nid = s.edit("Duplicate Layer", |d, _| {
let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let mut c = d.reid(&n);
c.name = Some(format!("{} copy", n.display_name()));
Ok(d.insert(par, idx + 1, c)?)
fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
let ids = rows_param(s, p)?;
let ids = row_roots(&s.doc()?.doc, &ids);
if ids.is_empty() {
return Err(bad("layer.duplicate", "no such rows"));
}
let copies = s.edit("Duplicate Selection", |d, _| {
let mut out = vec![];
for id in &ids {
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
let mut c = d.reid(&n);
if n.is_layer() {
c.name = Some(format!("{} copy", n.display_name()));
}
out.push(d.insert(par, idx + 1, c)?);
}
Ok(out)
})?;
Ok(json!({ "id": nid.0 }))
s.doc_mut()?.layer_rows = copies.clone();
Ok(json!({ "ids": copies.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("layer.setCurrent", "missing id"))?;
const C: &str = "layer.setCurrent";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let st = s.doc_mut()?;
if !st.doc.node(id).is_some_and(|n| n.is_layer()) {
return Err(bad("layer.setCurrent", "not a layer"));
}
st.active_layer = Some(id);
let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?;
st.active_layer = Some(layer);
st.layer_rows = vec![id];
st.revision += 1;
ok()
Ok(json!({ "id": id.0, "layer": layer.0 }))
}
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.highlight";
let mut ids = ids_param(p, "ids").ok_or_else(|| bad(C, "missing ids"))?;
ids.truncate(MAX_ROWS);
let mode = str_param(p, "mode").unwrap_or("set");
let st = s.doc_mut()?;
ids.retain(|id| st.doc.node(*id).is_some());
let mut rows = st.highlighted_rows();
match mode {
"set" => rows.clear(),
"add" | "toggle" => {}
other => return Err(bad(C, format!("mode is set, add or toggle, not `{other}`"))),
}
let mut last = None;
for id in ids {
if mode == "toggle" && rows.contains(&id) {
rows.retain(|r| *r != id);
} else if !rows.contains(&id) {
rows.push(id);
last = Some(id);
}
}
if let Some(layer) = last.or(rows.last().copied()).and_then(|id| st.doc.layer_containing(id)) {
st.active_layer = Some(layer);
}
st.layer_rows = rows;
st.revision += 1;
Ok(json!({ "rows": st.layer_rows.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("layer.setProps", "missing id"))?;
const C: &str = "layer.setProps";
check_options(C, p)?;
let ids = rows_param(s, p)?;
if ids.is_empty() {
return Err(bad(C, "no rows"));
}
s.edit("Layer Options", |d, sel| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
if let Some(v) = str_param(p, "name") {
n.name = Some(v.to_string());
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
n.visible = v;
}
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
n.locked = v;
}
if let NodeKind::Layer { template, printable, color, .. } = &mut n.kind {
if let Some(v) = p.get("template").and_then(Value::as_bool) {
*template = v;
}
if let Some(v) = p.get("printable").and_then(Value::as_bool) {
*printable = v;
}
if let Some(v) = p.get("color").and_then(Value::as_u64) {
*color = LayerColor::Preset((v % 27) as u8);
}
for id in &ids {
apply_options(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, p);
}
// Hidden or locked content can't stay selected.
let hidden: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
@@ -242,37 +560,166 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn collect(s: &mut Session, _: &Value) -> Result<Value> {
let ids = super::edit::selected_roots(s)?;
let lid = s.edit("Collect in New Layer", |d, _| {
let lid = d.add_layer(None);
fn collect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.collectInNew";
let ids = match ids_param(p, "ids") {
Some(ids) => ids,
None => {
let rows = s.doc()?.highlighted_rows();
if rows.is_empty() { super::edit::selected_roots(s)? } else { rows }
}
};
let ids = row_roots(&s.doc()?.doc, &ids);
let Some(top) = ids.last().copied() else { return Err(bad(C, "select rows or objects to collect")) };
let lid = s.edit("Collect in New Layer", |d, sel| {
// Where the topmost of them is: beside it in its layer, or on top of the layer holding
// its group.
let (parent, index) = match d.position(top) {
Some((par, i, _)) if par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer)) => (par, i + 1),
_ => (d.layer_containing(top), usize::MAX),
};
let layer = make_layer(d, None);
let lid = d.insert(parent, index, layer)?;
for id in &ids {
d.move_node(*id, Some(lid), usize::MAX)?;
}
sel.prune(d);
Ok(lid)
})?;
let st = s.doc_mut()?;
st.active_layer = Some(lid);
st.layer_rows = vec![lid];
Ok(json!({ "id": lid.0 }))
}
fn select_all_on(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("layer.selectAll", "missing id"))?;
let ids: Vec<NodeId> = s
.doc()?
.doc
.node(id)
.and_then(|n| n.children())
.map(|c| c.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect())
.unwrap_or_default();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
ok()
/// The selection column: select a row's art.
fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.selectAll";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let add = bool_or(p, "add", false);
let st = s.doc()?;
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
let ids: Vec<NodeId> = if !st.doc.is_editable(id) {
vec![]
} else if n.is_layer() {
n.layer_art(true)
} else {
vec![id]
};
let all_selected = !ids.is_empty() && ids.iter().all(|i| st.selection.contains(*i));
let rows = st.layer_rows.clone();
s.select(|_, sel| {
if !add {
sel.set(ids.iter().copied());
} else if all_selected {
for i in &ids {
sel.remove(*i);
}
} else {
for i in &ids {
sel.add(*i);
}
}
})?;
let st = s.doc_mut()?;
// The selection column leaves the highlighted rows as they are.
st.layer_rows = rows;
Ok(json!({ "selected": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Place {
Above,
Below,
Inside,
}
/// Drag rows in the Layers panel.
fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.move";
let mut ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?;
ids.truncate(MAX_ROWS);
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?;
let place = match str_param(p, "place").unwrap_or("inside") {
"above" => Place::Above,
"below" => Place::Below,
"inside" | "into" => Place::Inside,
other => return Err(bad(C, format!("place is above, below or inside, not `{other}`"))),
};
let copy = bool_or(p, "copy", false);
let st = s.doc()?;
let d = &st.doc;
d.node(target).ok_or(EngineError::NoNode(target))?;
let ids = row_roots(d, &ids);
if ids.is_empty() {
return Err(bad(C, "no such rows"));
}
// Never into itself or a row inside it.
let around = d.ancestry(target).unwrap_or_default();
if ids.iter().any(|i| around.contains(i)) {
return Err(bad(C, "a row can't go into itself"));
}
let nodes: Vec<&Node> = ids.iter().filter_map(|i| d.node(*i)).collect();
let (parent, anchor) = match place {
Place::Inside => (Some(target), None),
_ => match d.parent_of(target) {
// Objects can't be at the top level: beside a top-level layer means in it.
None if nodes.iter().any(|n| !n.is_layer()) => (Some(target), None),
parent => (parent, Some(target)),
},
};
if let Some(n) = nodes.iter().find(|n| !accepts(d, parent, n)) {
let into = parent.and_then(|p| d.node(p)).map_or("the top level", |p| if p.is_layer() { "a layer" } else { p.kind_label() });
return Err(bad(C, format!("{} can't go into {into}", n.display_name())));
}
if parent.is_some_and(|p| locked_within(d, p)) {
return Err(EngineError::Other("the layer or group is locked".into()));
}
let label = if copy { "Duplicate Rows" } else { "Move Rows" };
let moved = s.edit(label, |d, sel| {
let mut nodes = vec![];
for id in &ids {
nodes.push(if copy {
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
d.reid(&n)
} else {
Arc::unwrap_or_clone(d.remove(*id)?)
});
}
let count = d.children(parent).map_or(0, Vec::len);
let mut index = match anchor {
None => count,
Some(a) => {
let (_, i, _) = d.position(a).ok_or(EngineError::NoNode(a))?;
if place == Place::Above { i + 1 } else { i }
}
};
// The clipping path stays at the bottom of a clip group or a clipping layer.
if count > 0 && parent.and_then(|p| d.node(p)).is_some_and(Node::clips) {
index = index.max(1);
}
let mut out = vec![];
for n in nodes {
out.push(d.insert(parent, index, n)?);
index += 1;
}
sel.prune(d);
Ok(out)
})?;
let st = s.doc_mut()?;
st.layer_rows = moved.clone();
Ok(json!({ "ids": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("node.move", "missing id"))?;
const C: &str = "node.move";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let parent = id_param(p, "parent");
let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX) as usize;
if parent.is_none() && !s.doc()?.doc.node(id).is_some_and(|n| n.is_layer()) {
return Err(bad("node.move", "only layers can be at the top level"));
let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX).min(usize::MAX as u64) as usize;
let d = &s.doc()?.doc;
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
if !accepts(d, parent, n) {
return Err(bad(C, "layers go only at the top level or in layers, objects in layers and groups"));
}
s.edit("Reorder", |d, _| Ok(d.move_node(id, parent, index)?))?;
ok()
+429
View File
@@ -0,0 +1,429 @@
//! The Layers panel menu's operations on rows: Merge Selected, Flatten Artwork, Release to Layers,
//! Reverse Order, Template, Hide/Outline/Lock Others and their Show/Preview/Unlock All
//! counterparts, Locate Object. Rows default to the highlighted ones, else the current layer (see
//! [`super::layer`]).
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::layer::{make_layer, row_roots, rows_param};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"layer.merge",
"Merge Selected",
["Window", "Layers"],
None,
"{ids?} (default: the highlighted rows) merge two or more rows into the layer listed (highlighted) last: the other layers' contents and the other rows move into it, keeping their stacking order, and the emptied layers go. One undo step → {id}",
has_doc,
merge
),
cmd!(
"layer.flatten",
"Flatten Artwork",
["Window", "Layers"],
None,
"{id?} (default: the highlighted row's top-level layer, else the current one's) move the art of every other visible top-level layer into this one, keeping the stacking order; hidden layers and their art are deleted, template layers stay. One undo step → {id, discarded}",
has_doc,
flatten
),
cmd!(
"layer.releaseToLayers",
"Release to Layers (Sequence)",
["Window", "Layers"],
None,
"{id?: layer or group (default: the highlighted row, else the current layer), build?: bool} each object in it goes into a new sublayer of its own, in stacking order (a group's objects go into sublayers of its layer, where the group was). `build`: Release to Layers (Build), each new sublayer holds copies of the objects up to its own, so the layers add up. One undo step → {ids}",
has_doc,
release
),
cmd!(
"layer.releaseToLayersBuild",
"Release to Layers (Build)",
["Window", "Layers"],
None,
"{id?} layer.releaseToLayers with build: true → {ids}",
has_doc,
|s, p| {
let mut p = p.clone();
if let Some(m) = p.as_object_mut() {
m.insert("build".into(), json!(true));
} else {
p = json!({"build": true});
}
release(s, &p)
}
),
cmd!(
"layer.reverse",
"Reverse Order",
["Window", "Layers"],
None,
"{ids?} (default: the highlighted rows) reverse the stacking order of two or more rows that share a layer or group; each keeps the places the others held. One undo step",
has_doc,
reverse
),
cmd!(
"layer.template",
"Template",
["Window", "Layers"],
None,
"{ids?, on?: bool (default: toggle the first)} make layers templates (locked, dimmed, left out of print and export) or ordinary layers again (layer.setProps template) → {on}",
has_doc,
template
),
cmd!(
"layer.hideOthers",
"Hide Others",
["Window", "Layers"],
None,
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer). One undo step → {count}",
has_doc,
|s, p| others(s, p, Flag::Visible, false)
),
cmd!("layer.showAll", "Show All Layers", ["Window", "Layers"], None, "{} show every layer and sublayer → {count}", has_doc, |s, _| all(
s,
Flag::Visible,
true
)),
cmd!(
"layer.outlineOthers",
"Outline Others",
["Window", "Layers"],
None,
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
has_doc,
|s, p| others(s, p, Flag::Preview, false)
),
cmd!(
"layer.previewAll",
"Preview All Layers",
["Window", "Layers"],
None,
"{} turn Preview on for every layer and sublayer → {count}",
has_doc,
|s, _| all(s, Flag::Preview, true)
),
cmd!(
"layer.lockOthers",
"Lock Others",
["Window", "Layers"],
None,
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
has_doc,
|s, p| others(s, p, Flag::Locked, true)
),
cmd!(
"layer.unlockAll",
"Unlock All Layers",
["Window", "Layers"],
None,
"{} unlock every layer and sublayer → {count}",
has_doc,
|s, _| all(s, Flag::Locked, false)
),
cmd!(
"layer.locate",
"Locate Object",
["Window", "Layers"],
None,
"{id?} (default: the last selected object) highlight the object's row; the Layers panel opens the layers around it and scrolls to it → {id, ancestry: [layer…, id]}",
has_doc,
locate
),
]
}
/// A layer flag the panel menu sets on many layers.
#[derive(Clone, Copy)]
enum Flag {
Visible,
Locked,
Preview,
}
impl Flag {
fn get(self, n: &Node) -> bool {
match (self, &n.kind) {
(Flag::Visible, _) => n.visible,
(Flag::Locked, _) => n.locked,
(Flag::Preview, NodeKind::Layer { preview, .. }) => *preview,
(Flag::Preview, _) => true,
}
}
fn set(self, n: &mut Node, v: bool) {
match (self, &mut n.kind) {
(Flag::Visible, _) => n.visible = v,
(Flag::Locked, _) => n.locked = v,
(Flag::Preview, NodeKind::Layer { preview, .. }) => *preview = v,
(Flag::Preview, _) => {}
}
}
fn label(self, on: bool) -> &'static str {
match (self, on) {
(Flag::Visible, true) => "Show All Layers",
(Flag::Visible, false) => "Hide Others",
(Flag::Locked, true) => "Lock Others",
(Flag::Locked, false) => "Unlock All Layers",
(Flag::Preview, true) => "Preview All Layers",
(Flag::Preview, false) => "Outline Others",
}
}
}
/// Set `flag` to `v` on `ids` (layers that differ) in one undo step labelled `label` → how many
/// changed.
fn set_flag(s: &mut Session, ids: Vec<NodeId>, flag: Flag, v: bool, label: &str) -> Result<Value> {
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| flag.get(n) != v)).collect();
if !ids.is_empty() {
s.edit(label, |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
flag.set(n, v);
}
}
// Hidden or locked art can't stay selected.
let out: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
for o in out {
sel.remove(o);
}
Ok(())
})?;
}
Ok(json!({ "count": ids.len() }))
}
/// The top-level layers holding the rows a command acts on.
fn kept_layers(s: &Session, p: &Value) -> Result<HashSet<NodeId>> {
let ids = rows_param(s, p)?;
let d = &s.doc()?.doc;
Ok(ids.iter().filter_map(|id| d.layer_of(*id)).collect())
}
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool) -> Result<Value> {
let keep = kept_layers(s, p)?;
let ids: Vec<NodeId> = s.doc()?.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect();
set_flag(s, ids, flag, v, flag.label(v))
}
fn all(s: &mut Session, flag: Flag, v: bool) -> Result<Value> {
let mut ids = vec![];
s.doc()?.doc.walk(|n| {
if n.is_layer() {
ids.push(n.id);
}
});
set_flag(s, ids, flag, v, flag.label(v))
}
fn template(s: &mut Session, p: &Value) -> Result<Value> {
let ids = rows_param(s, p)?;
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(Node::is_layer)).collect();
let Some(first) = ids.first().and_then(|id| d.node(*id)) else { return Err(bad("layer.template", "select a layer")) };
let on = bool_or(p, "on", !first.is_template());
s.execute("layer.setProps", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "template": on}))?;
Ok(json!({ "on": on }))
}
/// Move the contents of `others` (layers give their children, anything else itself; none inside
/// another) into layer `into`, keeping the document's stacking order, then delete the emptied
/// layers.
fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
let own: Vec<NodeId> =
d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !others.contains(c)).collect()).unwrap_or_default();
let mut moved = vec![];
for id in others {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
if n.is_layer() {
moved.extend(n.children().into_iter().flatten().map(|c| c.id));
} else {
moved.push(*id);
}
}
// Where everything is now: the merged layer stacks its contents in this order.
let keys: HashMap<NodeId, Vec<usize>> = own.iter().chain(&moved).filter_map(|id| Some((*id, d.index_path(*id)?))).collect();
let mut taken = vec![];
for id in &moved {
taken.push(d.remove(*id)?);
}
for id in others {
if d.node(*id).is_some() {
d.remove(*id)?;
}
}
let ch = d.children_mut(Some(into))?;
let mut all = std::mem::take(ch);
all.extend(taken);
all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id)));
*ch = all;
Ok(())
}
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.merge";
let ids = rows_param(s, p)?;
let st = s.doc()?;
let d = &st.doc;
let mut seen = HashSet::new();
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some() && seen.insert(*id)).collect();
if ids.len() < 2 {
return Err(bad(C, "highlight two or more rows to merge"));
}
let into =
ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
let around = d.ancestry(into).unwrap_or_default();
// (Rows inside the target layer merge too: only rows inside other listed rows go with those.)
let rest: Vec<NodeId> = ids.iter().copied().filter(|id| *id != into).collect();
let others = row_roots(d, &rest);
if others.iter().any(|o| around.contains(o)) {
return Err(bad(C, "a layer can't be merged into its own sublayer"));
}
s.edit("Merge Selected", |d, sel| {
merge_into(d, into, &others)?;
sel.prune(d);
Ok(())
})?;
let st = s.doc_mut()?;
st.active_layer = Some(into);
st.layer_rows = vec![into];
Ok(json!({ "id": into.0 }))
}
fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.flatten";
let st = s.doc()?;
let d = &st.doc;
let from = id_param(p, "id").or_else(|| st.highlighted_rows().last().copied()).or(st.current_layer());
let into = from.and_then(|id| d.layer_of(id)).ok_or_else(|| bad(C, "no layer"))?;
if !d.node(into).is_some_and(|n| n.visible) {
return Err(EngineError::Other("the layer to flatten into is hidden".into()));
}
let others: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect();
let hidden: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect();
s.edit("Flatten Artwork", |d, sel| {
for h in &hidden {
d.remove(*h)?;
}
merge_into(d, into, &others)?;
sel.prune(d);
Ok(())
})?;
let st = s.doc_mut()?;
st.active_layer = Some(into);
st.layer_rows = vec![into];
Ok(json!({ "id": into.0, "discarded": hidden.len() }))
}
fn release(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.releaseToLayers";
let build = bool_or(p, "build", false);
let st = s.doc()?;
let id = match id_param(p, "id") {
Some(id) => id,
None => match st.highlighted_rows().as_slice() {
[id] => *id,
_ => st.current_layer().ok_or_else(|| bad(C, "no layer"))?,
},
};
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
let group = match n.kind {
NodeKind::Layer { .. } => false,
NodeKind::Group { .. } => true,
_ => return Err(bad(C, "release a layer or a group")),
};
// Its objects, bottom first (sublayers stay as they are).
let objects: Vec<NodeId> = n.children().into_iter().flatten().filter(|c| !c.is_layer()).map(|c| c.id).collect();
if objects.is_empty() {
return Err(EngineError::Other("there are no objects to release".into()));
}
let new = s.edit(if build { "Release to Layers (Build)" } else { "Release to Layers (Sequence)" }, |d, sel| {
// A group's objects go into sublayers of the layer holding it, where it was.
let (parent, mut index) = if group {
let layer = d.layer_containing(id).ok_or(EngineError::NoNode(id))?;
let (par, i, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
if par == Some(layer) { (layer, i) } else { (layer, usize::MAX) }
} else {
(id, 0)
};
let mut originals = vec![];
for o in &objects {
originals.push(Arc::unwrap_or_clone(d.remove(*o)?));
}
if group {
d.remove(id)?;
}
let mut new = vec![];
let last = originals.len().saturating_sub(1);
for k in 0..originals.len() {
let mut layer = make_layer(d, None);
let lid = layer.id;
let held: Vec<Node> = if build {
// Copies of the objects below and itself; the top layer keeps the originals.
(0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { d.reid(o) }).collect()
} else {
originals.get(k).cloned().into_iter().collect()
};
if let Some(ch) = layer.children_mut() {
ch.extend(held.into_iter().map(Arc::new));
}
let at = if group { index } else { index.min(d.children(Some(parent)).map_or(0, Vec::len)) };
d.insert(Some(parent), at, layer)?;
index = at.saturating_add(1);
new.push(lid);
}
sel.prune(d);
Ok(new)
})?;
s.doc_mut()?.layer_rows = new.clone();
Ok(json!({ "ids": new.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.reverse";
let ids = rows_param(s, p)?;
let d = &s.doc()?.doc;
let ids = row_roots(d, &ids);
// Rows grouped by the layer or group holding them (None: top-level layers).
let mut by_parent: Vec<(Option<NodeId>, Vec<usize>)> = vec![];
for id in &ids {
let Some((par, i, _)) = d.position(*id) else { continue };
match by_parent.iter_mut().find(|(p, _)| *p == par) {
Some((_, v)) => v.push(i),
None => by_parent.push((par, vec![i])),
}
}
by_parent.retain(|(_, v)| v.len() >= 2);
if by_parent.is_empty() {
return Err(bad(C, "highlight two or more rows in one layer or group"));
}
s.edit("Reverse Order", |d, _| {
for (par, mut slots) in by_parent {
slots.sort_unstable();
let ch = d.children_mut(par)?;
let nodes: Vec<Arc<Node>> = slots.iter().filter_map(|i| ch.get(*i).cloned()).collect();
for (slot, n) in slots.iter().zip(nodes.into_iter().rev()) {
if let Some(c) = ch.get_mut(*slot) {
*c = n;
}
}
}
Ok(())
})?;
ok()
}
fn locate(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc_mut()?;
let id = id_param(p, "id").or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?;
let ancestry = st.doc.ancestry(id).ok_or(EngineError::NoNode(id))?;
st.layer_rows = vec![id];
st.revision += 1;
Ok(json!({ "id": id.0, "ancestry": ancestry.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
+2
View File
@@ -30,6 +30,7 @@ pub(crate) mod graph;
mod halftone;
pub mod help;
mod layer;
mod layerpanel;
pub mod links;
mod live;
pub(crate) mod maskedit;
@@ -191,6 +192,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(gradient::specs());
v.extend(opacitymask::specs());
v.extend(layer::specs());
v.extend(layerpanel::specs());
v.extend(draw2::specs());
v.extend(xform::specs());
v.extend(effectcmd::specs());
+3
View File
@@ -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)),
+12 -18
View File
@@ -97,17 +97,12 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// Selectable objects: children of visible, unlocked layers (or of the isolation container).
/// The objects Select All takes: those of the isolated container, else of every layer (looking
/// through sublayers, which are not objects).
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
match iso.and_then(|i| d.node(i)) {
Some(c) => c.children().map(|v| v.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect()).unwrap_or_default(),
None => d
.layers
.iter()
.filter(|l| l.visible && !l.locked)
.flat_map(|l| l.children().into_iter().flatten())
.filter(|n| n.visible && !n.locked)
.map(|n| n.id)
.collect(),
Some(c) => c.layer_art(true),
None => d.selectable_art(),
}
}
@@ -250,7 +245,11 @@ fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Valu
/// inside accepted ones); Select > Reselect repeats `cmd` with `p`.
fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result<Value> {
fn visit(d: &Document, n: &Node, f: &impl Fn(&Document, &Node) -> bool, ids: &mut Vec<NodeId>) {
if n.visible && !n.locked && !n.is_layer() && f(d, n) {
// Hidden or locked objects and sublayers (and what they hold) are out of reach.
if !n.visible || n.locked || n.is_template() {
return;
}
if !n.is_layer() && f(d, n) {
return ids.push(n.id);
}
for c in n.children().into_iter().flatten() {
@@ -307,14 +306,9 @@ fn same_attribute(s: &mut Session, p: &Value) -> Result<Value> {
fn same_layers(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect();
let ids: Vec<NodeId> = layers
.iter()
.filter_map(|l| st.doc.node(*l))
.flat_map(|l| l.children().into_iter().flatten())
.filter(|n| n.visible && !n.locked)
.map(|n| n.id)
.collect();
// The layers or sublayers the selected objects are on (art in their sublayers too).
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_containing(*id)).collect();
let ids: Vec<NodeId> = layers.iter().filter_map(|l| st.doc.node(*l)).flat_map(|l| l.layer_art(true)).collect();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
ok()
}
+11
View File
@@ -52,6 +52,15 @@ pub fn node_summary(n: &Node) -> Value {
v["strokeOptions"] = stroke_options(&st);
}
match &n.kind {
NodeKind::Layer { color, template, printable, preview, dim_images, clip, .. } => {
let [r, g, b] = color.rgb();
v["color"] = json!(format!("#{r:02x}{g:02x}{b:02x}"));
v["template"] = json!(template);
v["printable"] = json!(printable);
v["preview"] = json!(preview);
v["dimImages"] = json!(dim_images);
v["clip"] = json!(clip);
}
NodeKind::Path { path, .. } => {
v["anchors"] = json!(path.anchor_count());
v["closed"] = json!(path.is_closed());
@@ -79,6 +88,8 @@ pub fn document(s: &Session) -> Value {
// Top of the stack first, like the Layers panel.
"layers": d.layers.iter().rev().map(|l| node_summary(l)).collect::<Vec<_>>(),
"currentLayer": st.active_layer.map(|l| l.0),
// The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`).
"layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::<Vec<_>>(),
"isolation": st.isolation.map(|l| l.0),
"selection": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
"selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)),
+32 -1
View File
@@ -102,6 +102,11 @@ pub struct DocState {
saved_doc: Arc<Document>,
/// The layer new art goes into (the "current layer" in the Layers panel).
pub active_layer: Option<NodeId>,
/// The rows highlighted in the Layers panel (layers, sublayers, groups or objects, in the order
/// they were clicked): what the panel's Duplicate, Delete, Merge, Options… and similar act on.
/// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`,
/// `layer.highlight`).
pub layer_rows: Vec<NodeId>,
/// Isolation mode container.
pub isolation: Option<NodeId>,
pub interaction: Option<Interaction>,
@@ -156,6 +161,7 @@ impl DocState {
path,
revision: 1,
active_layer,
layer_rows: vec![],
isolation: None,
interaction: None,
last_transform: None,
@@ -236,7 +242,13 @@ impl DocState {
{
return Some(i);
}
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer() && !n.locked)).or_else(|| self.doc.default_layer())
// A sublayer takes new art only while it and the layers around it are shown and unlocked.
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())
}
/// The highlighted Layers panel rows that still exist (ids are reused after undo, so a
/// remembered row must still be in the document).
pub fn highlighted_rows(&self) -> Vec<NodeId> {
self.layer_rows.iter().copied().filter(|id| self.doc.node(*id).is_some()).collect()
}
/// The object whose opacity mask View Opacity Mask shows ([`DocState::mask_view`]): only while
/// its mask is being edited, so leaving editing by any route (undo, deleting the object) ends it.
@@ -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;
+2 -2
View File
@@ -115,8 +115,8 @@ fn the_top_object_must_be_able_to_clip() {
assert_eq!(layer(&s), before);
// An explicit id picks the layer even with a group selected.
let l = before.id;
s.execute("layer.newSublayer", &json!({"parent": l.0})).unwrap();
let sub = layer(&s).children().unwrap().last().unwrap().id;
let sub = id_of(&s.execute("layer.newSublayer", &json!({"parent": l.0})).unwrap());
assert_eq!(s.doc().unwrap().doc.node(l).unwrap().children().unwrap().last().unwrap().id, sub, "on top of the layer's contents");
let err = s.execute("layer.clippingMask.toggle", &json!({"id": sub.0}));
assert!(err.is_err(), "an empty sublayer has nothing to clip by");
}
+454
View File
@@ -0,0 +1,454 @@
//! The Layers panel's commands: rows at every depth (layers, sublayers, groups, objects) are
//! clickable, highlighted and targeted; their art is selected through the selection column; rows
//! move, copy and reparent with validation; and the panel menu's operations (merge, flatten,
//! release to layers, reverse, template, others/all) are single undo steps.
use serde_json::{Value, json};
use vectorcraft_doc::{LayerColor, Node, NodeId, NodeKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn id(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
}
fn rect(s: &mut Session, x: f64) -> NodeId {
id(&s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap())
}
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn parent(s: &Session, id: NodeId) -> Option<NodeId> {
s.doc().unwrap().doc.parent_of(id)
}
fn children(s: &Session, id: NodeId) -> Vec<NodeId> {
node(s, id).children().unwrap().iter().map(|c| c.id).collect()
}
fn undos(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Layer 1 holding a sublayer `sub` with a rectangle `a` and a group `g` of rectangle `b`, plus a
/// rectangle `c` on Layer 1 itself → (layer, sub, a, g, b, c). Nothing selected.
fn tree(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId, NodeId, NodeId) {
let layer = s.doc().unwrap().doc.layers[0].id;
let c = rect(s, 300.0);
let sub = id(&run(s, "layer.newSublayer", json!({"name": "Sub"})));
let a = rect(s, 0.0);
let b = rect(s, 100.0);
run(s, "select.set", json!({"ids": [b.0]}));
let g = id(&run(s, "object.group", json!({})));
run(s, "select.none", json!({}));
(layer, sub, a, g, b, c)
}
#[test]
fn art_in_a_sublayer_is_selected_as_objects_not_the_sublayer() {
let mut s = session();
let (_, sub, a, g, _, c) = tree(&mut s);
assert_eq!(parent(&s, a), Some(sub), "new art went into the current sublayer");
let n = run(&mut s, "select.all", json!({}))["count"].as_u64().unwrap();
assert_eq!(n, 3);
let sel = s.doc().unwrap().selection.objects.clone();
assert!(sel.contains(&a) && sel.contains(&g) && sel.contains(&c) && !sel.contains(&sub), "{sel:?}");
// The Selection tool's click takes the object in the sublayer.
let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, vectorcraft_geom::Point::new(10.0, 10.0), Default::default()).unwrap();
assert_eq!(h.top_object(None), a);
// Deleting it works (layers were skipped by Clear before).
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "edit.clear", json!({}));
assert!(s.doc().unwrap().doc.node(a).is_none());
}
#[test]
fn clicking_rows_at_any_depth_highlights_them_and_sets_the_current_layer() {
let mut s = session();
let (layer, sub, a, g, b, c) = tree(&mut s);
let before = undos(&s);
for (row, current) in [(layer, layer), (sub, sub), (g, sub), (b, sub), (a, sub), (c, layer)] {
let r = run(&mut s, "layer.setCurrent", json!({"id": row.0}));
assert_eq!(NodeId(r["layer"].as_u64().unwrap()), current);
let st = s.doc().unwrap();
assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![row], Some(current)));
}
assert_eq!(undos(&s), before, "clicking rows is not an undo step");
// New art goes into the current sublayer.
run(&mut s, "layer.setCurrent", json!({"id": a.0}));
let n = rect(&mut s, 50.0);
assert_eq!(parent(&s, n), Some(sub));
assert!(s.execute("layer.setCurrent", &json!({"id": 9999})).is_err());
// Shift/Ctrl: several rows.
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0], "mode": "set"}));
run(&mut s, "layer.highlight", json!({"ids": [c.0], "mode": "add"}));
run(&mut s, "layer.highlight", json!({"ids": [a.0], "mode": "toggle"}));
let st = s.doc().unwrap();
assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![g, c], Some(layer)));
assert!(s.execute("layer.highlight", &json!({"ids": [a.0], "mode": "nope"})).is_err());
assert_eq!(document_rows(&s), vec![g.0, c.0], "document.inspect lists the rows");
}
fn document_rows(s: &Session) -> Vec<u64> {
crate::inspect::document(s)["layerRows"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
#[test]
fn selecting_art_makes_its_layer_current() {
let mut s = session();
let (layer, sub, a, _, _, c) = tree(&mut s);
run(&mut s, "select.set", json!({"ids": [c.0]}));
assert_eq!(s.doc().unwrap().current_layer(), Some(layer));
run(&mut s, "select.set", json!({"ids": [a.0]}));
assert_eq!(s.doc().unwrap().current_layer(), Some(sub));
let n = rect(&mut s, 200.0);
assert_eq!(parent(&s, n), Some(sub));
// A locked sublayer takes no new art: it goes to the top layer.
run(&mut s, "select.none", json!({}));
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": true}));
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
let n = rect(&mut s, 200.0);
assert_eq!(parent(&s, n), Some(layer));
}
#[test]
fn the_selection_column_selects_a_rows_art_and_shift_adds_or_removes_it() {
let mut s = session();
let (layer, sub, a, g, _, c) = tree(&mut s);
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
let sel = |s: &Session| s.doc().unwrap().selection.objects.clone();
run(&mut s, "layer.selectAll", json!({"id": layer.0}));
assert_eq!(sel(&s), vec![c, a, g], "the layer's art, its sublayer's too");
run(&mut s, "layer.selectAll", json!({"id": sub.0}));
assert_eq!(sel(&s), vec![a, g]);
run(&mut s, "layer.selectAll", json!({"id": c.0, "add": true}));
assert_eq!(sel(&s), vec![a, g, c]);
run(&mut s, "layer.selectAll", json!({"id": sub.0, "add": true}));
assert_eq!(sel(&s), vec![c], "all selected already: removed");
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![sub], "the highlighted row stays");
// Locked rows select nothing.
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": true}));
run(&mut s, "layer.selectAll", json!({"id": a.0}));
assert!(sel(&s).is_empty());
// Targeting a layer selects its sublayers' art too.
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": false}));
assert_eq!(ids_sel(&run(&mut s, "layer.target", json!({"id": layer.0}))), vec![c, a, g]);
}
fn ids_sel(v: &Value) -> Vec<NodeId> {
v["selected"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
}
#[test]
fn visibility_and_lock_per_row_are_undoable() {
let mut s = session();
let (_, sub, a, g, b, _) = tree(&mut s);
run(&mut s, "select.set", json!({"ids": [a.0, g.0]}));
let before = undos(&s);
run(&mut s, "layer.setProps", json!({"ids": [b.0, sub.0], "visible": false}));
assert!(!node(&s, b).visible && !node(&s, sub).visible);
assert!(s.doc().unwrap().selection.is_empty(), "hidden art is deselected");
assert_eq!(undos(&s), before + 1, "one step for several rows");
run(&mut s, "edit.undo", json!({}));
assert!(node(&s, b).visible && node(&s, sub).visible);
run(&mut s, "layer.setProps", json!({"id": a.0, "locked": true, "name": "Box"}));
assert!(node(&s, a).locked);
assert_eq!(node(&s, a).display_name(), "Box");
run(&mut s, "layer.setProps", json!({"id": a.0, "name": ""}));
assert_eq!(node(&s, a).display_name(), "<Rectangle>", "an empty name goes back to the generated one");
run(&mut s, "layer.setProps", json!({"id": sub.0, "name": ""}));
assert_eq!(node(&s, sub).display_name(), "Sub", "a layer keeps its name");
}
#[test]
fn layer_options_validate_and_template_locks_and_dims() {
let mut s = session();
let (layer, ..) = tree(&mut s);
for bad in [json!({"color": 27}), json!({"color": "#zz"}), json!({"color": [1]}), json!({"locked": "yes"}), json!({"dimImages": "half"})] {
let mut p = bad.clone();
p["id"] = json!(layer.0);
assert!(s.execute("layer.setProps", &p).is_err(), "{bad}");
}
let color = |s: &Session| match node(s, layer).kind {
NodeKind::Layer { color, .. } => color,
_ => panic!("not a layer"),
};
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "red"}));
assert_eq!(color(&s), LayerColor::Preset(1));
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "#123456"}));
assert_eq!(color(&s), LayerColor::Custom([0x12, 0x34, 0x56]));
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": 4}));
assert_eq!(color(&s), LayerColor::Preset(4));
run(&mut s, "layer.template", json!({"ids": [layer.0]}));
let n = node(&s, layer);
assert!(n.is_template() && n.locked);
assert!(matches!(n.kind, NodeKind::Layer { dim_images: Some(50), .. }));
run(&mut s, "layer.template", json!({"ids": [layer.0]}));
let n = node(&s, layer);
assert!(!n.is_template() && !n.locked);
assert!(matches!(n.kind, NodeKind::Layer { dim_images: None, .. }));
run(&mut s, "layer.setProps", json!({"id": layer.0, "preview": false, "dimImages": 30, "printable": false}));
assert!(matches!(node(&s, layer).kind, NodeKind::Layer { preview: false, dim_images: Some(30), printable: false, .. }));
let summary = &crate::inspect::document(&s)["layers"][0];
assert_eq!((summary["preview"].clone(), summary["dimImages"].clone()), (json!(false), json!(30)));
}
#[test]
fn new_layers_go_above_the_current_one_at_its_level() {
let mut s = session();
let (layer, sub, _, g, ..) = tree(&mut s);
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
let n = id(&run(&mut s, "layer.new", json!({})));
assert_eq!(parent(&s, n), Some(layer), "a sublayer beside the current sublayer");
assert_eq!(children(&s, layer).iter().position(|c| *c == n), children(&s, layer).iter().position(|c| *c == sub).map(|i| i + 1));
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![n]);
let top = id(&run(&mut s, "layer.new", json!({"top": true, "name": "Top", "color": "green", "locked": true})));
let st = s.doc().unwrap();
assert_eq!(st.doc.layers.last().unwrap().id, top);
assert!(st.doc.layers.last().unwrap().locked);
// Sublayers only go into layers.
assert!(s.execute("layer.newSublayer", &json!({"parent": g.0})).is_err());
let inner = id(&run(&mut s, "layer.newSublayer", json!({"parent": sub.0})));
assert_eq!(parent(&s, inner), Some(sub));
// Names count sublayers: no two layers share one.
let mut names = vec![];
s.doc().unwrap().doc.walk(|n| {
if n.is_layer() {
names.push(n.display_name());
}
});
let unique: std::collections::HashSet<&String> = names.iter().collect();
assert_eq!(unique.len(), names.len(), "{names:?}");
}
#[test]
fn rows_move_above_below_and_into_layers_and_groups() {
let mut s = session();
let (layer, sub, a, g, b, c) = tree(&mut s);
let before = undos(&s);
// Into the group: on top of its contents.
run(&mut s, "layer.move", json!({"ids": [a.0], "target": g.0, "place": "inside"}));
assert_eq!(children(&s, g), vec![b, a]);
// Below b, in the group.
run(&mut s, "layer.move", json!({"ids": [a.0], "target": b.0, "place": "below"}));
assert_eq!(children(&s, g), vec![a, b]);
// Above the sublayer, out into Layer 1.
run(&mut s, "layer.move", json!({"ids": [a.0], "target": sub.0, "place": "above"}));
assert_eq!(parent(&s, a), Some(layer));
assert_eq!(undos(&s), before + 3);
// Out of the layer into a new top-level layer: an object beside a top-level layer goes in it.
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
run(&mut s, "layer.move", json!({"ids": [c.0], "target": l2.0, "place": "above"}));
assert_eq!(parent(&s, c), Some(l2));
// A sublayer becomes a top-level layer, and back.
run(&mut s, "layer.move", json!({"ids": [sub.0], "target": l2.0, "place": "above"}));
assert_eq!(parent(&s, sub), None);
assert_eq!(s.doc().unwrap().doc.layers.last().unwrap().id, sub);
run(&mut s, "layer.move", json!({"ids": [sub.0], "target": layer.0}));
assert_eq!(parent(&s, sub), Some(layer));
// Several rows keep their stacking order.
run(&mut s, "layer.move", json!({"ids": [g.0, a.0], "target": l2.0}));
assert_eq!(children(&s, l2), vec![c, a, g]);
// Undo walks it back.
for _ in 0..4 {
run(&mut s, "edit.undo", json!({}));
}
assert_eq!(parent(&s, sub), Some(layer));
}
#[test]
fn moves_are_validated() {
let mut s = session();
let (layer, sub, a, g, b, _) = tree(&mut s);
let doc_before = s.doc().unwrap().doc.clone();
let bad = [
json!({"ids": [sub.0], "target": g.0, "place": "inside"}),
json!({"ids": [layer.0], "target": a.0, "place": "inside"}),
json!({"ids": [layer.0], "target": sub.0, "place": "above"}),
json!({"ids": [sub.0], "target": sub.0}),
json!({"ids": [g.0], "target": b.0, "place": "inside"}),
json!({"ids": [a.0], "target": b.0, "place": "inside"}),
json!({"ids": [a.0], "target": 9999}),
json!({"ids": [9999], "target": a.0}),
json!({"ids": [a.0], "target": g.0, "place": "sideways"}),
json!({"ids": [], "target": g.0}),
json!({"target": g.0}),
];
for p in bad {
assert!(s.execute("layer.move", &p).is_err(), "{p}");
}
// A locked group takes nothing.
run(&mut s, "layer.setProps", json!({"id": g.0, "locked": true}));
assert!(s.execute("layer.move", &json!({"ids": [a.0], "target": g.0})).is_err());
run(&mut s, "edit.undo", json!({}));
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &doc_before), "nothing changed");
// node.move: layers only at the top level or in layers.
assert!(s.execute("node.move", &json!({"id": sub.0, "parent": g.0, "index": 0})).is_err());
assert!(s.execute("node.move", &json!({"id": a.0, "index": 0})).is_err());
assert!(s.execute("node.move", &json!({"id": layer.0, "parent": sub.0, "index": 0})).is_err());
run(&mut s, "node.move", json!({"id": sub.0, "index": 5}));
assert_eq!(parent(&s, sub), None);
}
#[test]
fn alt_drag_copies_rows_and_the_selected_art_moves_between_layers() {
let mut s = session();
let (layer, sub, a, g, _, c) = tree(&mut s);
let count = s.doc().unwrap().doc.node_count();
let copies = ids(&run(&mut s, "layer.move", json!({"ids": [sub.0], "target": layer.0, "place": "above", "copy": true})));
assert_eq!(copies.len(), 1);
assert_eq!(s.doc().unwrap().doc.node_count(), count + 4, "the sublayer and its art (a, the group, b), copied");
assert_eq!(parent(&s, copies[0]), None, "a copy of the sublayer at the top level");
// Dragging the selected-art square: the selection moves into another layer.
run(&mut s, "select.set", json!({"ids": [a.0, g.0]}));
run(&mut s, "layer.move", json!({"ids": [a.0, g.0], "target": layer.0}));
assert_eq!(children(&s, layer), vec![c, sub, a, g]);
assert_eq!(s.doc().unwrap().selection.objects, vec![a, g], "it stays selected");
}
#[test]
fn delete_and_duplicate_rows() {
let mut s = session();
let (layer, sub, a, g, _, c) = tree(&mut s);
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0]}));
let copies = ids(&run(&mut s, "layer.duplicate", json!({})));
assert_eq!(copies.len(), 2);
assert_eq!(children(&s, sub).len(), 4);
assert_eq!(s.doc().unwrap().highlighted_rows(), copies);
run(&mut s, "layer.duplicate", json!({"id": sub.0}));
assert!(children(&s, layer).iter().any(|l| node(&s, *l).display_name() == "Sub copy"));
run(&mut s, "layer.highlight", json!({"ids": copies.iter().map(|c| c.0).collect::<Vec<_>>()}));
assert_eq!(run(&mut s, "layer.delete", json!({}))["deleted"], 2);
assert_eq!(children(&s, sub), vec![a, g]);
run(&mut s, "layer.delete", json!({"ids": [c.0, a.0]}));
assert!(s.doc().unwrap().doc.node(c).is_none() && s.doc().unwrap().doc.node(a).is_none());
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.node(c).is_some());
assert!(s.execute("layer.delete", &json!({"ids": [layer.0]})).is_err(), "the last layer stays");
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
run(&mut s, "layer.delete", json!({}));
assert!(s.doc().unwrap().doc.node(sub).is_none());
assert_eq!(s.doc().unwrap().current_layer(), Some(layer), "the current layer moves on");
}
#[test]
fn collect_merge_flatten_and_reverse() {
let mut s = session();
let (layer, sub, a, g, _, c) = tree(&mut s);
// Collect two rows of the sublayer in a new sublayer of it.
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0]}));
let col = id(&run(&mut s, "layer.collectInNew", json!({})));
assert_eq!((parent(&s, col), children(&s, col)), (Some(sub), vec![a, g]));
// Reverse their order.
run(&mut s, "layer.reverse", json!({"ids": [a.0, g.0]}));
assert_eq!(children(&s, col), vec![g, a]);
assert!(s.execute("layer.reverse", &json!({"ids": [a.0]})).is_err());
run(&mut s, "edit.undo", json!({}));
// Merge the sublayer into Layer 1 (listed last).
run(&mut s, "layer.merge", json!({"ids": [col.0, layer.0]}));
assert!(s.doc().unwrap().doc.node(col).is_none());
assert_eq!(children(&s, layer), vec![c, sub, a, g]);
assert!(s.execute("layer.merge", &json!({"ids": [layer.0, sub.0]})).is_err(), "not into its own sublayer");
assert!(s.execute("layer.merge", &json!({"ids": [layer.0]})).is_err());
// Flatten: other visible layers merge in, hidden ones go, templates stay.
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
let d = rect(&mut s, 0.0);
let l3 = id(&run(&mut s, "layer.new", json!({"top": true, "visible": false})));
let l4 = id(&run(&mut s, "layer.new", json!({"top": true, "template": true})));
let before = undos(&s);
let r = run(&mut s, "layer.flatten", json!({"id": layer.0}));
assert_eq!(r["discarded"], 1);
let st = s.doc().unwrap();
assert_eq!(st.doc.layers.iter().map(|l| l.id).collect::<Vec<_>>(), vec![layer, l4]);
assert!(st.doc.node(l2).is_none() && st.doc.node(l3).is_none());
assert_eq!(parent(&s, d), Some(layer));
assert_eq!(undos(&s), before + 1);
}
#[test]
fn release_to_layers_sequence_and_build() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let (a, b, c) = (rect(&mut s, 0.0), rect(&mut s, 50.0), rect(&mut s, 100.0));
let new = ids(&run(&mut s, "layer.releaseToLayers", json!({"id": layer.0})));
assert_eq!(new.len(), 3);
assert_eq!(children(&s, layer), new);
assert_eq!(new.iter().map(|l| children(&s, *l)).collect::<Vec<_>>(), vec![vec![a], vec![b], vec![c]]);
run(&mut s, "edit.undo", json!({}));
let new = ids(&run(&mut s, "layer.releaseToLayers", json!({"id": layer.0, "build": true})));
let counts: Vec<usize> = new.iter().map(|l| children(&s, *l).len()).collect();
assert_eq!(counts, vec![1, 2, 3], "the layers add up");
assert_eq!(children(&s, new[2]), vec![a, b, c], "the top one keeps the originals");
run(&mut s, "edit.undo", json!({}));
// A group's objects go into sublayers where the group was.
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let g = id(&run(&mut s, "object.group", json!({})));
let new = ids(&run(&mut s, "layer.releaseToLayersBuild", json!({"id": g.0})));
assert_eq!(new.len(), 2);
assert!(s.doc().unwrap().doc.node(g).is_none());
assert_eq!(children(&s, layer), vec![new[0], new[1], c]);
assert!(s.execute("layer.releaseToLayers", &json!({"id": c.0})).is_err());
}
#[test]
fn hide_lock_and_outline_others_and_their_all_counterparts() {
let mut s = session();
let l1 = s.doc().unwrap().doc.layers[0].id;
let l2 = id(&run(&mut s, "layer.new", json!({})));
let l3 = id(&run(&mut s, "layer.new", json!({})));
run(&mut s, "layer.setCurrent", json!({"id": l2.0}));
let flags = |s: &Session, f: fn(&Node) -> bool| [l1, l2, l3].map(|l| f(&node(s, l)));
assert_eq!(run(&mut s, "layer.hideOthers", json!({}))["count"], 2);
assert_eq!(flags(&s, |n| n.visible), [false, true, false]);
run(&mut s, "layer.showAll", json!({}));
assert_eq!(flags(&s, |n| n.visible), [true; 3]);
run(&mut s, "layer.lockOthers", json!({}));
assert_eq!(flags(&s, |n| n.locked), [true, false, true]);
run(&mut s, "layer.unlockAll", json!({}));
assert_eq!(flags(&s, |n| n.locked), [false; 3]);
run(&mut s, "layer.outlineOthers", json!({}));
let preview = |n: &Node| matches!(n.kind, NodeKind::Layer { preview: true, .. });
assert_eq!(flags(&s, preview), [false, true, false]);
run(&mut s, "layer.previewAll", json!({}));
assert_eq!(flags(&s, preview), [true; 3]);
assert_eq!(run(&mut s, "layer.previewAll", json!({}))["count"], 0, "nothing to do: no undo step");
}
#[test]
fn locate_object_highlights_its_row() {
let mut s = session();
let (layer, sub, _, g, b, _) = tree(&mut s);
run(&mut s, "select.set", json!({"ids": [g.0]}));
let r = run(&mut s, "layer.locate", json!({}));
assert_eq!(r["ancestry"], json!([layer.0, sub.0, g.0]));
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![g]);
assert_eq!(run(&mut s, "layer.locate", json!({"id": b.0}))["id"], b.0);
run(&mut s, "select.none", json!({}));
assert!(s.execute("layer.locate", &json!({})).is_err());
}
#[test]
fn the_clipping_mask_button_acts_on_the_highlighted_group() {
let mut s = session();
let (_, _, a, g, _, _) = tree(&mut s);
run(&mut s, "layer.move", json!({"ids": [a.0], "target": g.0}));
run(&mut s, "layer.setCurrent", json!({"id": g.0}));
assert_eq!(run(&mut s, "layer.clippingMask.toggle", json!({}))["clip"], true);
assert!(node(&s, g).clips());
}
+30
View File
@@ -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");
}
+46
View File
@@ -330,6 +330,9 @@ pub struct Renderer {
/// The keys and sizes of the recoloured images colour adjustments made in `images`, oldest
/// first (see [`Self::adjusted_image`]).
adjusted: std::collections::VecDeque<(String, usize)>,
/// Layer Options → Dim Images to, of the layer being drawn (screen views only): images show
/// faded to this opacity over white.
dim_images: Option<f32>,
}
#[derive(Clone, Copy, Debug, Default)]
@@ -406,6 +409,7 @@ impl Renderer {
ink_images: Default::default(),
stroke_slices: PtrMap::default(),
adjusted: Default::default(),
dim_images: None,
}
}
@@ -794,6 +798,31 @@ impl Renderer {
} else if !n.is_container() {
return;
}
// Layer Options on screen: a layer whose Preview is off draws in outline, and Dim Images
// fades its images (exports and thumbnails, which leave templates out, ignore both).
// (A template is dimmed as a whole already.)
if let NodeKind::Layer { preview, dim_images, template, .. } = &n.kind
&& f.opts.dim_templates
&& !f.opts.skip_templates
{
if !preview && !f.opts.outline {
let opts = RenderOptions { outline: true, ..f.opts.clone() };
return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force);
}
if let Some(p) = dim_images.filter(|_| !template) {
let outer = self.dim_images;
let dim = f32::from(p).clamp(0.0, 100.0) / 100.0;
self.dim_images = Some(outer.map_or(dim, |o| o.min(dim)));
self.draw_layer_node(ctx, f, n);
self.dim_images = outer;
return;
}
}
self.draw_layer_node(ctx, f, n);
}
/// [`Self::draw_node`] after culling and Layer Options.
fn draw_layer_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) {
if fx::has_object_fx(n) {
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
}
@@ -1309,6 +1338,21 @@ impl Renderer {
}
}
ctx.reset_paint_transform();
// A dimmed layer's images: white over them, so they show at that opacity on paper.
if let Some(dim) = self.dim_images.filter(|d| *d < 1.0 && !outline) {
ctx.set_paint(f.ink.fixed([255, 255, 255, ((1.0 - dim) * 255.0).round() as u8]));
match area {
Some((bp, rule)) => {
ctx.set_transform(f.view);
ctx.set_fill_rule(fill_rule(*rule));
ctx.fill_path(bp);
}
None => {
ctx.set_transform(f.view * im.xf);
ctx.fill_rect(&rect);
}
}
}
}
if outline {
let mut p = rect.to_path(0.1);
@@ -1592,6 +1636,8 @@ mod tests_isolation;
#[cfg(test)]
mod tests_knockout;
#[cfg(test)]
mod tests_layeropts;
#[cfg(test)]
mod tests_objectfx;
#[cfg(test)]
mod tests_setup;
+68
View File
@@ -0,0 +1,68 @@
//! Layer Options on screen: Preview off draws a layer in outline, Dim Images fades its images;
//! exports (which leave templates out) draw both as they are.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, ImageBlob, ImageObject, Node, NodeId, NodeKind};
use vectorcraft_geom::shapes;
use super::*;
/// A red square (10..40) and a 30×30 red image at (50, 10), on one layer set up by `layer`.
fn doc(layer: impl FnOnce(&mut NodeKind)) -> Document {
let mut d = Document::new(100.0, 100.0);
let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let l = d.layers[0].id;
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let sq = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), red);
d.insert(Some(l), 0, sq).unwrap();
let mut im = Node::new(
NodeId(0),
NodeKind::Image(ImageObject {
key: "red".into(),
width: 30,
height: 30,
xf: Affine::translate((50.0, 10.0)),
link: None,
placement: Default::default(),
}),
);
im.id = d.alloc_id();
d.insert(Some(l), 9, im).unwrap();
layer(&mut d.node_mut(l).unwrap().kind);
d
}
fn render(d: &Document, export: bool) -> Rendered {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), skip_templates: export, ..Default::default() };
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts)
}
#[test]
fn preview_off_draws_the_layer_in_outline_on_screen_only() {
let d = doc(|k| {
if let NodeKind::Layer { preview, .. } = k {
*preview = false;
}
});
assert_eq!(render(&d, false).pixel(25, 25), [255, 255, 255, 255], "no fill in outline");
assert_eq!(render(&d, true).pixel(25, 25), [255, 0, 0, 255], "exports draw it as it is");
}
#[test]
fn dim_images_fades_only_the_images() {
let plain = render(&doc(|_| {}), false);
assert_eq!(plain.pixel(65, 25), [255, 0, 0, 255]);
let d = doc(|k| {
if let NodeKind::Layer { dim_images, .. } = k {
*dim_images = Some(50);
}
});
let img = render(&d, false);
let [r, g, b, _] = img.pixel(65, 25);
assert!(r == 255 && (120..=135).contains(&g) && g == b, "half way to white: {:?}", img.pixel(65, 25));
assert_eq!(img.pixel(25, 25), [255, 0, 0, 255], "vector art stays");
assert_eq!(render(&d, true).pixel(65, 25), [255, 0, 0, 255], "exports are not dimmed");
}
+1
View File
@@ -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<_>>(),
})
+5
View File
@@ -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;
}
+283
View File
@@ -0,0 +1,283 @@
//! Layer Options (double-click a layer row, or ≡ › Options for Selection…) and the same dialog for
//! a new layer or sublayer (≡ › New Layer…, Alt-click Create New Layer). Objects' rows get Name,
//! Show and Lock only.
//!
//! Fields: `ids` (the rows edited; none for a new layer), `mode` (`edit`, `new` or `newSublayer`),
//! `layer` (the rows are layers), `name`, `color` (a preset's name such as "Light Blue", or
//! `#rrggbb`), `template`, `locked`, `visible`, `printable`, `preview`, `dimImages` and
//! `dimPercent`. OK runs `layer.setProps`, `layer.new` or `layer.newSublayer`.
use serde_json::{Value, json};
use vectorcraft_doc::{LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
use super::swatch_options::{grid, label};
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Layer Options.
pub const KIND: &str = "layerOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 320.0, ..DialogSpec::FORM };
fn heading(d: &Dialog) -> String {
if d.bool("layer") || d.str("mode") != "edit" { tl!("Layer Options").into() } else { tl!("Options").into() }
}
/// A layer colour as the dialog shows it: a preset's name, else `#rrggbb`.
fn color_field(c: LayerColor) -> String {
match c {
LayerColor::Preset(i) => LAYER_COLORS.get(i as usize).map_or("Light Blue", |(n, _)| *n).to_string(),
LayerColor::Custom([r, g, b]) => format!("#{r:02x}{g:02x}{b:02x}"),
}
}
/// The fields for `n` (a layer or any other row).
fn fields_of(n: &Node) -> serde_json::Map<String, Value> {
let mut f = serde_json::Map::new();
f.insert("name".into(), json!(n.display_name()));
f.insert("visible".into(), json!(n.visible));
f.insert("locked".into(), json!(n.locked));
f.insert("layer".into(), json!(n.is_layer()));
if let NodeKind::Layer { color, template, printable, preview, dim_images, .. } = &n.kind {
f.insert("color".into(), json!(color_field(*color)));
f.insert("template".into(), json!(template));
f.insert("printable".into(), json!(printable));
f.insert("preview".into(), json!(preview));
f.insert("dimImages".into(), json!(dim_images.is_some()));
f.insert("dimPercent".into(), json!(dim_images.unwrap_or(50)));
}
f
}
/// `ui.layerOptions {ids?|id?}`: open Layer Options for rows (default: the highlighted rows, else
/// the current layer), filled in from the first.
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let ids: Vec<NodeId> = match (p.get("ids").and_then(Value::as_array), p.get("id").and_then(Value::as_u64)) {
(Some(a), _) => a.iter().filter_map(Value::as_u64).map(NodeId).collect(),
(None, Some(id)) => vec![NodeId(id)],
(None, None) => {
let rows = st.highlighted_rows();
if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows }
}
};
let first = ids.first().and_then(|id| st.doc.node(*id)).ok_or("no such layer or object")?;
let mut f = fields_of(first);
f.insert("ids".into(), json!(ids.iter().map(|i| i.0).collect::<Vec<_>>()));
f.insert("mode".into(), json!("edit"));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f });
Ok(Value::Null)
}
/// `ui.newLayer {sublayer?}`: Layer Options for a new layer (or sublayer), with the name and
/// colour it would get.
pub fn open_new(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let mut layers = vec![];
st.doc.walk(|n| {
if n.is_layer() {
layers.push(n.display_name());
}
});
let mut i = layers.len() + 1;
while layers.contains(&format!("Layer {i}")) {
i += 1;
}
let sublayer = p.get("sublayer").and_then(Value::as_bool).unwrap_or(false);
let color = LayerColor::Preset((layers.len() % LAYER_COLORS.len()) as u8);
let fields = json!({
"mode": if sublayer { "newSublayer" } else { "new" }, "layer": true, "name": format!("Layer {i}"), "color": color_field(color),
"template": false, "locked": false, "visible": true, "printable": true, "preview": true, "dimImages": false, "dimPercent": 50,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str, enabled: bool) -> bool {
let v = d.bool(key);
let changed = widgets::check(ui, text, v, enabled);
if changed {
d.fields.insert(key.into(), json!(!v));
}
changed
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let layer = d.bool("layer");
let many = d.fields.get("ids").and_then(Value::as_array).is_some_and(|a| a.len() > 1);
grid(ui, |ui| {
if !many {
label(ui, tl!("Name:"));
form::text(ui, d, "name", 190.0);
ui.end_row();
}
if layer {
label(ui, tl!("Color:"));
ui.horizontal(|ui| {
let cur = d.str("color");
let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
let shown = if LAYER_COLORS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&cur)) { cur.clone() } else { "Custom".into() };
if let Some((n, _)) = widgets::dropdown(ui, "layer-color", &shown, &names, 140.0).and_then(|i| LAYER_COLORS.get(i)) {
d.fields.insert("color".into(), json!(n));
}
let [r, g, b] = LAYER_COLORS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(&cur))
.map(|(_, c)| *c)
.or_else(|| {
vectorcraft_color::Color::from_hex(&cur).map(|c| {
let [r, g, b, _] = c.to_rgba8(1.0);
[r, g, b]
})
})
.unwrap_or(LAYER_COLORS[0].1);
let mut rgb = [r, g, b];
if ui.color_edit_button_srgb(&mut rgb).changed() {
d.fields.insert("color".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
});
ui.end_row();
}
});
ui.add_space(8.0);
if layer {
ui.horizontal(|ui| {
ui.vertical(|ui| {
// A template is locked and dims its images; turning it off frees both.
if toggle(ui, d, "template", tl!("Template"), true) {
let on = d.bool("template");
d.fields.insert("locked".into(), json!(on));
d.fields.insert("dimImages".into(), json!(on));
}
toggle(ui, d, "visible", tl!("Show"), true);
toggle(ui, d, "preview", tl!("Preview"), true);
ui.horizontal(|ui| {
toggle(ui, d, "dimImages", tl!("Dim Images to:"), true);
let mut v = d.f64("dimPercent", 50.0);
if ui.add_enabled(d.bool("dimImages"), egui::DragValue::new(&mut v).range(0.0..=100.0).suffix("%")).changed() {
d.fields.insert("dimPercent".into(), json!(v.round()));
}
});
});
ui.add_space(24.0);
ui.vertical(|ui| {
let template = d.bool("template");
toggle(ui, d, "locked", tl!("Lock"), !template);
toggle(ui, d, "printable", tl!("Print"), !template);
});
});
} else {
toggle(ui, d, "visible", tl!("Show"), true);
toggle(ui, d, "locked", tl!("Lock"), true);
}
false
}
/// The command and params OK runs.
pub(crate) fn params(d: &Dialog) -> (&'static str, Value) {
let mut p = serde_json::Map::new();
let many = d.fields.get("ids").and_then(Value::as_array).is_some_and(|a| a.len() > 1);
if !many {
p.insert("name".into(), json!(d.str("name")));
}
p.insert("visible".into(), json!(d.bool("visible")));
p.insert("locked".into(), json!(d.bool("locked")));
if d.bool("layer") {
p.insert("color".into(), json!(d.str("color")));
p.insert("template".into(), json!(d.bool("template")));
p.insert("printable".into(), json!(d.bool("printable")));
p.insert("preview".into(), json!(d.bool("preview")));
p.insert("dimImages".into(), if d.bool("dimImages") { json!(d.f64("dimPercent", 50.0).clamp(0.0, 100.0)) } else { json!(false) });
}
match d.str("mode").as_str() {
"new" => ("layer.new", Value::Object(p)),
"newSublayer" => ("layer.newSublayer", Value::Object(p)),
_ => {
p.insert("ids".into(), d.fields.get("ids").cloned().unwrap_or(json!([])));
("layer.setProps", Value::Object(p))
}
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (cmd, p) = params(d);
run_and_close(app, cmd, p)
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app
}
fn draw(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
fn set(app: &mut VectorcraftApp, k: &str, v: Value) {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
}
#[test]
fn layer_options_edit_the_layer_and_ok_is_one_undo_step() {
let mut app = app();
let layer = app.session.doc().unwrap().doc.layers[0].id;
app.run("ui.layerOptions", json!({})).unwrap();
let d = app.ui.dialog.clone().unwrap();
assert_eq!((d.kind.as_str(), d.str("name"), d.str("color")), (KIND, "Layer 1".to_string(), "Light Blue".to_string()));
draw(&mut app);
set(&mut app, "name", json!("Sky"));
set(&mut app, "color", json!("#00ff00"));
set(&mut app, "dimImages", json!(true));
set(&mut app, "dimPercent", json!(30));
set(&mut app, "preview", json!(false));
let undo = app.session.doc().unwrap().history.undo.len();
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let n = app.session.doc().unwrap().doc.node(layer).unwrap().clone();
assert_eq!(n.display_name(), "Sky");
assert!(matches!(n.kind, NodeKind::Layer { color: LayerColor::Custom([0, 255, 0]), dim_images: Some(30), preview: false, .. }));
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
}
#[test]
fn new_layer_options_make_a_layer_and_objects_get_name_show_and_lock() {
let mut app = app();
app.run("ui.newLayer", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Layer 2");
set(&mut app, "name", json!("Ink"));
set(&mut app, "template", json!(true));
set(&mut app, "locked", json!(true));
draw(&mut app);
super::super::confirm(&mut app).unwrap();
let d = &app.session.doc().unwrap().doc;
let top = d.layers.last().unwrap();
assert_eq!(top.display_name(), "Ink");
assert!(top.is_template() && top.locked);
// An object's row: Name, Show and Lock.
let r = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
assert!(r.is_ok());
app.session.execute("layer.setCurrent", &json!({"id": app.session.doc().unwrap().doc.layers[0].id.0})).unwrap();
let rect = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
app.run("ui.layerOptions", json!({"id": rect})).unwrap();
assert!(!app.ui.dialog.as_ref().unwrap().bool("layer"));
draw(&mut app);
set(&mut app, "name", json!("Box"));
set(&mut app, "locked", json!(true));
let (cmd, p) = params(app.ui.dialog.as_ref().unwrap());
assert_eq!(cmd, "layer.setProps");
assert!(p.get("template").is_none());
super::super::confirm(&mut app).unwrap();
let n = app.session.doc().unwrap().doc.node(NodeId(rect)).unwrap().clone();
assert_eq!((n.display_name(), n.locked), ("Box".to_string(), true));
}
}
@@ -0,0 +1,117 @@
//! Layers panel › Panel Options…: Show Layers Only, the row size and which rows show thumbnails.
//!
//! Fields: `layersOnly`, `rowSize` (`small`, `medium`, `large` or `other`), `otherSize` (points,
//! 12–100, for `other`), `thumbLayers`, `thumbGroups` and `thumbObjects`. OK applies them to the
//! panel (kept with the UI state).
use serde_json::{Value, json};
use super::DialogSpec;
use super::swatch_options::{grid, label};
use crate::panels::layers::{PanelOptions, ROW_LARGE, ROW_MEDIUM, ROW_SMALL};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of the Layers Panel Options.
pub const KIND: &str = "layersPanelOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Layers Panel Options").into(), body, confirm, min_width: 300.0, ..DialogSpec::FORM };
const SIZES: [(&str, &str, f32); 3] = [("small", "Small", ROW_SMALL), ("medium", "Medium", ROW_MEDIUM), ("large", "Large", ROW_LARGE)];
/// `ui.layersPanelOptions`: open the dialog on the panel's options.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let o = &app.ui.layers_panel;
let size = SIZES.iter().find(|s| (s.2 - o.row()).abs() < 0.01).map_or("other", |s| s.0);
let fields = json!({
"layersOnly": o.layers_only, "rowSize": size, "otherSize": o.row(),
"thumbLayers": o.thumb_layers, "thumbGroups": o.thumb_groups, "thumbObjects": o.thumb_objects,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str) {
let v = d.bool(key);
if widgets::check(ui, text, v, true) {
d.fields.insert(key.into(), json!(!v));
}
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
check(ui, d, "layersOnly", tl!("Show Layers Only"));
ui.add_space(8.0);
grid(ui, |ui| {
label(ui, tl!("Row Size:"));
ui.horizontal(|ui| {
let cur = d.str("rowSize");
let labels: Vec<&str> = SIZES.iter().map(|s| s.1).chain(["Other"]).collect();
let shown = SIZES.iter().find(|s| s.0 == cur).map_or("Other", |s| s.1);
if let Some(i) = widgets::dropdown(ui, "layers-row-size", shown, &labels, 110.0) {
d.fields.insert("rowSize".into(), json!(SIZES.get(i).map_or("other", |s| s.0)));
}
let mut v = d.f64("otherSize", f64::from(ROW_MEDIUM));
if ui.add_enabled(d.str("rowSize") == "other", egui::DragValue::new(&mut v).range(12.0..=100.0).suffix(" pt")).changed() {
d.fields.insert("otherSize".into(), json!(v.round()));
}
});
ui.end_row();
});
ui.add_space(8.0);
label(ui, tl!("Thumbnails:"));
check(ui, d, "thumbLayers", tl!("Layers"));
check(ui, d, "thumbGroups", tl!("Group"));
check(ui, d, "thumbObjects", tl!("Object"));
false
}
/// The options the fields describe.
pub(crate) fn options(d: &Dialog) -> PanelOptions {
let size = d.str("rowSize");
let row_size = SIZES.iter().find(|s| s.0 == size).map_or_else(|| (d.f64("otherSize", f64::from(ROW_MEDIUM)) as f32).clamp(12.0, 100.0), |s| s.2);
PanelOptions {
layers_only: d.bool("layersOnly"),
row_size: if row_size.is_finite() { row_size } else { ROW_MEDIUM },
thumb_layers: d.bool("thumbLayers"),
thumb_groups: d.bool("thumbGroups"),
thumb_objects: d.bool("thumbObjects"),
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
app.ui.layers_panel = options(d);
app.ui.dialog = None;
Ok(serde_json::to_value(&app.ui.layers_panel).unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
#[test]
fn panel_options_round_trip_and_apply() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.run("ui.layersPanelOptions", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("rowSize"), "medium");
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(&mut app, ui.ctx()));
out.textures_delta.clear();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("layersOnly".into(), json!(true));
d.fields.insert("rowSize".into(), json!("other"));
d.fields.insert("otherSize".into(), json!(500));
d.fields.insert("thumbObjects".into(), json!(false));
super::super::confirm(&mut app).unwrap();
let o = &app.ui.layers_panel;
assert!(o.layers_only && !o.thumb_objects && o.thumb_layers);
assert_eq!(o.row(), 100.0, "kept to 100 pt");
app.run("ui.layersPanelOptions", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("rowSize".into(), json!("large"));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.ui.layers_panel.row(), ROW_LARGE);
}
}
+4
View File
@@ -33,6 +33,8 @@ mod gradient_stop;
pub mod graphic_style_options;
pub mod halftone;
pub mod import_pdf;
pub mod layer_options;
pub mod layers_panel_options;
pub mod liquify;
pub mod missing_links;
pub mod new_color_group;
@@ -267,6 +269,8 @@ registry! {
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
LayerOptions: [layer_options::KIND] => layer_options::SPEC,
LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC,
}
/// The button labels the shared dialog frame can show (OK, discard and the fixed Cancel/Close),
+336 -74
View File
@@ -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");
}
}
+3
View File
@@ -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.
+3
View File
@@ -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.
+31
View File
@@ -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.
+4
View File
@@ -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
View File
@@ -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(),
File diff suppressed because it is too large. Load diff
+36
View File
@@ -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 {
+33 -1
View File
@@ -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,
}
}
+4
View File
@@ -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;
+14 -2
View File
@@ -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`,
+8
View File
@@ -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
View File
@@ -512,7 +512,7 @@ paints behind the clipped art and its stroke over it, not clipped, on screen and
```
`layer.clippingMask.toggle {id?}` is the Layers panel's clipping mask button: the top object of the layer `id` (default:
the one selected group, else the current layer) becomes its clipping path (unpainted, moved to the bottom of the layer,
the one highlighted layer or group row, else the one selected group, else the current layer) becomes its clipping path (unpainted, moved to the bottom of the layer,
so art added later is clipped too); called again it releases the mask. It returns `{clip}`, and the Layers panel
underlines clipping-path names.
@@ -719,9 +719,58 @@ branch sets all of it. `paint.sampleColor {color}` puts a sampled colour (in its
{"name":"run_command","arguments":{"command":"appearance.copyFrom","params":{"source":12,"ids":[7,8]}}}
```
## The Layers panel: rows, layers and sublayers
Every layer, sublayer, group and object is a row of the Layers panel. Sublayers are layers inside layers: they are not
objects, so clicking or marquee-selecting art in a sublayer, Select All and the other Select commands take the art
itself, and each sublayer has its own colour for the selection highlight. `document.inspect` lists the tree (layers
report `color`, `template`, `printable`, `preview`, `dimImages` and `clip`), the `currentLayer` (where new art goes)
and `layerRows`, the rows highlighted in the panel.
- **Clicking rows** (not undo steps): `layer.setCurrent {id}` is a plain click on any row: it alone is highlighted
and its layer (the row itself when it is a layer or sublayer) becomes current. `layer.highlight {ids, mode?:
set|add|toggle}` is Shift-click (a range) and Ctrl/Cmd-click (toggle). Selecting art makes its layer current.
Without `ids`, the panel commands below act on the highlighted rows, else on the current layer.
- **Selection column**: `layer.selectAll {id, add?}` selects a row's visible, unlocked art (a layer's sublayers'
too); `add` (Shift) adds it, or removes it when it's all selected.
- **Eye and lock**: `layer.setProps {ids?, visible?, locked?, name?}` for any rows, plus Layer Options for layers:
`template` (also locks the layer and dims its images to 50% unless given), `printable`, `preview` (off: the layer
draws and is clicked in outline, Ctrl/Cmd-click its eye), `dimImages` (0–100, or false) and `color` (an index 0–26,
`#rrggbb` or a preset name). Several rows change in one undo step (a drag down the eye or lock column).
- **Dragging rows**: `layer.move {ids, target, place?: above|below|inside, copy?}` moves rows beside a row or into a
layer or group (on top of its contents), keeping their stacking order; `copy` (Alt-drag) moves copies. Layers go
only in layers or at the top level and objects in layers and groups (an object placed beside a top-level layer goes
inside it); a row never goes into itself or a row inside it, and a locked layer or group takes nothing. Dragging the
selected-art square is `layer.move` with the selected objects. `node.move {id, parent?, index}` is the low-level
form with the same rules.
- **Buttons**: `layer.new {name?, top?, …options}` (above the current layer at its level; `top` with Ctrl/Cmd),
`layer.newSublayer {parent?, name?, …options}` (on top of the parent's contents), `layer.delete {ids?}` (rows with
what they hold; the last layer stays), `layer.locate {id?}` (highlights the selected object's row; the panel opens
the rows around it) and `layer.clippingMask.toggle`.
- **Panel menu**: `layer.duplicate {ids?}`, `layer.merge {ids?}` (into the layer highlighted last, keeping the
stacking order), `layer.flatten {id?}` (every other visible layer's art into one layer; hidden layers are deleted,
templates stay), `layer.collectInNew {ids?}`, `layer.releaseToLayers {id?, build?}` and
`layer.releaseToLayersBuild` (each object of a layer or group in a sublayer of its own; Build adds up copies),
`layer.reverse {ids?}`, `layer.template {ids?, on?}`, `layer.hideOthers` / `layer.showAll`,
`layer.outlineOthers` / `layer.previewAll`, `layer.lockOthers` / `layer.unlockAll`, `layer.pasteRemembersLayers`,
`object.isolate {id}` / `object.exitIsolation`.
- **Dialogs and panel state** (control channel and the app's MCP): `ui.layerOptions {ids?}` and `ui.newLayer
{sublayer?}` open Layer Options (dialog `layerOptions`: name, color, template, locked, visible, printable, preview,
dimImages, dimPercent; OK is one undo step), `ui.layersPanelOptions` opens Panel Options (`layersPanelOptions`:
layersOnly, rowSize small|medium|large|other, otherSize, thumbLayers, thumbGroups, thumbObjects), and
`ui.layersExpand {ids?, open?}` opens or closes rows as their triangles do (Alt-click: everything inside).
```json
{"name":"run_command","arguments":{"command":"layer.newSublayer","params":{"name":"Shadows"}}}
{"name":"run_command","arguments":{"command":"layer.move","params":{"ids":[12,15],"target":7,"place":"inside"}}}
{"name":"run_command","arguments":{"command":"layer.setProps","params":{"ids":[7],"preview":false,"color":"Orange"}}}
{"name":"run_command","arguments":{"command":"layer.highlight","params":{"ids":[3,7]}}}
{"name":"run_command","arguments":{"command":"layer.merge","params":{}}}
```
## Targeting layers and moving appearances
`layer.target {id}` is the Layers panel's target circle: a layer gets its visible, unlocked art selected and is itself
`layer.target {id}` is the Layers panel's target circle: a layer gets its visible, unlocked art (its sublayers' too) selected and is itself
the target, so `appearance.*`, `effect.*`, `transparency.*` and the opacity-mask commands without `ids` act on the
layer (its opacity, its own fills and effects, an opacity mask on the whole layer); a group or object is simply
selected. `document.inspect` reports it as `target`, and any other selection change ends it.
+7
View File
@@ -99,6 +99,13 @@ to the target triple, so host build scripts aren't affected.
- `vectorcraft.wxs` (WiX) is a per-machine install into Program Files with a Start Menu shortcut
and an App Paths entry (Win+R `vectorcraft`). Same-version upgrades are allowed, so release
candidates replace each other.
- Double-clicking the MSI opens a setup wizard (WixUI_InstallDir without the licence page):
Welcome, install folder (remembered for upgrades in `HKLM\Software\VectorCraft\InstallDir`),
Ready, a progress page, and a Finish page that says where to find the app, with a ticked "Launch
VectorCraft" box. The pages name the full version (`vectorcraft.en-us.wxl`). `package.ps1` draws
the wizard's banner and side bitmaps from the app icon, so no WiX stock art ships. `msiexec /qn`
still installs silently. Building needs the WiX UI and Util extensions:
`wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`.
- The ARM64 build is cross-compiled on the x64 runner. `.github/workflows/windows-arm64.yml`
installs that MSI on a Windows 11 ARM64 runner, checks that both installed programs are ARM64,
runs `vectorcraft-cli --version` natively and uninstalls.
+42 -1
View File
@@ -13,6 +13,7 @@
Needs: Rust (MSVC toolchain + the target), the Windows SDK (rc.exe, signtool.exe),
and WiX v5: dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
.EXAMPLE
pwsh packaging/windows/package.ps1 -Arch x64
@@ -91,10 +92,50 @@ Copy-Item (Join-Path $Bin 'vectorcraft.exe'), (Join-Path $Bin 'vectorcraft-cli.e
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'vectorcraft.exe') (Join-Path $Stage 'vectorcraft-cli.exe')
# ---- MSI ---------------------------------------------------------------------------------------
# The setup wizard's art, drawn here from the app icon so the MSI shows VectorCraft rather than
# WiX's stock bitmaps: a 493x58 banner across the top of the inner pages (the page title is drawn
# over its left side, so that stays white) and a 493x312 backdrop for the Welcome and Finish pages
# (their text sits on the right; the left 164 px are ours). Both must be 24-bit BMPs.
function New-InstallerArt([string] $IconPng, [string] $BannerOut, [string] $DialogOut) {
Add-Type -AssemblyName System.Drawing
$paper = [System.Drawing.ColorTranslator]::FromHtml('#efe9dc')
$accent = [System.Drawing.ColorTranslator]::FromHtml('#e8573f')
$icon = [System.Drawing.Image]::FromFile($IconPng)
try {
foreach ($spec in @(@($BannerOut, 493, 58), @($DialogOut, 493, 312))) {
$bmp = New-Object System.Drawing.Bitmap $spec[1], $spec[2], ([System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
$g = [System.Drawing.Graphics]::FromImage($bmp)
try {
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.Clear([System.Drawing.Color]::White)
if ($spec[2] -eq 58) {
$g.DrawImage($icon, 493 - 46 - 8, 6, 46, 46)
} else {
$panel = New-Object System.Drawing.SolidBrush $paper
$stripe = New-Object System.Drawing.SolidBrush $accent
$g.FillRectangle($panel, 0, 0, 164, 312)
$g.FillRectangle($stripe, 160, 0, 4, 312)
$g.DrawImage($icon, 18, 64, 124, 124)
$panel.Dispose(); $stripe.Dispose()
}
} finally { $g.Dispose() }
$bmp.Save($spec[0], [System.Drawing.Imaging.ImageFormat]::Bmp)
$bmp.Dispose()
}
} finally { $icon.Dispose() }
}
$Banner = Join-Path $Stage 'installer-banner.bmp'
$Dialog = Join-Path $Stage 'installer-dialog.bmp'
New-InstallerArt (Join-Path $Root 'assets\app-icon\hicolor\256x256\apps\ai.storyteller.vectorcraft.png') $Banner $Dialog
$Msi = Join-Path $Dist "vectorcraft-$Version-windows-$Arch.msi"
Invoke-Native 'wix build' {
wix build (Join-Path $PSScriptRoot 'vectorcraft.wxs') -arch $Arch `
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\vectorcraft.ico')" `
-ext WixToolset.UI.wixext -ext WixToolset.Util.wixext `
-culture en-US -loc (Join-Path $PSScriptRoot 'vectorcraft.en-us.wxl') `
-d "Version=$MsiVersion" -d "DisplayVersion=$Version" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\vectorcraft.ico')" `
-d "BannerBmp=$Banner" -d "DialogBmp=$Dialog" `
-o $Msi
}
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Overrides for the setup wizard's WixUI strings, so its pages name the version being installed
([VectorcraftVersion] is the full version, pre-release tag included, set in vectorcraft.wxs).
-->
<WixLocalization Culture="en-US" xmlns="http://wixtoolset.org/schemas/v4/wxl">
<String Id="WelcomeDlgTitle" Value="{\WixUI_Font_Bigger}Welcome to the [ProductName] [VectorcraftVersion] Setup Wizard" />
<String Id="WelcomeDlgDescription" Value="The Setup Wizard will install [ProductName] version [VectorcraftVersion] on your computer. Click Next to continue or Cancel to exit the Setup Wizard." />
<String Id="VerifyReadyDlgInstallTitle" Value="{\WixUI_Font_Title}Ready to install [ProductName] [VectorcraftVersion]" />
<String Id="ExitDialogTitle" Value="{\WixUI_Font_Bigger}Completed the [ProductName] [VectorcraftVersion] Setup Wizard" />
</WixLocalization>
+42 -5
View File
@@ -1,13 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
VectorCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
VectorCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`, plus the UI and Util extensions:
`wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`). Built by package.ps1:
wix build vectorcraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
wix build vectorcraft.wxs -arch x64 -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext
-culture en-US -loc vectorcraft.en-us.wxl -d Version=1.2.3 -d DisplayVersion=1.2.3-rc.1
-d BinDir=... -d IconPath=... -d BannerBmp=... -d DialogBmp=... -o out.msi
Per-machine install into Program Files, a Start Menu shortcut and an App Paths entry (so
`vectorcraft` starts from the Run dialog), plus vectorcraft-cli.
Per-machine install with a setup wizard that names the version: Welcome, install folder (Program Files\VectorCraft by
default, remembered for upgrades), Ready, a progress page, and a Finish page with a ticked
"Launch VectorCraft" box. Installs a Start Menu shortcut and an App Paths entry (so `vectorcraft`
starts from the Run dialog), plus vectorcraft-cli. `msiexec /qn` still installs silently.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package
Name="VectorCraft"
Manufacturer="Learning Machines LLC"
@@ -34,9 +40,39 @@
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<!-- An upgrade installs where the user put the previous version (written by VectorcraftApp). -->
<Property Id="INSTALLFOLDER">
<RegistrySearch Id="PreviousInstallFolder" Root="HKLM" Key="Software\VectorCraft" Name="InstallDir" Type="raw" />
</Property>
<Feature Id="Main" Title="VectorCraft" Level="1">
<ComponentGroupRef Id="VectorcraftFiles" />
</Feature>
<!-- The setup wizard: WixUI_InstallDir without its licence page (the code is MIT OR
Apache-2.0; there is nothing to accept). A later NewDialog on the same button wins. -->
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
<UI>
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg" Order="2" Condition="NOT Installed" />
<Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2" />
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchVectorcraft"
Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed" />
</UI>
<!-- Our own art, generated by package.ps1 from the app icon (not WiX's stock bitmaps). -->
<WixVariable Id="WixUIBannerBmp" Value="$(var.BannerBmp)" />
<WixVariable Id="WixUIDialogBmp" Value="$(var.DialogBmp)" />
<!-- The full version (Version is numeric, so it drops a pre-release tag), shown on the wizard's
pages through vectorcraft.en-us.wxl. -->
<Property Id="VectorcraftVersion" Value="$(var.DisplayVersion)" />
<!-- Finish page: say where to find the app, and offer to start it (ticked by default). -->
<Property Id="WIXUI_EXITDIALOGOPTIONALTEXT" Value="VectorCraft $(var.DisplayVersion) is installed. You can start it any time from the Start menu." />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Launch VectorCraft" />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
<Property Id="WixShellExecTarget" Value="[#VectorcraftExe]" />
<!-- Runs in the wizard's own (unelevated) process, so the app starts as the user, not as admin. -->
<CustomAction Id="LaunchVectorcraft" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)" DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
</Package>
<Fragment>
@@ -49,6 +85,7 @@
</File>
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\vectorcraft.exe" Value="[#VectorcraftExe]" Type="string" />
<RegistryValue Root="HKLM" Key="Software\VectorCraft" Name="InstallDir" Value="[INSTALLFOLDER]" Type="string" />
</Component>
<Component Id="VectorcraftCli">