mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-21 14:44:18 -04:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c69173bb7 | ||
|
|
c7d29e5b92 | ||
|
|
b9aca50c3c | ||
|
|
a2314cf803 | ||
|
|
6c04fb56c8 | ||
|
|
0f162176be | ||
|
|
91eeb5610c | ||
|
|
632cff0068 | ||
|
|
1992239325 | ||
|
|
ded39b51cc | ||
|
|
5e9b7d09a2 | ||
|
|
a75b465419 | ||
|
|
aa9f0153a4 | ||
|
|
af98309c01 | ||
|
|
5dcc64e757 | ||
|
|
abfcd0800e | ||
|
|
fe5955e521 | ||
|
|
fe9838f296 | ||
|
|
9896eda208 | ||
|
|
aecf88c1c1 | ||
|
|
2c8b8b0cc1 | ||
|
|
f79858c12c | ||
|
|
8957545c33 | ||
|
|
bc762eee99 | ||
|
|
cfa36fc55f | ||
|
|
a3b6c3d969 | ||
|
|
7ff0269a1d | ||
|
|
cd1fac5393 | ||
|
|
525b79a51a | ||
|
|
6ca54579cd | ||
|
|
558b8b621c | ||
|
|
012fe675a2 | ||
|
|
12ad39e7cc | ||
|
|
f657110510 | ||
|
|
b10d6c63ee | ||
|
|
3eeb22179d | ||
|
|
b0ba64a940 | ||
|
|
050e355e6c | ||
|
|
b1e4c4e522 | ||
|
|
c7b46dbb3e | ||
|
|
4d52b043cf | ||
|
|
2b93e8a930 | ||
|
|
cc6fd8a5dc | ||
|
|
cd34e4097a | ||
|
|
d10962ac45 | ||
|
|
a31cdab606 | ||
|
|
975dea056c | ||
|
|
0a8d334510 | ||
|
|
a2abe63e8d | ||
|
|
0ff9e35374 | ||
|
|
e02c2dc899 | ||
|
|
8e4158b364 | ||
|
|
008644ea2c | ||
|
|
a8eb9024da | ||
|
|
05bc89827b | ||
|
|
e3978d158a | ||
|
|
83ad194819 | ||
|
|
61c333b8ed | ||
|
|
0cfdefa853 | ||
|
|
6c3f9f60aa |
No files matched your search
Generated
+1
@@ -4448,6 +4448,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
|
||||
@@ -308,7 +308,8 @@ fn notice(lines: &[&str]) {
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened.
|
||||
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
|
||||
/// (libraries, presets and plug-ins open wherever they are dropped).
|
||||
/// When the graphics are lost it hands the app to a new runner on a new canvas.
|
||||
struct WebShell {
|
||||
/// `None` once handed on.
|
||||
|
||||
@@ -63,6 +63,9 @@ raw-window-handle = "0.6"
|
||||
# objc2-foundation.
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
|
||||
# Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0,
|
||||
# already in the build through eframe.
|
||||
pollster = "1"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
+155
-32
@@ -80,8 +80,9 @@ impl eframe::App for App {
|
||||
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
|
||||
}
|
||||
file_access::unconfined(|| self.app.ui(ui));
|
||||
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.app.take_ime_discard() {
|
||||
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
@@ -114,8 +115,16 @@ impl App {
|
||||
vectorcraft_ui_egui::background::wait_all(&mut self.app);
|
||||
return;
|
||||
}
|
||||
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
|
||||
// again on its document when it answers.
|
||||
#[cfg(target_os = "macos")]
|
||||
open_files(&mut self.app, open_documents::take());
|
||||
if !self.app.file_dialog_open() {
|
||||
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
|
||||
// again on its document when it answers.
|
||||
if !self.app.file_dialog_open() {
|
||||
open_files(&mut self.app, open_documents::take());
|
||||
}
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
window::track(ctx, &mut self.app.ui.window);
|
||||
if self.app.ui.status == "quit" {
|
||||
@@ -255,48 +264,99 @@ fn save_prefs(app: &VectorcraftApp) {
|
||||
/// The window file dialogs belong to.
|
||||
type Parent = Option<std::sync::Arc<winit::window::Window>>;
|
||||
|
||||
/// A native file dialog for `request` over `parent` (Windows and Linux, where the portal then
|
||||
/// shows it over the window and keeps it in front; macOS shows them as before).
|
||||
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
|
||||
let d = match parent {
|
||||
Some(w) if !cfg!(target_os = "macos") => rfd::FileDialog::new().set_parent(&**w),
|
||||
_ => rfd::FileDialog::new(),
|
||||
};
|
||||
/// What rfd's blocking and asynchronous file dialogs are both set up with.
|
||||
trait DialogSettings: Sized {
|
||||
fn parent(self, window: &winit::window::Window) -> Self;
|
||||
fn title(self, title: &str) -> Self;
|
||||
fn filter(self, name: &str, exts: &[&str]) -> Self;
|
||||
fn folder(self, folder: &str) -> Self;
|
||||
fn file_name(self, name: &str) -> Self;
|
||||
}
|
||||
|
||||
macro_rules! dialog_settings {
|
||||
($($dialog:ty),*) => {$(
|
||||
impl DialogSettings for $dialog {
|
||||
fn parent(self, window: &winit::window::Window) -> Self {
|
||||
self.set_parent(window)
|
||||
}
|
||||
fn title(self, title: &str) -> Self {
|
||||
self.set_title(title)
|
||||
}
|
||||
fn filter(self, name: &str, exts: &[&str]) -> Self {
|
||||
self.add_filter(name, exts)
|
||||
}
|
||||
fn folder(self, folder: &str) -> Self {
|
||||
self.set_directory(folder)
|
||||
}
|
||||
fn file_name(self, name: &str) -> Self {
|
||||
self.set_file_name(name)
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
|
||||
|
||||
/// `d` set up for `request`: its title, file types, folder and suggested name.
|
||||
fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
|
||||
let pick = match request {
|
||||
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
|
||||
PickRequest::OpenMany => return fileio::place_filters().fold(d.set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)),
|
||||
PickRequest::OpenMany => return fileio::place_filters().fold(d.title("Place"), |d, (name, exts)| d.filter(name, exts)),
|
||||
PickRequest::Folder => return d,
|
||||
};
|
||||
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.add_filter(*name, exts));
|
||||
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.filter(name, exts));
|
||||
let d = match &pick.folder {
|
||||
Some(folder) => d.set_directory(folder),
|
||||
Some(folder) => d.folder(folder),
|
||||
None => d,
|
||||
};
|
||||
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
|
||||
if pick.name.is_empty() { d } else { d.file_name(&pick.name) }
|
||||
}
|
||||
|
||||
/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the
|
||||
/// window and keeps it in front).
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
|
||||
match parent {
|
||||
Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request),
|
||||
None => dialog_for(rfd::FileDialog::new(), request),
|
||||
}
|
||||
}
|
||||
|
||||
/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet.
|
||||
fn make_folder(request: &PickRequest) {
|
||||
if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request {
|
||||
let _ = std::fs::create_dir_all(folder);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||
make_folder(request);
|
||||
let paths = match request {
|
||||
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
|
||||
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
|
||||
PickRequest::Save(pick) => {
|
||||
// The Templates folder may not exist yet.
|
||||
if let Some(folder) = &pick.folder {
|
||||
let _ = std::fs::create_dir_all(folder);
|
||||
}
|
||||
dialog.save_file().into_iter().collect()
|
||||
}
|
||||
PickRequest::Save(_) => dialog.save_file().into_iter().collect(),
|
||||
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
|
||||
};
|
||||
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||
}
|
||||
|
||||
/// Show the dialog for `request` over `parent` now → the paths picked.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
|
||||
show_dialog(file_dialog(&request, parent), &request)
|
||||
}
|
||||
|
||||
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
|
||||
/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]);
|
||||
/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
|
||||
log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only");
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
||||
fn reveal(path: &str) -> Result<(), String> {
|
||||
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
||||
@@ -332,34 +392,97 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Linux and macOS: show the dialog for `request` over `parent` on a thread of its own, answering
|
||||
/// on the receiver. Shown on the UI thread, nothing would answer the compositor meanwhile on Linux,
|
||||
/// which then offers to kill the window as not responding (#592); on macOS the panel ran modally
|
||||
/// inside the window's event handler, and resizing it delivered window events to that handler
|
||||
/// again, which crashed the app (#867). From another thread, rfd runs the panel on the main thread
|
||||
/// from the run loop, outside the handler.
|
||||
fn start_pick(request: PickRequest, parent: &Parent) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
|
||||
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
|
||||
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
|
||||
/// offers to kill the window as not responding (#592).
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
|
||||
let dialog = file_dialog(&request, parent);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ui = ui.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(show_dialog(dialog, &request));
|
||||
ui.request_repaint();
|
||||
})
|
||||
.ok()?;
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
fn services(parent: Parent) -> Services {
|
||||
/// The paths a dialog shown as a sheet picks, once it closes.
|
||||
#[cfg(target_os = "macos")]
|
||||
type SheetAnswer = std::pin::Pin<Box<dyn std::future::Future<Output = Vec<String>> + Send>>;
|
||||
|
||||
/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver
|
||||
/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an
|
||||
/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered
|
||||
/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet
|
||||
/// returns at once and the window keeps running; a thread of its own waits for the answer.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
|
||||
use winit::raw_window_handle::HasWindowHandle as _;
|
||||
// rfd runs the panel modally after all unless it has the running app's window to hold the
|
||||
// sheet (and from another thread it can't reach the window): no dialog then (`pick_now`).
|
||||
let mtm = objc2::MainThreadMarker::new();
|
||||
let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning());
|
||||
let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else {
|
||||
log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some());
|
||||
return None;
|
||||
};
|
||||
// The thread first, so the answer of a sheet once shown always has somewhere to go.
|
||||
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<SheetAnswer>();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ui = ui.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
if let Ok(answer) = sheet_rx.recv() {
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(pollster::block_on(answer));
|
||||
ui.request_repaint();
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
make_folder(&request);
|
||||
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
|
||||
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
|
||||
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
|
||||
let answer: SheetAnswer = match request {
|
||||
PickRequest::Open(_) => {
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::OpenMany => {
|
||||
let picked = dialog.pick_files();
|
||||
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Save(_) => {
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Folder => {
|
||||
let picked = dialog.pick_folder();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
};
|
||||
// The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled.
|
||||
let _ = sheet_tx.send(answer);
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// The host's services; `ui` is woken when a file dialog answers.
|
||||
fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
|
||||
Services {
|
||||
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
|
||||
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
|
||||
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
|
||||
// Windows dialogs run the window's events while they are open; Linux's don't, and macOS
|
||||
// ones re-enter the window's event handler (#867).
|
||||
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
|
||||
// Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows
|
||||
// dialogs run the window's events while they are open.
|
||||
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
|
||||
dialogs_are_sheets: cfg!(target_os = "macos"),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(write_file)),
|
||||
// Background Save and Export write from a worker thread.
|
||||
@@ -584,7 +707,7 @@ fn main() -> std::process::ExitCode {
|
||||
"VectorCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned()));
|
||||
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
load_prefs(&mut app, saved);
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
if let Some(w) = cc.winit_window() {
|
||||
|
||||
@@ -65,3 +65,11 @@ sync_github_name = false
|
||||
[people.KattKnight]
|
||||
display_name = "KattKnight (ayla.dsc)"
|
||||
sync_github_name = false
|
||||
|
||||
[people.NoJuanNobody]
|
||||
real_name = "Alejandro Londono"
|
||||
display_name = "alex"
|
||||
sync_github_name = false
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
@@ -57,6 +57,19 @@ impl PaletteFormat {
|
||||
pub fn binary(self) -> bool {
|
||||
self == PaletteFormat::Ase
|
||||
}
|
||||
/// Does [`write`] keep a swatch with `paint` in a file of this format? None and patterns are
|
||||
/// never written; `.gpl` and `.ase` hold solid colors only, and CSS holds no freeform gradients.
|
||||
pub fn holds(self, paint: &Paint) -> bool {
|
||||
match paint {
|
||||
Paint::Solid { .. } => true,
|
||||
Paint::Gradient(g) => match self {
|
||||
PaletteFormat::Native => true,
|
||||
PaletteFormat::Css => g.gradient.kind != GradientKind::Freeform,
|
||||
PaletteFormat::Gpl | PaletteFormat::Ase => false,
|
||||
},
|
||||
Paint::None | Paint::Pattern { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The native file: a header around the library.
|
||||
@@ -88,7 +101,7 @@ const ACB_CUT_SHORT: &str = "the color book is cut short";
|
||||
/// colors in their own model. Writing `.ase` fails for a library with more colors and groups than
|
||||
/// [`read_bytes`] reads from one file.
|
||||
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> Result<Vec<u8>, String> {
|
||||
let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_));
|
||||
let keep = |w: &&Swatch| format.holds(&w.paint);
|
||||
let text = match format {
|
||||
PaletteFormat::Native => {
|
||||
let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::<Vec<_>>();
|
||||
@@ -792,6 +805,23 @@ mod tests {
|
||||
assert!(read(&text, "x").is_err(), "CSS is written only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holds_counts_the_swatches_each_format_writes() {
|
||||
// `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group.
|
||||
let mut lib = sample();
|
||||
let Paint::Gradient(mut mesh) = lib.swatches[1].paint.clone() else { panic!("Fade is a gradient") };
|
||||
mesh.gradient.kind = GradientKind::Freeform;
|
||||
lib.swatches.push(Swatch { name: "Mesh".into(), paint: Paint::Gradient(mesh), global: false, spot: false });
|
||||
lib.swatches.push(Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false });
|
||||
let held = |f: PaletteFormat| lib.iter().filter(|w| f.holds(&w.paint)).count();
|
||||
assert_eq!(PaletteFormat::ALL.map(held), [5, 3, 3, 4], "vcswatches, gpl, ase, css");
|
||||
for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] {
|
||||
assert_eq!(read_bytes(&write(&lib, f).unwrap(), "x").unwrap().len(), held(f), "{f:?}");
|
||||
}
|
||||
let css = written(&lib, PaletteFormat::Css);
|
||||
assert_eq!(css.lines().filter(|l| l.starts_with(" --")).count(), held(PaletteFormat::Css), "one property each: {css}");
|
||||
}
|
||||
|
||||
/// A swatch exchange file of `blocks` (type, body).
|
||||
fn ase(blocks: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = b"ASEF\0\x01\0\0".to_vec();
|
||||
@@ -1088,5 +1118,15 @@ mod tests {
|
||||
prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches);
|
||||
}
|
||||
}
|
||||
|
||||
/// A written `.vcswatches`, `.gpl` or `.ase` file reads back with as many swatches as
|
||||
/// [`PaletteFormat::holds`] counts.
|
||||
#[test]
|
||||
fn written_files_read_back_with_the_swatches_holds_counts(lib in arb_library()) {
|
||||
for f in [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase] {
|
||||
let back = read_bytes(&write(&lib, f).unwrap(), &lib.name).unwrap();
|
||||
prop_assert_eq!(back.len(), lib.iter().filter(|w| f.holds(&w.paint)).count(), "{:?}", f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ pub struct Selection {
|
||||
/// Direct-selected anchors per path. A path in `objects` with no entry here is fully selected.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub anchors: BTreeMap<NodeId, BTreeSet<AnchorRef>>,
|
||||
/// Key object for Align.
|
||||
/// Key object for Align, one object of a selection of two or more. [`Selection::prune`] clears
|
||||
/// it when every selected object is the key or lies inside it, as when one object is left
|
||||
/// selected or when the key is a group selected with only its own members.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<NodeId>,
|
||||
/// The object, group or layer targeted through its target circle in the Layers panel, which
|
||||
@@ -118,7 +120,15 @@ impl Selection {
|
||||
pub fn prune(&mut self, doc: &Document) {
|
||||
self.objects.retain(|id| doc.node(*id).is_some());
|
||||
self.anchors.retain(|id, _| doc.node(*id).is_some());
|
||||
if self.key.is_some_and(|k| doc.node(k).is_none()) {
|
||||
// The key object stays while another selected object lies outside it.
|
||||
let keep = self.key.and_then(|k| doc.node(k)).is_some_and(|n| {
|
||||
let mut inside = BTreeSet::new();
|
||||
n.walk(&mut |c| {
|
||||
inside.insert(c.id);
|
||||
});
|
||||
self.objects.iter().any(|o| !inside.contains(o))
|
||||
});
|
||||
if !keep {
|
||||
self.key = None;
|
||||
}
|
||||
if self.target.is_some_and(|t| doc.node(t).is_none()) {
|
||||
@@ -128,6 +138,15 @@ impl Selection {
|
||||
// Guides on a hidden or locked layer can't stay selected.
|
||||
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
|
||||
}
|
||||
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
|
||||
/// around them (and so the key object when it is one): hidden or locked art can't stay
|
||||
/// selected.
|
||||
pub fn deselect_uneditable(&mut self, doc: &Document) {
|
||||
let out: Vec<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
|
||||
for o in out {
|
||||
self.remove(o);
|
||||
}
|
||||
}
|
||||
/// 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) {
|
||||
@@ -253,4 +272,47 @@ mod tests {
|
||||
s.set([NodeId(3)]);
|
||||
assert_eq!(s.target, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_object_needs_two_or_more_selected_objects() {
|
||||
let mut s = Selection::default();
|
||||
s.set([NodeId(1), NodeId(2)]);
|
||||
s.key = Some(NodeId(2));
|
||||
s.remove(NodeId(1));
|
||||
assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key");
|
||||
s.remove(NodeId(2));
|
||||
assert_eq!(s.key, None, "the key object itself left");
|
||||
// Pruning drops objects the document no longer has and clears the key once no other
|
||||
// selected object lies outside it.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let mut ids = vec![];
|
||||
for _ in 0..4 {
|
||||
let id = d.alloc_id();
|
||||
ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap());
|
||||
}
|
||||
s.set(ids.iter().copied());
|
||||
s.key = Some(ids[3]);
|
||||
d.remove(ids[0]).unwrap();
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays");
|
||||
s.toggle(ids[1]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays");
|
||||
s.toggle(ids[2]);
|
||||
s.prune(&d);
|
||||
assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left");
|
||||
// A group key selected with only its own member is cleared too; another selected object
|
||||
// outside the group keeps it.
|
||||
let member = d.alloc_id();
|
||||
d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap();
|
||||
s.set([member, ids[3]]);
|
||||
s.key = Some(ids[3]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, None, "a group and its own member");
|
||||
s.set([member, ids[3], ids[2]]);
|
||||
s.key = Some(ids[3]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "another object outside the group");
|
||||
}
|
||||
}
|
||||
@@ -96,8 +96,8 @@ pub struct CharStyle {
|
||||
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub char_align: CharAlign,
|
||||
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
|
||||
/// font's own proportional widths, OpenType `palt` (#966). Vertical type sets them as before
|
||||
/// for now.
|
||||
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
|
||||
/// its proportional heights, `vpal`.
|
||||
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub proportional_metrics: bool,
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address};
|
||||
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
@@ -248,6 +248,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The image markup some browsers put as text next to a copied picture names it too (#1089);
|
||||
/// markup with words in it is text.
|
||||
#[test]
|
||||
fn markup_of_only_images_names_a_picture() {
|
||||
for m in [
|
||||
r#"<img src="https://static.example.com/eagle.png" jsaction="" class="sFlh5c" style="max-width: 351px" alt="">"#,
|
||||
r#"<meta http-equiv="content-type" content="text/html; charset=utf-8"><img src="a.png">"#,
|
||||
"<html><body><!--StartFragment--><IMG SRC=\"a.png\"/><!--EndFragment--></body></html>",
|
||||
r#"<a href="https://x.org"><img src="a.png"></a>"#,
|
||||
"https://example.com/cat.png",
|
||||
] {
|
||||
assert!(names_a_picture(m), "{m}");
|
||||
}
|
||||
for t in ["<p>Hello</p>", r#"<img src="a.png"> and a caption"#, "<b>bold</b>", "<imgx>", "<img", "plain words", "<br>", ""] {
|
||||
assert!(!names_a_picture(t), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_exports_svg_that_round_trips_through_paste() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -39,6 +39,30 @@ pub fn is_address(text: &str) -> bool {
|
||||
!t.chars().any(char::is_whitespace) && SCHEMES.iter().any(|s| t.get(..s.len()).is_some_and(|h| h.eq_ignore_ascii_case(s)) && t.len() > s.len())
|
||||
}
|
||||
|
||||
/// Is `text` only a reference to a picture: an address ([`is_address`]), or markup that shows
|
||||
/// nothing but images (`<img …>`)? A browser's Copy Image puts one of them next to the picture
|
||||
/// (some put the image's HTML there as text, #1089): then the picture is what Paste takes.
|
||||
pub fn names_a_picture(text: &str) -> bool {
|
||||
is_address(text) || only_images(text)
|
||||
}
|
||||
|
||||
/// Is `html` markup whose only content is `<img>` elements: no text outside its tags (whitespace
|
||||
/// between them aside), and at least one image?
|
||||
fn only_images(html: &str) -> bool {
|
||||
let mut images = 0;
|
||||
let mut rest = html.trim();
|
||||
while !rest.is_empty() {
|
||||
let Some(tag) = rest.strip_prefix('<') else { return false };
|
||||
let Some(end) = tag.find('>') else { return false };
|
||||
let name = tag.get(..end).unwrap_or_default().split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default();
|
||||
if name.eq_ignore_ascii_case("img") {
|
||||
images += 1;
|
||||
}
|
||||
rest = tag.get(end + 1..).unwrap_or_default().trim_start();
|
||||
}
|
||||
images > 0
|
||||
}
|
||||
|
||||
/// How much of a copied file [`file_flavour`] needs to tell what it is.
|
||||
pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ use crate::{DocState, EngineError};
|
||||
pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
|
||||
let label = str_param(p, "label").unwrap_or("Batch").to_string();
|
||||
// The active artboard the app passes, for the steps that act on it.
|
||||
let artboard = p.get("artboard").and_then(Value::as_u64);
|
||||
// What an error rolls back besides the documents: session-level state a step may change
|
||||
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
|
||||
let (start, active): (Vec<u64>, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active);
|
||||
s.batch_stash = Some(vec![]);
|
||||
let r = run_steps(s, &label, &mut cmds);
|
||||
let r = run_steps(s, &label, artboard, &mut cmds);
|
||||
let stash = s.batch_stash.take().unwrap_or_default();
|
||||
let results = match r {
|
||||
Ok(results) => results,
|
||||
@@ -43,7 +45,10 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Run the steps → their results. Each runs inside an interaction of the document active when it
|
||||
/// starts (one for all the steps in that document), so a step may open, switch or close documents.
|
||||
fn run_steps(s: &mut Session, label: &str, cmds: &mut [Value]) -> Result<Vec<Value>> {
|
||||
/// A step that acts on the active artboard ([`crate::ON_ACTIVE_ARTBOARD`]) and names none gets
|
||||
/// `artboard`, as the Artboards panel shows it in the document the step runs in: past that
|
||||
/// document's last artboard, the last one.
|
||||
fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Value]) -> Result<Vec<Value>> {
|
||||
let mut results = Vec::with_capacity(cmds.len());
|
||||
for c in cmds {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
@@ -53,7 +58,15 @@ fn run_steps(s: &mut Session, label: &str, cmds: &mut [Value]) -> Result<Vec<Val
|
||||
if s.active().is_some() {
|
||||
s.begin_interaction(label)?;
|
||||
}
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
let mut params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
if let Some(a) = artboard
|
||||
&& crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str())
|
||||
&& params.get("artboard").is_none()
|
||||
&& let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1))
|
||||
&& let Some(o) = params.as_object_mut()
|
||||
{
|
||||
o.insert("artboard".into(), json!(usize::try_from(a).map_or(last, |a| a.min(last))));
|
||||
}
|
||||
let (v, noted) =
|
||||
s.execute_step(&id, ¶ms).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?;
|
||||
// The step's journal entry records what it took from the preferences or the clock (a
|
||||
|
||||
@@ -123,7 +123,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
screens::export_for_screens
|
||||
),
|
||||
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", has_doc, batch::batch),
|
||||
cmd!(
|
||||
query "command.batch",
|
||||
"Batch",
|
||||
[],
|
||||
None,
|
||||
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align or select.allOnArtboard step that names none gets it, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
|
||||
has_doc,
|
||||
batch::batch
|
||||
),
|
||||
cmd!(
|
||||
query "document.formats",
|
||||
"File Formats",
|
||||
|
||||
@@ -551,11 +551,7 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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();
|
||||
for h in hidden {
|
||||
sel.remove(h);
|
||||
}
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
|
||||
@@ -188,11 +188,7 @@ fn set_flag(s: &mut Session, ids: Vec<NodeId>, flag: Flag, v: bool, label: &str)
|
||||
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);
|
||||
}
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"All Artwork Above",
|
||||
["Object", "Lock"],
|
||||
None,
|
||||
"{} lock objects in the same layer that are above and overlap the selection → {count}",
|
||||
"{} lock objects in the same layer that are above and overlap the selection, deselecting every selected object left locked or hidden (the key object too) → {count}",
|
||||
has_selection,
|
||||
|s, _| above(s, true)
|
||||
),
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"All Artwork Above",
|
||||
["Object", "Hide"],
|
||||
None,
|
||||
"{} hide objects in the same layer that are above and overlap the selection → {count}",
|
||||
"{} hide objects in the same layer that are above and overlap the selection, deselecting every selected object left hidden or locked (the key object too) → {count}",
|
||||
has_selection,
|
||||
|s, _| above(s, false)
|
||||
),
|
||||
@@ -228,7 +228,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
}
|
||||
let label = if lock { "Lock" } else { "Hide" };
|
||||
let n = hits.len();
|
||||
s.edit(label, |d, _| {
|
||||
s.edit(label, |d, sel| {
|
||||
for id in &hits {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
if lock {
|
||||
@@ -238,6 +238,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
|
||||
@@ -150,7 +150,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Object Properties",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
|
||||
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
|
||||
has_doc,
|
||||
set_props
|
||||
),
|
||||
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
|
||||
has_selection,
|
||||
align
|
||||
),
|
||||
@@ -168,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Distribute",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
|
||||
has_multi,
|
||||
distribute
|
||||
),
|
||||
@@ -177,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Distribute Spacing",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
|
||||
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
|
||||
has_multi,
|
||||
distribute_spacing
|
||||
),
|
||||
@@ -363,7 +363,12 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = -n.as_f64().unwrap_or(90.0).to_radians();
|
||||
Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t)
|
||||
}
|
||||
_ => Affine::scale_non_uniform(-1.0, 1.0),
|
||||
None | Some(Value::Null) => Affine::scale_non_uniform(-1.0, 1.0),
|
||||
Some(Value::String(a)) if a == "vertical" => Affine::scale_non_uniform(-1.0, 1.0),
|
||||
Some(v) => {
|
||||
let given = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`")));
|
||||
}
|
||||
};
|
||||
apply_transform(s, "Reflect", ids, about(o, m), p)
|
||||
}
|
||||
@@ -372,8 +377,8 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
|
||||
let m =
|
||||
if str_param(p, "axis") == Some("vertical") { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
|
||||
let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical");
|
||||
let m = if vertical { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
|
||||
apply_transform(s, "Shear", ids, about(o, m), p)
|
||||
}
|
||||
|
||||
@@ -849,6 +854,7 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let hides = p.get("visible").and_then(Value::as_bool) == Some(false) || p.get("locked").and_then(Value::as_bool) == Some(true);
|
||||
// When every object already has every value given, the document and its undo history stay as
|
||||
// they are.
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
@@ -868,9 +874,13 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) {
|
||||
// Hidden or locked objects can't stay selected.
|
||||
if hides {
|
||||
s.select(|d, sel| sel.deselect_uneditable(d))?;
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
s.edit("Object Properties", |d, _| {
|
||||
s.edit("Object Properties", |d, sel| {
|
||||
for id in &ids {
|
||||
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if let Some(v) = str_param(p, "name") {
|
||||
@@ -916,6 +926,10 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Hidden or locked objects can't stay selected.
|
||||
if hides {
|
||||
sel.deselect_uneditable(d);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
@@ -936,22 +950,49 @@ impl Session {
|
||||
|
||||
/// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds.
|
||||
fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
|
||||
match str_param(p, "bounds") {
|
||||
match choice(p, cmd, "bounds", &["preview", "geometric"])? {
|
||||
None => Ok(s.prefs.use_preview_bounds),
|
||||
Some("preview") => Ok(true),
|
||||
Some("geometric") => Ok(false),
|
||||
Some(b) => Err(bad(cmd, format!("bounds must be \"preview\" or \"geometric\", not `{b}`"))),
|
||||
Some(b) => Ok(b == "preview"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Param `key` of the Align commands and Shear: one of `values`, or `None` when it is missing or
|
||||
/// null. Any other value is an error that lists `values`.
|
||||
fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result<Option<&'a str>> {
|
||||
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
match v.as_str() {
|
||||
Some(s) if values.contains(&s) => Ok(Some(s)),
|
||||
_ => Err(bad(cmd, format!("`{key}` must be one of {}", values.join(", ")))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizontal` and `vertical` of `object.align` and `object.distribute`, each checked by [`choice`].
|
||||
fn direction<'a>(p: &'a Value, cmd: &str) -> Result<(Option<&'a str>, Option<&'a str>)> {
|
||||
Ok((choice(p, cmd, "horizontal", &["left", "center", "right"])?, choice(p, cmd, "vertical", &["top", "center", "bottom"])?))
|
||||
}
|
||||
|
||||
/// `ids` with their bounds (`preview`: visual bounds).
|
||||
fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> {
|
||||
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
|
||||
}
|
||||
|
||||
/// Is every move in `moves` a rounding error (1e-9 pt or less on both axes)?
|
||||
fn moves_nothing(moves: &[(NodeId, Vec2)]) -> bool {
|
||||
moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
|
||||
/// Move each object of `moves` by its offset, as undo step `label`; pattern tiles move too when
|
||||
/// [`transform_patterns`] says so for `ids`. When every move is a rounding error (1e-9 pt or less
|
||||
/// on both axes), the document and its undo history stay as they are.
|
||||
fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)]) -> Result<Value> {
|
||||
let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() };
|
||||
if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(label, |d, _| {
|
||||
for (id, dv) in moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(*dv), sc);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
|
||||
@@ -965,8 +1006,12 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
|
||||
match align_to(s, p) {
|
||||
Some("artboard") => {
|
||||
let b = st.doc.bounds_of(ids, preview).unwrap_or_default();
|
||||
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
|
||||
// it, or with an index the document doesn't have, it uses the artboard under the center
|
||||
// of the selection, or the first one when no artboard is there.
|
||||
let named = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| st.doc.artboards.get(i));
|
||||
let i = st.doc.artboard_at(b.center()).unwrap_or(0);
|
||||
Ok(st.doc.artboards.get(i).map(|a| a.rect).unwrap_or(b))
|
||||
Ok(named.or(st.doc.artboards.get(i)).map(|a| a.rect).unwrap_or(b))
|
||||
}
|
||||
Some("key") => {
|
||||
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
|
||||
@@ -988,9 +1033,12 @@ fn key_root(s: &Session, ids: &[NodeId]) -> Option<NodeId> {
|
||||
fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let preview = preview_bounds(s, p, "object.align")?;
|
||||
let (h, v) = direction(p, "object.align")?;
|
||||
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
|
||||
if h.is_none() && v.is_none() {
|
||||
return Err(bad("object.align", "give horizontal or vertical"));
|
||||
}
|
||||
let r = reference_rect(s, p, &ids, preview)?;
|
||||
let h = str_param(p, "horizontal");
|
||||
let v = str_param(p, "vertical");
|
||||
let key = key_root(s, &ids);
|
||||
let moves: Vec<(NodeId, Vec2)> = {
|
||||
let d = &s.doc()?.doc;
|
||||
@@ -1014,26 +1062,14 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// When nothing moves, the document and its undo history stay as they are.
|
||||
if moves_nothing(&moves) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Align", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(*dv), false);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
apply_moves(s, "Align", p, &ids, &moves)
|
||||
}
|
||||
|
||||
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let preview = preview_bounds(s, p, "object.distribute")?;
|
||||
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
|
||||
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match (str_param(p, "horizontal"), str_param(p, "vertical")) {
|
||||
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? {
|
||||
(Some(h), _) => (
|
||||
true,
|
||||
match h {
|
||||
@@ -1068,24 +1104,12 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
|
||||
})
|
||||
.collect();
|
||||
// When nothing moves, the document and its undo history stay as they are.
|
||||
if moves_nothing(&moves) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Distribute", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(*dv), false);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
apply_moves(s, "Distribute", p, &ids, &moves)
|
||||
}
|
||||
|
||||
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let horiz = str_param(p, "axis") != Some("vertical");
|
||||
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
|
||||
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
|
||||
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
|
||||
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
|
||||
@@ -1095,7 +1119,11 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
|
||||
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
|
||||
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|g| g.is_finite());
|
||||
let spacing = p
|
||||
.get("spacing")
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad("object.distributeSpacing", "`spacing` must be a number")))
|
||||
.transpose()?;
|
||||
let gap = match spacing {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
@@ -1115,19 +1143,7 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
|
||||
let moves: Vec<(NodeId, Vec2)> =
|
||||
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
|
||||
// When nothing moves, the document and its undo history stay as they are.
|
||||
if moves_nothing(&moves) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Distribute Spacing", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(*dv), false);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
apply_moves(s, "Distribute Spacing", p, &ids, &moves)
|
||||
}
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
|
||||
@@ -19,7 +19,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
all
|
||||
),
|
||||
cmd!("select.allOnArtboard", "All on Active Artboard", ["Select"], Some("Cmd+Alt+A"), "{artboard?: index}", has_doc, all_on_artboard),
|
||||
cmd!(
|
||||
"select.allOnArtboard",
|
||||
"All on Active Artboard",
|
||||
["Select"],
|
||||
Some("Cmd+Alt+A"),
|
||||
"{artboard?: index (0-based; the app passes the active one; default: the first)}",
|
||||
has_doc,
|
||||
all_on_artboard
|
||||
),
|
||||
cmd!("select.none", "Deselect", ["Select"], Some("Cmd+Shift+A"), "{}", has_doc, none),
|
||||
cmd!("select.reselect", "Reselect", ["Select"], Some("Cmd+6"), "{}", has_doc, reselect),
|
||||
cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse),
|
||||
@@ -57,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Key Object",
|
||||
[],
|
||||
None,
|
||||
"{id?: a selected object (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it",
|
||||
"{id?: a selected object, one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members)",
|
||||
has_doc,
|
||||
key
|
||||
),
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Swatch Library…",
|
||||
["Window", "Swatches"],
|
||||
None,
|
||||
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase",
|
||||
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count: the swatches written (gpl and ase hold solid colors only, and css leaves freeform gradients out), library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase",
|
||||
has_doc,
|
||||
save
|
||||
),
|
||||
@@ -454,7 +454,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?;
|
||||
let bytes = palette_io::write(&lib, format).map_err(|e| bad(C, e))?;
|
||||
let data = if format.binary() { FileData::Binary(bytes) } else { FileData::Text(String::from_utf8_lossy(&bytes).into_owned()) };
|
||||
let mut out = json!({"format": format.id(), "count": lib.len()});
|
||||
let mut out = json!({"format": format.id(), "count": lib.iter().filter(|w| format.holds(&w.paint)).count()});
|
||||
s.swatch_libraries.write(p, &lib.name, format.id(), data, &mut out, C)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
|
||||
@@ -369,10 +369,27 @@ impl DocState {
|
||||
/// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas.
|
||||
pub const MAX_COORD: f64 = 4.0e6;
|
||||
|
||||
/// The commands that act on the active artboard (#693), which the app passes as their `artboard`:
|
||||
/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, and All on
|
||||
/// Active Artboard selects on it. A `command.batch` given `artboard` passes it on to each of their
|
||||
/// steps that names none.
|
||||
pub const ON_ACTIVE_ARTBOARD: [&str; 5] = ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack", "object.align", "select.allOnArtboard"];
|
||||
|
||||
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
|
||||
/// (dates from the clock: `cmd::clock_date`); an action leaves them out
|
||||
/// (dates from the clock: `cmd::clock_date`; the active artboard the app passes to the
|
||||
/// [`ON_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves them out
|
||||
/// ([`Session::journal_for_action`]).
|
||||
const REPLAY_ONLY: [(&str, &str); 3] = [("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified")];
|
||||
const REPLAY_ONLY: [(&str, &str); 9] = [
|
||||
("file.new", "created"),
|
||||
("document.save", "modified"),
|
||||
("file.saveAs", "modified"),
|
||||
("edit.pasteInPlace", "artboard"),
|
||||
("edit.pasteInFront", "artboard"),
|
||||
("edit.pasteInBack", "artboard"),
|
||||
("object.align", "artboard"),
|
||||
("select.allOnArtboard", "artboard"),
|
||||
("command.batch", "artboard"),
|
||||
];
|
||||
|
||||
/// `p`, the params of command `id`, without its [`REPLAY_ONLY`] values (a batch: its steps').
|
||||
fn strip_replay_only(id: &str, p: &mut Value) {
|
||||
@@ -1349,10 +1366,17 @@ impl Session {
|
||||
let Some(mut it) = st.interaction.take() else { return Ok(()) };
|
||||
let Some(preview) = it.preview.take() else { return Ok(()) };
|
||||
let perspective_again = it.perspective_again.take();
|
||||
let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||||
// Alt pressed or released during a move drag turns it into a copy or back: name it for
|
||||
// what it did in the end.
|
||||
match (it.label.as_str(), copy) {
|
||||
("Move", true) => it.label = "Copy".into(),
|
||||
("Copy", false) if preview.0 == "object.transform" => it.label = "Move".into(),
|
||||
_ => {}
|
||||
}
|
||||
st.keep_interaction(it);
|
||||
if preview.0 == "object.transform" {
|
||||
let m = cmd::matrix_param(&preview.1, "matrix");
|
||||
let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||||
if let Some(m) = m {
|
||||
st.last_transform = Some((m, copy));
|
||||
st.last_perspective = None;
|
||||
|
||||
@@ -311,6 +311,57 @@ fn layers_and_lock_hide() {
|
||||
assert!(s.doc().unwrap().doc.node(b).is_none());
|
||||
}
|
||||
|
||||
/// A key object is one object of a selection of several: a Shift-click (`select.toggle`) or a
|
||||
/// hidden or deleted row that leaves one object selected clears the key, and `select.key` sets
|
||||
/// none on a single selected object. A Shift-drag marquee that leaves two or more objects selected
|
||||
/// keeps the key in either order of its ids. `select.key` also sets none on a group selected with
|
||||
/// only its own member, and sets it once another object is selected.
|
||||
#[test]
|
||||
fn a_lone_selected_object_is_not_the_key_object() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 100.0, 30.0, 20.0, 20.0);
|
||||
let c = rect(&mut s, 200.0, 60.0, 10.0, 10.0);
|
||||
let key = |s: &Session| s.doc().unwrap().selection.key;
|
||||
let objects = |s: &Session| s.doc().unwrap().selection.objects.clone();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": b.0})).unwrap();
|
||||
assert_eq!(key(&s), Some(b));
|
||||
s.execute("select.toggle", &json!({"id": a.0})).unwrap();
|
||||
assert_eq!(objects(&s), vec![b]);
|
||||
assert_eq!(key(&s), None, "the Shift-click left one object");
|
||||
s.execute("select.key", &json!({"id": b.0})).unwrap();
|
||||
assert_eq!(key(&s), None, "one object selected");
|
||||
for ids in [[a.0, c.0], [c.0, a.0]] {
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": b.0})).unwrap();
|
||||
s.execute("select.toggle", &json!({"ids": ids})).unwrap();
|
||||
assert_eq!((objects(&s), key(&s)), (vec![b, c], Some(b)), "the marquee toggled {ids:?}");
|
||||
}
|
||||
// The Group Selection tool's second click on a member adds its group.
|
||||
s.execute("select.set", &json!({"ids": [b.0, c.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute("select.add", &json!({"ids": [g.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": g.0})).unwrap();
|
||||
assert_eq!(key(&s), None, "a group and its own member");
|
||||
s.execute("select.add", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": g.0})).unwrap();
|
||||
assert_eq!(key(&s), Some(g), "another object outside the group");
|
||||
s.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": g.0})).unwrap();
|
||||
assert_eq!(key(&s), Some(g));
|
||||
s.execute("layer.setProps", &json!({"id": a.0, "visible": false})).unwrap();
|
||||
assert_eq!((objects(&s), key(&s)), (vec![g], None), "hiding the other object's row left one object");
|
||||
s.execute("layer.setProps", &json!({"id": a.0, "visible": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": g.0})).unwrap();
|
||||
assert_eq!(key(&s), Some(g));
|
||||
s.execute("layer.delete", &json!({"id": a.0})).unwrap();
|
||||
assert_eq!(objects(&s), vec![g]);
|
||||
assert_eq!(key(&s), None, "deleting the other object's row left one object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_and_compound() {
|
||||
let mut s = session();
|
||||
@@ -330,6 +381,65 @@ fn clipping_and_compound() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
|
||||
}
|
||||
|
||||
/// `object.align` without `horizontal` or `vertical` (an unknown key such as `align` counts as
|
||||
/// neither) returns an error: nothing moves, and the undo and redo stacks stay as they were.
|
||||
#[test]
|
||||
fn align_without_a_direction_is_refused() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let stacks = |s: &Session| (s.doc().unwrap().history.undo.len(), s.doc().unwrap().history.redo.len());
|
||||
let before = stacks(&s);
|
||||
for p in [json!({}), json!({"align": "left"}), json!({"to": "artboard"}), json!({"horizontal": null, "vertical": null})] {
|
||||
let e = s.execute("object.align", &p).expect_err(&p.to_string());
|
||||
assert_eq!(e.to_string(), "invalid parameters for `object.align`: give horizontal or vertical", "{p}");
|
||||
}
|
||||
assert_eq!(stacks(&s), before, "no undo step, and the redo step is kept");
|
||||
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 50.0, "nothing moved");
|
||||
}
|
||||
|
||||
/// Align, Distribute and Distribute Spacing return an error that lists the choices of a param
|
||||
/// given a value outside them: nothing moves and no undo step is recorded. A null value counts as
|
||||
/// not given, and Distribute Spacing without an `axis` spaces the objects horizontally.
|
||||
#[test]
|
||||
fn align_and_distribute_reject_bad_params_without_changing_state() {
|
||||
let mut s = session();
|
||||
let ids: Vec<NodeId> = [0.0, 50.0, 200.0].into_iter().map(|x| rect(&mut s, x, x / 2.0, 10.0, 10.0)).collect();
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()})).unwrap();
|
||||
let bounds = |s: &Session| ids.iter().map(|id| s.doc().unwrap().doc.node(*id).unwrap().geometric_bounds().unwrap()).collect::<Vec<_>>();
|
||||
let before = bounds(&s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let h = "`horizontal` must be one of left, center, right";
|
||||
let v = "`vertical` must be one of top, center, bottom";
|
||||
let to = "`to` must be one of selection, artboard, key";
|
||||
for (cmd, p, msg) in [
|
||||
("object.align", json!({"horizontal": "middle"}), h),
|
||||
("object.align", json!({"vertical": "centre"}), v),
|
||||
("object.align", json!({"horizontal": true}), h),
|
||||
("object.align", json!({"horizontal": "left", "to": "page"}), to),
|
||||
("object.align", json!({"horizontal": "left", "to": 0}), to),
|
||||
("object.align", json!({"horizontal": "left", "bounds": false}), "`bounds` must be one of preview, geometric"),
|
||||
("object.align", json!({"horizontal": "left", "vertical": "middle"}), v),
|
||||
("object.distribute", json!({"horizontal": "middle"}), h),
|
||||
("object.distribute", json!({"vertical": "centre"}), v),
|
||||
("object.distribute", json!({"horizontal": "left", "vertical": "middle"}), v),
|
||||
("object.distributeSpacing", json!({"axis": "diagonal"}), "`axis` must be one of horizontal, vertical"),
|
||||
("object.distributeSpacing", json!({"axis": "vertical", "spacing": "5"}), "`spacing` must be a number"),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}"));
|
||||
assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {msg}"));
|
||||
}
|
||||
assert_eq!(bounds(&s), before, "nothing moved");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
|
||||
s.execute("object.align", &json!({"horizontal": null, "vertical": "top", "to": null, "bounds": null})).unwrap();
|
||||
assert!(bounds(&s).iter().all(|b| b.y0 == 0.0), "aligned to the top");
|
||||
s.execute("object.distributeSpacing", &json!({"axis": null, "spacing": null})).unwrap();
|
||||
assert_eq!(bounds(&s).iter().map(|b| b.x0).collect::<Vec<_>>(), [0.0, 100.0, 200.0], "equal horizontal gaps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_left() {
|
||||
let mut s = session();
|
||||
@@ -340,6 +450,37 @@ fn align_left() {
|
||||
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 0.0);
|
||||
}
|
||||
|
||||
/// Align to Artboard aligns to artboard `artboard` (the app passes the active one). Without it, or
|
||||
/// with one the document doesn't have, it aligns to the artboard under the center of the
|
||||
/// selection, or to the first artboard when no artboard is there. Align to Selection does not use
|
||||
/// `artboard`.
|
||||
#[test]
|
||||
fn align_to_artboard_uses_the_artboard_named() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
let boards: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let a = rect(&mut s, boards[1].x0 + 10.0, boards[1].y0 + 10.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, boards[1].x0 + 50.0, boards[1].y0 + 40.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
|
||||
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard", "artboard": 2})).unwrap();
|
||||
assert_eq!((x0(&s, a), x0(&s, b)), (boards[2].x0, boards[2].x0), "the left edges are on artboard 2's");
|
||||
for (p, left) in [
|
||||
(json!({"horizontal": "left", "to": "artboard"}), boards[1].x0),
|
||||
(json!({"horizontal": "left", "to": "artboard", "artboard": 9}), boards[1].x0),
|
||||
(json!({"horizontal": "left", "to": "selection", "artboard": 2}), boards[1].x0 + 10.0),
|
||||
] {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.align", &p).unwrap();
|
||||
assert_eq!((x0(&s, a), x0(&s, b)), (left, left), "{p}");
|
||||
}
|
||||
// A selection whose center is on no artboard aligns to the first.
|
||||
let c = rect(&mut s, boards[1].x0 + 10.0, boards[1].y1 + 50.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap();
|
||||
assert_eq!(x0(&s, c), boards[0].x0, "off every artboard: the first");
|
||||
}
|
||||
|
||||
/// #541: with the Selection tool, a click on one object of the selection makes it the key object:
|
||||
/// aligning to the key moves the others to it, Distribute Spacing spaces them from it, and a click
|
||||
/// on the key again lets it go.
|
||||
|
||||
@@ -66,6 +66,65 @@ fn recorded_actions_leave_out_replay_only_values() {
|
||||
assert_eq!(s.journal_for_action(1), s.journal[1..].to_vec());
|
||||
}
|
||||
|
||||
/// The artboard the app passes to Paste in Place, in Front and in Back, to Align to Artboard, to
|
||||
/// All on Active Artboard and to a batch (its active one) is journaled and left out of recorded
|
||||
/// actions, so a played action uses the artboard active when it plays.
|
||||
#[test]
|
||||
fn recorded_actions_leave_out_the_artboard_the_app_passes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2, "created": null})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let start = s.journal.len();
|
||||
let steps = [
|
||||
("edit.pasteInPlace", json!({"artboard": 1})),
|
||||
("edit.pasteInFront", json!({"artboard": 1})),
|
||||
("edit.pasteInBack", json!({"artboard": 1})),
|
||||
("select.allOnArtboard", json!({"artboard": 1})),
|
||||
("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})),
|
||||
("command.batch", json!({"artboard": 1, "commands": [{"command": "select.allOnArtboard", "params": {}}]})),
|
||||
];
|
||||
for (id, p) in &steps {
|
||||
s.execute(id, p).unwrap();
|
||||
}
|
||||
assert!(s.journal[start..].iter().all(|(_, p)| p["artboard"] == json!(1)), "the journal keeps it: {:?}", &s.journal[start..]);
|
||||
let action = s.journal_for_action(start);
|
||||
assert_eq!(action.len(), steps.len());
|
||||
for ((id, mut p), (step, recorded)) in steps.into_iter().zip(action) {
|
||||
p.as_object_mut().unwrap().remove("artboard");
|
||||
assert_eq!((step.as_str(), recorded), (id, p));
|
||||
}
|
||||
}
|
||||
|
||||
/// A batch passes its `artboard` (the app's active one) to each step that acts on the active
|
||||
/// artboard and names none, when the step runs: once a step deletes the last artboard, the new
|
||||
/// last one, and in a new document with fewer artboards, its last one. A step that names an
|
||||
/// artboard keeps it.
|
||||
#[test]
|
||||
fn a_batch_passes_its_artboard_to_each_step_when_it_runs() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
let boards: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let ids: Vec<u64> = boards
|
||||
.iter()
|
||||
.map(|b| {
|
||||
s.execute("shape.rectangle", &json!({"x": b.x0 + 10.0, "y": b.y0 + 10.0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()
|
||||
})
|
||||
.collect();
|
||||
let picked = |s: &Session| s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect::<Vec<_>>();
|
||||
let step = |command: &str, params: Value| json!({"command": command, "params": params});
|
||||
let all_on_artboard = step("select.allOnArtboard", json!({}));
|
||||
s.execute("command.batch", &json!({"artboard": 2, "commands": [all_on_artboard]})).unwrap();
|
||||
assert_eq!(picked(&s), [ids[2]], "the batch's artboard 3");
|
||||
s.execute("command.batch", &json!({"artboard": 2, "commands": [step("select.allOnArtboard", json!({"artboard": 0}))]})).unwrap();
|
||||
assert_eq!(picked(&s), [ids[0]], "an artboard the step names is kept");
|
||||
s.execute("command.batch", &json!({"artboard": 2, "commands": [step("artboard.delete", json!({"index": 2})), all_on_artboard]})).unwrap();
|
||||
assert_eq!(picked(&s), [ids[1]], "artboard 2, the last one once artboard 3 is deleted");
|
||||
let new = step("file.new", json!({"width": 100, "height": 100}));
|
||||
s.execute("command.batch", &json!({"artboard": 2, "commands": [new, all_on_artboard]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "in the new document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_records_its_date_and_replays_it() {
|
||||
let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned();
|
||||
@@ -296,3 +355,19 @@ fn an_undo_group_is_one_undo_step() {
|
||||
assert!((width(&s) - 120.0).abs() < 1e-9);
|
||||
assert_eq!(undo_len(&s), undo + 2);
|
||||
}
|
||||
|
||||
/// A move drag is named for what its last preview did: Alt pressed or released during the drag
|
||||
/// turns it into a copy or back (the Selection and Direct Selection tools).
|
||||
#[test]
|
||||
fn a_move_drag_is_named_for_whether_it_copied() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
let last = |s: &Session| s.doc().unwrap().history.undo.last().map(|e| e.label.clone());
|
||||
for (begin, copy, label) in [("Move", true, "Copy"), ("Copy", false, "Move"), ("Move", false, "Move")] {
|
||||
s.begin_interaction(begin).unwrap();
|
||||
s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": copy})).unwrap();
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(last(&s).as_deref(), Some(label), "begun as {begin}, copy: {copy}");
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,89 @@ fn visibility_and_lock_per_row_are_undoable() {
|
||||
assert_eq!(node(&s, sub).display_name(), "Sub", "a layer keeps its name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_set_props_deselects_the_objects_it_hides_or_locks() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
|
||||
for (prop, value) in [("visible", false), ("locked", true)] {
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
run(&mut s, "select.key", json!({"id": b.0}));
|
||||
let mut p = json!({"ids": [b.0]});
|
||||
p[prop] = json!(value);
|
||||
run(&mut s, "object.setProps", p);
|
||||
assert!(!s.doc().unwrap().doc.is_editable(b));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![a], None), "{prop}: {value} deselects the key object");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.is_editable(b));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![a, b], Some(b)), "undoing {prop}: {value} selects it again as the key");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_set_props_deselects_the_objects_inside_a_group_or_layer_it_hides_or_locks() {
|
||||
let mut s = session();
|
||||
let (_, sub, a, g, b, c) = tree(&mut s);
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
|
||||
run(&mut s, "object.setProps", json!({"ids": [g.0], "locked": true}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, c], "b is in the locked group");
|
||||
run(&mut s, "select.key", json!({"id": a.0}));
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(a));
|
||||
run(&mut s, "object.setProps", json!({"ids": [sub.0], "visible": false}));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![c], None), "a, the key object, is in the hidden sublayer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_set_props_that_hides_or_locks_nothing_keeps_the_selection() {
|
||||
let mut s = session();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let x = rect(&mut s, 0.0);
|
||||
run(&mut s, "select.set", json!({"ids": [x.0]}));
|
||||
run(&mut s, "object.lock", json!({}));
|
||||
run(&mut s, "layer.setProps", json!({"id": layer.0, "locked": true}));
|
||||
run(&mut s, "object.unlockAll", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.selection.objects == vec![x] && !st.doc.is_editable(x), "Unlock All selects x, and its layer still locks it");
|
||||
for p in [json!({"opacity": 50}), json!({"visible": true, "locked": false})] {
|
||||
run(&mut s, "object.setProps", p.clone());
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![x], "{p} keeps x selected");
|
||||
}
|
||||
assert_eq!(node(&s, x).opacity, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_set_props_that_hides_or_locks_deselects_objects_hidden_or_locked_before() {
|
||||
let mut s = session();
|
||||
let (x, y, z) = (rect(&mut s, 0.0), rect(&mut s, 100.0), rect(&mut s, 200.0));
|
||||
run(&mut s, "object.setProps", json!({"ids": [x.0], "visible": false}));
|
||||
run(&mut s, "select.set", json!({"ids": [x.0, y.0, z.0]}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![x, y, z], "select.set selects the hidden x");
|
||||
run(&mut s, "object.setProps", json!({"ids": [y.0], "locked": true}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![z], "locking y deselects x, hidden before the call, too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_set_props_that_changes_nothing_deselects_what_it_leaves_hidden_or_locked() {
|
||||
let mut s = session();
|
||||
let (x, y) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
|
||||
for (prop, value) in [("visible", false), ("locked", true)] {
|
||||
let mut p = json!({"ids": [x.0]});
|
||||
p[prop] = json!(value);
|
||||
run(&mut s, "object.setProps", p.clone());
|
||||
run(&mut s, "select.set", json!({"ids": [x.0, y.0]}));
|
||||
run(&mut s, "select.key", json!({"id": x.0}));
|
||||
let before = undos(&s);
|
||||
run(&mut s, "object.setProps", p);
|
||||
assert_eq!(undos(&s), before, "{prop}: {value} again records no undo step");
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![y], None), "{prop}: {value} again deselects x, the key object");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.is_editable(x));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_options_validate_and_template_locks_and_dims() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -94,6 +94,49 @@ fn hide_all_artwork_above() {
|
||||
assert!(node(&s, over).visible);
|
||||
}
|
||||
|
||||
fn key(s: &Session) -> Option<NodeId> {
|
||||
s.doc().unwrap().selection.key
|
||||
}
|
||||
|
||||
/// Lock › All Artwork Above deselects the selected objects it locks: here the key object, in a
|
||||
/// group above the other selected object. Undo selects it again as the key object.
|
||||
#[test]
|
||||
fn lock_all_artwork_above_deselects_the_objects_it_locks() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
|
||||
sel(&mut s, &[b]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("select.key", &json!({"id": b.0})).unwrap();
|
||||
s.execute("object.lock.above", &json!({})).unwrap();
|
||||
assert!(node(&s, g).locked);
|
||||
assert_eq!((selected(&s), key(&s)), (vec![a], None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!node(&s, g).locked);
|
||||
assert_eq!((selected(&s), key(&s)), (vec![a, b], Some(b)));
|
||||
}
|
||||
|
||||
/// Hide › All Artwork Above deselects the selected objects it hides (here one in a sublayer above)
|
||||
/// and selected objects that were hidden before. The one object left can't be the key object, so
|
||||
/// the key goes too; undo brings both back.
|
||||
#[test]
|
||||
fn hide_all_artwork_above_deselects_the_objects_it_hides() {
|
||||
let mut s = session();
|
||||
let hidden = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
|
||||
s.execute("layer.setProps", &json!({"id": hidden.0, "visible": false})).unwrap();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let sub = id_of(&s.execute("layer.newSublayer", &json!({})).unwrap());
|
||||
let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
|
||||
sel(&mut s, &[hidden, a, b]);
|
||||
s.execute("select.key", &json!({"id": a.0})).unwrap();
|
||||
s.execute("object.hide.above", &json!({})).unwrap();
|
||||
assert!(!node(&s, sub).visible);
|
||||
assert_eq!((selected(&s), key(&s)), (vec![a], None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((selected(&s), key(&s)), (vec![hidden, a, b], Some(a)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_and_hide_other_layers() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -452,6 +452,50 @@ fn transform_pattern_tiles_is_the_transforms_default() {
|
||||
assert_eq!(tiles(&s), [0.5, 0.0, 0.0, 0.5, 5.0, 0.0]);
|
||||
}
|
||||
|
||||
/// General › Transform Pattern Tiles (#394) applies to Align and Distribute as it does to the
|
||||
/// transforms: off, the tiles of a pattern-filled square stay in place when the square moves;
|
||||
/// on, they move with it, and `patterns: false` keeps them in place for one call.
|
||||
#[test]
|
||||
fn align_and_distribute_follow_transform_pattern_tiles() {
|
||||
use vectorcraft_color::Paint;
|
||||
let mut s = new_doc();
|
||||
let plain = square(&mut s, 0.0, 0.0);
|
||||
s.execute("select.set", &json!({"ids": [plain.0]})).unwrap();
|
||||
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
let far = square(&mut s, 300.0, 0.0);
|
||||
let filled = square(&mut s, 100.0, 0.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [filled.0], "swatch": "Dots"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [plain.0, filled.0, far.0]})).unwrap();
|
||||
let tiles = |s: &Session| match s.doc().unwrap().doc.node(filled).unwrap().appearance.fill_paint() {
|
||||
Paint::Pattern { xf, .. } => xf.as_coeffs(),
|
||||
p => panic!("not a pattern: {p:?}"),
|
||||
};
|
||||
// Align left moves the square 100 pt left, to the left edge of `plain`. Both Distribute
|
||||
// commands move it 50 pt right, halfway between the other two.
|
||||
for (cmd, p, dx) in [
|
||||
("object.align", json!({"horizontal": "left"}), -100.0),
|
||||
("object.distribute", json!({"horizontal": "left"}), 50.0),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal"}), 50.0),
|
||||
] {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: off, the tiles stay");
|
||||
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(false), "{cmd}: the journal keeps the choice");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
set_pref(&mut s, "transformPatternTiles", json!(true));
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, dx, 0.0], "{cmd}: on, they move with the square");
|
||||
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "{cmd}: the journal keeps the choice");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let mut p = p;
|
||||
p["patterns"] = json!(false);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: the param wins");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
set_pref(&mut s, "transformPatternTiles", json!(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the
|
||||
/// swatch; on, only the same tint.
|
||||
#[test]
|
||||
|
||||
@@ -283,6 +283,26 @@ fn swatch_exchange_files_save_with_models_kinds_and_groups() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_counts_the_swatches_the_file_holds() {
|
||||
let mut s = session();
|
||||
// A new document has 46 solid colors and 4 linear or radial gradients besides None. Mesh is a freeform gradient.
|
||||
run(&mut s, "swatch.new", json!({"name": "Mesh", "gradient": {"kind": "freeform"}}));
|
||||
let mut counts = vec![];
|
||||
for format in ["vcswatches", "gpl", "ase", "css"] {
|
||||
let r = run(&mut s, "swatch.library.save", json!({ "format": format }));
|
||||
// The libraries load back with that many swatches, and the CSS file has one property for each.
|
||||
let held = if format == "css" {
|
||||
r["data"].as_str().unwrap().lines().filter(|l| l.starts_with(" --")).count() as u64
|
||||
} else {
|
||||
let load = json!({"data": r["data"], "dataBase64": r["dataBase64"], "name": format!("Saved.{format}")});
|
||||
run(&mut s, "swatch.library.load", load)["count"].as_u64().unwrap()
|
||||
};
|
||||
counts.push((format, r["count"].as_u64().unwrap(), held));
|
||||
}
|
||||
assert_eq!(counts, [("vcswatches", 51, 51), ("gpl", 46, 46), ("ase", 46, 46), ("css", 50, 50)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_libraries_copy_into_the_user_folder_without_replacing_files() {
|
||||
use vectorcraft_testkit::ase::{Block, ase, sample};
|
||||
|
||||
@@ -104,6 +104,37 @@ fn reflect_and_shear_tools() {
|
||||
assert!((b.width() - 150.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
/// Reflect and Shear return an error for an `axis` they don't take (Reflect's names it and also
|
||||
/// takes an angle): nothing moves and no undo step is recorded. A null `axis` counts as not given: Reflect
|
||||
/// then reflects across the vertical axis and Shear shears along the horizontal one.
|
||||
#[test]
|
||||
fn reflect_and_shear_reject_unknown_axes_without_changing_state() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 30.0, 10.0);
|
||||
let n = undo_len(&s);
|
||||
let reflect = "axis must be \"vertical\", \"horizontal\" or an angle in degrees";
|
||||
let shear = "`axis` must be one of horizontal, vertical";
|
||||
for (cmd, p, msg) in [
|
||||
("object.reflect", json!({"axis": "diagonal", "origin": [0, 0]}), format!("{reflect}, not `diagonal`")),
|
||||
("object.reflect", json!({"axis": "45", "origin": [0, 0]}), format!("{reflect}, not `45`")),
|
||||
("object.reflect", json!({"axis": true, "origin": [0, 0]}), format!("{reflect}, not `true`")),
|
||||
("object.shear", json!({"angle": 30, "axis": "diagonal"}), shear.to_string()),
|
||||
("object.shear", json!({"angle": 30, "axis": 90}), shear.to_string()),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}"));
|
||||
assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {msg}"));
|
||||
}
|
||||
assert!(close(bounds(&s, a), Rect::new(10.0, 20.0, 40.0, 30.0)), "nothing moved: {:?}", bounds(&s, a));
|
||||
assert_eq!(undo_len(&s), n, "no undo step");
|
||||
s.execute("object.reflect", &json!({"axis": null, "origin": [0, 0]})).unwrap();
|
||||
assert!(close(bounds(&s, a), Rect::new(-40.0, 20.0, -10.0, 30.0)), "across the vertical axis: {:?}", bounds(&s, a));
|
||||
s.execute("object.reflect", &json!({"axis": 0, "origin": [0, 0]})).unwrap();
|
||||
assert!(close(bounds(&s, a), Rect::new(-40.0, -30.0, -10.0, -20.0)), "an angle still reflects: {:?}", bounds(&s, a));
|
||||
s.execute("object.shear", &json!({"angle": 45, "axis": null, "origin": [0, 0]})).unwrap();
|
||||
let b = bounds(&s, a);
|
||||
assert!((b.y0 + 30.0).abs() < 1e-6 && (b.y1 + 20.0).abs() < 1e-6 && b.width() > 30.0, "along the horizontal axis: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_command_maps_corners() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -321,6 +321,9 @@ fn structured_junk() {
|
||||
("object.setBounds", json!({"width": 0, "height": 0})),
|
||||
("object.setBounds", json!({"width": -10, "height": 1e308, "reference": 99})),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": -1e308})),
|
||||
("object.distributeSpacing", json!({"axis": "vertical", "spacing": 0})),
|
||||
("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard", "bounds": "preview"})),
|
||||
("object.distribute", json!({"vertical": "center", "bounds": "geometric"})),
|
||||
("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})),
|
||||
("artboard.setProps", json!({"index": 0, "width": 1e300, "height": 1e-300, "scaleArt": true, "strokes": true, "corners": true})),
|
||||
("artboard.setProps", json!({"index": 0, "x": -1e308, "width": 0, "height": 1, "scaleArt": true, "patterns": true})),
|
||||
|
||||
@@ -624,6 +624,17 @@ fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool {
|
||||
lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL)
|
||||
}
|
||||
|
||||
/// How far apart two pixels' red, green or blue may be for them to be the same colour (a colour
|
||||
/// and its CMYK equivalent stay within it; a red and a dark red don't).
|
||||
const SAME_COLOUR: u32 = 64;
|
||||
|
||||
/// Are two pixels different colours ([`differ`] also takes a red and a dark red of about the same
|
||||
/// lightness to be alike)?
|
||||
fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool {
|
||||
let (p, q) = (on_white(p), on_white(q));
|
||||
p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR)
|
||||
}
|
||||
|
||||
/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, and of
|
||||
/// those whose neighbours all differ too, away from type (where one draws an object that the other
|
||||
/// doesn't, not the edges of one drawn a little differently, or type laid out a little
|
||||
@@ -672,6 +683,20 @@ impl PageView {
|
||||
type_areas(&b.layers, rb, self.scale, &mut areas);
|
||||
compared(&self.page, &y, &areas)
|
||||
}
|
||||
|
||||
/// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page
|
||||
/// ([`differ_in_colour`]), type included: what taking an object out of the layers changes
|
||||
/// there, when it is covered by one of about the same lightness (#1102).
|
||||
fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 {
|
||||
let printed = as_printed(b);
|
||||
let b = printed.as_ref().unwrap_or(b);
|
||||
let y = self.layers.render_region(b, rb, self.scale, false);
|
||||
if (y.width, y.height) != (self.page.width, self.page.height) {
|
||||
return 1.0;
|
||||
}
|
||||
let differing = self.page.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| differ_in_colour(p, q)).count();
|
||||
differing as f64 / (f64::from(y.width) * f64::from(y.height)).max(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// How `a` on `ra` and `b` on `rb` (rectangles of the same size) draw differently ([`compared`]),
|
||||
@@ -823,6 +848,14 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) ->
|
||||
let artboard_page =
|
||||
artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0));
|
||||
let whole = matches!(page, Page::Art) && !artboard_page;
|
||||
if whole {
|
||||
// Its page is the file's own (its bounding box), as when it comes in as its page: art
|
||||
// reaching past the editing data's artboard stays on it (#1102).
|
||||
doc.artboards.truncate(1);
|
||||
if let Some(a) = doc.artboards.first_mut() {
|
||||
a.rect = rect;
|
||||
}
|
||||
}
|
||||
frame = Some(Frame { page: visible, page_rect, rect, whole });
|
||||
}
|
||||
let mut infos = vec![];
|
||||
@@ -940,7 +973,7 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
|
||||
if found.is_empty() || found.len() > MAX_WEIGHED {
|
||||
return 0;
|
||||
}
|
||||
let mut with = view.difference(doc, rect).any;
|
||||
let with = view.difference(doc, rect).any;
|
||||
// Taking a text object out changes only what is drawn in its box. When the art would still
|
||||
// differ from the page by more than `compare` allows with every candidate gone, the layers
|
||||
// aren't used whatever is pruned: don't draw them again for each one (#758).
|
||||
@@ -949,6 +982,9 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
|
||||
if with - boxes / rect.area().max(1e-9) > MAX_DIFFERENCE {
|
||||
return 0;
|
||||
}
|
||||
// Weighed by colour, not only lightness: a text object's red stroke over a dark red one is no
|
||||
// less there for being about as light (#1102).
|
||||
let mut with = view.colour_difference(doc, rect);
|
||||
let mut removed = 0;
|
||||
for (id, bounds) in found {
|
||||
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
|
||||
@@ -958,7 +994,7 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
|
||||
if !take_out(&mut without.layers, id, true) {
|
||||
continue;
|
||||
}
|
||||
let d = view.difference(&without, rect).any;
|
||||
let d = view.colour_difference(&without, rect);
|
||||
if d - with <= UNSEEN_MARGIN {
|
||||
take_out(&mut doc.layers, id, false);
|
||||
with = d;
|
||||
@@ -1121,6 +1157,47 @@ mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
|
||||
/// `doc` with a square ring round (20, 20)–(80, 80) (or at `at`), stroked `color` `width` wide,
|
||||
/// named `name`.
|
||||
fn ring(doc: &mut Document, name: Option<&str>, color: vectorcraft_color::Color, width: f64, at: Rect) {
|
||||
let stroke = vectorcraft_color::Paint::solid(color);
|
||||
let mut n = Node::path(
|
||||
doc.alloc_id(),
|
||||
vectorcraft_geom::shapes::rectangle(at),
|
||||
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, stroke, width),
|
||||
);
|
||||
n.name = name.map(str::to_string);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
|
||||
/// A text object's red stroke drawn over a dark red one of about the same lightness is drawn
|
||||
/// by the page: it isn't taken out as unseen (#1102). An object the page doesn't draw still is.
|
||||
#[test]
|
||||
fn a_stroke_over_one_as_light_is_not_taken_for_unseen() {
|
||||
let (dark, red) = (vectorcraft_color::Color::rgb(0.55, 0.0, 0.0), vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
|
||||
let square = Rect::new(20.0, 20.0, 80.0, 80.0);
|
||||
let mut page = Document::new(100.0, 100.0);
|
||||
ring(&mut page, None, dark, 12.0, square);
|
||||
ring(&mut page, None, red, 6.0, square);
|
||||
let mut layers = Document::new(100.0, 100.0);
|
||||
ring(&mut layers, Some(MADE), dark, 12.0, square);
|
||||
ring(&mut layers, Some(MADE), red, 6.0, square);
|
||||
ring(&mut layers, Some(MADE), red, 2.0, Rect::new(40.0, 40.0, 60.0, 60.0));
|
||||
let rect = page.artboards[0].rect;
|
||||
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
|
||||
let mut view = PageView::new(&frame);
|
||||
assert_eq!(prune_unseen(&frame, &mut view, &mut layers), 1, "only the ring the page doesn't draw");
|
||||
let widths: Vec<f64> = layers.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect();
|
||||
assert_eq!(widths, [12.0, 6.0]);
|
||||
// Taken by lightness alone, the red and the dark red are alike.
|
||||
let px = |c: vectorcraft_color::Color| {
|
||||
let [r, g, b] = c.to_rgb().map(|v| (v * 255.0).round() as u8);
|
||||
[r, g, b, 255]
|
||||
};
|
||||
assert!(!differ(&px(dark), &px(red)) && differ_in_colour(&px(dark), &px(red)));
|
||||
}
|
||||
|
||||
fn piece(text: &str, x: f64, y: f64) -> Arc<Node> {
|
||||
Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(x, y), text, CharStyle::default())))))
|
||||
}
|
||||
|
||||
@@ -654,6 +654,20 @@ fn art_the_layers_print_outside_an_eps_page_of_its_art_makes_it_come_in_as_its_p
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// An EPS whose page is its art's box keeps that page when it comes in through its layers, as it
|
||||
/// does as its page: not the editing data's artboard (#1102).
|
||||
#[test]
|
||||
fn an_eps_page_of_its_art_stays_its_page_through_the_layers() {
|
||||
let file = eps_of_art([10, 10, 14, 14], &page_square(10, 10), &(layer("Art", true, &square(10, 10)) + &layer("Far", false, &square(80, 80))));
|
||||
let r = import(&file).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art", "Far"], "{:?}", r.warnings);
|
||||
let boards: Vec<_> = r.document.artboards.iter().map(|a| a.rect).collect();
|
||||
let art = r.document.layers[0].children().unwrap()[0].visual_bounds().unwrap();
|
||||
assert_eq!(boards.len(), 1);
|
||||
assert!((boards[0].width() - 4.0).abs() < 1e-6 && (boards[0].height() - 4.0).abs() < 1e-6, "{boards:?}");
|
||||
assert!(boards[0].inflate(1e-6, 1e-6).contains_rect(art), "{boards:?} round {art:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_eps_saved_as_its_artboard_keeps_the_art_outside_it() {
|
||||
// The page is the artboard (0 0 100 100): the art beyond it is the pasteboard's.
|
||||
|
||||
@@ -138,7 +138,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"run_command",
|
||||
"Run command",
|
||||
"Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"align\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).",
|
||||
"Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"horizontal\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).",
|
||||
obj(
|
||||
json!({"command": string("Command id, e.g. shape.rectangle, object.group, paint.setFill"), "params": {"type": "object", "description": "Command parameters"}}),
|
||||
&["command"],
|
||||
|
||||
@@ -268,23 +268,42 @@ pub fn merge_regions(regions: &[&Region]) -> PathData {
|
||||
/// path, from the arrangement itself, made once for every merge of the set: the faces' contours
|
||||
/// share their edges exactly, so the edges between merged faces vanish. Uniting the faces' tidied
|
||||
/// outlines ([`merge_regions`]) could leave those edges in, as a hairline gap or a spur, where
|
||||
/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut)
|
||||
/// fall back to [`merge_regions`].
|
||||
/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut, as
|
||||
/// [`shape_builder`](crate::shape_builder) gives them) merge in their planar map the same way
|
||||
/// (#893). Either falls back to [`merge_regions`] when it can't place a face.
|
||||
pub struct FaceMerger {
|
||||
/// The arrangement (`None`: open paths, or the sweep failed) and the number of shapes.
|
||||
arr: Option<Arrangement>,
|
||||
shapes: usize,
|
||||
/// With open paths, the shapes, and their planar map once a merge has needed it.
|
||||
lines: Option<(Vec<Shape>, std::cell::OnceCell<Option<crate::planar::Map>>)>,
|
||||
}
|
||||
|
||||
impl FaceMerger {
|
||||
pub fn new(shapes: &[Shape]) -> Self {
|
||||
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
|
||||
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len() }
|
||||
// The Shape Builder's faces come from the planar map only up to its cap.
|
||||
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
|
||||
let lines = (has_open && segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new()));
|
||||
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines }
|
||||
}
|
||||
|
||||
/// The union of `faces`.
|
||||
pub fn merge(&self, faces: &[&Region]) -> PathData {
|
||||
self.exact(faces).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
|
||||
self.exact(faces).or_else(|| self.planar(faces)).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
|
||||
}
|
||||
|
||||
/// `faces` merged in the planar map of shapes with open paths: each face found again by a
|
||||
/// point inside it, and the result checked to cover what they cover.
|
||||
fn planar(&self, faces: &[&Region]) -> Option<PathData> {
|
||||
let (shapes, map) = self.lines.as_ref()?;
|
||||
let map = map.get_or_init(|| crate::planar::map(shapes)).as_ref()?;
|
||||
let inside: Vec<Point> = faces.iter().map(|r| crate::planar::interior_point(&r.path)).collect::<Option<_>>()?;
|
||||
let merged = map.merge(&inside)?;
|
||||
// A face not found as itself (two points in one face of the map) shows in the area.
|
||||
let want: f64 = faces.iter().map(|r| crate::area(&r.path, FillRule::NonZero)).sum();
|
||||
let outlines: f64 = faces.iter().map(|r| r.path.to_bezpath().perimeter(1e-6)).sum();
|
||||
((crate::area(&merged, FillRule::NonZero) - want).abs() <= 1e-9 * want.max(1.0) + crate::DEFAULT_PRECISION * outlines).then_some(merged)
|
||||
}
|
||||
|
||||
fn exact(&self, faces: &[&Region]) -> Option<PathData> {
|
||||
|
||||
+206
-17
@@ -149,6 +149,20 @@ struct Edge {
|
||||
source: usize,
|
||||
}
|
||||
|
||||
/// `segs` (a chain) moved to start at `a` and end at `b`, the handles next to each moving with it.
|
||||
fn pin_ends(segs: &mut [Seg], a: Point, b: Point) {
|
||||
if let Some(s) = segs.first_mut() {
|
||||
let d = a - s.c.p0;
|
||||
s.c.p0 = a;
|
||||
s.c.p1 += d;
|
||||
}
|
||||
if let Some(s) = segs.last_mut() {
|
||||
let d = b - s.c.p3;
|
||||
s.c.p3 = b;
|
||||
s.c.p2 += d;
|
||||
}
|
||||
}
|
||||
|
||||
/// Half-edge `2e` runs along edge `e`, `2e + 1` back along it.
|
||||
fn twin(h: usize) -> usize {
|
||||
h ^ 1
|
||||
@@ -465,7 +479,24 @@ fn span(sp: &SubPath, u0: f64, u1: f64) -> Option<SubPath> {
|
||||
segs_to_subpath(&segs, false)
|
||||
}
|
||||
|
||||
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
/// The planar graph of `shapes` (in the sweep's frame) with its faces walked: what [`build`] makes
|
||||
/// Live Paint faces and edges from, and what [`Map::merge`] merges faces in.
|
||||
pub(crate) struct Map {
|
||||
edges: Vec<Edge>,
|
||||
around: Vec<Vec<usize>>,
|
||||
slot_of: Vec<usize>,
|
||||
/// Bounded faces: their cycle of half-edges, path and area.
|
||||
faces: Vec<(Vec<usize>, BezPath, f64)>,
|
||||
/// Outer boundaries of connected pieces, and the face each one is a hole in.
|
||||
holes: Vec<(Vec<usize>, BezPath)>,
|
||||
face_holes: Vec<Vec<usize>>,
|
||||
tidy: Tidy,
|
||||
/// The inputs' anchors (sorted by x) and how close results snap back onto them.
|
||||
anchors: Vec<Point>,
|
||||
snap: f64,
|
||||
}
|
||||
|
||||
pub(crate) fn map(shapes: &[Shape]) -> Option<Map> {
|
||||
let mut inputs: Vec<(BezPath, usize)> = Vec::new();
|
||||
for (i, s) in shapes.iter().enumerate() {
|
||||
let mut bp = BezPath::new();
|
||||
@@ -508,11 +539,11 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
.filter_map(|k| grid.get(&k))
|
||||
.flatten()
|
||||
.copied()
|
||||
.find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps));
|
||||
.find_map(|v| points.get(v).filter(|q| q.distance(p) <= eps).map(|&q| (v, q)));
|
||||
near.unwrap_or_else(|| {
|
||||
points.push(p);
|
||||
grid.entry((cx, cy)).or_default().push(points.len() - 1);
|
||||
points.len() - 1
|
||||
(points.len() - 1, p)
|
||||
})
|
||||
};
|
||||
let mut edges: Vec<Edge> = Vec::new();
|
||||
@@ -520,11 +551,16 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
let outlines = Outlines::new(&inputs, eps);
|
||||
for idx in top.segment_indices() {
|
||||
let half = idx.first_half();
|
||||
let segs: Vec<Seg> = positions[idx].path.segments().map(to_seg).collect();
|
||||
let ends = [vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())];
|
||||
if segs.is_empty() || ends[0] == ends[1] {
|
||||
let mut segs: Vec<Seg> = positions[idx].path.segments().map(to_seg).collect();
|
||||
let ((v0, p0), (v1, p1)) = (vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo()));
|
||||
let ends = [v0, v1];
|
||||
if segs.is_empty() || v0 == v1 {
|
||||
continue;
|
||||
}
|
||||
// Every edge at a vertex starts or ends exactly on it, so faces meeting there share its
|
||||
// coordinates: merging them (a union) then cancels their common edge, which it can't when
|
||||
// each face carries its own rounding of the corner (#893).
|
||||
pin_ends(&mut segs, p0, p1);
|
||||
let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue };
|
||||
// Coincident pieces the sweep left apart are one edge (the front-most input's).
|
||||
let key = (ends[0].min(ends[1]), ends[0].max(ends[1]));
|
||||
@@ -637,22 +673,89 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
}
|
||||
}
|
||||
|
||||
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
|
||||
// The inputs' anchors, which results land on again up to the sweep's tolerance.
|
||||
let snap = 2.0 * eps;
|
||||
let mut anchors: Vec<Point> = shapes.iter().flat_map(|s| s.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect();
|
||||
anchors.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
|
||||
Some(Map { edges, around, slot_of, faces, holes, face_holes, tidy, anchors, snap })
|
||||
}
|
||||
|
||||
impl Map {
|
||||
/// The union of the faces that hold `inside` (document points, one per face), traced along the
|
||||
/// map's own edges: those between two merged faces drop out exactly, where a boolean union of
|
||||
/// the faces' outlines can leave them in when its sweep rounds their shared corners apart
|
||||
/// (#893). Outer outlines come out positive, holes negative. None when a point is in no face.
|
||||
pub(crate) fn merge(&self, inside: &[Point]) -> Option<PathData> {
|
||||
let halves = 2 * self.edges.len();
|
||||
// The face on each half-edge's side; holes bound the face around them.
|
||||
let mut face_of: Vec<Option<usize>> = vec![None; halves];
|
||||
for (i, (cycle, ..)) in self.faces.iter().enumerate() {
|
||||
let hole_cycles = self.face_holes.get(i)?.iter().filter_map(|&k| self.holes.get(k)).map(|(c, _)| c);
|
||||
for &h in std::iter::once(cycle).chain(hole_cycles).flatten() {
|
||||
*face_of.get_mut(h)? = Some(i);
|
||||
}
|
||||
}
|
||||
let mut picked = vec![false; self.faces.len()];
|
||||
for &p in inside {
|
||||
let q = SHEAR * p;
|
||||
// The smallest face around it: faces of a piece inside a hole lie within the face around.
|
||||
let (i, _) = self
|
||||
.faces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (_, bp, _))| bp.bounding_box().contains(q) && bp.winding(q) != 0)
|
||||
.min_by(|a, b| a.1.2.total_cmp(&b.1.2))?;
|
||||
*picked.get_mut(i)? = true;
|
||||
}
|
||||
let merged = |h: usize| face_of.get(h).copied().flatten().is_some_and(|f| picked.get(f) == Some(&true));
|
||||
let boundary = |h: usize| merged(h) && !merged(twin(h));
|
||||
let mut used = vec![false; halves];
|
||||
let mut subpaths = Vec::new();
|
||||
for start in 0..halves {
|
||||
if !boundary(start) || used.get(start).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let mut cycle = vec![];
|
||||
let mut h = start;
|
||||
while !used.get(h).copied().unwrap_or(true) {
|
||||
if let Some(u) = used.get_mut(h) {
|
||||
*u = true;
|
||||
}
|
||||
cycle.push(h);
|
||||
// Turn from the way back as a face walk does, past the edges inside the merged
|
||||
// faces (between two of them, or a line's loose end), to the next boundary edge.
|
||||
let back = twin(h);
|
||||
let list = self.around.get(origin(&self.edges, back)?)?;
|
||||
let k = *self.slot_of.get(back)?;
|
||||
let n = list.len();
|
||||
h = (1..=n).filter_map(|j| list.get((k + n * 2 - j) % n).copied()).find(|&g| boundary(g))?;
|
||||
}
|
||||
if h != start {
|
||||
return None;
|
||||
}
|
||||
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(&self.edges, h)).collect();
|
||||
subpaths.push(segs_to_subpath(&tidy_segments(segs, true, &self.tidy), true)?);
|
||||
}
|
||||
(!subpaths.is_empty()).then(|| unshear(PathData::new(subpaths), &self.anchors, self.snap))
|
||||
}
|
||||
}
|
||||
|
||||
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
let m = map(shapes)?;
|
||||
let Map { edges, around, faces, holes, face_holes, tidy, anchors, snap, .. } = &m;
|
||||
let snap = *snap;
|
||||
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
|
||||
let subpath = |cycle: &[usize]| {
|
||||
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(&edges, h)).collect();
|
||||
segs_to_subpath(&tidy_segments(segs, true, &tidy), true)
|
||||
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(edges, h)).collect();
|
||||
segs_to_subpath(&tidy_segments(segs, true, tidy), true)
|
||||
};
|
||||
let regions = faces
|
||||
.iter()
|
||||
.zip(&face_holes)
|
||||
.zip(face_holes)
|
||||
.filter_map(|((cycle, _, _), hs)| {
|
||||
let mut subpaths = vec![subpath(cycle)?];
|
||||
subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c)));
|
||||
let path = unshear(PathData::new(subpaths), &anchors, snap);
|
||||
let path = unshear(PathData::new(subpaths), anchors, snap);
|
||||
let sources =
|
||||
interior_point(&path).map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()).unwrap_or_default();
|
||||
Some(Region { path, sources })
|
||||
@@ -680,13 +783,13 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
return Some((segs, true, source));
|
||||
}
|
||||
*u = true;
|
||||
segs.extend(half_segs(&edges, h));
|
||||
let v = origin(&edges, twin(h))?;
|
||||
let Some((a, b)) = passes(v) else { return Some((segs, origin(&edges, h0) == Some(v), source)) };
|
||||
segs.extend(half_segs(edges, h));
|
||||
let v = origin(edges, twin(h))?;
|
||||
let Some((a, b)) = passes(v) else { return Some((segs, origin(edges, h0) == Some(v), source)) };
|
||||
h = if a == twin(h) { b } else { a };
|
||||
}
|
||||
};
|
||||
for v in 0..nv {
|
||||
for v in 0..around.len() {
|
||||
if passes(v).is_some() {
|
||||
continue;
|
||||
}
|
||||
@@ -709,8 +812,8 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
let edge_shapes = chains
|
||||
.into_iter()
|
||||
.filter_map(|(segs, closed, source)| {
|
||||
let sp = segs_to_subpath(&tidy_segments(segs, closed, &tidy), closed)?;
|
||||
Some(Shape::new(unshear(PathData::new(vec![sp]), &anchors, snap), FillRule::NonZero, shapes.get(source)?.key))
|
||||
let sp = segs_to_subpath(&tidy_segments(segs, closed, tidy), closed)?;
|
||||
Some(Shape::new(unshear(PathData::new(vec![sp]), anchors, snap), FillRule::NonZero, shapes.get(source)?.key))
|
||||
})
|
||||
.collect();
|
||||
Some((regions, edge_shapes))
|
||||
@@ -913,6 +1016,92 @@ mod tests {
|
||||
assert_eq!(edges.len(), 1);
|
||||
}
|
||||
|
||||
/// A strip merged across an axonometric grid of lines (#893, the reporter's file): faces meeting
|
||||
/// at a vertex share its exact coordinates, so the merge is one outline with no edge left inside.
|
||||
#[test]
|
||||
fn faces_share_their_corners_exactly_so_merging_them_leaves_no_seam() {
|
||||
const LINES: &[[f64; 4]] = &[
|
||||
[416.57797027866155, 268.45362144397683, 416.5779702786616, 811.5463785560231],
|
||||
[243.12292300414873, 511.1134238473054, 713.4550472745127, 782.6598024033286],
|
||||
[836.8770769958512, 511.1134238473054, 366.5449527254871, 782.6598024033286],
|
||||
[457.718646852441, 268.45362144397683, 457.718646852441, 811.5463785560231],
|
||||
[263.6932612910385, 475.4845528055331, 734.0253855614023, 747.0309313615563],
|
||||
[816.3067387089613, 475.4845528055331, 345.9746144385975, 747.0309313615563],
|
||||
[498.85932342622044, 268.45362144397683, 498.85932342622044, 811.5463785560231],
|
||||
[284.26359957792823, 439.8556817637607, 754.5957238482922, 711.4020603197839],
|
||||
[795.7364004220717, 439.8556817637607, 325.40427615170773, 711.4020603197839],
|
||||
[539.9999999999998, 268.45362144397683, 539.9999999999998, 811.5463785560231],
|
||||
[304.83393786481787, 404.22681072198844, 775.1660621351818, 675.7731892780117],
|
||||
[775.1660621351821, 404.22681072198844, 304.833937864818, 675.7731892780117],
|
||||
[581.1406765737793, 268.45362144397683, 581.1406765737793, 811.5463785560231],
|
||||
[325.4042761517076, 368.59793968021614, 795.7364004220715, 640.1443182362394],
|
||||
[754.5957238482922, 368.59793968021614, 284.26359957792835, 640.1443182362394],
|
||||
[622.2813531475588, 268.45362144397683, 622.2813531475588, 811.5463785560231],
|
||||
[345.97461443859737, 332.9690686384438, 816.3067387089613, 604.515447194467],
|
||||
[734.0253855614026, 332.9690686384438, 263.6932612910385, 604.515447194467],
|
||||
[663.422029721338, 268.45362144397683, 663.422029721338, 811.5463785560231],
|
||||
[366.54495272548706, 297.3401975966715, 836.877076995851, 568.8865761526947],
|
||||
[713.4550472745128, 297.3401975966715, 243.12292300414884, 568.8865761526947],
|
||||
];
|
||||
let s: Vec<Shape> = LINES.iter().enumerate().map(|(i, &[x0, y0, x1, y1])| line((x0, y0), (x1, y1), i as u64)).collect();
|
||||
let (faces, _) = live_paint(&s);
|
||||
let corners: Vec<Point> = faces.iter().flat_map(|f| f.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect();
|
||||
for p in &corners {
|
||||
for q in &corners {
|
||||
assert!(p == q || p.distance(*q) > 1e-6, "one corner, two positions: {p:?} {q:?}");
|
||||
}
|
||||
}
|
||||
// Down the strip between the lines x ≈ 498.9 and x = 540: 11 triangles, one outline.
|
||||
let mut strip: Vec<&Region> = vec![];
|
||||
for k in 0..=240 {
|
||||
if let Some(f) = faces.iter().find(|f| f.contains(Point::new(519.43, 420.0 + f64::from(k))))
|
||||
&& !strip.iter().any(|g| std::ptr::eq(*g, f))
|
||||
{
|
||||
strip.push(f);
|
||||
}
|
||||
}
|
||||
assert_eq!(strip.len(), 11);
|
||||
// As the Shape Builder merges them: in the map, so no sweep can round the corners apart.
|
||||
let merged = crate::FaceMerger::new(&s).merge(&strip);
|
||||
assert_eq!(merged.subpaths.len(), 1, "{merged:?}");
|
||||
let want: f64 = strip.iter().map(|f| area(f)).sum();
|
||||
assert!((crate::area(&merged, FillRule::NonZero) - want).abs() < 1e-6 * want);
|
||||
}
|
||||
|
||||
/// Merging faces of a grid of lines in the map: a ring keeps its hole, cells that only touch at
|
||||
/// a corner stay two outlines, a line's loose end inside the merge leaves no spur.
|
||||
#[test]
|
||||
fn faces_merge_in_the_map_with_holes_corners_and_loose_ends() {
|
||||
let mut s = vec![];
|
||||
for k in 0..4 {
|
||||
let v = f64::from(k) * 10.0;
|
||||
s.push(line((v, -5.0), (v, 35.0), 0));
|
||||
s.push(line((-5.0, v), (35.0, v), 1));
|
||||
}
|
||||
// A loose end poking into the middle cell.
|
||||
s.push(line((15.0, 10.0), (15.0, 15.0), 2));
|
||||
let (faces, _) = live_paint(&s);
|
||||
let cell = |x: f64, y: f64| at(&faces, x, y)[0];
|
||||
let merger = crate::FaceMerger::new(&s);
|
||||
let m = map(&s).unwrap();
|
||||
let check = |picked: &[&Region], subpaths: usize, area_want: f64| {
|
||||
// Merged in the map itself, not by the fallback union.
|
||||
let inside: Vec<Point> = picked.iter().map(|f| interior_point(&f.path).unwrap()).collect();
|
||||
assert_eq!(m.merge(&inside).as_ref(), Some(&merger.merge(picked)));
|
||||
let m = merger.merge(picked);
|
||||
assert_eq!(m.subpaths.len(), subpaths, "{m:?}");
|
||||
assert!((crate::area(&m, FillRule::NonZero) - area_want).abs() < 1e-6, "{}", crate::area(&m, FillRule::NonZero));
|
||||
m
|
||||
};
|
||||
let all: Vec<&Region> = faces.iter().collect();
|
||||
let whole = check(&all, 1, 900.0);
|
||||
assert_eq!(whole.subpaths[0].anchors.len(), 4, "one square, no anchors left from the inner lines or the loose end");
|
||||
let ring: Vec<&Region> =
|
||||
[(5., 5.), (15., 5.), (25., 5.), (5., 15.), (25., 15.), (5., 25.), (15., 25.), (25., 25.)].iter().map(|&(x, y)| cell(x, y)).collect();
|
||||
check(&ring, 2, 800.0);
|
||||
check(&[cell(5.0, 5.0), cell(15.0, 15.0)], 2, 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_lines_cut_regions() {
|
||||
// A line across a rectangle: two regions, the line in three pieces keyed by its index.
|
||||
|
||||
@@ -136,6 +136,8 @@ pub(crate) struct TextLine {
|
||||
/// The spaces read from gaps between glyphs: their byte offset in the text, and the gap from
|
||||
/// the end of the glyph before to the start of the next (points).
|
||||
gaps: Vec<(usize, f64)>,
|
||||
/// Each glyph's advance in the file's font (points), with [`Self::baseline`].
|
||||
advances: Vec<f64>,
|
||||
}
|
||||
|
||||
/// What a finished line of type tells beyond its text object (see [`crate::import_lines`]).
|
||||
@@ -168,6 +170,7 @@ impl TextLine {
|
||||
column: false,
|
||||
ink: vec![],
|
||||
gaps: vec![],
|
||||
advances: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +231,8 @@ impl TextLine {
|
||||
&& at.dir.dot(self.at.dir) > 0.9995
|
||||
&& (at.slant - self.at.slant).abs() < 1e-3
|
||||
&& (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15
|
||||
&& gap > -size * 0.3
|
||||
// Proportional widths close CJK marks up by up to half an em.
|
||||
&& gap > -size * 0.6
|
||||
&& gap < size * 3.0;
|
||||
// Or on a curve: turned a little from the glyph before it, and starting about where
|
||||
// that one ends.
|
||||
@@ -263,6 +267,7 @@ impl TextLine {
|
||||
self.last = at.origin;
|
||||
self.last_dir = at.dir;
|
||||
self.baseline.push(at.origin);
|
||||
self.advances.push(advance);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -329,6 +334,14 @@ impl TextLine {
|
||||
// The glyphs' tops lean along the baseline (+x), up being -y in the type's own space.
|
||||
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle) * Affine::skew(-self.at.slant, 0.0);
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Glyphs spaced each their own way (character tightening, optical kerning) keep their
|
||||
// place: each character its own tracking.
|
||||
let own = (self.upright.is_none() && t.para.direction.is_none())
|
||||
.then(|| own_spacing(&t, &self.baseline, &self.advances, path.as_ref(), self.at.dir, self.at.size))
|
||||
.flatten();
|
||||
if let Some(own) = &own {
|
||||
t.runs = tracked_each(std::mem::take(&mut t.runs), own, self.at.size);
|
||||
}
|
||||
if let Some(path) = path {
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
@@ -358,7 +371,7 @@ impl TextLine {
|
||||
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let size = self.at.size;
|
||||
let mut bounds = laid.bounds;
|
||||
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
|
||||
if own.is_none() && chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
|
||||
// The spaces read from gaps keep their own place only in text left in the file's order.
|
||||
let total: usize = t.runs.iter().map(|r| r.text.len()).sum();
|
||||
let gaps: Vec<(usize, f64)> =
|
||||
@@ -373,6 +386,59 @@ impl TextLine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The space each glyph of `t` (laid out untracked) leaves after its advance to put the next one
|
||||
/// where the file did (points; the last glyph's is the one before it), when that varies by more
|
||||
/// than a fiftieth of an em: one tracking value can't place them. Glyph origins are `baseline`,
|
||||
/// measured along `path` (type on a path: one curve per glyph) or along `dir`. None when the
|
||||
/// glyphs can't be matched to the characters one to one (spaces, ligatures), or when the font
|
||||
/// laid out isn't the file's (its advances differ from `advances`): spacing measured against a
|
||||
/// stand-in's widths would keep the stand-in's differences once the font is installed.
|
||||
fn own_spacing(t: &TextObject, baseline: &[Point], advances: &[f64], path: Option<&BezPath>, dir: Vec2, size: f64) -> Option<Vec<f64>> {
|
||||
let text: String = t.runs.iter().map(|r| r.text.as_str()).collect();
|
||||
let chars = text.chars().count();
|
||||
if chars < 3 || text.contains(' ') || baseline.len() != chars || advances.len() != chars {
|
||||
return None;
|
||||
}
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let advs: Vec<f64> = laid.glyphs.iter().map(|g| g.advance).collect();
|
||||
if advs.len() != chars || advs.iter().zip(advances).any(|(a, b)| (a - b).abs() > size * 0.01) {
|
||||
return None;
|
||||
}
|
||||
let dist: Vec<f64> = match path {
|
||||
Some(bp) => bp.segments().take(chars - 1).map(|seg| kurbo::ParamCurveArclen::arclen(&seg, 1e-3)).collect(),
|
||||
None => baseline.windows(2).map(|w| (w[1] - w[0]).dot(dir)).collect(),
|
||||
};
|
||||
if dist.len() != chars - 1 {
|
||||
return None;
|
||||
}
|
||||
let mut extra: Vec<f64> = dist.iter().zip(&advs).map(|(d, a)| d - a).collect();
|
||||
let mean = extra.iter().sum::<f64>() / extra.len() as f64;
|
||||
if extra.iter().all(|e| (e - mean).abs() <= size * 0.02) {
|
||||
return None;
|
||||
}
|
||||
extra.push(*extra.last()?);
|
||||
Some(extra)
|
||||
}
|
||||
|
||||
/// `runs` with each character's tracking from `own` (points, one per character), characters with
|
||||
/// the same tracking kept in one run.
|
||||
fn tracked_each(runs: Vec<TextRun>, own: &[f64], size: f64) -> Vec<TextRun> {
|
||||
let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 1000.0).round();
|
||||
let mut out: Vec<TextRun> = vec![];
|
||||
let mut k = 0;
|
||||
for r in runs {
|
||||
for c in r.text.chars() {
|
||||
let style = CharStyle { tracking: own.get(k).map_or(0.0, |e| em(*e)), ..r.style.clone() };
|
||||
k += 1;
|
||||
match out.last_mut() {
|
||||
Some(last) if last.style == style && last.inline == r.inline => last.text.push(c),
|
||||
_ => out.push(TextRun { text: c.to_string(), style, inline: r.inline.clone() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The tracking (thousandths of an em) that sets a line of `chars` characters, laid out untracked
|
||||
/// as `laid`, `extra` points longer, as the file set it: the same after every character, except
|
||||
/// the spaces read from gaps (`gaps`: byte offset, gap in points) where that would put the glyphs
|
||||
|
||||
@@ -464,3 +464,12 @@ fn art_past_the_page_clip_keeps_the_clip() {
|
||||
assert!(!all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })));
|
||||
assert!(!r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Glyphs closed up by up to half an em (the proportional widths of CJK punctuation) still
|
||||
/// continue their line.
|
||||
#[test]
|
||||
fn glyphs_closed_up_by_half_an_em_stay_one_line() {
|
||||
let t = texts(&open(&one_page("BT /F1 20 Tf 10 40 Td [(T) 400 (i) 450 (g) 0 (h) 0 (t)] TJ ET", HELVETICA, &[])));
|
||||
assert_eq!(t.len(), 1, "one line: {:?}", t.iter().map(|t| t.plain_text()).collect::<Vec<_>>());
|
||||
assert_eq!(t[0].plain_text(), "Tight");
|
||||
}
|
||||
@@ -329,3 +329,46 @@ fn type_in_another_installed_version_reopens_in_that_version() {
|
||||
assert_eq!(found, [("Hamburgefonstiv".to_string(), version.map(String::from))], "{version:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph origins of the type objects of `d`, in document space, in order.
|
||||
fn origins(d: &Document) -> Vec<Point> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
out.extend(vectorcraft_text::layout(FontDb::global(), t).glyphs.iter().map(|g| t.xf * g.origin));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Type spaced glyph by glyph (character tightening, optical kerning) comes back with each glyph
|
||||
/// in its place, not evened out by one tracking value; evenly spaced type keeps one run.
|
||||
#[test]
|
||||
fn glyphs_spaced_each_their_own_way_keep_their_place() {
|
||||
let runs = [("T", 0.0), ("i", -120.0), ("g", 160.0), ("h", -60.0), ("tl", 0.0), ("y", 0.0)];
|
||||
let mut t = TextObject::point(Point::new(20.0, 100.0), "", style(40.0));
|
||||
t.runs = runs.iter().map(|(s, tr)| TextRun { text: s.to_string(), style: CharStyle { tracking: *tr, ..style(40.0) }, inline: None }).collect();
|
||||
let d = doc(vec![t]);
|
||||
let want = origins(&d);
|
||||
let back = import_as(&pdf(&d, false).bytes, TextAs::default());
|
||||
let got = origins(&back);
|
||||
assert_eq!(got.len(), want.len(), "{:?}", texts(&back));
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert!((g.x - w.x).abs() < 0.1 && (g.y - w.y).abs() < 0.1, "{got:?}\nvs {want:?}");
|
||||
}
|
||||
let mut runs_back = 0;
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
runs_back += t.runs.len();
|
||||
}
|
||||
});
|
||||
assert!(runs_back > 1, "one tracking can't place them");
|
||||
// Evenly tracked: one run.
|
||||
let even = doc(vec![TextObject::point(Point::new(20.0, 100.0), "Tightly", CharStyle { tracking: -50.0, ..style(40.0) })]);
|
||||
let back = import_as(&pdf(&even, false).bytes, TextAs::default());
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
assert_eq!(t.runs.len(), 1, "{:?}", t.runs.iter().map(|r| r.style.tracking).collect::<Vec<_>>());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -203,8 +203,17 @@ impl Manifest {
|
||||
let resolved = match spec {
|
||||
ParamSpec::Number { min, max, .. } => json!(v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?.clamp(*min, *max)),
|
||||
ParamSpec::Int { min, max, .. } => {
|
||||
let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?;
|
||||
json!((f.round().clamp(*min as f64, *max as f64)) as i64)
|
||||
// Keep JSON integers exact: f64 cannot represent every i64 or its bounds.
|
||||
let value = if let Some(i) = v.as_i64() {
|
||||
i.clamp(*min, *max)
|
||||
} else if v.is_u64() {
|
||||
// Past i64::MAX, so past any maximum.
|
||||
*max
|
||||
} else {
|
||||
let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?;
|
||||
(f.round() as i64).clamp(*min, *max)
|
||||
};
|
||||
json!(value)
|
||||
}
|
||||
ParamSpec::Bool { .. } => json!(v.as_bool().ok_or_else(|| wrong("true or false"))?),
|
||||
ParamSpec::Choice { options, .. } => {
|
||||
@@ -303,6 +312,29 @@ mod tests {
|
||||
assert!(m.resolve_params(&Value::Null).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_parameters_preserve_precision_and_exact_bounds() {
|
||||
let m = Manifest::parse(
|
||||
br#"{"id":"org.example.generative","name":"Generative Pattern","kind":"filter",
|
||||
"params":{"seed":{"type":"int","min":-9007199254740995,"max":9007199254740995}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
for (given, expected) in [
|
||||
(json!(9007199254740992_i64), 9007199254740992_i64),
|
||||
(json!(9007199254740993_i64), 9007199254740993_i64),
|
||||
(json!(-9007199254740993_i64), -9007199254740993_i64),
|
||||
(json!(i64::MAX), 9007199254740995_i64),
|
||||
(json!(i64::MIN), -9007199254740995_i64),
|
||||
(json!(u64::MAX), 9007199254740995_i64),
|
||||
(json!(1e30), 9007199254740995_i64),
|
||||
(json!(-1e30), -9007199254740995_i64),
|
||||
(json!(3.6), 4_i64),
|
||||
] {
|
||||
let resolved = m.resolve_params(&json!({"seed": given})).unwrap();
|
||||
assert_eq!(resolved["seed"], json!(expected), "input: {given}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_manifests() {
|
||||
for bad in [
|
||||
|
||||
@@ -167,11 +167,28 @@ impl OtFeatures {
|
||||
}
|
||||
|
||||
/// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in
|
||||
/// horizontal type; vertical type (`vpal`) needs vertical shaping, so it sets as before.
|
||||
/// horizontal type; vertical type takes proportional heights instead
|
||||
/// ([`Self::proportional_vertical`]).
|
||||
pub(crate) fn proportional(&self, st: &CharStyle) -> bool {
|
||||
st.proportional_metrics && !self.vertical
|
||||
}
|
||||
|
||||
/// Does text in style `st` take proportional heights (`vpal`)? Proportional Metrics in
|
||||
/// vertical type.
|
||||
pub(crate) fn proportional_vertical(&self, st: &CharStyle) -> bool {
|
||||
st.proportional_metrics && self.vertical
|
||||
}
|
||||
|
||||
/// The harfrust features for vertical text in style `st` shaped top to bottom: [`Self::resolve`],
|
||||
/// with `vpal` when `proportional`.
|
||||
pub(crate) fn resolve_vertical(&self, st: &CharStyle, proportional: bool) -> Vec<Feature> {
|
||||
let mut v = self.resolve(st);
|
||||
if proportional {
|
||||
v.push(f(b"vpal", true));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The harfrust features for text in style `st` with full-width glyphs on their full widths:
|
||||
/// [`Self::resolve`] without `palt`.
|
||||
pub(crate) fn resolve_fixed_width(&self, st: &CharStyle) -> Vec<Feature> {
|
||||
|
||||
@@ -55,6 +55,14 @@ impl Ctx<'_> {
|
||||
let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx;
|
||||
g.dx += extra * 0.5;
|
||||
g.adv += extra;
|
||||
// Proportional Metrics: `vpal` moves it and changes its length down the column,
|
||||
// in fonts with vertical metrics (else the cell stays as it is, #966).
|
||||
if let Some(a) = g.vpal.filter(|_| g.tcy.is_none() && g.face.vertical_glyph(g.gid).is_some()) {
|
||||
g.dx += a.dx;
|
||||
g.dy += a.dy;
|
||||
g.adv += a.advance;
|
||||
g.proportional = a.advance != 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
@@ -1178,6 +1186,7 @@ mod wrapping_tests {
|
||||
inline: None,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
vpal: None,
|
||||
level: unicode_bidi::Level::ltr(),
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -52,12 +52,25 @@ pub(crate) struct SGlyph {
|
||||
/// Proportional Metrics changed the glyph's advance (`palt`): it has its proportional width,
|
||||
/// and Japanese composition takes no more space off it (#966).
|
||||
pub proportional: bool,
|
||||
/// Vertical type with Proportional Metrics: what `vpal` does to the glyph when it stands
|
||||
/// upright ([`crate::layout`] applies it).
|
||||
pub vpal: Option<VAdjust>,
|
||||
/// Resolved Unicode bidi embedding level (logical source order).
|
||||
pub level: Level,
|
||||
/// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) standing in for a glyph.
|
||||
pub inline: Option<InlineBox>,
|
||||
}
|
||||
|
||||
/// How `vpal` moves an upright glyph in vertical type and changes its length down the column, in
|
||||
/// points: `advance` down the column, `dx` and `dy` in glyph space (y down; `dy` is down the column
|
||||
/// once the glyph stands upright).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(crate) struct VAdjust {
|
||||
pub advance: f64,
|
||||
pub dx: f64,
|
||||
pub dy: f64,
|
||||
}
|
||||
|
||||
/// Placement of an inline graphic's art, glyph space being points with the pen at the origin on
|
||||
/// the baseline (y down).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -121,6 +134,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
|
||||
h.ch = '-';
|
||||
h.inline = None;
|
||||
h.proportional = false;
|
||||
h.vpal = None;
|
||||
h
|
||||
}
|
||||
|
||||
@@ -427,6 +441,7 @@ fn inline_glyph(face: &Arc<FontFace>, st: &CharStyle, art: &InlineArt, run: usiz
|
||||
tcy: None,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
vpal: None,
|
||||
// U+FFFC is a bidi neutral (ON): its level, resolved from its neighbours, places it in
|
||||
// RTL text like any other glyph once the line is reordered.
|
||||
level,
|
||||
@@ -475,11 +490,14 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' ');
|
||||
|
||||
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
|
||||
// Proportional Metrics: which glyphs `palt` gave another advance than their full width.
|
||||
// Proportional Metrics: which glyphs `palt` gave another advance than their full width, and
|
||||
// what `vpal` does to each in vertical type.
|
||||
let mut proportional = vec![];
|
||||
let mut vpal = vec![];
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let shape = |feats: &[Feature]| {
|
||||
// gid, cluster, x and y advance, x and y offset.
|
||||
let shape = |feats: &[Feature], direction: Direction| {
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
@@ -491,7 +509,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
buf.add(c, cl);
|
||||
}
|
||||
}
|
||||
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.set_direction(direction);
|
||||
buf.guess_segment_properties();
|
||||
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
|
||||
// glyphs for it. This retains context at real style/font segmentation boundaries without
|
||||
@@ -506,18 +524,38 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
gb.glyph_infos()
|
||||
.iter()
|
||||
.zip(gb.glyph_positions())
|
||||
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset))
|
||||
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.y_advance, pos.x_offset, pos.y_offset))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
raw = shape(&feats.resolve(st));
|
||||
let along = if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight };
|
||||
raw = shape(&feats.resolve(st), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect();
|
||||
let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(&raw).all(|(a, b)| a.0 == b.0);
|
||||
if feats.proportional(st) {
|
||||
// `palt` is positioning only: the same glyphs set on their full widths tell which
|
||||
// advances it changed (kerning and the rest change both alike).
|
||||
let full = shape(&feats.resolve_fixed_width(st));
|
||||
if full.len() == raw.len() && full.iter().zip(&raw).all(|(a, b)| a.0 == b.0) {
|
||||
let full = shape(&feats.resolve_fixed_width(st), along);
|
||||
if same_glyphs(&full) {
|
||||
proportional = full.iter().zip(&raw).map(|(a, b)| a.2 != b.2).collect();
|
||||
}
|
||||
}
|
||||
if feats.proportional_vertical(st) {
|
||||
// `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set
|
||||
// top to bottom with and without it tell how it moves each and changes its advance down
|
||||
// the column (y up, advances negative).
|
||||
let (with, without) =
|
||||
(shape(&feats.resolve_vertical(st, true), Direction::TopToBottom), shape(&feats.resolve_vertical(st, false), Direction::TopToBottom));
|
||||
if same_glyphs(&with) && same_glyphs(&without) {
|
||||
vpal = with
|
||||
.iter()
|
||||
.zip(&without)
|
||||
.map(|(a, b)| {
|
||||
let adj =
|
||||
VAdjust { advance: f64::from(b.3 - a.3) * k * vs, dx: f64::from(a.4 - b.4) * k * hs, dy: -f64::from(a.5 - b.5) * k * vs };
|
||||
(adj != VAdjust::default()).then_some(adj)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
});
|
||||
if shaped.is_none() {
|
||||
// Fallback: nominal glyphs and hmtx advances, no shaping.
|
||||
@@ -573,6 +611,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
tcy: None,
|
||||
lead: 0.0,
|
||||
proportional: proportional.get(gi).copied().unwrap_or(false),
|
||||
vpal: vpal.get(gi).copied().flatten(),
|
||||
level: *level,
|
||||
inline: None,
|
||||
});
|
||||
|
||||
@@ -753,8 +753,8 @@ fn layout_10k_area_text_is_fast() {
|
||||
/// Proportional Metrics (#966): in horizontal type full-width glyphs take the font's proportional
|
||||
/// widths (`palt`), and Japanese composition takes nothing more off the punctuation `palt`
|
||||
/// re-spaced (the flush bracket at a paragraph's start, consecutive punctuation, half width at a
|
||||
/// line's end); punctuation it leaves full width is trimmed as before. Vertical type sets as
|
||||
/// before. Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not
|
||||
/// line's end); punctuation it leaves full width is trimmed as before (vertical type: see
|
||||
/// `tests_vertical`). Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not
|
||||
/// 〘〙 (skipped without it).
|
||||
#[test]
|
||||
fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() {
|
||||
@@ -765,32 +765,29 @@ fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() {
|
||||
}
|
||||
let st = |on: bool| CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), proportional_metrics: on, ..CharStyle::default() };
|
||||
// Right-aligned in a frame ten ems wide, so a line's end trim moves the line.
|
||||
let lay = |text: &str, on: bool, m: Mojikumi, vertical: bool| {
|
||||
let lay = |text: &str, on: bool, m: Mojikumi| {
|
||||
let mut t = area(text, st(on), Rect::new(0.0, 0.0, 200.0, 200.0), Justify::Right);
|
||||
t.vertical = vertical;
|
||||
t.para.mojikumi = m;
|
||||
layout(db(), &t)
|
||||
};
|
||||
let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
|
||||
let x0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().x0).collect::<Vec<_>>();
|
||||
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01);
|
||||
let off = adv(&lay("「あ漢」", false, Mojikumi::None, false));
|
||||
let on = adv(&lay("「あ漢」", true, Mojikumi::None, false));
|
||||
let off = adv(&lay("「あ漢」", false, Mojikumi::None));
|
||||
let on = adv(&lay("「あ漢」", true, Mojikumi::None));
|
||||
assert!(off.iter().all(|a| (a - 20.0).abs() < 0.01), "{off:?}");
|
||||
assert!(on[0] < 15.0 && on[1] < 19.0 && on[3] < 15.0, "kana and brackets narrower: {on:?}");
|
||||
assert!((on[2] - 20.0).abs() < 0.01, "the kanji keeps its em: {on:?}");
|
||||
// Re-spaced punctuation: Line-end Punctuation Half Width changes nothing, at the paragraph's
|
||||
// start (「), between punctuation (」、) or at the line's end (」).
|
||||
for text in ["「あ」、漢", "漢「あ」"] {
|
||||
let (half, none) = (lay(text, true, Mojikumi::LineEndHalf, false), lay(text, true, Mojikumi::None, false));
|
||||
let (half, none) = (lay(text, true, Mojikumi::LineEndHalf), lay(text, true, Mojikumi::None));
|
||||
assert!(near(&adv(&half), &adv(&none)), "{text}: {:?} {:?}", adv(&half), adv(&none));
|
||||
assert!(near(&x0(&half), &x0(&none)), "{text}: {:?} {:?}", x0(&half), x0(&none));
|
||||
}
|
||||
// Punctuation `palt` leaves full width: flush at the paragraph's start, half width at the end.
|
||||
let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf, false), lay("〘あ〙", true, Mojikumi::None, false));
|
||||
let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf), lay("〘あ〙", true, Mojikumi::None));
|
||||
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
|
||||
let moved = x0(&half)[1] - x0(&none)[1];
|
||||
assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}");
|
||||
// Vertical type sets as before.
|
||||
assert!(near(&adv(&lay("「あ漢」", true, Mojikumi::None, true)), &adv(&lay("「あ漢」", false, Mojikumi::None, true))));
|
||||
}
|
||||
@@ -539,3 +539,46 @@ fn kinsoku_set_decides_whether_a_small_kana_may_start_a_line() {
|
||||
assert_eq!(stop(Kinsoku::Soft), [0, 1, 1], "vertical {vertical_type}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Proportional Metrics in vertical type (#966): upright glyphs take the font's proportional
|
||||
/// heights (`vpal`, from shaping them top to bottom) and move with them, so each glyph's ink stays in
|
||||
/// its shorter cell. Line-end Punctuation Half Width takes nothing more off the punctuation `vpal`
|
||||
/// re-spaced, and trims the punctuation it leaves full height (〘〙 in Shippori Mincho) as before.
|
||||
/// Off, the column is as before, and a font without vertical metrics (the bundled Source Sans 3)
|
||||
/// keeps today's cells. Needs craft-fonts' Shippori Mincho (skipped without it).
|
||||
#[test]
|
||||
fn proportional_metrics_set_upright_glyphs_on_their_proportional_heights() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
let lay = |text: &str, font: &str, on: bool, m: Mojikumi| {
|
||||
let mut t = vertical_styled(text, CharStyle { size: 20.0, font_family: font.into(), proportional_metrics: on, ..CharStyle::default() });
|
||||
t.para.mojikumi = m;
|
||||
layout(FontDb::global(), &t)
|
||||
};
|
||||
let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
|
||||
let y0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().y0).collect::<Vec<_>>();
|
||||
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01);
|
||||
// Without vertical metrics the cells stay as they are.
|
||||
let (on, off) = (lay("§§", "Source Sans 3", true, Mojikumi::None), lay("§§", "Source Sans 3", false, Mojikumi::None));
|
||||
assert!(near(&adv(&on), &adv(&off)) && near(&y0(&on), &y0(&off)), "{:?} {:?}", adv(&on), adv(&off));
|
||||
if FontDb::global().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
return;
|
||||
}
|
||||
let text = "「あ」、ッ漢。";
|
||||
let off = lay(text, "Shippori Mincho", false, Mojikumi::None);
|
||||
assert!(off.glyphs.iter().all(|g| (g.advance - 20.0).abs() < 0.01), "off: an em each, as before: {:?}", adv(&off));
|
||||
let on = lay(text, "Shippori Mincho", true, Mojikumi::None);
|
||||
assert!(on.glyphs[0].advance < 12.0 && on.glyphs[4].advance < 16.0, "「 and ッ shorter: {:?}", adv(&on));
|
||||
assert!((on.glyphs[5].advance - 20.0).abs() < 0.01, "the kanji keeps its em: {:?}", adv(&on));
|
||||
// Each glyph's ink stays in its own cell, so none runs into the next.
|
||||
for g in &on.glyphs {
|
||||
let b = g.outline.bounding_box();
|
||||
assert!(b.y0 > g.origin.y - 0.5 && b.y1 < g.origin.y + g.advance + 0.5, "byte {}: ink {b:?} in [{}, +{}]", g.byte, g.origin.y, g.advance);
|
||||
}
|
||||
// Re-spaced punctuation: Line-end Punctuation Half Width changes nothing.
|
||||
let half = lay(text, "Shippori Mincho", true, Mojikumi::LineEndHalf);
|
||||
assert!(near(&adv(&half), &adv(&on)) && near(&y0(&half), &y0(&on)), "{:?} {:?}", adv(&half), adv(&on));
|
||||
// Punctuation `vpal` leaves full height: set flush at the paragraph's start, as before.
|
||||
let (half, none) = (lay("〘あ〙", "Shippori Mincho", true, Mojikumi::LineEndHalf), lay("〘あ〙", "Shippori Mincho", true, Mojikumi::None));
|
||||
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
|
||||
}
|
||||
+153
-13
@@ -3,7 +3,8 @@
|
||||
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
|
||||
//! path's anchors on that segment, drag to move selected anchors (the one pressed on snaps to
|
||||
//! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45°
|
||||
//! steps), drag a direction handle to
|
||||
//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it), drag a
|
||||
//! direction handle to
|
||||
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
|
||||
//! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are
|
||||
//! deselected, the others selected), drag a corner widget of any path (a star, a pen path) to round its corners
|
||||
@@ -28,7 +29,7 @@ use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{HitKind, hit_test};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, Rect};
|
||||
|
||||
use crate::bbox::move_delta;
|
||||
@@ -50,8 +51,11 @@ enum State {
|
||||
grab: Point,
|
||||
began: bool,
|
||||
},
|
||||
/// Moving (Alt: copying) the selected objects; `grab`: the anchor an Alt-press picked the
|
||||
/// path by, which snaps as a dragged anchor does.
|
||||
MoveObject {
|
||||
start: Point,
|
||||
grab: Option<Point>,
|
||||
began: bool,
|
||||
},
|
||||
/// Dragging a curved segment: it follows the pointer (`path.reshapeSegment`).
|
||||
@@ -113,6 +117,9 @@ pub struct DirectSelectionTool {
|
||||
guides: Vec<Overlay>,
|
||||
/// What the dragged anchors snap to, gathered when the drag begins.
|
||||
anchor_snap: Option<PointSnap>,
|
||||
/// What the anchor holding paths moved whole snaps to while Alt makes a copy: the original left
|
||||
/// behind too. Gathered with [`Self::anchor_snap`], before the previews move the original.
|
||||
copy_snap: Option<PointSnap>,
|
||||
/// What the dragged handle snaps to.
|
||||
handle_snap: HandleSnap,
|
||||
/// What the objects being moved snap to, gathered when the move begins.
|
||||
@@ -131,6 +138,7 @@ impl DirectSelectionTool {
|
||||
mesh: MeshEdit::default(),
|
||||
guides: vec![],
|
||||
anchor_snap: None,
|
||||
copy_snap: None,
|
||||
handle_snap: HandleSnap::default(),
|
||||
move_snap: None,
|
||||
hover: None,
|
||||
@@ -319,7 +327,7 @@ impl Tool for DirectSelectionTool {
|
||||
} else {
|
||||
h.leaf
|
||||
};
|
||||
self.state = State::MoveObject { start: p, began: false };
|
||||
self.state = State::MoveObject { start: p, grab: None, began: false };
|
||||
if cx.selection.contains(target) {
|
||||
vec![]
|
||||
} else if ev.mods.shift || cx.selection.contains(h.leaf) {
|
||||
@@ -368,8 +376,21 @@ impl Tool for DirectSelectionTool {
|
||||
if cx.doc.node(id).is_some_and(is_area_type) {
|
||||
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
|
||||
}
|
||||
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
|
||||
self.state = State::MoveAnchors { start: p, grab, began: false };
|
||||
// Alt picks the whole path (and Alt-drag copies it), as on a segment.
|
||||
if ev.mods.alt {
|
||||
self.state = State::MoveObject { start: p, grab: Some(grab), began: false };
|
||||
let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none();
|
||||
return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
|
||||
}
|
||||
// A path with all its anchors selected is stored whole (no partial entry).
|
||||
let already = cx.selection.contains(id) && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai)));
|
||||
// Paths all selected whole move as objects, held by this anchor, so Alt can turn
|
||||
// the copy on and off during the drag as with the Selection tool.
|
||||
self.state = if already && cx.selection.anchors.is_empty() {
|
||||
State::MoveObject { start: p, grab: Some(grab), began: false }
|
||||
} else {
|
||||
State::MoveAnchors { start: p, grab, began: false }
|
||||
};
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))];
|
||||
}
|
||||
@@ -409,7 +430,7 @@ impl Tool for DirectSelectionTool {
|
||||
return vec![Action::Exec("select.anchors".into(), json!({"id": h.leaf.0, "anchors": anchors, "mode": "set"}))];
|
||||
}
|
||||
// Clicking the fill selects the whole leaf path (all anchors).
|
||||
self.state = State::MoveObject { start: p, began: false };
|
||||
self.state = State::MoveObject { start: p, grab: None, began: false };
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
|
||||
}
|
||||
@@ -459,19 +480,31 @@ impl Tool for DirectSelectionTool {
|
||||
));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::MoveObject { start, began }) => {
|
||||
(PointerKind::Drag, State::MoveObject { start, grab, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Move".into()));
|
||||
self.state = State::MoveObject { start, began: true };
|
||||
self.move_snap = Some(MoveSnap::new(cx));
|
||||
out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() }));
|
||||
self.state = State::MoveObject { start, grab, began: true };
|
||||
if grab.is_some() {
|
||||
// Alt can go down or up at any time: gather both while nothing has moved.
|
||||
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
|
||||
self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default())));
|
||||
} else {
|
||||
self.move_snap = Some(MoveSnap::new(cx));
|
||||
}
|
||||
}
|
||||
let mut d = move_delta(start, p, ev.mods.shift);
|
||||
if let Some(snap) = &self.move_snap {
|
||||
(d, self.guides) = snap.snap(cx, start, d);
|
||||
self.guides.clear();
|
||||
let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap };
|
||||
if let (Some(grab), Some(snap)) = (grab, snap) {
|
||||
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
|
||||
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
|
||||
(d, self.guides) = (q - grab, guides);
|
||||
} else if let Some(snap) = &self.move_snap {
|
||||
(d, self.guides) = snap.snap(cx, start, d, ev.mods.alt);
|
||||
}
|
||||
out.push(Action::Preview(
|
||||
"object.transform".into(),
|
||||
@@ -554,7 +587,7 @@ impl Tool for DirectSelectionTool {
|
||||
) => {
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
(self.anchor_snap, self.move_snap) = (None, None);
|
||||
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
|
||||
@@ -838,6 +871,45 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]);
|
||||
}
|
||||
|
||||
/// Pressing an anchor of a path whose anchors are all selected (stored as the whole path)
|
||||
/// keeps them all selected, so the drag moves them all; Shift still deselects that anchor.
|
||||
#[test]
|
||||
fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0));
|
||||
assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0));
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]);
|
||||
}
|
||||
|
||||
/// Alt-pressing an anchor picks the whole path, as Alt on a segment does, and the drag copies
|
||||
/// it as the Selection tool's Alt-drag does.
|
||||
#[test]
|
||||
fn alt_dragging_an_anchor_copies_the_whole_path() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let mut whole = Selection::default();
|
||||
whole.set([id]);
|
||||
let p = paint();
|
||||
for (s, pick) in [(Selection::default(), true), (anchors_of(id, &[(0, 0)]), true), (whole, false)] {
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(alt));
|
||||
let set = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
|
||||
assert_eq!(a, if pick { set } else { vec![] }, "{s:?}");
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt));
|
||||
assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects_anchors() {
|
||||
let (d, id) = doc_with_rect();
|
||||
@@ -1037,6 +1109,74 @@ mod tests {
|
||||
(v.clone(), labels)
|
||||
}
|
||||
|
||||
/// Alt-dragging a path by an anchor copies it, and that anchor snaps as a dragged anchor does:
|
||||
/// onto another path's anchor, or onto an anchor of the original the copy leaves behind.
|
||||
#[test]
|
||||
fn alt_dragged_copies_snap_by_the_anchor_pressed() {
|
||||
let (d, _, b) = two_squares();
|
||||
let p = paint();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let mut whole = Selection::default();
|
||||
whole.set([b]);
|
||||
let c = cx(&d, &whole, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let copy =
|
||||
|dx: f64, dy: f64| json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": true});
|
||||
// B's top-left corner, pressed 1 pt off, onto A's bottom-right one.
|
||||
let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), alt);
|
||||
assert_eq!((v, labels), (copy(-100.0, -100.0), vec!["anchor".to_string()]));
|
||||
// B's top-left corner onto the original B's top-right one: the copy sits beside it.
|
||||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (402.0, 301.0), alt);
|
||||
assert_eq!((v, labels), (copy(100.0, 0.0), vec!["anchor".to_string()]));
|
||||
// The history says what the drag did.
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0).with_mods(alt));
|
||||
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0).with_mods(alt));
|
||||
assert_eq!(out.first(), Some(&Action::Begin("Copy".into())));
|
||||
}
|
||||
|
||||
/// Dragging a fully selected path by an anchor moves it whole, so Alt pressed or released
|
||||
/// during the drag turns the copy on and off, as with the Selection tool; the anchor pressed
|
||||
/// snaps all along, onto the original only while it is left behind.
|
||||
#[test]
|
||||
fn alt_during_a_drag_by_an_anchor_toggles_the_copy() {
|
||||
let (d, _, b) = two_squares();
|
||||
let p = paint();
|
||||
let (none, alt) = (Mods::default(), Mods { alt: true, ..Default::default() });
|
||||
let mut whole = Selection::default();
|
||||
whole.set([b]);
|
||||
let c = cx(&d, &whole, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let preview = |dx: f64, dy: f64, copy: bool| {
|
||||
Action::Preview(
|
||||
"object.transform".into(),
|
||||
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": copy}),
|
||||
)
|
||||
};
|
||||
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)).is_empty());
|
||||
assert_eq!(
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)),
|
||||
vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)]
|
||||
);
|
||||
// The tool sees the document as previewed: B moved along, not where the copy leaves it.
|
||||
let (mut moved, _, b2) = two_squares();
|
||||
let l = moved.layers[0].id;
|
||||
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 350.0, 350.0));
|
||||
moved.remove(b2).unwrap();
|
||||
moved.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let c = cx(&moved, &whole, &p);
|
||||
// Alt down mid-drag: a copy, whose pressed corner can land on the original's top-right one.
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(alt)), vec![preview(100.0, 0.0, true)]);
|
||||
// Alt up: a move again, which leaves nothing behind to snap to.
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(none)), vec![preview(102.0, 1.0, false)]);
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 402.0, 301.0)), vec![Action::Commit]);
|
||||
// With only some anchors selected, the drag still moves just those.
|
||||
let one = anchors_of(b, &[(0, 0)]);
|
||||
let c = cx(&d, &one, &p);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(alt));
|
||||
assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}");
|
||||
}
|
||||
|
||||
/// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors
|
||||
/// of its own path, and with several selected the one pressed on snaps, the others following.
|
||||
#[test]
|
||||
|
||||
@@ -179,10 +179,11 @@ impl Targets {
|
||||
Self::collect_inner(doc, exclude, None, visible, Gather::Bounds)
|
||||
}
|
||||
|
||||
/// Targets for moving the selection of `cx`: [`Self::collect`]'s, styled as `cx` asks, and with
|
||||
/// Smart Guides › Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]).
|
||||
pub fn for_move(cx: &ToolContext) -> Self {
|
||||
let mut t = Self::collect(cx.doc, &cx.selection.objects, None).styled(cx);
|
||||
/// Targets for moving the selection of `cx`: [`Self::collect`]'s but `exclude` (the selection,
|
||||
/// or nothing for a copy, which leaves it behind), styled as `cx` asks, and with Smart Guides ›
|
||||
/// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]).
|
||||
pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self {
|
||||
let mut t = Self::collect(cx.doc, exclude, None).styled(cx);
|
||||
if cx.spacing_guides {
|
||||
t.boxes = object_boxes(cx.doc, &cx.selection.objects);
|
||||
}
|
||||
@@ -1229,7 +1230,7 @@ mod tests {
|
||||
}
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA)).count();
|
||||
let t = Targets::for_move(&cx(&d, &s, &p));
|
||||
let t = Targets::for_move(&cx(&d, &s, &p), &s.objects);
|
||||
// 21 after B: 20, as A and B, with both gaps marked (a line and two ticks each).
|
||||
let (dv, ov) = t.snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
|
||||
assert_eq!(dv, Vec2::new(-1.0, 0.0));
|
||||
@@ -1241,7 +1242,7 @@ mod tests {
|
||||
let id = wide.alloc_id();
|
||||
wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default()))
|
||||
.unwrap();
|
||||
let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p)).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0);
|
||||
let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p), &s.objects).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0);
|
||||
assert_eq!(dv, Vec2::new(2.0, 0.0));
|
||||
assert!(lines(&ov) >= 6, "{ov:?}");
|
||||
// A column: 28 under D, as C and D are 30 apart.
|
||||
@@ -1253,7 +1254,7 @@ mod tests {
|
||||
assert_eq!(dv, Vec2::new(0.0, 2.0));
|
||||
// Off: nowhere to go, and no marks.
|
||||
let off = ToolContext { spacing_guides: false, ..cx(&d, &s, &p) };
|
||||
let (dv, ov) = Targets::for_move(&off).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
|
||||
let (dv, ov) = Targets::for_move(&off, &s.objects).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
|
||||
assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0));
|
||||
}
|
||||
|
||||
|
||||
@@ -79,36 +79,47 @@ pub struct SelectionTool {
|
||||
/// Snap to Grid lands its bounds on the grid (#740), taking over from the rest; otherwise Smart
|
||||
/// Guides line its bounds up with the other art, and with them off, View → Snap to Point lands the
|
||||
/// point it was grabbed by on an anchor or a ruler guide. Snap to Pixel puts its top-left on whole
|
||||
/// pixels.
|
||||
/// pixels. A copy (Alt, which can go down or up at any time during the drag) leaves the original
|
||||
/// behind, so it snaps to the original too.
|
||||
pub(crate) struct MoveSnap {
|
||||
bounds: Option<Rect>,
|
||||
targets: Option<Targets>,
|
||||
points: Option<Targets>,
|
||||
/// The targets and points for a copy, gathered with the others before the previews move the
|
||||
/// original.
|
||||
copy_targets: Option<Targets>,
|
||||
copy_points: Option<Targets>,
|
||||
}
|
||||
|
||||
impl MoveSnap {
|
||||
pub(crate) fn new(cx: &ToolContext) -> Self {
|
||||
let guides = cx.smart_guides && !cx.snap_to_grid;
|
||||
let points = |exclude: &[NodeId]| if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, exclude) };
|
||||
Self {
|
||||
bounds: selection_bounds(cx),
|
||||
targets: (cx.smart_guides && !cx.snap_to_grid).then(|| Targets::for_move(cx)),
|
||||
points: if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, &cx.selection.objects) },
|
||||
targets: guides.then(|| Targets::for_move(cx, &cx.selection.objects)),
|
||||
points: points(&cx.selection.objects),
|
||||
copy_targets: guides.then(|| Targets::for_move(cx, &[])),
|
||||
copy_points: points(&[]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The move by `d` of the selection grabbed at `start`, snapped, and its guides.
|
||||
pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2) -> (Vec2, Vec<Overlay>) {
|
||||
/// The move (`copy`: the copy) by `d` of the selection grabbed at `start`, snapped, and its
|
||||
/// guides.
|
||||
pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec<Overlay>) {
|
||||
let (targets, points) = if copy { (&self.copy_targets, &self.copy_points) } else { (&self.targets, &self.points) };
|
||||
let mut guides = vec![];
|
||||
if cx.snap_to_grid
|
||||
&& let Some(b) = self.bounds
|
||||
{
|
||||
d += grid_pull(b + d, cx.grid_step());
|
||||
}
|
||||
if let Some(t) = &self.points {
|
||||
if let Some(t) = points {
|
||||
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
|
||||
d = q - start;
|
||||
guides = ov;
|
||||
}
|
||||
if let (Some(t), Some(b)) = (&self.targets, self.bounds) {
|
||||
if let (Some(t), Some(b)) = (targets, self.bounds) {
|
||||
let (adj, ov) = t.snap_rect(b + d, cx.snap_tol());
|
||||
d += adj;
|
||||
guides = ov;
|
||||
@@ -331,7 +342,7 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
let mut d = move_delta(start, p, m.shift);
|
||||
if let Some(snap) = &self.moving {
|
||||
(d, self.guides) = snap.snap(cx, start, d);
|
||||
(d, self.guides) = snap.snap(cx, start, d, m.alt);
|
||||
}
|
||||
self.state = State::Moving { start, began: true, deselect: None, key: None };
|
||||
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y)));
|
||||
@@ -622,6 +633,38 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Commit]);
|
||||
}
|
||||
|
||||
/// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as
|
||||
/// gathered before the previews moved it); released, the move doesn't.
|
||||
#[test]
|
||||
fn an_alt_copy_snaps_to_the_original_left_behind() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let (mut d, id) = doc_with_rect();
|
||||
// An artboard whose edges and centre are out of reach.
|
||||
d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0);
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let mut t = SelectionTool::default();
|
||||
let dx = |a: &[Action]| {
|
||||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||||
v["matrix"][4].as_f64().unwrap()
|
||||
};
|
||||
t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 150.0, 150.0));
|
||||
assert_eq!(dx(&t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Drag, 200.0, 150.0))), 50.0);
|
||||
// The previews moved the rectangle: the tool sees it 50 pt along.
|
||||
let mut moved = d.clone();
|
||||
let l = moved.layers[0].id;
|
||||
moved.remove(id).unwrap();
|
||||
let r = vectorcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0));
|
||||
moved.insert(Some(l), 0, Node::path(id, r, Appearance::default_art())).unwrap();
|
||||
let c = cx(&moved, &s, &p);
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
// The copy's left edge, 3 pt past the original's right edge (x = 200), lands on it.
|
||||
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0).with_mods(alt))), 100.0);
|
||||
// Without Alt nothing stays behind there.
|
||||
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0);
|
||||
}
|
||||
|
||||
/// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by
|
||||
/// whichever of its edges or its centre is nearest a gridline, on each axis.
|
||||
#[test]
|
||||
|
||||
@@ -362,7 +362,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let artboard_tool = app.session.tool_id() == "artboard";
|
||||
let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten();
|
||||
for (i, ab) in doc.artboards.iter().enumerate() {
|
||||
let r = xf.rect_to_screen(ab.rect);
|
||||
let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) };
|
||||
painter.add(Shape::closed_line(xf.quad(ab.rect), Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c)));
|
||||
// The bleed (Document Setup) as a red outline around the artboard.
|
||||
@@ -370,7 +369,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed)));
|
||||
}
|
||||
if (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() {
|
||||
artboard_label(&painter, r, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
|
||||
// At the artboard's own top-left corner, which a rotated view turns away from its
|
||||
// bounding box's (#1095).
|
||||
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
|
||||
artboard_label(&painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
|
||||
}
|
||||
}
|
||||
if app.ui.view.guides {
|
||||
@@ -905,7 +907,8 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e
|
||||
v.artboard = i;
|
||||
}
|
||||
// A press with a selection tool inside an artboard makes it the active one (#693), as the
|
||||
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it.
|
||||
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to
|
||||
// Artboard aligns to it, and All on Active Artboard selects on it.
|
||||
if ev.kind == PointerKind::Down
|
||||
&& vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id())
|
||||
&& let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos))
|
||||
@@ -1752,16 +1755,16 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
|
||||
/// out rather than spilling over its neighbours.
|
||||
const MIN_LABELLED_ARTBOARD: f32 = 24.0;
|
||||
|
||||
/// Artboard `name` above the top-left corner of its on-screen rect `r`, no wider than the
|
||||
/// artboard: a longer name ends in "…", and an artboard too narrow shows none (#949).
|
||||
fn artboard_label(painter: &egui::Painter, r: egui::Rect, name: &str, color: Color32) {
|
||||
if r.width() < MIN_LABELLED_ARTBOARD {
|
||||
/// Artboard `name` above its top-left `corner` on screen, no wider than the artboard (`width` on
|
||||
/// screen): a longer name ends in "…", and an artboard too narrow shows none (#949).
|
||||
fn artboard_label(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) {
|
||||
if !width.is_finite() || width < MIN_LABELLED_ARTBOARD {
|
||||
return;
|
||||
}
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(name.to_owned(), egui::FontId::proportional(11.0), color);
|
||||
job.wrap = egui::text::TextWrapping { max_width: r.width(), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||||
job.wrap = egui::text::TextWrapping { max_width: width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||||
let galley = painter.layout_job(job);
|
||||
painter.galley(r.left_top() - vec2(0.0, 4.0 + galley.size().y), galley, color);
|
||||
painter.galley(corner - vec2(0.0, 4.0 + galley.size().y), galley, color);
|
||||
}
|
||||
|
||||
/// Is overlay label `text` the Artboard tool's "01 - <artboard name>"? It holds a name, so it is
|
||||
@@ -2192,6 +2195,37 @@ mod tests {
|
||||
assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0));
|
||||
}
|
||||
|
||||
/// A Shift-click with the Selection tool on an empty spot inside an artboard makes it the active
|
||||
/// one and keeps the selection, and Align to Artboard then aligns the objects to it unless the
|
||||
/// call names another artboard.
|
||||
#[test]
|
||||
fn a_shift_click_activates_its_artboard_and_align_to_artboard_aligns_to_it() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let mut rect = |x: f64, y: f64| {
|
||||
let r = app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + x, "y": boards[0].y0 + y, "width": 20, "height": 20}));
|
||||
vectorcraft_doc::NodeId(r.unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let ids = [rect(10.0, 15.0), rect(50.0, 40.0)];
|
||||
app.session.execute("select.set", &json!({"ids": [ids[0].0, ids[1].0]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
app.select_tool("selection");
|
||||
let view = app.view_info();
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, pressure: 1.0 }, view);
|
||||
}
|
||||
assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, ids, "the selection stays");
|
||||
let lefts = |app: &VectorcraftApp| ids.map(|id| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0);
|
||||
app.run("object.align", json!({"horizontal": "left", "to": "artboard"})).unwrap();
|
||||
assert_eq!(lefts(&app), [boards[2].x0; 2], "the left edges are on the active artboard's");
|
||||
app.run("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})).unwrap();
|
||||
assert_eq!(lefts(&app), [boards[1].x0; 2], "an artboard the caller names is kept");
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
|
||||
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
|
||||
#[test]
|
||||
@@ -2240,8 +2274,9 @@ mod tests {
|
||||
fn artboard_labels_fit_their_artboard() {
|
||||
let ctx = egui::Context::default();
|
||||
let drawn = |width: f32| {
|
||||
let r = egui::Rect::from_min_size(pos2(50.0, 50.0), vec2(width, 40.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| artboard_label(ui.painter(), r, "01 - A long artboard name", Color32::WHITE));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
artboard_label(ui.painter(), pos2(50.0, 50.0), width, "01 - A long artboard name", Color32::WHITE)
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
.into_iter()
|
||||
@@ -2473,8 +2508,10 @@ mod tests {
|
||||
used
|
||||
};
|
||||
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
// Direct Selection used last: the drag moves the rectangle's bottom-right corner only.
|
||||
// Direct Selection used last: the drag moves the rectangle's bottom-right corner only (once
|
||||
// the rectangle isn't selected whole, which would move all its anchors).
|
||||
app.select_tool("directSelection");
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
let corner = xf.to_screen(Point::new(200.0, 150.0));
|
||||
assert_eq!(cmd_drag(&mut app, corner, corner + vec2(20.0, 20.0)), "directSelection");
|
||||
let b = bounds(&app);
|
||||
|
||||
@@ -104,6 +104,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
|
||||
"nativeMenuBar": app.services.native_menu.is_some(),
|
||||
// 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<_>>(),
|
||||
// A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting
|
||||
// for the person at the computer: what asked for it runs again when it answers.
|
||||
"fileDialog": app.picks.waiting_kind(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -146,12 +146,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
}
|
||||
if let Some(a) = act {
|
||||
let r = if matches!(a, Action::Import) {
|
||||
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
|
||||
let current = preset.name.clone();
|
||||
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t))
|
||||
} else {
|
||||
run(app, d, a, &preset.name)
|
||||
let r = match a {
|
||||
// Their file dialogs, shown off the UI thread, import into or export from the dialog
|
||||
// as it is then.
|
||||
Action::Import | Action::Export => {
|
||||
let (current, export) = (preset.name.clone(), matches!(a, Action::Export));
|
||||
crate::picks::in_dialog(app, d, move |app, d| run(app, d, if export { Action::Export } else { Action::Import }, ¤t))
|
||||
}
|
||||
a => run(app, d, a, &preset.name),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
|
||||
@@ -103,12 +103,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
});
|
||||
if let Some(a) = act {
|
||||
let r = if matches!(a, Action::Import) {
|
||||
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
|
||||
let current = preset.name.clone();
|
||||
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t))
|
||||
} else {
|
||||
run(app, d, a, &preset.name)
|
||||
let r = match a {
|
||||
// Their file dialogs, shown off the UI thread, import into or export from the dialog
|
||||
// as it is then.
|
||||
Action::Import | Action::Export => {
|
||||
let (current, export) = (preset.name.clone(), matches!(a, Action::Export));
|
||||
crate::picks::in_dialog(app, d, move |app, d| run(app, d, if export { Action::Export } else { Action::Import }, ¤t))
|
||||
}
|
||||
a => run(app, d, a, &preset.name),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
|
||||
@@ -151,12 +151,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
let name = preset.name.clone();
|
||||
if let Some(a) = act {
|
||||
let r = if matches!(a, Action::Import) {
|
||||
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
|
||||
let current = name.clone();
|
||||
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t))
|
||||
} else {
|
||||
run(app, d, a, &name)
|
||||
let r = match a {
|
||||
// Their file dialogs, shown off the UI thread, import into or export from the dialog
|
||||
// as it is then.
|
||||
Action::Import | Action::Export => {
|
||||
let (current, export) = (name.clone(), matches!(a, Action::Export));
|
||||
crate::picks::in_dialog(app, d, move |app, d| run(app, d, if export { Action::Export } else { Action::Import }, ¤t))
|
||||
}
|
||||
a => run(app, d, a, &name),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
|
||||
@@ -112,12 +112,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
});
|
||||
if let Some(a) = act {
|
||||
let r = if matches!(a, Action::Import) {
|
||||
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
|
||||
let current = name.clone();
|
||||
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t))
|
||||
} else {
|
||||
run(app, d, a, &name)
|
||||
let r = match a {
|
||||
// Their file dialogs, shown off the UI thread, import into or export from the dialog
|
||||
// as it is then.
|
||||
Action::Import | Action::Export => {
|
||||
let (current, export) = (name.clone(), matches!(a, Action::Export));
|
||||
crate::picks::in_dialog(app, d, move |app, d| run(app, d, if export { Action::Export } else { Action::Import }, ¤t))
|
||||
}
|
||||
a => run(app, d, a, &name),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
|
||||
@@ -656,6 +656,8 @@
|
||||
Appearance of Black Vzhled černé
|
||||
Devices Zařízení
|
||||
A file dialog is open: choose there first Je otevřené dialogové okno souboru: nejprve vyberte v něm
|
||||
The document was closed while its file dialog was open Dokument byl zavřen, zatímco bylo otevřené jeho dialogové okno souboru
|
||||
The dialog was closed before its file dialog answered Dialogové okno se zavřelo dřív, než odpovědělo jeho dialogové okno souboru
|
||||
Shaper Shaper
|
||||
Expand Shaper Group Rozšířit skupinu Shaper
|
||||
Release Shaper Group Uvolnit skupinu Shaper
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3384,6 +3384,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art oder type
|
||||
@msg artboard {0} does not exist Zeichenfläche {0} ist nicht vorhanden
|
||||
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard in diesem Maßstab misst die Zeichnung {w} × {h} pt, mehr als die Arbeitsfläche fasst: Wählen Sie einen kleineren Maßstab oder passen Sie sie an die Zeichenfläche an
|
||||
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis muss "vertical", "horizontal" oder ein Winkel in Grad sein, nicht `{v}`
|
||||
@msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black oder eine Farbe wie "#ff8800"
|
||||
@msg bad color {v} ungültige Farbe {v}
|
||||
@msg bad colour `{hex}` (#rrggbb) ungültige Farbe `{hex}` (#rrggbb)
|
||||
@@ -3426,6 +3427,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg freeform line {i} must be an array of point indices Freiformlinie {i} muss ein Array von Punktindizes sein
|
||||
@msg give both `start` and `end` [x, y] (or neither) geben Sie sowohl `start` als auch `end` [x, y] an (oder keines von beiden)
|
||||
@msg give corners of the path: anchors between two straight sides geben Sie Ecken des Pfads an: Ankerpunkte zwischen zwei geraden Seiten
|
||||
@msg give horizontal or vertical geben Sie horizontal oder vertical an
|
||||
@msg give the path `id` and the width point `index` geben Sie die `id` des Pfads und den `index` des Breitenpunkts an
|
||||
@msg guides are locked die Hilfslinien sind gesperrt
|
||||
@msg image '{key}' has no pixels and was left out das Bild '{key}' hat keine Pixel und wurde ausgelassen
|
||||
@@ -3831,6 +3833,8 @@ theme Appearance Erscheinungsbild
|
||||
Images: link to the file instead of only embedding it. Bilder: mit der Datei verknüpfen, statt sie nur einzubetten.
|
||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft-Dokumente: ein gesperrtes Objekt, das aktualisiert wird, wenn sich die Datei ändert.
|
||||
A file dialog is open: choose there first Ein Dateidialog ist geöffnet: Treffen Sie zuerst dort Ihre Auswahl
|
||||
The document was closed while its file dialog was open Das Dokument wurde geschlossen, während sein Dateidialog geöffnet war
|
||||
The dialog was closed before its file dialog answered Das Dialogfeld wurde geschlossen, bevor sein Dateidialog geantwortet hat
|
||||
Shaper Shaper
|
||||
Expand Shaper Group Shaper-Gruppe umwandeln
|
||||
Release Shaper Group Shaper-Gruppe zurückwandeln
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3485,6 +3485,7 @@ axis Both Ambos
|
||||
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art o type
|
||||
@msg artboard {0} does not exist la mesa de trabajo {0} no existe
|
||||
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard a esta escala el dibujo mide {w} × {h} pt, más de lo que cabe en el lienzo: elige una escala menor o ajústalo a la mesa de trabajo
|
||||
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis debe ser "vertical", "horizontal" o un ángulo en grados, no `{v}`
|
||||
@msg background {other}: transparent, white, black or a colour such as "#ff8800" fondo {other}: transparent, white, black o un color como "#ff8800"
|
||||
@msg bad color {v} color no válido {v}
|
||||
@msg bad colour `{hex}` (#rrggbb) color no válido `{hex}` (#rrggbb)
|
||||
@@ -3527,6 +3528,7 @@ axis Both Ambos
|
||||
@msg freeform line {i} must be an array of point indices la línea de forma libre {i} debe ser una lista de índices de puntos
|
||||
@msg give both `start` and `end` [x, y] (or neither) indica `start` y `end` [x, y] (o ninguno de los dos)
|
||||
@msg give corners of the path: anchors between two straight sides indica esquinas del trazado: puntos de ancla entre dos lados rectos
|
||||
@msg give horizontal or vertical indica horizontal o vertical
|
||||
@msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura
|
||||
@msg guides are locked las guías están bloqueadas
|
||||
@msg image '{key}' has no pixels and was left out la imagen '{key}' no tiene píxeles y se omitió
|
||||
@@ -3932,6 +3934,8 @@ axis Both Ambos
|
||||
Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo.
|
||||
A file dialog is open: choose there first Hay un cuadro de diálogo de archivos abierto: elija primero en él
|
||||
The document was closed while its file dialog was open Se cerró el documento mientras su cuadro de diálogo de archivos estaba abierto
|
||||
The dialog was closed before its file dialog answered El cuadro de diálogo se cerró antes de que respondiera su cuadro de diálogo de archivos
|
||||
Shaper Shaper
|
||||
Expand Shaper Group Expandir grupo Shaper
|
||||
Release Shaper Group Soltar grupo Shaper
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3482,6 +3482,7 @@ axis Both Les deux
|
||||
@msg antiAlias `{id}`: none, art or type antiAlias `{id}` : none, art ou type
|
||||
@msg artboard {0} does not exist le plan de travail {0} n’existe pas
|
||||
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard à cette échelle, le dessin mesure {w} × {h} pt, plus que ne peut contenir la zone de travail : choisissez une échelle plus petite ou ajustez-le au plan de travail
|
||||
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis doit être "vertical", "horizontal" ou un angle en degrés, pas `{v}`
|
||||
@msg background {other}: transparent, white, black or a colour such as "#ff8800" arrière-plan {other} : transparent, white, black ou une couleur comme "#ff8800"
|
||||
@msg bad color {v} couleur non valide {v}
|
||||
@msg bad colour `{hex}` (#rrggbb) couleur non valide `{hex}` (#rrggbb)
|
||||
@@ -3524,6 +3525,7 @@ axis Both Les deux
|
||||
@msg freeform line {i} must be an array of point indices la ligne de forme libre {i} doit être un tableau d’indices de points
|
||||
@msg give both `start` and `end` [x, y] (or neither) indiquez à la fois `start` et `end` [x, y] (ou aucun des deux)
|
||||
@msg give corners of the path: anchors between two straight sides indiquez des angles du tracé : des points d’ancrage entre deux côtés droits
|
||||
@msg give horizontal or vertical indiquez horizontal ou vertical
|
||||
@msg give the path `id` and the width point `index` indiquez l’`id` du tracé et l’`index` du point de largeur
|
||||
@msg guides are locked les repères sont verrouillés
|
||||
@msg image '{key}' has no pixels and was left out l’image « {key} » n’a aucun pixel et a été ignorée
|
||||
@@ -3929,6 +3931,8 @@ axis Both Les deux
|
||||
Images: link to the file instead of only embedding it. Images : lier le fichier au lieu de seulement l’incorporer.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Documents VectorCraft : un objet verrouillé, mis à jour quand le fichier change.
|
||||
A file dialog is open: choose there first Une boîte de dialogue de fichiers est ouverte : faites d’abord votre choix dans celle-ci
|
||||
The document was closed while its file dialog was open Le document a été fermé pendant que sa boîte de dialogue de fichiers était ouverte
|
||||
The dialog was closed before its file dialog answered La boîte de dialogue a été fermée avant la réponse de sa boîte de dialogue de fichiers
|
||||
Shaper Shaper
|
||||
Expand Shaper Group Décomposer le groupe Shaper
|
||||
Release Shaper Group Libérer le groupe Shaper
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3485,6 +3485,7 @@ axis Both Entrambi
|
||||
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art o type
|
||||
@msg artboard {0} does not exist la tavola da disegno {0} non esiste
|
||||
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard a questa scala il disegno misura {w} × {h} pt, più di quanto possa contenere l'area di disegno: scegli una scala più piccola o adattalo alla tavola da disegno
|
||||
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis deve essere "vertical", "horizontal" o un angolo in gradi, non `{v}`
|
||||
@msg background {other}: transparent, white, black or a colour such as "#ff8800" sfondo {other}: transparent, white, black o un colore come "#ff8800"
|
||||
@msg bad color {v} colore non valido {v}
|
||||
@msg bad colour `{hex}` (#rrggbb) colore non valido `{hex}` (#rrggbb)
|
||||
@@ -3527,6 +3528,7 @@ axis Both Entrambi
|
||||
@msg freeform line {i} must be an array of point indices la linea a forma libera {i} deve essere un array di indici di punti
|
||||
@msg give both `start` and `end` [x, y] (or neither) specifica sia `start` sia `end` [x, y] (o nessuno dei due)
|
||||
@msg give corners of the path: anchors between two straight sides specifica angoli del tracciato: punti di ancoraggio tra due lati diritti
|
||||
@msg give horizontal or vertical specifica horizontal o vertical
|
||||
@msg give the path `id` and the width point `index` specifica l'`id` del tracciato e l'`index` del punto di larghezza
|
||||
@msg guides are locked le guide sono bloccate
|
||||
@msg image '{key}' has no pixels and was left out l'immagine '{key}' non ha pixel ed è stata omessa
|
||||
@@ -3932,6 +3934,8 @@ axis Both Entrambi
|
||||
Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia.
|
||||
A file dialog is open: choose there first È aperta una finestra di dialogo dei file: scegli prima lì
|
||||
The document was closed while its file dialog was open Il documento è stato chiuso mentre la sua finestra di dialogo dei file era aperta
|
||||
The dialog was closed before its file dialog answered La finestra di dialogo è stata chiusa prima che rispondesse la sua finestra di dialogo dei file
|
||||
Shaper Shaper
|
||||
Expand Shaper Group Espandi gruppo Shaper
|
||||
Release Shaper Group Rilascia gruppo Shaper
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3290,6 +3290,8 @@ theme Appearance 外観
|
||||
Images: link to the file instead of only embedding it. 画像:埋め込むだけでなく、ファイルにリンクします。
|
||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。
|
||||
A file dialog is open: choose there first ファイルダイアログが開いています。先にそちらで選択してください
|
||||
The document was closed while its file dialog was open ファイルダイアログが開いている間にドキュメントが閉じられました
|
||||
The dialog was closed before its file dialog answered ファイルダイアログが応答する前にダイアログが閉じられました
|
||||
Shaper Shaper
|
||||
Expand Shaper Group Shaper グループを拡張
|
||||
Release Shaper Group Shaper グループを解除
|
||||
@@ -3528,6 +3530,8 @@ theme Appearance 外観
|
||||
@msg nothing to align 整列するものがありません
|
||||
@msg select a clip group クリップグループを選択してください
|
||||
@msg select two or more objects to distribute 分布させるオブジェクトを 2 つ以上選択してください
|
||||
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis には "vertical"、"horizontal"、度単位の角度のいずれかを指定してください (`{v}` は指定できません)
|
||||
@msg give horizontal or vertical horizontal または vertical を指定してください
|
||||
@msg selection has no bounds 選択範囲に境界がありません
|
||||
@msg the top object must be a path, compound path or text object to use as a clipping mask クリッピングマスクとして使うには、最前面のオブジェクトがパス、複合パス、テキストオブジェクトのいずれかである必要があります
|
||||
@msg no target object has an opacity mask: select one or give `id` 不透明マスクのあるターゲットオブジェクトがありません: 該当するオブジェクトを選択するか、`id` を指定してください
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2718,6 +2718,8 @@
|
||||
Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda.
|
||||
A file dialog is open: choose there first Há uma caixa de diálogo de arquivos aberta: escolha nela primeiro
|
||||
The document was closed while its file dialog was open O documento foi fechado enquanto sua caixa de diálogo de arquivos estava aberta
|
||||
The dialog was closed before its file dialog answered O diálogo foi fechado antes que sua caixa de diálogo de arquivos respondesse
|
||||
Shaper Formador
|
||||
Expand Shaper Group Expandir Grupo Formador
|
||||
Release Shaper Group Liberar Grupo Formador
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3481,6 +3481,7 @@ axis Both Оба
|
||||
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art или type
|
||||
@msg artboard {0} does not exist монтажная область {0} не существует
|
||||
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard при этом масштабе рисунок имеет размер {w} × {h} пт, что больше холста: выберите меньший масштаб или впишите его в монтажную область
|
||||
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis должно быть "vertical", "horizontal" или углом в градусах, а не `{v}`
|
||||
@msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black или цвет, например "#ff8800"
|
||||
@msg bad color {v} недопустимый цвет {v}
|
||||
@msg bad colour `{hex}` (#rrggbb) недопустимый цвет `{hex}` (#rrggbb)
|
||||
@@ -3523,6 +3524,7 @@ axis Both Оба
|
||||
@msg freeform line {i} must be an array of point indices произвольная линия {i} должна быть массивом индексов точек
|
||||
@msg give both `start` and `end` [x, y] (or neither) укажите и `start`, и `end` [x, y] (или ни одного)
|
||||
@msg give corners of the path: anchors between two straight sides укажите углы контура: опорные точки между двумя прямыми сторонами
|
||||
@msg give horizontal or vertical укажите horizontal или vertical
|
||||
@msg give the path `id` and the width point `index` укажите `id` контура и `index` точки ширины
|
||||
@msg guides are locked направляющие заблокированы
|
||||
@msg image '{key}' has no pixels and was left out изображение '{key}' не имеет пикселей и было пропущено
|
||||
@@ -3928,6 +3930,8 @@ axis Both Оба
|
||||
Images: link to the file instead of only embedding it. Изображения: связывать с файлом вместо простого внедрения.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Документы VectorCraft: один заблокированный объект, обновляемый при изменении файла.
|
||||
A file dialog is open: choose there first Открыто окно выбора файла: сначала сделайте выбор в нём
|
||||
The document was closed while its file dialog was open Документ был закрыт, пока было открыто его окно выбора файла
|
||||
The dialog was closed before its file dialog answered Диалоговое окно было закрыто до того, как ответило его окно выбора файла
|
||||
Shaper Shaper
|
||||
Expand Shaper Group Разобрать группу Shaper
|
||||
Release Shaper Group Освободить группу Shaper
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -923,6 +923,37 @@ fn complete_languages_translate_every_message() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameter errors of Align, Distribute Spacing, Reflect and Shear, whose text the scan above
|
||||
/// doesn't read, are translated whole in each language of [`COMPLETE_MESSAGES`].
|
||||
#[test]
|
||||
fn align_reflect_and_shear_param_errors_are_translated() {
|
||||
use serde_json::json;
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for x in [0, 50] {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
}
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
for (cmd, p) in [
|
||||
("object.align", json!({})),
|
||||
("object.align", json!({"horizontal": "middle"})),
|
||||
("object.align", json!({"vertical": "centre"})),
|
||||
("object.align", json!({"horizontal": "left", "to": "page"})),
|
||||
("object.align", json!({"horizontal": "left", "bounds": "visual"})),
|
||||
("object.distributeSpacing", json!({"axis": "diagonal"})),
|
||||
("object.distributeSpacing", json!({"spacing": "5"})),
|
||||
("object.reflect", json!({"axis": "diagonal"})),
|
||||
("object.shear", json!({"axis": "diagonal"})),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).unwrap_err();
|
||||
let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{cmd} {p}: {e}") };
|
||||
for code in COMPLETE_MESSAGES {
|
||||
let shown = message(Lang::from_code(code).unwrap(), &e.to_string());
|
||||
assert!(!shown.contains(msg.as_str()), "{code}: {shown}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Languages whose catalogs cover every status and error message.
|
||||
const COMPLETE_MESSAGES: &[&str] = &["de", "es", "fr", "it", "ja", "ru", "uk"];
|
||||
|
||||
|
||||
@@ -3468,6 +3468,7 @@ axis Both Обидві
|
||||
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art або type
|
||||
@msg artboard {0} does not exist монтажна область {0} не існує
|
||||
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard при цьому масштабі малюнок має розмір {w} × {h} пт, що більше полотна: виберіть менший масштаб або впишіть його в монтажну область
|
||||
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis має бути "vertical", "horizontal" або кутом у градусах, а не `{v}`
|
||||
@msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black або колір, наприклад "#ff8800"
|
||||
@msg bad color {v} неприпустимий колір {v}
|
||||
@msg bad colour `{hex}` (#rrggbb) неприпустимий колір `{hex}` (#rrggbb)
|
||||
@@ -3510,6 +3511,7 @@ axis Both Обидві
|
||||
@msg freeform line {i} must be an array of point indices довільна лінія {i} має бути масивом індексів точок
|
||||
@msg give both `start` and `end` [x, y] (or neither) вкажіть `start`, `end` [x, y] (або жодного)
|
||||
@msg give corners of the path: anchors between two straight sides вкажіть кути контуру: опорні точки між двома прямими сторонами
|
||||
@msg give horizontal or vertical вкажіть horizontal або vertical
|
||||
@msg give the path `id` and the width point `index` вкажіть `id` контуру та `index` точки ширини
|
||||
@msg guides are locked напрямні заблоковані
|
||||
@msg image '{key}' has no pixels and was left out зображення '{key}' не має пікселів і було пропущено
|
||||
@@ -3913,6 +3915,8 @@ axis Both Обидві
|
||||
Images: link to the file instead of only embedding it. Зображення: пов’язувати із файлом замість простого впровадження.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Документи VectorCraft: один заблокований об’єкт, який оновлюється під час зміни файлу.
|
||||
A file dialog is open: choose there first Відкрито вікно вибору файлу: спочатку зробіть вибір у ньому
|
||||
The document was closed while its file dialog was open Документ було закрито, поки було відкрито його вікно вибору файлу
|
||||
The dialog was closed before its file dialog answered Діалогове вікно було закрито раніше, ніж відповіло його вікно вибору файлу
|
||||
Shaper Формоутворення
|
||||
Expand Shaper Group Розібрати групу формоутворення
|
||||
Release Shaper Group Звільнити групу формоутворення
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -3540,6 +3540,8 @@ axis Both 两者
|
||||
Images: link to the file instead of only embedding it. 图像:链接到文件,而不只是嵌入。
|
||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文档:一个锁定对象,文件更改时随之更新。
|
||||
A file dialog is open: choose there first 文件对话框已打开:请先在其中选择
|
||||
The document was closed while its file dialog was open 文件对话框打开期间,文档已关闭
|
||||
The dialog was closed before its file dialog answered 文件对话框给出结果之前,对话框已关闭
|
||||
Expand Shaper Group 扩展 Shaper 组
|
||||
Release Shaper Group 释放 Shaper 组
|
||||
Select Shaper Face 选择 Shaper 区域
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3319,6 +3319,8 @@ axis Both 兩者
|
||||
Images: link to the file instead of only embedding it. 影像:連結至檔案,而不只是嵌入。
|
||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文件:單一鎖定物件,檔案變更時隨之更新。
|
||||
A file dialog is open: choose there first 檔案對話方塊已開啟:請先在其中選擇
|
||||
The document was closed while its file dialog was open 檔案對話方塊開啟期間,文件已關閉
|
||||
The dialog was closed before its file dialog answered 檔案對話方塊回應之前,對話方塊已關閉
|
||||
Shaper Shaper
|
||||
Expand Shaper Group 展開 Shaper 群組
|
||||
Release Shaper Group 釋放 Shaper 群組
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
+49
-12
@@ -19,31 +19,25 @@ const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"
|
||||
/// and flattener, PDF, print and perspective grid presets files are imported. → `document.open`'s result for a document opened now (its
|
||||
/// `warnings` say what didn't come in as it was), else null.
|
||||
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value, String> {
|
||||
let ext = fileio::extension(name);
|
||||
let opens = opens_as(name);
|
||||
// A WebAssembly plug-in is installed.
|
||||
if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) {
|
||||
if opens == Some(OpensAs::Plugin) {
|
||||
let r =
|
||||
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}))?;
|
||||
app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or(name)));
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let presets = [
|
||||
(vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"),
|
||||
(vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"),
|
||||
(vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"),
|
||||
(vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"),
|
||||
];
|
||||
if let Some((_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) {
|
||||
if let Some(OpensAs::Presets { import, what }) = opens {
|
||||
let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?;
|
||||
let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
app.status(format!("Imported {what}: {}", names.join(", ")));
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) {
|
||||
if opens == Some(OpensAs::Library) {
|
||||
return crate::panels::libraries::import(app, json!({"data": String::from_utf8_lossy(bytes)})).map(|_| Value::Null);
|
||||
}
|
||||
let swatches = vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str());
|
||||
if swatches || ext == vectorcraft_doc::style_libs::STYLES_EXT {
|
||||
let swatches = opens == Some(OpensAs::SwatchLibrary);
|
||||
if swatches || opens == Some(OpensAs::StyleLibrary) {
|
||||
let p = match path {
|
||||
Some(path) => serde_json::json!({ "path": path }),
|
||||
// `.ase` swatch libraries are binary.
|
||||
@@ -63,6 +57,49 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
|
||||
open_document(app, name, bytes, path, &Value::Null)
|
||||
}
|
||||
|
||||
/// The kinds of file File → Open installs, imports or shows in the library panel or the Libraries
|
||||
/// panel ([`opens_as`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum OpensAs {
|
||||
/// A WebAssembly plug-in: installed.
|
||||
Plugin,
|
||||
/// Flattener, PDF, print or perspective grid presets: imported with command `import`, and
|
||||
/// `what` names them in the status bar.
|
||||
Presets { import: &'static str, what: &'static str },
|
||||
/// A Libraries panel file: added to the Libraries panel as a new library.
|
||||
Library,
|
||||
/// A swatch library: opened in the library panel.
|
||||
SwatchLibrary,
|
||||
/// A graphic style library: opened in the library panel.
|
||||
StyleLibrary,
|
||||
}
|
||||
|
||||
/// The kind of file named `name` (by its extension) that File → Open installs, imports or shows in
|
||||
/// the library panel or the Libraries panel ([`open_bytes`]), else `None`. A drop opens these files
|
||||
/// the same way, wherever they are dropped ([`VectorcraftApp::drop_target`]).
|
||||
pub fn opens_as(name: &str) -> Option<OpensAs> {
|
||||
let ext = fileio::extension(name);
|
||||
let presets = [
|
||||
(vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"),
|
||||
(vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"),
|
||||
(vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"),
|
||||
(vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"),
|
||||
];
|
||||
if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) {
|
||||
Some(OpensAs::Plugin)
|
||||
} else if let Some(&(_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) {
|
||||
Some(OpensAs::Presets { import, what })
|
||||
} else if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) {
|
||||
Some(OpensAs::Library)
|
||||
} else if vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str()) {
|
||||
Some(OpensAs::SwatchLibrary)
|
||||
} else if ext == vectorcraft_doc::style_libs::STYLES_EXT {
|
||||
Some(OpensAs::StyleLibrary)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a document through the engine loader with the `document.open` options in `p` →
|
||||
/// `document.open`'s result.
|
||||
pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value, String> {
|
||||
|
||||
+41
-12
@@ -243,9 +243,13 @@ pub struct Services {
|
||||
/// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then.
|
||||
pub native_menu: Option<native_menu::NativeMenu>,
|
||||
/// Show file dialogs off the UI thread (desktop Linux, where a dialog in line holds the window
|
||||
/// and the compositor finds it not answering): what asked runs again with the answer
|
||||
/// ([`picks`]). Without it they are shown in line.
|
||||
/// and the compositor finds it not answering, #592) or as sheets on the window (macOS, where a
|
||||
/// dialog in line aborts the app, #867): what asked runs again with the answer ([`picks`]).
|
||||
/// Without it they are shown in line.
|
||||
pub start_pick: Option<picks::StartPick>,
|
||||
/// [`Self::start_pick`]'s dialogs are sheets on the window (macOS): while one is open the app
|
||||
/// neither quits nor closes documents, as under the modal dialog before.
|
||||
pub dialogs_are_sheets: bool,
|
||||
}
|
||||
|
||||
/// Cached canvas raster.
|
||||
@@ -580,9 +584,9 @@ impl VectorcraftApp {
|
||||
return r;
|
||||
}
|
||||
let mut params = params;
|
||||
// Paste in place, in front, in back (#693) and Select › All on Active Artboard (#1006): onto
|
||||
// or on the active artboard, the view's.
|
||||
if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack" | "select.allOnArtboard")
|
||||
// Paste in place, in front, in back (#693), Select › All on Active Artboard (#1006) and Align to
|
||||
// Artboard: onto, on or to the active artboard, the view's. A batch passes it on to those steps.
|
||||
if (vectorcraft_engine::ON_ACTIVE_ARTBOARD.contains(&id) || id == "command.batch")
|
||||
&& params.get("artboard").is_none()
|
||||
&& self.view().is_some()
|
||||
&& let Some(n) = self.session.active().map(|d| d.doc.artboards.len())
|
||||
@@ -679,8 +683,13 @@ impl VectorcraftApp {
|
||||
let reply = req.reply.clone();
|
||||
// Guarded per request: a panic must not drop the channel (taken out of `self` above).
|
||||
let roots = self.automation_roots.clone();
|
||||
let outcome = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req)))
|
||||
.unwrap_or_else(|msg| control::err(format!("internal error: {msg} (please report this bug)")));
|
||||
let outcome = match file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(msg) => {
|
||||
self.picks.recover();
|
||||
control::err(format!("internal error: {msg} (please report this bug)"))
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
control::Outcome::Done(v) => {
|
||||
let _ = reply.send(v);
|
||||
@@ -816,6 +825,18 @@ impl VectorcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A file dialog is open off the UI thread (a sheet on macOS, [`picks`]): what asked for it runs
|
||||
/// again when it answers.
|
||||
pub fn file_dialog_open(&self) -> bool {
|
||||
self.picks.is_waiting()
|
||||
}
|
||||
|
||||
/// A file dialog is open as a sheet on the window ([`Services::dialogs_are_sheets`]): nothing
|
||||
/// closes or quits until it answers.
|
||||
pub fn file_sheet_open(&self) -> bool {
|
||||
self.services.dialogs_are_sheets && self.file_dialog_open()
|
||||
}
|
||||
|
||||
/// Show a transient status message.
|
||||
pub fn status(&mut self, s: impl Into<String>) {
|
||||
self.ui.status = s.into();
|
||||
@@ -904,6 +925,7 @@ impl VectorcraftApp {
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten();
|
||||
if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.logic_frame(ctx))) {
|
||||
self.picks.recover();
|
||||
self.status(format!("Internal error: {msg} (please report this bug)"));
|
||||
}
|
||||
}
|
||||
@@ -957,10 +979,14 @@ impl VectorcraftApp {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
// The window's close button (or the system quitting the app) asks about unsaved documents,
|
||||
// once the saves running in the background are done.
|
||||
// once the saves running in the background are done. Not while a file dialog's sheet is on
|
||||
// the window: what asked for it runs again when it answers.
|
||||
if ctx.input(|i| i.viewport().close_requested()) {
|
||||
background::wait_all(self);
|
||||
if unsaved::any_dirty(self) {
|
||||
if self.file_sheet_open() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.status(picks::busy());
|
||||
} else if unsaved::any_dirty(self) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
if let Err(e) = unsaved::close_all(self, "quit") {
|
||||
self.status(e);
|
||||
@@ -1001,8 +1027,8 @@ impl VectorcraftApp {
|
||||
self.take_dropped_files(ctx);
|
||||
}
|
||||
|
||||
/// Files dropped on the window: documents opened, pictures and text placed on the canvas
|
||||
/// ([`Self::drop_target`]).
|
||||
/// Files dropped on the window: documents, libraries, presets and plug-ins opened, pictures
|
||||
/// and text placed on the canvas ([`Self::drop_target`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn take_dropped_files(&mut self, ctx: &egui::Context) {
|
||||
let (dropped, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.modifiers.shift));
|
||||
@@ -1078,7 +1104,9 @@ impl VectorcraftApp {
|
||||
// The native menu's key equivalents become the input their keys make, ahead of what came
|
||||
// after them (see `native_menu`).
|
||||
let keys = self.services.native_menu.as_mut().map(native_menu::NativeMenu::take_keys).unwrap_or_default();
|
||||
if !keys.is_empty() {
|
||||
// The menu bar does nothing while a file dialog is open (`native_menu`): a key that raced it
|
||||
// going inert is dropped.
|
||||
if !keys.is_empty() && !self.file_dialog_open() {
|
||||
let events: Vec<egui::Event> = keys.into_iter().flat_map(|k| native_menu::key_events(k, || self.system_clipboard_text())).collect();
|
||||
raw.events.splice(0..0, events);
|
||||
}
|
||||
@@ -1135,6 +1163,7 @@ impl VectorcraftApp {
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten();
|
||||
if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.ui_frame(ui))) {
|
||||
self.picks.recover();
|
||||
self.status(format!("Internal error: {msg} (please report this bug)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,16 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("view.textThreads", "Hide Text Threads", "Cmd+Shift+Y", "{} toggle the thread lines between threaded text frames"),
|
||||
("view.gradientAnnotator", "Hide Gradient Annotator", "Cmd+Alt+G", "{} toggle the Gradient tool's annotator"),
|
||||
("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"),
|
||||
("type.alignLeft", "Align left", "Cmd+Shift+L", "{} align the selected type's paragraphs (or those the Type tool's selection touches) left"),
|
||||
(
|
||||
"type.alignCenter",
|
||||
"Align center",
|
||||
"Cmd+Shift+C",
|
||||
"{} align the selected type's paragraphs (or those the Type tool's selection touches) centred",
|
||||
),
|
||||
("type.alignRight", "Align right", "Cmd+Shift+R", "{} align the selected type's paragraphs (or those the Type tool's selection touches) right"),
|
||||
("type.justifyLeft", "Justify with last line aligned left", "Cmd+Shift+J", "{} justify the selected type's paragraphs, last line aligned left"),
|
||||
("type.justifyAll", "Justify all lines", "Cmd+Shift+F", "{} justify every line of the selected type's paragraphs"),
|
||||
(
|
||||
"type.bold",
|
||||
"Bold",
|
||||
@@ -994,6 +1004,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
"type.hiddenCharacters" => flag(&mut app.ui.view.hidden_chars),
|
||||
"type.bold" => crate::panels::character::toggle_face(app, Face::Bold),
|
||||
"type.italic" => crate::panels::character::toggle_face(app, Face::Italic),
|
||||
id if let Some((_, justify)) = crate::panels::paragraph::ALIGN_COMMANDS.iter().find(|c| c.0 == id) => {
|
||||
crate::panels::paragraph::align(app, justify)
|
||||
}
|
||||
"view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator),
|
||||
"effect.last" => match app.last_effect.clone() {
|
||||
Some((e, params)) => {
|
||||
@@ -1876,6 +1889,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||
// Save is off for a clean document that already has its own file.
|
||||
"file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()),
|
||||
"type.bold" | "type.italic" => crate::panels::character::text_style(app).is_some(),
|
||||
id if crate::panels::paragraph::ALIGN_COMMANDS.iter().any(|c| c.0 == id) => crate::panels::character::text_style(app).is_some(),
|
||||
"file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()),
|
||||
"file.place"
|
||||
| "file.export.svg"
|
||||
|
||||
@@ -458,7 +458,31 @@ fn take_submenu(bar: &mut MenuBar, command: &str) -> Option<Node> {
|
||||
|
||||
/// VectorCraft's menus as a Mac menu bar, as they show now.
|
||||
pub fn layout(app: &VectorcraftApp) -> Layout {
|
||||
mac_layout(app, &from_tree(app, &menus::menu_tree_named(app.session.prefs.font_names_in_english)), crate::i18n::current())
|
||||
let mut layout = mac_layout(app, &from_tree(app, &menus::menu_tree_named(app.session.prefs.font_names_in_english)), crate::i18n::current());
|
||||
if app.file_dialog_open() {
|
||||
for menu in &mut layout.bar.menus {
|
||||
inert(&mut menu.children);
|
||||
}
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
/// While a file dialog is open (a sheet on the window, [`crate::picks`]) the menu bar does
|
||||
/// nothing, as under the modal dialog before: what asked for the dialog runs again when it
|
||||
/// answers, on the document as it was. Hide and Minimize still work.
|
||||
fn modal_allows(item: &Item) -> bool {
|
||||
matches!(item.command, Some(HIDE | HIDE_OTHERS | MINIMIZE))
|
||||
}
|
||||
|
||||
/// Disable every item of `nodes` (and their submenus) [`modal_allows`] doesn't keep.
|
||||
fn inert(nodes: &mut [Node]) {
|
||||
for node in nodes {
|
||||
match node {
|
||||
Node::Item(item) => item.enabled &= modal_allows(item),
|
||||
Node::Submenu { children, .. } => inert(children),
|
||||
Node::Separator | Node::Header(_) | Node::Standard(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------- the app's side
|
||||
@@ -516,8 +540,13 @@ impl NativeMenu {
|
||||
|
||||
/// Run the items clicked in the native menu since the last frame, like in-window menu clicks.
|
||||
pub fn run(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let picking = app.file_dialog_open();
|
||||
let Some(menu) = app.services.native_menu.as_mut() else { return };
|
||||
for it in std::mem::take(&mut menu.clicks) {
|
||||
// A click that raced the menu going inert under a file dialog.
|
||||
if picking && !modal_allows(&it) {
|
||||
continue;
|
||||
}
|
||||
match it.command {
|
||||
Some(MINIMIZE) => ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)),
|
||||
Some(command) => {
|
||||
@@ -555,8 +584,8 @@ pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
|
||||
/// What the menus' rows depend on, cheaply (no allocation): commands run, the active document,
|
||||
/// its revision (edits, selection and view changes), history and clipboard, the language,
|
||||
/// shortcuts and plug-ins. `input` forces a change on frames with a click or a key press, which
|
||||
/// covers what the hash doesn't list (panels shown, a tool picked).
|
||||
/// shortcuts, plug-ins and an open file dialog. `input` forces a change on frames with a click or
|
||||
/// a key press, which covers what the hash doesn't list (panels shown, a tool picked).
|
||||
fn state_hash(app: &VectorcraftApp, input: Option<u64>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
input.hash(&mut h);
|
||||
@@ -567,6 +596,7 @@ fn state_hash(app: &VectorcraftApp, input: Option<u64>) -> u64 {
|
||||
(d.uid, d.revision, d.history.undo.len(), d.history.redo.len(), d.transparency_grid, d.print_tiling).hash(&mut h);
|
||||
}
|
||||
(app.system_paste, app.last_effect.is_some(), app.ui.recent_files.len(), app.ui.recent_files.first(), app.recent_fonts().len()).hash(&mut h);
|
||||
app.file_dialog_open().hash(&mut h);
|
||||
(crate::i18n::current().code(), menus::plugin_revision()).hash(&mut h);
|
||||
crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed).hash(&mut h);
|
||||
h.finish()
|
||||
|
||||
@@ -143,6 +143,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn defaults_are_valid_commands() {
|
||||
@@ -154,4 +155,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Actions recorded with Align to Artboard and Paste in Place, and the default Center on
|
||||
/// Artboard, play on the artboard active when they play, not on the one active when they were
|
||||
/// recorded.
|
||||
#[test]
|
||||
fn actions_play_on_the_artboard_active_when_they_play() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let r = app.run("shape.rectangle", json!({"x": boards[1].x0 + 10.0, "y": boards[1].y0 + 15.0, "width": 20, "height": 20})).unwrap();
|
||||
let rect = vectorcraft_doc::NodeId(r["id"].as_u64().unwrap());
|
||||
let bounds = |app: &VectorcraftApp, ids: &[vectorcraft_doc::NodeId]| app.session.active().unwrap().doc.bounds_of(ids, false).unwrap();
|
||||
// Each recorded as the panel records an action, with artboard 0 active.
|
||||
let record = |app: &mut VectorcraftApp, name: &str, id: &str, params: Value| {
|
||||
let start = app.session.journal.len();
|
||||
app.run(id, params).unwrap();
|
||||
let steps = app.session.journal_for_action(start);
|
||||
app.ui.action_sets[0].actions.push(Action { name: name.into(), steps });
|
||||
app.ui.action_sets[0].actions.len() - 1
|
||||
};
|
||||
app.run("edit.copy", json!({})).unwrap();
|
||||
let align = record(&mut app, "Align Left", "object.align", json!({"horizontal": "left", "to": "artboard"}));
|
||||
assert_eq!(bounds(&app, &[rect]).x0, boards[0].x0, "aligned to artboard 0 while recording");
|
||||
let paste = record(&mut app, "Paste in Place", "edit.pasteInPlace", json!({}));
|
||||
app.view_mut().unwrap().artboard = 2;
|
||||
app.run("select.set", json!({"ids": [rect.0]})).unwrap();
|
||||
play(&mut app, 0, align).unwrap();
|
||||
assert_eq!(bounds(&app, &[rect]).x0, boards[2].x0, "aligned to artboard 2, active when the action plays");
|
||||
let ids: Vec<vectorcraft_doc::NodeId> = play(&mut app, 0, paste).unwrap()["results"][0]["ids"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| vectorcraft_doc::NodeId(v.as_u64().unwrap()))
|
||||
.collect();
|
||||
let b = bounds(&app, &ids);
|
||||
assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0), "pasted onto artboard 2, where it was on artboard 1");
|
||||
app.view_mut().unwrap().artboard = 1;
|
||||
app.run("select.set", json!({"ids": [rect.0]})).unwrap();
|
||||
let center = app.ui.action_sets[0].actions.iter().position(|a| a.name == "Center on Artboard").unwrap();
|
||||
play(&mut app, 0, center).unwrap();
|
||||
assert_eq!(bounds(&app, &[rect]).center(), boards[1].center(), "centered on artboard 1, the active one");
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ impl AlignTo {
|
||||
/// `object.align` params: a single object aligns to the artboard (Illustrator does the same).
|
||||
pub fn align_params(base: Value, to: AlignTo, n_selected: usize) -> Value {
|
||||
let mut p = base;
|
||||
let to = if n_selected == 1 && to == AlignTo::Selection { AlignTo::Artboard } else { to };
|
||||
let to = if n_selected == 1 { AlignTo::Artboard } else { to };
|
||||
p["to"] = json!(to.param());
|
||||
p
|
||||
}
|
||||
@@ -184,7 +184,47 @@ mod tests {
|
||||
assert_eq!(p["to"], "artboard");
|
||||
let p = align_params(json!({"vertical": "top"}), AlignTo::Selection, 3);
|
||||
assert_eq!(p["to"], "selection");
|
||||
assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "key");
|
||||
assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "artboard");
|
||||
}
|
||||
|
||||
/// A Shift-click that leaves the key object selected on its own lets the key go. With Align to
|
||||
/// Key Object chosen, the align buttons of the Control bar and the Properties panel, and then
|
||||
/// the Align panel's, align that object to the artboard.
|
||||
#[test]
|
||||
fn a_lone_object_aligns_to_the_artboard() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
let ctx = egui::Context::default();
|
||||
set_pstate(&ctx, "align-to", AlignTo::Key);
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
app.run("select.key", json!({"id": b})).unwrap();
|
||||
app.run("select.toggle", json!({"id": a})).unwrap();
|
||||
let id = vectorcraft_doc::NodeId(b.as_u64().unwrap());
|
||||
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
// `align_buttons` (the Control bar and the Properties panel): one frame finds the first
|
||||
// button, and the next clicks Horizontal Align Right, the third.
|
||||
let at = std::cell::Cell::new(egui::Pos2::ZERO);
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
at.set(ui.cursor().min);
|
||||
align_buttons(app, ui, 24.0);
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
frame(&mut app, vec![]);
|
||||
let right = at.get() + egui::vec2(2.0 * (24.0 + ctx.global_style().spacing.item_spacing.x) + 12.0, 12.0);
|
||||
let press = |pressed| egui::Event::PointerButton { pos: right, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(right), press(true)]);
|
||||
frame(&mut app, vec![press(false)]);
|
||||
assert_eq!(bounds(&app).x1, 200.0, "the artboard's right edge");
|
||||
// The Align panel's Horizontal Align Left.
|
||||
let p = align_params(json!({"horizontal": "left", "bounds": "geometric"}), align_to(&app, &ctx), selection_len(&app));
|
||||
app.run("object.align", p).unwrap();
|
||||
assert_eq!(bounds(&app).x0, 0.0, "the artboard's left edge");
|
||||
}
|
||||
|
||||
/// #541: while the selection has a key object Align aligns to it, whatever Align To says;
|
||||
|
||||
@@ -655,7 +655,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
// Proportional Metrics: full-width glyphs on the font's proportional widths (`palt`).
|
||||
// Proportional Metrics: full-width glyphs on the font's proportional widths (`palt`, `vpal`).
|
||||
let proportional = s.as_ref().is_some_and(|s| s.proportional_metrics);
|
||||
if menu_item(ui, tl!("Proportional Metrics"), has, proportional) {
|
||||
format(app, json!({"proportionalMetrics": !proportional}));
|
||||
|
||||
@@ -730,8 +730,10 @@ impl LibraryKind for GraphicStyleLibraries {
|
||||
}
|
||||
fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.separator();
|
||||
// As a command, so its file dialog, shown off the UI thread, loads the library when it
|
||||
// answers.
|
||||
if menu_item(ui, tl!("Other Library…"), true, false)
|
||||
&& let Err(e) = other_library(app, None)
|
||||
&& let Err(e) = app.run("window.graphicStyleLibrary.other", json!({}))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,24 @@ fn format(app: &mut VectorcraftApp, p: Value) {
|
||||
para_cmd(app, "text.setFormat", p);
|
||||
}
|
||||
|
||||
/// The alignment shortcuts' commands (Cmd+Shift+L, C, R, J and F, as in page layout apps) and the
|
||||
/// `justify` each sets ([`align`]).
|
||||
pub const ALIGN_COMMANDS: [(&str, &str); 5] = [
|
||||
("type.alignLeft", "left"),
|
||||
("type.alignCenter", "center"),
|
||||
("type.alignRight", "right"),
|
||||
("type.justifyLeft", "justifyLeft"),
|
||||
("type.justifyAll", "justifyAll"),
|
||||
];
|
||||
|
||||
/// Align the paragraphs of the selected type, or those the Type tool's selection touches, as
|
||||
/// `justify` (an [`ALIGN_COMMANDS`] alignment, #1111).
|
||||
pub(crate) fn align(app: &mut VectorcraftApp, justify: &str) -> Result<Value, String> {
|
||||
text_style(app).ok_or("select some type first")?;
|
||||
para_cmd(app, "text.setStyle", json!({ "justify": justify }));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
|
||||
if let Some((id, a, b)) = super::character::text_editing(app) {
|
||||
super::character::end_typing(app);
|
||||
|
||||
@@ -108,14 +108,14 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
run(app, "edit.colorSettings", json!({"bpc": !st.bpc}));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::flat_button(ui, tl!("Load Profile…"), 110.0).clicked()
|
||||
&& let Some(path) = app
|
||||
.services
|
||||
.pick_open
|
||||
.as_mut()
|
||||
.and_then(|f| f(&crate::FilePick { filters: vec![("ICC Profiles", &["icc", "icm"])], ..Default::default() }))
|
||||
{
|
||||
run(app, "color.loadProfile", json!({ "path": path }));
|
||||
if widgets::flat_button(ui, tl!("Load Profile…"), 110.0).clicked() {
|
||||
// Its file dialog, shown off the UI thread, loads the profile when it answers.
|
||||
crate::picks::button(app, |app| {
|
||||
let pick = crate::FilePick { filters: vec![("ICC Profiles", &["icc", "icm"])], ..Default::default() };
|
||||
if let Some(path) = crate::picks::open(app, &pick) {
|
||||
run(app, "color.loadProfile", json!({ "path": path }));
|
||||
}
|
||||
});
|
||||
}
|
||||
if widgets::flat_button(ui, tl!("Gamut Check"), 100.0).clicked() {
|
||||
match app.run("color.gamutCheck", json!({})) {
|
||||
|
||||
@@ -1092,8 +1092,10 @@ impl LibraryKind for SwatchLibraries {
|
||||
}
|
||||
fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.separator();
|
||||
// As a command, so its file dialog, shown off the UI thread, loads the library when it
|
||||
// answers.
|
||||
if menu_item(ui, tl!("Other Library…"), true, false)
|
||||
&& let Err(e) = other_library(app, None)
|
||||
&& let Err(e) = app.run("window.swatchLibrary.other", json!({}))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
|
||||
+120
-22
@@ -1,13 +1,20 @@
|
||||
//! File dialogs that leave the window running (#592).
|
||||
//! File dialogs that leave the window running (#592, #867).
|
||||
//!
|
||||
//! The app asks for a path where it needs one (Open, Save As, Export, Place, Relink, presets…)
|
||||
//! and goes on with it at once, so a dialog shown on the UI thread holds the window until it
|
||||
//! closes. On Windows the dialog runs the window's events meanwhile; on Linux nothing does, so a
|
||||
//! Wayland compositor finds the window not answering and offers to kill it, and on macOS they
|
||||
//! re-enter the window's event handler, which crashed the app when the panel was resized (#867).
|
||||
//! There the host shows dialogs on another thread ([`Services::start_pick`]): asking returns no path for now,
|
||||
//! and when the dialog answers, what asked (the command, the dialog's OK, a panel's button: the
|
||||
//! *entry*) runs again and gets the picked path at once.
|
||||
//! closes. On Windows the dialog runs the window's events meanwhile. On Linux nothing does, so a
|
||||
//! Wayland compositor finds the window not answering and offers to kill it (#592); on macOS the
|
||||
//! panel's modal loop runs inside winit's event handler, and an event winit had held back meanwhile
|
||||
//! re-enters it and aborts the app (#867). There the host shows dialogs off the UI thread (Linux)
|
||||
//! or as sheets (macOS) ([`Services::start_pick`]): asking returns no path for now, and when the
|
||||
//! dialog answers, what asked (the command, the dialog's OK, a panel's button: the *entry*) runs
|
||||
//! again, on the document it asked for, and gets the picked path at once. Everything that asks
|
||||
//! must run as an entry ([`as_entry`]): outside one the dialog is shown in line, which macOS
|
||||
//! refuses.
|
||||
//!
|
||||
//! While a dialog is open ([`VectorcraftApp::file_dialog_open`]) another is refused; on macOS,
|
||||
//! where it is a sheet on the window, the menu bar does nothing and the app neither quits nor
|
||||
//! closes documents, as under the modal dialog before.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
@@ -48,11 +55,35 @@ pub(crate) enum Entry {
|
||||
Call(Rc<dyn Fn(&mut VectorcraftApp)>),
|
||||
}
|
||||
|
||||
/// Commands that ask for a file but act on no document: their answer goes on even when the
|
||||
/// document active at the time has closed meanwhile.
|
||||
const DOCUMENT_FREE: &[&str] = &[
|
||||
"file.open",
|
||||
"file.newFromTemplate",
|
||||
"ui.installPlugin",
|
||||
"window.swatchLibrary.other",
|
||||
"window.graphicStyleLibrary.other",
|
||||
"shortcuts.import",
|
||||
"shortcuts.export",
|
||||
];
|
||||
|
||||
impl Entry {
|
||||
/// The document it acts on when it runs again: the active one, unless it acts on none.
|
||||
fn document(&self, app: &VectorcraftApp) -> Option<u64> {
|
||||
match self {
|
||||
Entry::Command(id, _) if DOCUMENT_FREE.contains(&id.as_str()) => None,
|
||||
_ => app.session.active().map(|d| d.uid),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A dialog shown off the UI thread, waiting for its answer.
|
||||
struct Waiting {
|
||||
request: PickRequest,
|
||||
answer: Receiver<PickAnswer>,
|
||||
entry: Entry,
|
||||
/// The active document when it was asked for ([`vectorcraft_engine::DocState::uid`]).
|
||||
doc: Option<u64>,
|
||||
}
|
||||
|
||||
/// The app's file dialogs ([`VectorcraftApp`]'s `picks`).
|
||||
@@ -63,6 +94,8 @@ pub(crate) struct Picks {
|
||||
waiting: Option<Waiting>,
|
||||
/// A picked answer for the entry running again: the dialog it asks for gets it.
|
||||
answer: Option<(PickRequest, PickAnswer)>,
|
||||
/// Another dialog was refused while this one is open ([`busy`]).
|
||||
refused: bool,
|
||||
}
|
||||
|
||||
impl Picks {
|
||||
@@ -70,6 +103,39 @@ impl Picks {
|
||||
pub(crate) fn is_entry_free(&self) -> bool {
|
||||
self.entry.is_none()
|
||||
}
|
||||
|
||||
/// A dialog shown off the UI thread is open.
|
||||
pub(crate) fn is_waiting(&self) -> bool {
|
||||
self.waiting.is_some()
|
||||
}
|
||||
|
||||
/// After a panic the guard caught (a bug): no entry is running any more, and an answer kept for
|
||||
/// one is stale. The open dialog's answer still arrives.
|
||||
pub(crate) fn recover(&mut self) {
|
||||
self.entry = None;
|
||||
self.answer = None;
|
||||
}
|
||||
|
||||
/// The kind of dialog open off the UI thread (`ui.inspect`'s `fileDialog`).
|
||||
pub(crate) fn waiting_kind(&self) -> Option<&'static str> {
|
||||
self.waiting.as_ref().map(|w| match w.request {
|
||||
PickRequest::Open(_) => "open",
|
||||
PickRequest::OpenMany => "place",
|
||||
PickRequest::Save(_) => "save",
|
||||
PickRequest::Folder => "folder",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What the status bar says when a dialog is asked for while another is open.
|
||||
pub(crate) fn busy() -> &'static str {
|
||||
tl!("A file dialog is open: choose there first")
|
||||
}
|
||||
|
||||
/// What the status bar says when the dialog whose button asked closed before the file dialog
|
||||
/// answered.
|
||||
pub(crate) fn dialog_gone() -> &'static str {
|
||||
tl!("The dialog was closed before its file dialog answered")
|
||||
}
|
||||
|
||||
/// Run `f` as `entry`: a dialog it asks for off the UI thread runs `entry` again when it
|
||||
@@ -102,8 +168,11 @@ pub(crate) fn in_dialog(
|
||||
let (again, kind) = (f.clone(), d.kind.clone());
|
||||
let entry = move || {
|
||||
Entry::Call(Rc::new(move |app: &mut VectorcraftApp| {
|
||||
let Some(mut d) = app.ui.dialog.take() else { return };
|
||||
let r = if d.kind == kind { again(app, &mut d) } else { Ok(()) };
|
||||
let Some(mut d) = app.ui.dialog.take_if(|d| d.kind == kind) else {
|
||||
app.status(dialog_gone());
|
||||
return;
|
||||
};
|
||||
let r = again(app, &mut d);
|
||||
app.ui.dialog = Some(d);
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
@@ -168,15 +237,23 @@ fn ask(app: &mut VectorcraftApp, request: PickRequest) -> Option<PickAnswer> {
|
||||
if !can(app, &request) {
|
||||
return None;
|
||||
}
|
||||
if let (Some(entry), Some(start)) = (app.picks.entry.clone(), app.services.start_pick.as_mut()) {
|
||||
if app.picks.waiting.is_some() {
|
||||
app.status(tl!("A file dialog is open: choose there first"));
|
||||
return None;
|
||||
}
|
||||
if let Some(answer) = start(request.clone()) {
|
||||
app.picks.waiting = Some(Waiting { request, answer, entry });
|
||||
return None;
|
||||
if app.services.start_pick.is_some() && app.picks.waiting.is_some() {
|
||||
app.status(busy());
|
||||
app.picks.refused = true;
|
||||
return None;
|
||||
}
|
||||
match (app.picks.entry.clone(), app.services.start_pick.as_mut()) {
|
||||
(Some(entry), Some(start)) => {
|
||||
if let Some(answer) = start(request.clone()) {
|
||||
let doc = entry.document(app);
|
||||
app.picks.waiting = Some(Waiting { request, answer, entry, doc });
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// Shown in line it holds the window (Linux) or is refused (macOS): what asks must run as
|
||||
// an entry.
|
||||
(None, Some(_)) => log::warn!("a file dialog asked for outside a command, a dialog's OK or a button: {request:?}"),
|
||||
_ => {}
|
||||
}
|
||||
let s = &mut app.services;
|
||||
let one = |p: Option<String>| Some(p.into_iter().collect());
|
||||
@@ -199,20 +276,39 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let answer = match w.answer.try_recv() {
|
||||
Ok(answer) => answer,
|
||||
Err(TryRecvError::Empty) => {
|
||||
// What asked gave up for now: no "cancelled" while the dialog is open.
|
||||
// What asked gave up for now: no "cancelled" while the dialog is open, unless it was
|
||||
// another dialog, refused.
|
||||
if app.ui.status == "cancelled" {
|
||||
app.ui.status.clear();
|
||||
app.ui.status = if std::mem::take(&mut app.picks.refused) { busy().into() } else { String::new() };
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
// The host wakes the window when the dialog answers; this is in case it can't.
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(1));
|
||||
return;
|
||||
}
|
||||
// The dialog's thread is gone: as cancelled.
|
||||
Err(TryRecvError::Disconnected) => vec![],
|
||||
};
|
||||
let Some(Waiting { request, entry, .. }) = app.picks.waiting.take() else { return };
|
||||
let Some(Waiting { request, entry, doc, .. }) = app.picks.waiting.take() else { return };
|
||||
app.picks.refused = false;
|
||||
// Once more: what waited for the dialog (files from the Finder, the menu bar) goes on.
|
||||
ctx.request_repaint();
|
||||
if answer.is_empty() {
|
||||
return;
|
||||
}
|
||||
// On the document it was asked for: another may be active by now (opened from the Finder, by
|
||||
// an agent), or it may be gone.
|
||||
if let Some(uid) = doc {
|
||||
let Some(i) = app.session.documents().iter().position(|d| d.uid == uid) else {
|
||||
app.status(tl!("The document was closed while its file dialog was open"));
|
||||
return;
|
||||
};
|
||||
if app.session.active_index() != Some(i)
|
||||
&& let Err(e) = app.run("document.activate", serde_json::json!({ "index": i }))
|
||||
{
|
||||
app.status(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
app.picks.answer = Some((request, answer));
|
||||
rerun(app, entry);
|
||||
// An entry that took another way this time leaves the answer unused.
|
||||
@@ -227,8 +323,10 @@ fn rerun(app: &mut VectorcraftApp, entry: Entry) {
|
||||
app.ui.dialog = Some(*d);
|
||||
crate::dialogs::confirm(app).map(|_| ())
|
||||
}
|
||||
// An entry again, so a dialog it asks for next is shown off the UI thread too.
|
||||
Entry::Call(f) => {
|
||||
f(app);
|
||||
let again = f.clone();
|
||||
as_entry(app, move || Entry::Call(again), |app| f(app));
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,8 +42,9 @@ pub struct DropAt {
|
||||
pub enum DropTarget {
|
||||
/// The canvas of the document it was dropped on.
|
||||
Place(DropAt),
|
||||
/// Opened as a document: no document is open, the drop missed the canvas (the tab bar), or it
|
||||
/// is a document dropped where the platform doesn't say.
|
||||
/// Opened as File → Open opens it: no document is open, the drop missed the canvas (the tab
|
||||
/// bar), it is a document dropped where the platform doesn't say, or it is a library, a presets
|
||||
/// file or a plug-in.
|
||||
Open,
|
||||
}
|
||||
|
||||
@@ -300,10 +301,12 @@ impl VectorcraftApp {
|
||||
/// Where file `name` dropped with the pointer at `pos` (screen points) goes: as in Illustrator,
|
||||
/// onto the canvas of the open document (Shift embeds it), else opened. Where the platform
|
||||
/// doesn't say (`None`: desktop drags carry no position), a document opens as a tab of its
|
||||
/// own and a picture or text is placed in the middle of the view.
|
||||
/// own and a picture or text is placed in the middle of the view. A library, a presets file or
|
||||
/// a plug-in opens wherever it is dropped, as File → Open opens it ([`io::opens_as`]).
|
||||
pub fn drop_target(&self, name: &str, pos: Option<egui::Pos2>, shift: bool) -> DropTarget {
|
||||
let (Some(rect), Some(st), Some(v)) = (self.canvas_rect, self.session.active(), self.view()) else { return DropTarget::Open };
|
||||
let at = match pos {
|
||||
_ if io::opens_as(name).is_some() => return DropTarget::Open,
|
||||
Some(p) if !rect.contains(p) => return DropTarget::Open,
|
||||
Some(p) => crate::canvas::Xf::new(rect, v).to_doc(p),
|
||||
None if is_document(name) => return DropTarget::Open,
|
||||
@@ -386,7 +389,9 @@ pub fn drain(app: &mut VectorcraftApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Files dragged over the window: the canvas is outlined where they would be placed.
|
||||
/// Files dragged over the window: the canvas is outlined where they would be placed. On the web
|
||||
/// the browser gives a dragged file's type but not its name, so the canvas is outlined for any
|
||||
/// file dragged over it, including libraries, presets and plug-ins, which open when dropped.
|
||||
pub fn paint_drop_highlight(app: &VectorcraftApp, ctx: &egui::Context, painter: &egui::Painter, rect: egui::Rect) {
|
||||
let pos = drag_pos(ctx);
|
||||
let placed = ctx.input(|i| {
|
||||
|
||||
@@ -464,6 +464,8 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
|
||||
Some(w) => w(&path, &bytes).map(|_| json!({"path": path})),
|
||||
None => Err("no file writer".into()),
|
||||
},
|
||||
// Its save dialog is open: the file is written when it answers.
|
||||
None if app.file_dialog_open() => Err("cancelled".into()),
|
||||
None => match app.services.download.as_mut() {
|
||||
Some(dl) => {
|
||||
dl("VectorCraft Shortcuts.json", &bytes);
|
||||
@@ -698,33 +700,13 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let import = d.fields.remove("__import").is_some();
|
||||
let export = d.fields.remove("__export").is_some();
|
||||
app.ui.dialog = Some(d);
|
||||
// Their file dialogs, shown off the UI thread, import into or export from the dialog as it is
|
||||
// when they answer.
|
||||
if import {
|
||||
// Import replaces the working set (committed on OK like other edits).
|
||||
let saved = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone());
|
||||
match run_command(app, "shortcuts.import", &json!({})) {
|
||||
Some(Ok(_)) => {
|
||||
let (o, s) = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone());
|
||||
(app.ui.shortcut_overrides, app.ui.shortcut_set) = saved;
|
||||
sync(&app.ui);
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("overrides".into(), serde_json::to_value(&o).unwrap_or(json!({})));
|
||||
d.fields.insert("set".into(), json!(s));
|
||||
d.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set.")));
|
||||
}
|
||||
}
|
||||
Some(Err(e)) if e != "cancelled" => app.status(e),
|
||||
_ => {}
|
||||
}
|
||||
crate::picks::button(app, import_into_dialog);
|
||||
}
|
||||
if export {
|
||||
let (ov, set) = app.ui.dialog.as_ref().map(|d| (dialog_overrides(d), d.str("set"))).unwrap_or_default();
|
||||
let saved = std::mem::replace(&mut app.ui.shortcut_overrides, ov);
|
||||
let saved_set = std::mem::replace(&mut app.ui.shortcut_set, set);
|
||||
if let Some(Err(e)) = run_command(app, "shortcuts.export", &json!({})) {
|
||||
app.status(e);
|
||||
}
|
||||
app.ui.shortcut_overrides = saved;
|
||||
app.ui.shortcut_set = saved_set;
|
||||
crate::picks::button(app, export_from_dialog);
|
||||
}
|
||||
if cancel {
|
||||
app.ui.dialog = None;
|
||||
@@ -733,6 +715,51 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Keyboard Shortcuts dialog, while it is open.
|
||||
fn shortcuts_dialog(app: &VectorcraftApp) -> bool {
|
||||
app.ui.dialog.as_ref().is_some_and(|d| d.kind == "shortcuts")
|
||||
}
|
||||
|
||||
/// The dialog's Import…: the imported set replaces its working set (committed on OK like other
|
||||
/// edits).
|
||||
fn import_into_dialog(app: &mut VectorcraftApp) {
|
||||
if !shortcuts_dialog(app) {
|
||||
app.status(crate::picks::dialog_gone());
|
||||
return;
|
||||
}
|
||||
let saved = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone());
|
||||
match run_command(app, "shortcuts.import", &json!({})) {
|
||||
Some(Ok(_)) => {
|
||||
let (o, s) = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone());
|
||||
(app.ui.shortcut_overrides, app.ui.shortcut_set) = saved;
|
||||
sync(&app.ui);
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("overrides".into(), serde_json::to_value(&o).unwrap_or(json!({})));
|
||||
d.fields.insert("set".into(), json!(s));
|
||||
d.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set.")));
|
||||
}
|
||||
}
|
||||
Some(Err(e)) if e != "cancelled" => app.status(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog's Export…: its working set, as edited so far.
|
||||
fn export_from_dialog(app: &mut VectorcraftApp) {
|
||||
if !shortcuts_dialog(app) {
|
||||
app.status(crate::picks::dialog_gone());
|
||||
return;
|
||||
}
|
||||
let (ov, set) = app.ui.dialog.as_ref().map(|d| (dialog_overrides(d), d.str("set"))).unwrap_or_default();
|
||||
let saved = std::mem::replace(&mut app.ui.shortcut_overrides, ov);
|
||||
let saved_set = std::mem::replace(&mut app.ui.shortcut_set, set);
|
||||
if let Some(Err(e)) = run_command(app, "shortcuts.export", &json!({})) {
|
||||
app.status(e);
|
||||
}
|
||||
app.ui.shortcut_overrides = saved;
|
||||
app.ui.shortcut_set = saved_set;
|
||||
}
|
||||
|
||||
/// Apply a recorded chord to `key` in the working set, warning about (and resolving) conflicts.
|
||||
fn record(d: &mut Dialog, ov: &mut BTreeMap<String, String>, key: &str, chord: &str) {
|
||||
d.fields.insert("__recording".into(), json!(""));
|
||||
|
||||
@@ -511,6 +511,44 @@ mod tests {
|
||||
assert!(!app.ui.view.bounding_box);
|
||||
}
|
||||
|
||||
/// Cmd+Shift+L, C, R, J and F align paragraphs (#1111): every paragraph of the selected type,
|
||||
/// or while the Type tool edits text, those its selection touches.
|
||||
#[test]
|
||||
fn cmd_shift_letters_align_paragraphs() {
|
||||
use vectorcraft_doc::Justify;
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
|
||||
let chord =
|
||||
|key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT };
|
||||
let paras = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).justify).collect::<Vec<_>>(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
for (key, want) in [
|
||||
(Key::C, Justify::Center),
|
||||
(Key::R, Justify::Right),
|
||||
(Key::J, Justify::JustifyLeft),
|
||||
(Key::F, Justify::JustifyAll),
|
||||
(Key::L, Justify::Left),
|
||||
] {
|
||||
frame(&mut app, vec![chord(key)]);
|
||||
assert_eq!(paras(&app), [want, want], "{key:?}");
|
||||
}
|
||||
// With the Type tool, only the paragraph its selection touches.
|
||||
app.select_tool("type");
|
||||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||||
app.session.set_tool_option("select", &json!({"start": 5, "end": 6}));
|
||||
assert!(app.session.tool_wants_text());
|
||||
frame(&mut app, vec![chord(Key::R)]);
|
||||
assert_eq!(paras(&app), [Justify::Left, Justify::Right]);
|
||||
// Nothing selected: nothing to align.
|
||||
app.select_tool("selection");
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
assert!(!crate::menus::enabled(&app, "type.alignCenter"));
|
||||
}
|
||||
|
||||
/// Tab shows and hides the panels, and in the Type tool it types a tab, without moving the
|
||||
/// keyboard focus on to a field: the keys after it (a tool letter, the next letter typed) still
|
||||
/// work.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! reads from a paste itself ([`VectorcraftApp::paste_from_host`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, EMF, Flavour, PASTE_ORDER, PDF, SVG, TEXT, is_address, looks_like_svg};
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, EMF, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
@@ -90,8 +90,9 @@ impl VectorcraftApp {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(f) = cb.read(&PASTE_ORDER) else { return Ok(()) };
|
||||
// Text that is only an address goes with a browser's copied picture: the picture.
|
||||
let f = if f.mime == TEXT && is_address(&String::from_utf8_lossy(&f.data)) { cb.read(&[BITMAP]).unwrap_or(f) } else { f };
|
||||
// Text that only names a picture (its address, or its image markup) goes with a
|
||||
// browser's copied picture: the picture.
|
||||
let f = if f.mime == TEXT && names_a_picture(&String::from_utf8_lossy(&f.data)) { cb.read(&[BITMAP]).unwrap_or(f) } else { f };
|
||||
import_command(&f, center)
|
||||
}
|
||||
// Text only: SVG markup is art, other text is left alone.
|
||||
|
||||
@@ -107,3 +107,26 @@ fn white_background_hides_the_grid_and_paper_tints_the_artboard() {
|
||||
let paper = egui::Color32::from_rgb(0xff, 0xee, 0xcc);
|
||||
assert!(all.iter().any(|s| matches!(s, egui::Shape::Path(p) if p.fill == paper)), "the artboard shows the paper colour");
|
||||
}
|
||||
|
||||
/// An artboard's name sits above the artboard's own top-left corner, also when a rotated view
|
||||
/// turns that corner away from the corner of its box on screen (#1095).
|
||||
#[test]
|
||||
fn an_artboard_name_stays_at_its_corner_in_a_rotated_view() {
|
||||
let mut app = app();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
for rotation in [0.0, 60.0, -74.0] {
|
||||
app.view_mut().unwrap().rotation = rotation;
|
||||
let (shapes, _, _) = frame(&mut app);
|
||||
let corner = outlines(&shapes, egui::Color32::from_gray(0))[0][0];
|
||||
let mut all = vec![];
|
||||
flatten(&shapes, &mut all);
|
||||
let label = all
|
||||
.iter()
|
||||
.find_map(|s| match s {
|
||||
egui::Shape::Text(t) if t.galley.text().starts_with("01 - ") => Some(t.pos + egui::vec2(0.0, t.galley.size().y + 4.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{rotation}°: no name"));
|
||||
assert!((label - corner).length() < 1.0, "{rotation}°: the name ends at {label:?}, the corner is {corner:?}");
|
||||
}
|
||||
}
|
||||
@@ -429,3 +429,58 @@ fn the_app_bar_hides_its_menus_with_the_mac_menu_bar() {
|
||||
assert_eq!(n(&native) + 1, n(&in_window), "{title}");
|
||||
}
|
||||
}
|
||||
|
||||
/// While a file dialog's sheet is on the window the menu bar does nothing, as under the modal
|
||||
/// dialog before: every item is disabled but Hide, Hide Others and Minimize, a click that raced it
|
||||
/// isn't run (Minimize still is), and a key equivalent doesn't reach the app. Once the dialog
|
||||
/// answers, the menus are as before and work again.
|
||||
#[test]
|
||||
fn the_menu_bar_is_inert_while_a_file_dialog_is_open() {
|
||||
let mut m = Mac::new(true);
|
||||
m.app.services.dialogs_are_sheets = true;
|
||||
let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut m.app.services);
|
||||
m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap();
|
||||
m.app.run("select.none", json!({})).unwrap();
|
||||
m.frame(vec![]);
|
||||
let rows = |bar: &MenuBar| bar.items().iter().map(|it| (it.command, it.params.clone(), it.enabled)).collect::<Vec<_>>();
|
||||
let before = m.fake.last();
|
||||
let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE];
|
||||
assert!(system.iter().all(|c| find(&before, c).enabled));
|
||||
assert!(m.app.run("file.open", json!({})).is_err());
|
||||
assert!(m.app.file_dialog_open());
|
||||
m.frame(vec![]);
|
||||
let inert = m.fake.last();
|
||||
for it in inert.items() {
|
||||
let kept = it.command.is_some_and(|c| system.contains(&c));
|
||||
assert_eq!(it.enabled, kept, "{:?} {} while the dialog is open", it.command, it.label);
|
||||
}
|
||||
// A click that raced the menu going inert isn't run; Minimize is.
|
||||
let rulers = (m.app.ui.view.rulers, find(&before, "view.rulers").clone());
|
||||
m.fake.report(native_menu::Event::Click(rulers.1.clone()));
|
||||
m.frame(vec![]);
|
||||
assert_eq!(m.app.ui.view.rulers, rulers.0);
|
||||
m.fake.report(native_menu::Event::Click(find(&inert, native_menu::MINIMIZE).clone()));
|
||||
assert!(m.frame(vec![]).viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Minimized(true)));
|
||||
// A key equivalent that raced it doesn't reach egui.
|
||||
let mut k = crate::shortcuts::parse("Cmd+A").unwrap();
|
||||
k.modifiers.mac_cmd = k.modifiers.command;
|
||||
m.fake.report(native_menu::Event::Key(k));
|
||||
let mut raw = egui::RawInput::default();
|
||||
m.app.raw_input_hook(&mut raw);
|
||||
assert!(!raw.events.iter().any(|e| matches!(e, Event::Key { .. })), "{:?}", raw.events);
|
||||
m.key_equivalent("Cmd+A");
|
||||
assert_eq!(m.selected(), 0, "⌘A selected nothing");
|
||||
// Cancelled: the menus are as before, and work again.
|
||||
shown.answer(&[]);
|
||||
m.frame(vec![]);
|
||||
m.frame(vec![]);
|
||||
assert!(!m.app.file_dialog_open());
|
||||
// Every row from before is back as it was (tests running alongside may add plug-in rows).
|
||||
let after = rows(&m.fake.last());
|
||||
assert!(rows(&before).iter().all(|r| after.contains(r)), "the menus are as before");
|
||||
m.fake.report(native_menu::Event::Click(rulers.1));
|
||||
m.frame(vec![]);
|
||||
assert_eq!(m.app.ui.view.rulers, !rulers.0);
|
||||
m.key_equivalent("Cmd+A");
|
||||
assert_eq!(m.selected(), 2, "⌘A selected every object");
|
||||
}
|
||||
@@ -1,65 +1,159 @@
|
||||
//! File dialogs shown off the UI thread (Linux, #592): what asked runs again with the answer.
|
||||
//! File dialogs shown off the UI thread (Linux, #592) or as sheets on the window (macOS, #867):
|
||||
//! what asked runs again with the answer, on the document it asked for; while a sheet is open
|
||||
//! nothing closes or quits; and nothing outside `picks` shows a dialog in line.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use serde_json::json;
|
||||
use egui::{Pos2, Rect, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::panels::graphic_styles::GraphicStyleLibraries;
|
||||
use crate::panels::library_panel::library_menu;
|
||||
use crate::panels::swatches::SwatchLibraries;
|
||||
use crate::picks::{self, PickRequest};
|
||||
use crate::state::Dialog;
|
||||
use crate::{FilePick, Services, VectorcraftApp};
|
||||
|
||||
const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="10" height="10"/></svg>"#;
|
||||
|
||||
/// What the status bar says when an answer comes for a document no longer open.
|
||||
const CLOSED: &str = "The document was closed while its file dialog was open";
|
||||
|
||||
/// A dialog asked for, and where its answer goes.
|
||||
type Asked = (PickRequest, Sender<Vec<String>>);
|
||||
|
||||
/// The dialogs showing.
|
||||
#[derive(Clone, Default)]
|
||||
struct Shown(Rc<RefCell<Vec<Asked>>>);
|
||||
pub(crate) struct Shown(Rc<RefCell<Vec<Asked>>>);
|
||||
|
||||
impl Shown {
|
||||
fn requests(&self) -> Vec<PickRequest> {
|
||||
pub(crate) fn requests(&self) -> Vec<PickRequest> {
|
||||
self.0.borrow().iter().map(|(r, _)| r.clone()).collect()
|
||||
}
|
||||
/// The last dialog shown answers `paths`.
|
||||
fn answer(&self, paths: &[&str]) {
|
||||
pub(crate) fn answer(&self, paths: &[&str]) {
|
||||
let (_, tx) = self.0.borrow_mut().pop().expect("a dialog is showing");
|
||||
tx.send(paths.iter().map(|p| p.to_string()).collect()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A desktop app whose dialogs show off the UI thread (none in line), reading [`SVG`] from any path
|
||||
/// and writing files to `written`.
|
||||
fn app() -> (VectorcraftApp, Shown, Rc<RefCell<Vec<String>>>) {
|
||||
let (shown, written) = (Shown::default(), Rc::new(RefCell::new(vec![])));
|
||||
/// The paths files were written to.
|
||||
pub(crate) type Written = Rc<RefCell<Vec<String>>>;
|
||||
|
||||
/// Give `services` dialogs shown off the UI thread (none in line: those fail the test), a reader of
|
||||
/// [`SVG`] at any path and a writer that lists the paths written.
|
||||
pub(crate) fn off_the_ui_thread(services: &mut Services) -> (Shown, Written) {
|
||||
let (shown, written) = (Shown::default(), Written::default());
|
||||
let (s, w) = (shown.clone(), written.clone());
|
||||
let in_line = |_: &FilePick| -> Option<String> { panic!("no dialog on the UI thread") };
|
||||
let services = Services {
|
||||
pick_open: Some(Box::new(in_line)),
|
||||
pick_save: Some(Box::new(in_line)),
|
||||
pick_folder: Some(Box::new(|| panic!("no dialog on the UI thread"))),
|
||||
read: Some(Box::new(|_: &str| Ok(SVG.to_vec()))),
|
||||
write: Some(Box::new(move |path: &str, _: &[u8]| {
|
||||
w.borrow_mut().push(path.to_string());
|
||||
Ok(())
|
||||
})),
|
||||
start_pick: Some(Box::new(move |request: PickRequest| {
|
||||
let (tx, rx) = channel();
|
||||
s.0.borrow_mut().push((request, tx));
|
||||
Some(rx)
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
services.pick_open = Some(Box::new(in_line));
|
||||
services.pick_save = Some(Box::new(in_line));
|
||||
services.pick_open_multi = Some(Box::new(|| panic!("no dialog on the UI thread")));
|
||||
services.pick_folder = Some(Box::new(|| panic!("no dialog on the UI thread")));
|
||||
services.read = Some(Box::new(|_: &str| Ok(SVG.to_vec())));
|
||||
services.write = Some(Box::new(move |path: &str, _: &[u8]| {
|
||||
w.borrow_mut().push(path.to_string());
|
||||
Ok(())
|
||||
}));
|
||||
services.start_pick = Some(Box::new(move |request: PickRequest| {
|
||||
let (tx, rx) = channel();
|
||||
s.0.borrow_mut().push((request, tx));
|
||||
Some(rx)
|
||||
}));
|
||||
(shown, written)
|
||||
}
|
||||
|
||||
/// A desktop app whose dialogs show off the UI thread, as sheets on the window when `sheets`
|
||||
/// (macOS) or as windows of their own (Linux).
|
||||
fn with_dialogs(sheets: bool) -> (VectorcraftApp, Shown, Written) {
|
||||
let mut services = Services { dialogs_are_sheets: sheets, ..Default::default() };
|
||||
let (shown, written) = off_the_ui_thread(&mut services);
|
||||
(VectorcraftApp::new(Session::new(), services), shown, written)
|
||||
}
|
||||
|
||||
/// The macOS app: its dialogs are sheets.
|
||||
fn app() -> (VectorcraftApp, Shown, Written) {
|
||||
with_dialogs(true)
|
||||
}
|
||||
|
||||
fn poll(app: &mut VectorcraftApp) {
|
||||
picks::poll(app, &egui::Context::default());
|
||||
}
|
||||
|
||||
fn dialog_kind(app: &VectorcraftApp) -> Option<&str> {
|
||||
app.ui.dialog.as_ref().map(|d| d.kind.as_str())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------- driven like a person
|
||||
|
||||
/// Room for a dialog, a panel or a menu (the libraries menu lists every library).
|
||||
const SCREEN: (f32, f32) = (1600.0, 6000.0);
|
||||
|
||||
/// One frame of `draw` with `events` → its texts and where they were drawn.
|
||||
fn frame(
|
||||
ctx: &egui::Context,
|
||||
app: &mut VectorcraftApp,
|
||||
events: Vec<egui::Event>,
|
||||
draw: &impl Fn(&mut VectorcraftApp, &mut egui::Ui),
|
||||
) -> Vec<(String, Rect)> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(SCREEN.0, SCREEN.1))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| draw(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
/// Where text `label` was drawn (menu items pad theirs).
|
||||
fn at(texts: &[(String, Rect)], label: &str) -> Pos2 {
|
||||
texts
|
||||
.iter()
|
||||
.find(|(t, _)| t.trim() == label)
|
||||
.unwrap_or_else(|| panic!("no `{label}` in {:?}", texts.iter().map(|t| &t.0).collect::<Vec<_>>()))
|
||||
.1
|
||||
.center()
|
||||
}
|
||||
|
||||
/// Draw `draw` until it settles, then click its text `label`.
|
||||
fn click(app: &mut VectorcraftApp, label: &str, draw: impl Fn(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
frame(&ctx, app, vec![], &draw);
|
||||
frame(&ctx, app, vec![], &draw);
|
||||
let pos = at(&frame(&ctx, app, vec![], &draw), label);
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&ctx, app, vec![egui::Event::PointerMoved(pos), button(true), button(false)], &draw);
|
||||
}
|
||||
|
||||
/// The open dialog, drawn as the app draws it.
|
||||
fn dialogs(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
crate::dialogs::show(app, ui.ctx());
|
||||
}
|
||||
|
||||
/// The active document written to `Poster.vectorcraft` in a folder of its own named after `tag`
|
||||
/// → (the folder, the file).
|
||||
fn saved_document(app: &mut VectorcraftApp, tag: &str) -> (std::path::PathBuf, String) {
|
||||
let b64 = app.run("document.serialize", json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string();
|
||||
let dir = std::env::temp_dir().join(format!("vc-picks-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("Poster.vectorcraft");
|
||||
std::fs::write(&file, vectorcraft_format::base64_decode(&b64).unwrap()).unwrap();
|
||||
(dir, file.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- the tests
|
||||
|
||||
#[test]
|
||||
fn file_open_opens_what_its_dialog_picks_when_it_answers() {
|
||||
let (mut app, shown, _) = app();
|
||||
@@ -95,7 +189,7 @@ fn save_as_goes_on_with_the_path_picked() {
|
||||
shown.answer(&["/art/poster.svg"]);
|
||||
poll(&mut app);
|
||||
// SVG Options asks next, and its OK writes the file there.
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("svgOptions"));
|
||||
assert_eq!(dialog_kind(&app), Some("svgOptions"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(*written.borrow(), ["/art/poster.svg"]);
|
||||
}
|
||||
@@ -111,3 +205,613 @@ fn a_dialogs_folder_button_fills_its_field_when_the_picker_answers() {
|
||||
poll(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("folder")).as_deref(), Some("/out"));
|
||||
}
|
||||
|
||||
/// Separations Preview › Load Profile… asks off the UI thread, and the profile picked is loaded
|
||||
/// when the dialog answers (here a missing file, whose error names it).
|
||||
#[test]
|
||||
fn load_profile_asks_off_the_ui_thread() {
|
||||
let (mut app, shown, _) = app();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
click(&mut app, "Load Profile…", crate::panels::separations::show);
|
||||
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(p)] if p.filters.iter().any(|(_, e)| e.contains(&"icc"))));
|
||||
poll(&mut app);
|
||||
assert!(app.ui.status.is_empty(), "{}", app.ui.status);
|
||||
shown.answer(&["/no/such/profile.icc"]);
|
||||
poll(&mut app);
|
||||
assert!(app.ui.status.contains("/no/such/profile.icc"), "color.loadProfile ran on the path: {}", app.ui.status);
|
||||
}
|
||||
|
||||
/// The Swatches libraries menu's Other Library… asks off the UI thread; the file picked (another
|
||||
/// document) loads as a library and opens in the library panel.
|
||||
#[test]
|
||||
fn other_swatch_library_asks_off_the_ui_thread() {
|
||||
let (mut app, shown, _) = app();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("swatch.new", json!({"name": "Signal", "color": "#ff3300"})).unwrap();
|
||||
let (dir, file) = saved_document(&mut app, "swatches");
|
||||
click(&mut app, "Other Library…", library_menu::<SwatchLibraries>);
|
||||
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
|
||||
poll(&mut app);
|
||||
assert!(app.ui.library_panel.is_none() && app.ui.status.is_empty(), "{}", app.ui.status);
|
||||
shown.answer(&[&file]);
|
||||
poll(&mut app);
|
||||
let open = app.ui.library_panel.clone().unwrap_or_else(|| panic!("no library open: {}", app.ui.status));
|
||||
assert_eq!(open.kind, "swatches");
|
||||
let (_, lib) = vectorcraft_engine::cmd::swatchlib::library(&app.session, &open.id).unwrap();
|
||||
assert!(lib.swatch("Signal").is_some(), "{}", open.id);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The Graphic Styles libraries menu's Other Library… asks off the UI thread; the file picked
|
||||
/// (another document) loads as a library and opens in the library panel.
|
||||
#[test]
|
||||
fn other_graphic_style_library_asks_off_the_ui_thread() {
|
||||
let (mut app, shown, _) = app();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
|
||||
app.run("graphicStyle.new", json!({"name": "Poster Look", "id": id})).unwrap();
|
||||
let (dir, file) = saved_document(&mut app, "styles");
|
||||
click(&mut app, "Other Library…", library_menu::<GraphicStyleLibraries>);
|
||||
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
|
||||
shown.answer(&[&file]);
|
||||
poll(&mut app);
|
||||
let open = app.ui.library_panel.clone().unwrap_or_else(|| panic!("no library open: {}", app.ui.status));
|
||||
assert_eq!(open.kind, "graphicStyles");
|
||||
let (_, lib) = vectorcraft_engine::cmd::stylelib::library(&app.session, &open.id).unwrap();
|
||||
assert!(lib.style("Poster Look").is_some(), "{}", open.id);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The Keyboard Shortcuts dialog's Import… and Export… ask off the UI thread: the set imported
|
||||
/// replaces the dialog's working set (kept on OK), Export… writes it where the dialog answers, and
|
||||
/// an answer once the dialog is closed does nothing.
|
||||
#[test]
|
||||
fn keyboard_shortcuts_import_and_export_ask_off_the_ui_thread() {
|
||||
let (mut app, shown, written) = app();
|
||||
let set = crate::shortcut_editor::export_json("Custom", &BTreeMap::from([("view.grid".to_string(), "Ctrl+Alt+G".to_string())]));
|
||||
let (_, want) = crate::shortcut_editor::import_json(&set).unwrap();
|
||||
let bytes = serde_json::to_vec(&set).unwrap();
|
||||
app.services.read = Some(Box::new(move |_: &str| Ok(bytes.clone())));
|
||||
app.run("edit.keyboardShortcuts", json!({})).unwrap();
|
||||
click(&mut app, "Import…", dialogs);
|
||||
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
|
||||
shown.answer(&["/keys/mine.json"]);
|
||||
poll(&mut app);
|
||||
let d = app.ui.dialog.as_ref().expect("the dialog stays open");
|
||||
assert_eq!((d.kind.as_str(), &d.fields["overrides"]), ("shortcuts", &serde_json::to_value(&want).unwrap()));
|
||||
assert!(d.str("__message").starts_with("Imported"), "{}", d.str("__message"));
|
||||
assert!(app.ui.shortcut_overrides.is_empty(), "kept only on OK");
|
||||
click(&mut app, "Export…", dialogs);
|
||||
assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)]));
|
||||
shown.answer(&["/keys/out.json"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(*written.borrow(), ["/keys/out.json"]);
|
||||
// Closed before the dialog answers: nothing is written, and the status says why.
|
||||
click(&mut app, "Export…", dialogs);
|
||||
app.ui.dialog = None;
|
||||
shown.answer(&["/keys/late.json"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(*written.borrow(), ["/keys/out.json"]);
|
||||
assert_eq!(app.ui.status, picks::dialog_gone());
|
||||
// The command waits for its dialog too: no data in place of the file.
|
||||
assert_eq!(app.run("shortcuts.export", json!({})), Err("cancelled".to_string()));
|
||||
shown.answer(&["/keys/command.json"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(*written.borrow(), ["/keys/out.json", "/keys/command.json"]);
|
||||
}
|
||||
|
||||
/// A dialog's folder button answered after the dialog closed fills nothing, and the status says
|
||||
/// why.
|
||||
#[test]
|
||||
fn a_folder_for_a_closed_dialog_is_dropped() {
|
||||
let (mut app, shown, _) = app();
|
||||
let mut d = Dialog::new("exportForScreens", json!({"folder": ""}));
|
||||
app.ui.dialog = Some(d.clone());
|
||||
picks::folder_field(&mut app, &mut d, "folder");
|
||||
app.ui.dialog = None;
|
||||
shown.answer(&["/out"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(dialog_kind(&app), None);
|
||||
assert_eq!(app.ui.status, picks::dialog_gone());
|
||||
}
|
||||
|
||||
/// Export… in the PDF, Print, Transparency Flattener and Perspective Grid Presets dialogs asks off
|
||||
/// the UI thread and writes the selected preset where the dialog answers; the dialog stays open.
|
||||
#[test]
|
||||
fn preset_dialogs_export_asks_off_the_ui_thread() {
|
||||
use vectorcraft_engine::cmd::{flatten, pdfcmds, perspgrid, printpresets};
|
||||
let cases = [
|
||||
("pdf.preset.save", json!({"name": "Mine"}), "ui.pdfPresetsDialog", pdfcmds::PRESET_FORMAT),
|
||||
("print.presets.save", json!({"name": "Mine", "settings": {"copies": 2}}), "ui.printPresetsDialog", printpresets::PRESET_FORMAT),
|
||||
("flattener.presets.save", json!({"name": "Mine", "balance": 20}), "ui.flattenerPresetsDialog", flatten::PRESET_FORMAT),
|
||||
("perspective.presets.save", json!({"name": "Mine"}), "ui.perspectivePresetsDialog", perspgrid::PRESET_FORMAT),
|
||||
];
|
||||
for (save, params, open, ext) in cases {
|
||||
let (mut app, shown, written) = app();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run(save, params).unwrap();
|
||||
app.run(open, json!({"selected": "Mine"})).unwrap();
|
||||
let kind = dialog_kind(&app).unwrap().to_string();
|
||||
click(&mut app, "Export…", dialogs);
|
||||
assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)]), "{open}: {:?}", shown.requests());
|
||||
assert!(written.borrow().is_empty(), "{open}");
|
||||
let path = format!("/presets/mine.{ext}");
|
||||
shown.answer(&[&path]);
|
||||
poll(&mut app);
|
||||
assert_eq!(*written.borrow(), [path], "{open}: {}", app.ui.status);
|
||||
assert_eq!(dialog_kind(&app), Some(kind.as_str()), "{open}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Save As answers on the document that asked for it: the one made active meanwhile isn't saved,
|
||||
/// and the one that asked is active again.
|
||||
#[test]
|
||||
fn save_as_saves_the_document_that_asked() {
|
||||
let (mut app, shown, written) = app();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("file.new", json!({"width": 200, "height": 160})).unwrap();
|
||||
let uids: Vec<u64> = app.session.documents().iter().map(|d| d.uid).collect();
|
||||
app.run("document.activate", json!({"index": 0})).unwrap();
|
||||
assert!(app.run("file.saveAs", json!({})).is_err());
|
||||
app.run("document.activate", json!({"index": 1})).unwrap();
|
||||
shown.answer(&["/art/a.svg"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(app.session.active().map(|d| d.uid), Some(uids[0]));
|
||||
assert_eq!(dialog_kind(&app), Some("svgOptions"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(*written.borrow(), ["/art/a.svg"]);
|
||||
let paths: Vec<Option<String>> = app.session.documents().iter().map(|d| d.path.clone()).collect();
|
||||
assert_eq!(paths, [Some("/art/a.svg".to_string()), None]);
|
||||
assert_eq!(app.session.active().map(|d| d.uid), Some(uids[0]));
|
||||
}
|
||||
|
||||
/// An answer for a document closed meanwhile (here through the engine, as an agent can) is dropped:
|
||||
/// nothing is written, no other document is saved, and the status says why.
|
||||
#[test]
|
||||
fn an_answer_for_a_closed_document_is_dropped() {
|
||||
let (mut app, shown, written) = app();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("file.new", json!({"width": 200, "height": 160})).unwrap();
|
||||
app.run("document.activate", json!({"index": 0})).unwrap();
|
||||
assert!(app.run("file.saveAs", json!({})).is_err());
|
||||
app.session.execute("file.close", &json!({"index": 0})).unwrap();
|
||||
app.sync_views();
|
||||
shown.answer(&["/art/a.svg"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(app.ui.status, CLOSED);
|
||||
assert!(written.borrow().is_empty());
|
||||
assert_eq!(dialog_kind(&app), None, "no SVG Options");
|
||||
assert_eq!(app.session.documents().iter().map(|d| d.path.clone()).collect::<Vec<_>>(), [None]);
|
||||
assert!(!app.file_dialog_open());
|
||||
}
|
||||
|
||||
/// A button whose action asks for an open dialog and, once that answers, a save dialog shows both
|
||||
/// off the UI thread, then finishes with both paths.
|
||||
#[test]
|
||||
fn a_buttons_second_dialog_shows_off_the_ui_thread_too() {
|
||||
let (mut app, shown, _) = app();
|
||||
let (from, done) = (Rc::new(RefCell::new(None::<String>)), Rc::new(RefCell::new(vec![])));
|
||||
let (f, d) = (from.clone(), done.clone());
|
||||
picks::button(&mut app, move |app| {
|
||||
if f.borrow().is_none() {
|
||||
let picked = picks::open(app, &FilePick::default());
|
||||
*f.borrow_mut() = picked;
|
||||
}
|
||||
let Some(source) = f.borrow().clone() else { return };
|
||||
if let Some(to) = picks::save(app, &FilePick::named("copy.svg")) {
|
||||
d.borrow_mut().push((source, to));
|
||||
}
|
||||
});
|
||||
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
|
||||
shown.answer(&["/art/in.svg"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(from.borrow().as_deref(), Some("/art/in.svg"));
|
||||
assert_eq!(shown.requests(), [PickRequest::Save(FilePick::named("copy.svg"))]);
|
||||
assert!(app.file_dialog_open() && done.borrow().is_empty());
|
||||
shown.answer(&["/art/out.svg"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(*done.borrow(), [("/art/in.svg".to_string(), "/art/out.svg".to_string())]);
|
||||
assert!(!app.file_dialog_open());
|
||||
}
|
||||
|
||||
/// While a dialog's sheet is on the window (macOS) nothing quits or closes: Quit, Close and Close
|
||||
/// All say a file dialog is open and close nothing; once it answers or is cancelled they work
|
||||
/// again.
|
||||
#[test]
|
||||
fn nothing_closes_or_quits_while_a_sheet_is_open() {
|
||||
let (mut app, shown, _) = app();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("file.new", json!({"width": 200, "height": 160})).unwrap();
|
||||
assert!(!app.file_dialog_open() && !app.file_sheet_open());
|
||||
assert!(app.run("file.open", json!({})).is_err());
|
||||
assert!(app.file_dialog_open() && app.file_sheet_open());
|
||||
let refused = |app: &mut VectorcraftApp| {
|
||||
for (id, p) in [("app.quit", json!({})), ("file.close", json!({})), ("file.close", json!({"index": 0})), ("file.closeAll", json!({}))] {
|
||||
assert_eq!(app.run(id, p.clone()), Err(picks::busy().to_string()), "{id} {p}");
|
||||
}
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_ne!(app.ui.status, "quit");
|
||||
assert_eq!(dialog_kind(app), None, "no Save Changes either");
|
||||
};
|
||||
refused(&mut app);
|
||||
// Answered: the file opens, and documents close again.
|
||||
shown.answer(&["/art/c.svg"]);
|
||||
poll(&mut app);
|
||||
assert!(!app.file_dialog_open());
|
||||
assert_eq!(app.session.documents().len(), 3);
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
// Cancelled: the same.
|
||||
assert!(app.run("file.saveAs", json!({})).is_err());
|
||||
refused(&mut app);
|
||||
shown.answer(&[]);
|
||||
poll(&mut app);
|
||||
assert!(!app.file_dialog_open());
|
||||
app.run("file.closeAll", json!({})).unwrap();
|
||||
assert!(app.session.documents().is_empty());
|
||||
assert!(app.run("file.open", json!({})).is_err());
|
||||
assert!(app.run("app.quit", json!({})).is_err());
|
||||
shown.answer(&[]);
|
||||
poll(&mut app);
|
||||
app.run("app.quit", json!({})).unwrap();
|
||||
assert_eq!(app.ui.status, "quit");
|
||||
}
|
||||
|
||||
/// On Linux the dialog is a window of its own, not a sheet: Close and Quit go on while it is open,
|
||||
/// and its answer for the document closed meanwhile is dropped, nothing written.
|
||||
#[test]
|
||||
fn on_linux_documents_close_while_a_dialog_is_open() {
|
||||
let (mut app, shown, written) = with_dialogs(false);
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("file.new", json!({"width": 200, "height": 160})).unwrap();
|
||||
app.run("document.activate", json!({"index": 0})).unwrap();
|
||||
assert!(app.run("file.saveAs", json!({})).is_err());
|
||||
assert!(app.file_dialog_open() && !app.file_sheet_open());
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
shown.answer(&["/art/a.svg"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(app.ui.status, CLOSED);
|
||||
assert!(written.borrow().is_empty());
|
||||
assert_eq!(dialog_kind(&app), None);
|
||||
// Quit goes on too.
|
||||
assert!(app.run("file.open", json!({})).is_err());
|
||||
assert!(app.file_dialog_open());
|
||||
app.run("app.quit", json!({})).unwrap();
|
||||
assert_eq!(app.ui.status, "quit");
|
||||
}
|
||||
|
||||
/// File › Open acts on no document: its answer opens the file even when the document active at
|
||||
/// the time closed meanwhile (Linux).
|
||||
#[test]
|
||||
fn an_open_answer_goes_on_after_the_active_document_closes() {
|
||||
let (mut app, shown, _) = with_dialogs(false);
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
assert!(app.run("file.open", json!({})).is_err());
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
assert!(app.session.documents().is_empty());
|
||||
shown.answer(&["/art/b.svg"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(app.session.documents().iter().map(|d| d.path.clone()).collect::<Vec<_>>(), [Some("/art/b.svg".to_string())]);
|
||||
assert_ne!(app.ui.status, CLOSED);
|
||||
}
|
||||
|
||||
/// Save Changes' Save, answered by its Save dialog, saves and closes the document it asked about,
|
||||
/// even when an earlier document closed meanwhile (Linux), then asks about the next one.
|
||||
#[test]
|
||||
fn save_changes_answers_for_its_own_document() {
|
||||
let (mut app, shown, written) = with_dialogs(false);
|
||||
let modified = |app: &mut VectorcraftApp| app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
// X clean, A modified and untitled, C modified with a file.
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
modified(&mut app);
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
modified(&mut app);
|
||||
app.session.active_mut().unwrap().path = Some("/art/c.vectorcraft".into());
|
||||
let uids: Vec<u64> = app.session.documents().iter().map(|d| d.uid).collect();
|
||||
app.run("file.closeAll", json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["uid"].clone()), Some(json!(uids[1])), "asks about A");
|
||||
// Save: A's Save dialog shows, and X closes meanwhile.
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)]));
|
||||
app.run("file.close", json!({"index": 0})).unwrap();
|
||||
shown.answer(&["/art/a.vectorcraft"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(*written.borrow(), ["/art/a.vectorcraft"], "{}", app.ui.status);
|
||||
assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::<Vec<_>>(), [uids[2]], "A closed, C untouched");
|
||||
let d = app.ui.dialog.as_ref().expect("Save Changes asks about C next");
|
||||
assert_eq!((d.kind.as_str(), &d.fields["uid"]), (crate::unsaved::KIND, &json!(uids[2])));
|
||||
}
|
||||
|
||||
/// A dialog asked for while another is open is refused, and the status bar says so rather than
|
||||
/// "cancelled".
|
||||
#[test]
|
||||
fn a_refused_dialog_says_why() {
|
||||
let (mut app, shown, _) = app();
|
||||
assert!(app.run("file.open", json!({})).is_err());
|
||||
poll(&mut app);
|
||||
assert!(app.ui.status.is_empty(), "{}", app.ui.status);
|
||||
crate::menus::invoke(&mut app, "file.open", json!({}));
|
||||
assert_eq!(shown.requests().len(), 1);
|
||||
poll(&mut app);
|
||||
assert_eq!(app.ui.status, picks::busy());
|
||||
shown.answer(&[]);
|
||||
poll(&mut app);
|
||||
// The next dialog's own "cancelled" is cleared as before.
|
||||
crate::menus::invoke(&mut app, "file.open", json!({}));
|
||||
poll(&mut app);
|
||||
assert!(app.ui.status.is_empty(), "{}", app.ui.status);
|
||||
}
|
||||
|
||||
/// After a panic inside what asks (a bug the frame's guard catches and recovers from), no entry is
|
||||
/// left behind: the next command's answer runs that command, not the one that panicked.
|
||||
#[test]
|
||||
fn a_panic_inside_an_entry_leaves_none_behind() {
|
||||
let (mut app, shown, written) = app();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
let r = vectorcraft_engine::guard::catch_panic(|| picks::button(&mut app, |_| panic!("a bug")));
|
||||
assert!(r.is_err());
|
||||
app.picks.recover();
|
||||
assert!(app.picks.is_entry_free());
|
||||
assert!(app.run("file.open", json!({})).is_err());
|
||||
shown.answer(&["/art/b.svg"]);
|
||||
poll(&mut app);
|
||||
assert_eq!(app.session.documents().len(), 2, "File › Open ran again");
|
||||
assert!(written.borrow().is_empty());
|
||||
}
|
||||
|
||||
/// One whole frame of the app (its logic and its window); `close` presses the window's close
|
||||
/// button.
|
||||
fn app_frame(ctx: &egui::Context, app: &mut VectorcraftApp, close: bool) -> Vec<egui::ViewportCommand> {
|
||||
let mut raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 900.0))), ..Default::default() };
|
||||
if close {
|
||||
raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
|
||||
}
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The window's close button doesn't close the window while a dialog's sheet is on it: the status
|
||||
/// says a file dialog is open. On Linux, and once the sheet answers, it closes.
|
||||
#[test]
|
||||
fn the_window_stays_open_while_a_sheet_is_on_it() {
|
||||
for sheets in [true, false] {
|
||||
let (mut app, shown, _) = with_dialogs(sheets);
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app_frame(&ctx, &mut app, false);
|
||||
assert!(app.run("file.open", json!({})).is_err());
|
||||
let commands = app_frame(&ctx, &mut app, true);
|
||||
assert_eq!(commands.contains(&egui::ViewportCommand::CancelClose), sheets, "sheets: {sheets}");
|
||||
assert_eq!(app.ui.status == picks::busy(), sheets, "sheets: {sheets}: {}", app.ui.status);
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
shown.answer(&[]);
|
||||
app_frame(&ctx, &mut app, false);
|
||||
assert!(!app_frame(&ctx, &mut app, true).contains(&egui::ViewportCommand::CancelClose), "sheets: {sheets}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `ui.inspect`'s `fileDialog` is the kind of dialog open (open, save, place, folder), null once it
|
||||
/// answers.
|
||||
#[test]
|
||||
fn inspect_tells_the_file_dialog_open() {
|
||||
let (mut app, shown, _) = app();
|
||||
let ctx = egui::Context::default();
|
||||
let shows = |app: &VectorcraftApp| crate::control::inspect(app, &ctx)["fileDialog"].clone();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
assert_eq!(shows(&app), Value::Null);
|
||||
/// What asks for a kind of dialog.
|
||||
type Ask = fn(&mut VectorcraftApp);
|
||||
let asks: [(&str, Ask); 4] = [
|
||||
("open", |app| assert!(app.run("file.open", json!({})).is_err())),
|
||||
("save", |app| assert!(app.run("file.saveAs", json!({})).is_err())),
|
||||
("place", |app| assert!(app.run("file.place", json!({})).is_err())),
|
||||
("folder", |app| {
|
||||
let mut d = Dialog::new("exportForScreens", json!({"folder": ""}));
|
||||
app.ui.dialog = Some(d.clone());
|
||||
picks::folder_field(app, &mut d, "folder");
|
||||
}),
|
||||
];
|
||||
for (kind, ask) in asks {
|
||||
ask(&mut app);
|
||||
assert!(app.file_dialog_open(), "{kind}");
|
||||
assert_eq!(shows(&app), json!(kind));
|
||||
shown.answer(&[]);
|
||||
poll(&mut app);
|
||||
assert!(!app.file_dialog_open(), "{kind}");
|
||||
assert_eq!(shows(&app), Value::Null, "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ nothing asks in line
|
||||
|
||||
/// `src` with comments and the insides of string and char literals blanked: only code is left.
|
||||
fn code_only(src: &str) -> String {
|
||||
let b: Vec<char> = src.chars().collect();
|
||||
let ident = |i: usize| i < b.len() && (b[i].is_alphanumeric() || b[i] == '_');
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
let next = b.get(i + 1).copied();
|
||||
if c == '/' && next == Some('/') {
|
||||
while i < b.len() && b[i] != '\n' {
|
||||
i += 1;
|
||||
}
|
||||
} else if c == '/' && next == Some('*') {
|
||||
let mut depth = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == '/' && b.get(i + 1) == Some(&'*') {
|
||||
depth += 1;
|
||||
i += 2;
|
||||
} else if b[i] == '*' && b.get(i + 1) == Some(&'/') {
|
||||
depth -= 1;
|
||||
i += 2;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out.push(' ');
|
||||
} else if c == 'r' && (i == 0 || !ident(i - 1) || (b[i - 1] == 'b' && (i < 2 || !ident(i - 2)))) && matches!(next, Some('"' | '#')) {
|
||||
// A raw string r"…", r#"…"# (or a raw identifier r#name, kept).
|
||||
let hashes = b[i + 1..].iter().take_while(|&&c| c == '#').count();
|
||||
if b.get(i + 1 + hashes) != Some(&'"') {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let close: Vec<char> = std::iter::once('"').chain(std::iter::repeat_n('#', hashes)).collect();
|
||||
i += 2 + hashes;
|
||||
while i < b.len() && !b[i..].starts_with(&close) {
|
||||
i += 1;
|
||||
}
|
||||
i += close.len();
|
||||
out.push_str("\"\"");
|
||||
} else if c == '"' {
|
||||
i += 1;
|
||||
while i < b.len() && b[i] != '"' {
|
||||
i += if b[i] == '\\' { 2 } else { 1 };
|
||||
}
|
||||
i += 1;
|
||||
out.push_str("\"\"");
|
||||
} else if c == '\'' && next == Some('\\') {
|
||||
// An escaped char literal.
|
||||
i += 2;
|
||||
while i < b.len() && b[i] != '\'' {
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
out.push_str("' '");
|
||||
} else if c == '\'' && b.get(i + 2) == Some(&'\'') {
|
||||
i += 3;
|
||||
out.push_str("' '");
|
||||
} else {
|
||||
// A lifetime's quote, or code.
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `code` (from [`code_only`]) without its `#[cfg(test)]` items: test modules and their
|
||||
/// declarations, test helpers.
|
||||
fn without_tests(code: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = code;
|
||||
while let Some(at) = rest.find("#[cfg(test)]") {
|
||||
out.push_str(&rest[..at]);
|
||||
let item = &rest[at..];
|
||||
// The item ends at its first `;` outside braces, or with the braces it opens.
|
||||
let mut depth = 0usize;
|
||||
let mut end = item.len();
|
||||
for (k, ch) in item.char_indices() {
|
||||
match ch {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
end = k + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
';' if depth == 0 => {
|
||||
end = k + 1;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
rest = &item[end..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The calls in `code` (from [`code_only`]) of the host's in-line dialogs: [`Services`]'
|
||||
/// `pick_open`, `pick_save`, `pick_folder` and `pick_open_multi` used other than to ask whether
|
||||
/// the host has them (`.is_some()`, `.is_none()`).
|
||||
fn in_line_dialogs(code: &str) -> Vec<String> {
|
||||
let mut found = vec![];
|
||||
for name in ["pick_open", "pick_save", "pick_folder", "pick_open_multi"] {
|
||||
let field = format!(".{name}");
|
||||
let mut rest = code;
|
||||
while let Some(at) = rest.find(&field) {
|
||||
rest = &rest[at + field.len()..];
|
||||
// Another field whose name starts with this one's (`pick_open_multi`).
|
||||
if rest.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
|
||||
continue;
|
||||
}
|
||||
// What follows, over line breaks.
|
||||
let next: String = rest.chars().filter(|c| !c.is_whitespace()).take(40).collect();
|
||||
if !next.starts_with(".is_some()") && !next.starts_with(".is_none()") {
|
||||
found.push(format!("{field}{next}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Nothing but `picks` shows the host's file dialogs: everything else asks through it, which shows
|
||||
/// them off the UI thread when what asks runs as an entry (#867). Asking whether the host has one
|
||||
/// is fine.
|
||||
#[test]
|
||||
fn only_picks_shows_the_hosts_dialogs() {
|
||||
// The scan finds the in-line call Load Profile… made, split over lines, and not presence checks
|
||||
// or comments.
|
||||
let before = r#"if widgets::flat_button(ui, tl!("Load Profile…"), 110.0).clicked()
|
||||
&& let Some(path) = app
|
||||
.services
|
||||
.pick_open
|
||||
.as_mut()
|
||||
.and_then(|f| f(&crate::FilePick { filters: vec![("ICC Profiles", &["icc", "icm"])], ..Default::default() }))
|
||||
{"#;
|
||||
assert_eq!(in_line_dialogs(&code_only(before)).len(), 1);
|
||||
assert_eq!(in_line_dialogs(&code_only("let s = &mut app.services; let p = (s.pick_save.as_mut()?)(&pick);")).len(), 1);
|
||||
assert_eq!(in_line_dialogs(&code_only("let Some(f) = &mut s.pick_open else { return };")).len(), 1);
|
||||
let fine = "let paths = if app.services.pick_open_multi.is_some() { 1 } else { 2 }; // app.services.pick_open.as_mut()\n\
|
||||
let t = \"services.pick_folder.take()\"; let c = '\"'; let u = s.pick_folder.is_none();";
|
||||
assert_eq!(in_line_dialogs(&code_only(fine)), Vec::<String>::new());
|
||||
assert_eq!(
|
||||
without_tests(&code_only("fn a() {}\n#[cfg(test)]\nmod tests;\n#[cfg(test)]\nmod t { fn b() { x.pick_open.take(); } }\nfn c() {}")),
|
||||
"fn a() {}\n\n\nfn c() {}"
|
||||
);
|
||||
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
// Each source file's code without its tests, by its path under `src`.
|
||||
let mut scanned = BTreeMap::new();
|
||||
let mut stack = vec![dir.clone()];
|
||||
while let Some(d) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if name.ends_with(".rs") && !name.starts_with("tests") && name != "picks.rs" {
|
||||
let key = path.strip_prefix(&dir).unwrap().to_string_lossy().replace('\\', "/");
|
||||
scanned.insert(key, without_tests(&code_only(&std::fs::read_to_string(&path).unwrap())));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Code after a test item is still read: lib.rs declares its test modules first, and the
|
||||
// shortcut editor's mirror macro has a test-only block.
|
||||
for (file, code) in [
|
||||
("lib.rs", "fnfile_dialog_open"),
|
||||
("shortcut_editor.rs", "fnimport_into_dialog"),
|
||||
("ui_fonts.rs", "fnpainted_text"),
|
||||
("place.rs", ".pick_open_multi.is_some()"),
|
||||
("panels/separations.rs", "picks::open("),
|
||||
] {
|
||||
let flat: String = scanned.get(file).unwrap_or_else(|| panic!("{file} not read")).chars().filter(|c| !c.is_whitespace()).collect();
|
||||
assert!(flat.contains(code), "{file} lost `{code}`");
|
||||
}
|
||||
let found: Vec<String> = scanned.iter().flat_map(|(file, code)| in_line_dialogs(code).into_iter().map(move |c| format!("{file}: {c}"))).collect();
|
||||
assert!(found.is_empty(), "file dialogs shown in line, not through `picks`: {found:#?}");
|
||||
}
|
||||
@@ -108,8 +108,8 @@ fn laid_out(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Rect {
|
||||
app.canvas_rect.expect("the canvas is laid out")
|
||||
}
|
||||
|
||||
/// Where the platform tells where files were dropped (the web), they go as in Illustrator: placed
|
||||
/// at the pointer on the canvas whatever they are, opened off it (the tab bar).
|
||||
/// Where the platform tells where files were dropped (the web), documents, pictures and text go
|
||||
/// as in Illustrator: placed at the pointer on the canvas, opened off it (the tab bar).
|
||||
#[test]
|
||||
fn a_drop_with_a_position_places_on_the_canvas_and_opens_off_it() {
|
||||
let mut app = app();
|
||||
@@ -141,6 +141,57 @@ fn a_drop_without_a_position_opens_documents_and_places_pictures() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Swatch libraries and color books, graphic style libraries, Libraries panel files, presets and
|
||||
/// plug-ins open as File → Open opens them, dropped with or without a position, on the canvas too.
|
||||
#[test]
|
||||
fn libraries_presets_and_plugins_open_wherever_they_are_dropped() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
let rect = laid_out(&mut app, &ctx);
|
||||
let on_canvas = rect.center() + vec2(60.0, -40.0);
|
||||
let libraries = ["a.vcswatches", "a.gpl", "a.ase", "a.acb", "a.vcstyles", "a.vclibrary"];
|
||||
let presets_and_plugins = ["a.vcflattener", "a.vcpdfpresets", "a.vcprintpresets", "a.vcperspective", "a.wasm"];
|
||||
for name in libraries.into_iter().chain(presets_and_plugins) {
|
||||
assert_eq!(app.drop_target(name, None, false), DropTarget::Open, "{name}");
|
||||
assert_eq!(app.drop_target(name, Some(on_canvas), true), DropTarget::Open, "{name} on the canvas");
|
||||
}
|
||||
}
|
||||
|
||||
/// A swatch library dropped on the window while a document is open loads in the library panel,
|
||||
/// as File → Open loads it.
|
||||
#[test]
|
||||
fn a_dropped_swatch_library_opens_in_the_library_panel() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
laid_out(&mut app, &ctx);
|
||||
let gpl = temp_file("dropped.gpl", b"GIMP Palette\nName: Dropped\n#\n255 0 0\tRed\n 0 128 255\tSky\n");
|
||||
frame(&mut app, &ctx, vec![], &[&gpl], false);
|
||||
let id = format!("loaded/{gpl}");
|
||||
assert_eq!(app.ui.library_panel.as_ref().map(|o| o.id.as_str()), Some(id.as_str()), "{}", app.ui.status);
|
||||
let lib = app.session.execute("swatch.library.get", &json!({"library": id})).unwrap();
|
||||
let names: Vec<&str> = lib["swatches"].as_array().unwrap().iter().filter_map(|w| w["name"].as_str()).collect();
|
||||
assert_eq!((lib["name"].as_str(), names), (Some("Dropped"), vec!["Red", "Sky"]));
|
||||
assert_eq!((app.session.documents().len(), images(&app, 0)), (1, 0), "neither opened as a document nor placed");
|
||||
assert_eq!(app.ui.recent_files.first(), Some(&gpl), "added to Open Recent Files as File → Open adds it");
|
||||
}
|
||||
|
||||
/// A Libraries panel file dropped on the window while a document is open is added to the
|
||||
/// Libraries panel as a new library, as File → Open adds it.
|
||||
#[test]
|
||||
fn a_dropped_libraries_panel_file_is_added_as_a_new_library() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
laid_out(&mut app, &ctx);
|
||||
let file = json!({"name": "Dropped", "colors": [{"name": "Red", "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}}]});
|
||||
let path = temp_file("dropped.vclibrary", file.to_string().as_bytes());
|
||||
frame(&mut app, &ctx, vec![], &[&path], false);
|
||||
assert_eq!(app.ui.status, "Imported library Dropped");
|
||||
let lib = app.session.execute("library.get", &json!({})).unwrap();
|
||||
let colors: Vec<&str> = lib["colors"].as_array().unwrap().iter().filter_map(|c| c["name"].as_str()).collect();
|
||||
assert_eq!((lib["name"].as_str(), colors), (Some("Dropped"), vec!["Red"]));
|
||||
assert_eq!((app.session.documents().len(), images(&app, 0)), (1, 0), "neither opened as a document nor placed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_png_is_placed_in_the_middle_of_the_view() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -168,7 +168,8 @@ fn text_from_another_app_pastes_as_point_text_and_svg_text_as_art() {
|
||||
}
|
||||
|
||||
/// A browser's Copy Image: the picture with its address as text (and HTML, which Paste doesn't
|
||||
/// read). The picture is pasted; an address alone pastes as text, nothing is fetched (#597).
|
||||
/// read), or its markup as text. The picture is pasted; an address alone pastes as text, nothing
|
||||
/// is fetched (#597, #1089).
|
||||
#[test]
|
||||
fn an_image_copied_in_a_browser_pastes_as_the_picture_not_its_address() {
|
||||
let (mut app, board) = app();
|
||||
@@ -177,6 +178,11 @@ fn an_image_copied_in_a_browser_pastes_as_the_picture_not_its_address() {
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
let NodeKind::Image(im) = &pasted(&app)[0].kind else { panic!("not an image: {:?}", pasted(&app)[0].kind) };
|
||||
assert!(im.link.is_none() && (im.width, im.height) == (4, 2));
|
||||
// Some browsers (on Linux) put the image's markup there as its text: the picture still (#1089).
|
||||
let markup = br#"<img src="https://example.com/images/cat.png" jsaction="" class="sFlh5c" style="max-width: 351px" alt="">"#.to_vec();
|
||||
copy_elsewhere(&board, vec![(TEXT, markup), (PNG, blue_png())]);
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
assert!(matches!(pasted(&app)[0].kind, NodeKind::Image(_)), "{:?}", pasted(&app)[0].kind);
|
||||
copy_elsewhere(&board, vec![(TEXT, url)]);
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
let NodeKind::Text(t) = &pasted(&app)[0].kind else { panic!("not type") };
|
||||
|
||||
@@ -253,7 +253,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
|
||||
});
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
bottom_controls(app, ui, &t);
|
||||
bottom_controls(app, ui, &t, cols);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -419,16 +419,21 @@ fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.separator();
|
||||
}
|
||||
|
||||
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
|
||||
ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0));
|
||||
/// The Fill/Stroke proxy, Color/Gradient/None, the drawing modes, screen mode and Edit Toolbar, as
|
||||
/// large as the toolbar's `cols` (one or two) leave room for (#1129): in two columns the proxy and
|
||||
/// buttons grow and the three drawing modes show side by side.
|
||||
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, cols: usize) {
|
||||
let two = cols == 2;
|
||||
ui.vertical_centered(|ui| crate::panels::proxy(app, ui, if two { 54.0 } else { 36.0 }));
|
||||
ui.add_space(5.0);
|
||||
// Color (the last solid colour), Gradient (the last gradient) and None, as commands.
|
||||
let (chip, gap) = if two { (17.0, 4.0) } else { (11.0, 2.0) };
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space((ui.available_width() - 27.0) / 2.0);
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
ui.add_space((ui.available_width() - 3.0 * chip - 2.0 * gap) / 2.0);
|
||||
ui.spacing_mut().item_spacing.x = gap;
|
||||
let mut clicked = None;
|
||||
for (tip, cmd) in [("Color (,)", "paint.lastColor"), ("Gradient (.)", "paint.lastGradient"), ("None (/)", "paint.none")] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(7.5, 7.5), Sense::click());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(chip, chip), Sense::click());
|
||||
match cmd {
|
||||
"paint.lastColor" => widgets::paint_chip(ui, r, &Paint::solid(app.session.last_solid)),
|
||||
"paint.lastGradient" => widgets::gradient_chip(ui, r, &app.session.last_gradient.gradient),
|
||||
@@ -443,15 +448,28 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let modes = [
|
||||
(vectorcraft_engine::DrawMode::Normal, "dc-draw-normal", "Draw Normal", "normal"),
|
||||
(vectorcraft_engine::DrawMode::Behind, "dc-draw-behind", "Draw Behind", "behind"),
|
||||
(vectorcraft_engine::DrawMode::Inside, "dc-draw-inside", "Draw Inside", "inside"),
|
||||
];
|
||||
if two {
|
||||
// Side by side, each one a click away.
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space((ui.available_width() - 3.0 * 22.0) / 2.0);
|
||||
for (mode, icon, name, id) in modes {
|
||||
if widgets::icon_button(ui, icon, &format!("{} (Shift+D)", tl!(name)), app.session.draw_mode == mode, 22.0).clicked() {
|
||||
app.run("view.drawMode", json!({ "mode": id })).ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.vertical_centered(|ui| {
|
||||
let modes = ["dc-draw-normal", "dc-draw-behind", "dc-draw-inside"];
|
||||
let names = ["Draw Normal", "Draw Behind", "Draw Inside"];
|
||||
let m = match app.session.draw_mode {
|
||||
vectorcraft_engine::DrawMode::Normal => 0,
|
||||
vectorcraft_engine::DrawMode::Behind => 1,
|
||||
vectorcraft_engine::DrawMode::Inside => 2,
|
||||
};
|
||||
if widgets::icon_button(ui, modes[m], &format!("{} (Shift+D)", tl!(names[m])), m != 0, 26.0).clicked() {
|
||||
// One column: the current mode's button, which cycles them.
|
||||
if !two
|
||||
&& let Some((mode, icon, name, _)) = modes.into_iter().find(|m| m.0 == app.session.draw_mode)
|
||||
&& widgets::icon_button(ui, icon, &format!("{} (Shift+D)", tl!(name)), mode != vectorcraft_engine::DrawMode::Normal, 26.0).clicked()
|
||||
{
|
||||
app.run("view.drawMode", json!({})).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() {
|
||||
@@ -1105,6 +1123,25 @@ pub(crate) mod tests {
|
||||
frame_in(app, ctx, time + 0.1, vec![], height)
|
||||
}
|
||||
|
||||
/// The Color, Gradient and None buttons grow with the toolbar, and in two columns the three
|
||||
/// drawing modes sit side by side, each setting its mode (#1129).
|
||||
#[test]
|
||||
fn the_paint_and_drawing_mode_buttons_fit_the_toolbar_width() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", serde_json::json!({"width": 200, "height": 200})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame_in(&mut app, &ctx, 0.0, vec![], 900.0);
|
||||
assert_eq!(widget_rects(&ctx, vec2(11.0, 11.0)).len(), 3, "one column: three 11 pt chips");
|
||||
app.ui.toolbar_double = true;
|
||||
frame_in(&mut app, &ctx, 1.0, vec![], 900.0);
|
||||
assert_eq!(widget_rects(&ctx, vec2(17.0, 17.0)).len(), 3, "two columns: three 17 pt chips");
|
||||
let modes = widget_rects(&ctx, vec2(22.0, 22.0));
|
||||
assert_eq!(modes.len(), 3, "the three drawing modes side by side");
|
||||
assert!(modes.iter().all(|r| (r.top() - modes[0].top()).abs() < 0.5), "{modes:?}");
|
||||
click_in(&mut app, &ctx, 2.0, modes[1].center(), PointerButton::Primary, 900.0);
|
||||
assert_eq!(app.session.draw_mode, vectorcraft_engine::DrawMode::Behind);
|
||||
}
|
||||
|
||||
/// How many columns of tool buttons `rects` make.
|
||||
fn columns(rects: &[egui::Rect]) -> usize {
|
||||
let mut lefts: Vec<i32> = rects.iter().map(|r| r.left().round() as i32).collect();
|
||||
|
||||
@@ -12,12 +12,20 @@ use serde_json::{Value, json};
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, io};
|
||||
|
||||
/// Dialog kind. Fields: `index` (document), `name` (its title), `then` (`close`, `closeAll` or
|
||||
/// `quit`: what continues after this document) and `discard` (Don't Save).
|
||||
/// Dialog kind. Fields: `index` (document), `uid` (the same document, which `index` may no longer
|
||||
/// point at once a file dialog answers), `name` (its title), `then` (`close`, `closeAll` or `quit`:
|
||||
/// what continues after this document) and `discard` (Don't Save).
|
||||
pub const KIND: &str = "saveChanges";
|
||||
|
||||
/// Nothing closes while a file dialog's sheet is on the window (macOS): what asked for it runs
|
||||
/// again when it answers. (Elsewhere the dialog is a window of its own, and closing goes on.)
|
||||
fn refuse_while_picking(app: &VectorcraftApp) -> Result<(), String> {
|
||||
if app.file_sheet_open() { Err(crate::picks::busy().into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// File → Close (or a tab's ×) for document `i`.
|
||||
pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
|
||||
refuse_while_picking(app)?;
|
||||
// A save still running decides whether there is anything left to save.
|
||||
crate::background::wait_all(app);
|
||||
let dirty = app.session.documents().get(i).ok_or("no such document")?.is_dirty();
|
||||
@@ -27,6 +35,7 @@ pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
|
||||
/// File → Close All (`then` = `closeAll`) or Quit (`quit`): ask about the next modified document,
|
||||
/// or finish once none is left.
|
||||
pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result<Value, String> {
|
||||
refuse_while_picking(app)?;
|
||||
crate::background::wait_all(app);
|
||||
if let Some(i) = app.session.documents().iter().position(|d| d.is_dirty()) {
|
||||
return ask(app, i, then);
|
||||
@@ -54,8 +63,9 @@ pub fn any_dirty(app: &VectorcraftApp) -> bool {
|
||||
/// Show document `i` and ask whether to save it.
|
||||
fn ask(app: &mut VectorcraftApp, i: usize, then: &str) -> Result<Value, String> {
|
||||
app.session.set_active(i);
|
||||
let name = app.session.documents()[i].title();
|
||||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "index": i, "name": name, "then": then })));
|
||||
let doc = &app.session.documents()[i];
|
||||
let (name, uid) = (doc.title(), doc.uid);
|
||||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "index": i, "uid": uid, "name": name, "then": then })));
|
||||
Ok(json!({ "pending": KIND }))
|
||||
}
|
||||
|
||||
@@ -72,7 +82,12 @@ fn close_now(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
|
||||
/// Answer the open dialog: save (or, with `discard`, don't), close the document and carry on.
|
||||
pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let d = app.ui.dialog.take().ok_or("no dialog open")?;
|
||||
let i = d.fields.get("index").and_then(Value::as_u64).map(|i| i as usize).filter(|i| *i < app.session.documents().len());
|
||||
let docs = app.session.documents();
|
||||
// By its uid: documents may have closed while its Save dialog was open.
|
||||
let i = match d.fields.get("uid").and_then(Value::as_u64) {
|
||||
Some(uid) => docs.iter().position(|doc| doc.uid == uid),
|
||||
None => d.fields.get("index").and_then(Value::as_u64).map(|i| i as usize).filter(|i| *i < docs.len()),
|
||||
};
|
||||
let i = i.ok_or("no such document")?;
|
||||
if !d.bool("discard") {
|
||||
app.session.set_active(i);
|
||||
|
||||
@@ -51,7 +51,7 @@ the two can still win that race.
|
||||
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
|
||||
| `engine.commands` | | every command with label, shortcut, params doc, enablement |
|
||||
| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does |
|
||||
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way) |
|
||||
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) |
|
||||
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
|
||||
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected |
|
||||
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
||||
@@ -69,12 +69,13 @@ the two can still win that race.
|
||||
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
|
||||
| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab |
|
||||
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
|
||||
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
|
||||
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`) and plug-ins (`.wasm`) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
|
||||
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
|
||||
| `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `<stem>.html` (client-side `<map>`) or `<stem>.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) |
|
||||
| `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields |
|
||||
| `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning |
|
||||
| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`. Headless it returns `{dataBase64, width, height}` |
|
||||
| Native file dialogs | any command with no `path` that needs one (`file.open`, `file.saveAs`, `file.place`, exports…) | on macOS (a sheet on the window) and Linux the dialog doesn't hold the window: the call returns `"cancelled"` at once, `ui.inspect` reports `fileDialog`, and when the person at the computer picks a file the command runs again on the document it was asked for. While it is open another dialog is refused: that call returns `"cancelled"` too, and the status bar (`ui.inspect` → `ui.status`) says `"A file dialog is open: choose there first"`; on macOS `app.quit`, `file.close` and `file.closeAll` return that message and the menu bar does nothing (#592, #867). On Windows the dialog is shown in line: the call returns once it closes. Pass `path` to skip the dialog |
|
||||
| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once, `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. With the preferences off (`prefs.set`), saves return once written |
|
||||
| `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). Fields: `tab` (`artboards` \| `assets`), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one store-only zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager |
|
||||
| Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`) |
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
|
||||
|
||||
- **Open** sets `Services::open_async`, which shows `rfd::AsyncFileDialog`. The bytes arrive in `Services::inbox`, which the app drains every frame.
|
||||
- **Save / Save As / Export** go through `Services::download`: a Blob, an object URL and a temporary `<a download>`, all created from Rust. There is no save dialog, so the suggested name becomes the download name.
|
||||
- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files dropped on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.)
|
||||
- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files to be placed on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. On the web and the desktop, swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped, as File › Open opens them (`io::opens_as`). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.)
|
||||
- **Data Recovery** keeps its copies in `localStorage` (`vectorcraft-recovery/<area>/<name>`, shared by the site's tabs), so tabs can't lock their areas the way desktop apps lock a folder (`RecoveryStore::lock`). Each tab holds its area with a heartbeat it refreshes every minute and, where the browser has Web Locks (secure pages: `https://` or localhost), with a Web Lock named after the area (`apps/vectorcraft-web/src/locks.rs`, through `js_sys::Reflect` since web-sys has them only as an unstable API). The browser releases the lock only when the tab is gone, not while its timers are paused in the background, which a heartbeat can't tell apart (#367). `navigator.locks.query()` is asynchronous, so the shell keeps a snapshot refreshed every 10 seconds (taken once before the first frame); a snapshot older than a minute counts every area as held. An area is offered as a crash's leftovers only when no tab holds its lock and its heartbeat is older than three intervals. Without Web Locks a paused tab can still be taken for gone; it repairs that when it resumes: its next heartbeat (or recovery save) writes again the copies missing from its area, and a heartbeat newer than the one the other tab wrote taking the area over makes the area running again for that tab.
|
||||
- **Losing the graphics (#369):** the browser can drop the page's WebGPU device or WebGL context (a GPU reset, a driver update, too many tabs on the GPU; or a script calling `device.destroy()` / `WEBGL_lose_context.loseContext()`). The app goes on without it, so the shell watches for it: wgpu's device-lost callback for WebGPU, the canvas's `webglcontextlost` event for WebGL2. Both report to a `graphics::GraphicsLoss`, and the next frame of `WebShell::logic` handles it:
|
||||
1. `VectorcraftApp::graphics_lost` writes Data Recovery copies of the modified documents at once (to browser storage, also when Data Recovery is turned off).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.ai` artboard layout when read as its PDF part, #1068) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EPS page and stacked text strokes through the layers, #1102) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public
|
||||
@@ -44,7 +44,7 @@ private editing data. VectorCraft:
|
||||
|
||||
### Illustrator's private editing data
|
||||
|
||||
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
|
||||
## Format by format
|
||||
|
||||
@@ -80,6 +80,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | An EPS keeps its own page and its stacked text strokes through its layers (#1102) |
|
||||
| 2026-10-11 | minor | A `.ai` read as its PDF part keeps its artboard layout from the editing data (#1068) |
|
||||
| 2026-10-11 | minor | Spot colours defined in Lab keep their Lab values, from the editing data and from a PDF part whose ink alternate is an ICC Lab profile (#1032) |
|
||||
| 2026-10-11 | minor | `.ai` in PostScript form opens through its layers, also without its prolog (#1027) |
|
||||
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
|
||||
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
|
||||
@@ -88,7 +88,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
- **Done:** scaled text's size and horizontal-scale controls account for object/group transforms (#1034),
|
||||
including exact sizes across grouped labels and resetting horizontal stretch while keeping editable text.
|
||||
- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Transform effect dialog's invisible checkboxes and missing options (#885);
|
||||
- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937); Transform effect dialog's invisible checkboxes and missing options (#885);
|
||||
PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834).
|
||||
- **Impact:** wrong output in everyday work.
|
||||
- **Estimate:** 8–15 h.
|
||||
@@ -96,8 +96,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
## G4 Advanced type
|
||||
|
||||
- **Missing:** hyphenation and justification options, Optical Margin Alignment (type core); CJK vertical
|
||||
composition (ruby, mojikumi sets and dialog, proportional vertical metrics #966, kinsoku settings #633, manual
|
||||
tate-chu-yoko), Middle Eastern features beyond bidi, variable font axes, spell check with an open dictionary,
|
||||
composition (ruby, mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern
|
||||
features beyond bidi, variable font axes, spell check with an open dictionary,
|
||||
Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement).
|
||||
Itemized in [type-parity.md](type-parity.md).
|
||||
- **Impact:** professional typography and Japanese/Chinese publishing.
|
||||
@@ -203,6 +203,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | G4: proportional vertical metrics (#966) done; estimate unchanged |
|
||||
| 2026-10-11 | minor | G11: #893 fixed (merged regions of crossing lines left seams); #937 still open, estimate unchanged |
|
||||
| 2026-10-11 | minor | #1034: corrected scaled type controls; optional group font-size preservation remains separate. Estimates unchanged at this rounding. |
|
||||
| 2026-10-11 | minor | G9: legacy `.ai` without the prolog opens with its layers (#1027) |
|
||||
| 2026-10-11 | minor | G9: `.ai` pattern fills and global colours read from the editing data (#1025) |
|
||||
|
||||
+48
-8
@@ -435,7 +435,7 @@ Long-tail commands:
|
||||
```json
|
||||
{"name":"list_commands","arguments":{"filter":"align"}}
|
||||
{"name":"run_command","arguments":{"command":"select.all"}}
|
||||
{"name":"run_command","arguments":{"command":"object.align","params":{"align":"left"}}}
|
||||
{"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left"}}}
|
||||
{"name":"run_command","arguments":{"command":"object.group"}}
|
||||
{"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/art.pdf","range":"1, 3","compatibility":"1.5"}}}
|
||||
{"name":"run_command","arguments":{"command":"document.pdfSettings","params":{"marks":{"trim":true},"includeDocument":true}}}
|
||||
@@ -725,6 +725,12 @@ are kept, and selecting an ancestor together with its descendants deletes that s
|
||||
Without `ids`, direct-selected anchors or selected ruler guides retain their usual Clear behavior.
|
||||
Cut still removes the objects it copied, including a whole compound when a member is selected.
|
||||
|
||||
`object.setProps` with `visible: false` or `locked: true` deselects every selected object that is hidden or
|
||||
locked after the call: the selected objects it hides or locks, selected objects inside them, and selected objects
|
||||
that were already hidden or locked. The key object is cleared when it is one of them, and `edit.undo` selects
|
||||
them again with the key object. When the call changes nothing, it records no undo step and still deselects them.
|
||||
Other `object.setProps` calls leave the selection unchanged.
|
||||
|
||||
## Saved selections
|
||||
|
||||
Select → Save Selection… keeps the selected objects under a name, in the document: `select.save {name?}` (default
|
||||
@@ -925,7 +931,8 @@ groups; `.gpl` is 8-bit RGB; a swatch exchange `.ase` file keeps solid colors in
|
||||
(RGB, CMYK, Lab or Gray) as global, spot or process colors, and color groups, writes a tint swatch
|
||||
as the color it shows and leaves gradients out; CSS writes custom properties); without `path`
|
||||
it returns `{data}`, or `{dataBase64}` for `.ase`, and `user: true` saves into the user library
|
||||
folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? |
|
||||
folder of the desktop app (listed as category `user`, User Defined). The reply's `count` is the number of swatches
|
||||
written: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out. `swatch.library.load {path? | data? |
|
||||
dataBase64?, name?}` loads a `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or color book (`.acb`) file, or another
|
||||
document's swatches, as a library to add from. From an `.ase` file it reads RGB, CMYK, Lab and Gray colors as global,
|
||||
spot or process swatches and keeps their color groups. A color book (such as a Pantone book you own) gives its RGB,
|
||||
@@ -1468,6 +1475,31 @@ one undo step.
|
||||
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"makeSidesEqual":true}}}
|
||||
```
|
||||
|
||||
## Align to Artboard
|
||||
|
||||
`object.align {to: "artboard", artboard?}` aligns the selected objects to artboard `artboard` (a 0-based index).
|
||||
Without `artboard`, or with an index the document doesn't have, it aligns them to the artboard under the center of the
|
||||
selection, or to the first artboard when no artboard is under the center. With `to: "selection"` or `"key"`,
|
||||
`object.align` does not use `artboard`. `select.allOnArtboard {artboard?}` (Select › All on Active Artboard) selects
|
||||
the objects on artboard `artboard`, or on the first artboard when the call gives none; an index the document doesn't
|
||||
have is an error.
|
||||
|
||||
The desktop app adds its active artboard, the one the Artboards panel highlights, to the `object.align` and
|
||||
`select.allOnArtboard` calls it runs without `artboard`, as it does for `edit.pasteInPlace`, `edit.pasteInFront` and
|
||||
`edit.pasteInBack`, and to each `command.batch`. A batch passes its `artboard` on to each of these steps that names
|
||||
none, when the step runs. When the document a step runs in has no artboard at that index (for example, after an
|
||||
earlier step deleted the active artboard when it was the last one), the step gets that document's last artboard. In
|
||||
remote mode, MCP's `run_command` calls and each step of the `command_batch` tool get the active artboard too. The
|
||||
Actions panel plays an action as a `command.batch`. An action recorded in the Actions panel leaves the artboard out,
|
||||
and its steps of these commands get the artboard that is active when the action plays. A press with the Selection,
|
||||
Direct Selection or Group Selection tool inside an artboard makes that artboard the active one; with the Selection
|
||||
tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","to":"artboard","artboard":1}}}
|
||||
{"name":"run_command","arguments":{"command":"select.allOnArtboard","params":{"artboard":1}}}
|
||||
```
|
||||
|
||||
## Use Preview Bounds
|
||||
|
||||
With the preference `usePreviewBounds` on (`prefs.set {key: "usePreviewBounds", value: true}`; also the Align panel
|
||||
@@ -1481,6 +1513,11 @@ Effects off a 100 pt wide rectangle with a 10 pt stroke set to `width: 220` gets
|
||||
{"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","bounds":"preview"}}}
|
||||
```
|
||||
|
||||
`object.align` needs `horizontal` or `vertical`. The three Align commands, `object.reflect` and `object.shear` return
|
||||
an error that lists the choices of a `horizontal`, `vertical`, `to`, `axis` or `bounds` param given another value
|
||||
(`object.reflect` also takes an angle and names the value given), and one for a `spacing` that is not a number; nothing
|
||||
moves.
|
||||
|
||||
## Selection preferences
|
||||
|
||||
The Selection & Anchor Display and General preferences apply to `pointer_gesture` as they do to the mouse:
|
||||
@@ -1508,8 +1545,9 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
|
||||
- `hideCornerWidgetAbove` (177° by default): corners wider than this show no Live Corners widget (a rectangle's right
|
||||
angles hide below 90°).
|
||||
- `transformPatternTiles` (off by default): the default of the transforms' `patterns` param (`object.transform`,
|
||||
`object.move`, `object.rotate`, `object.scale`, `object.reflect`, `object.shear`, `object.transformEach`, the
|
||||
Selection and transform tools, the dialogs' Transform Patterns): pattern fills and strokes move with the art.
|
||||
`object.move`, `object.rotate`, `object.scale`, `object.reflect`, `object.shear`, `object.transformEach`,
|
||||
`object.align`, `object.distribute`, `object.distributeSpacing`, the Selection and transform tools, the dialogs'
|
||||
Transform Patterns): pattern fills and strokes move with the art.
|
||||
- `selectSameTintPercent` (off by default): `select.same.fillColor`, `strokeColor` and `fillAndStroke` take every tint
|
||||
of a global or spot swatch; on, only the same tint.
|
||||
|
||||
@@ -2631,10 +2669,12 @@ without ideographs uses its em box. One undo step.
|
||||
|
||||
`text.setFormat {proportionalMetrics: true}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end,
|
||||
proportionalMetrics}` (a range) set full-width glyphs on the proportional widths the font gives them with the
|
||||
OpenType `palt` feature; glyphs the font doesn't re-space keep their full width. Line-end Punctuation Half Width then takes nothing more off the punctuation `palt` re-spaced (the opening
|
||||
bracket at a line's start, consecutive punctuation, the closing mark at a line's end); punctuation left full width is
|
||||
trimmed as before. Off by default and saved only when on. Horizontal type only for now: vertical type sets as before.
|
||||
One undo step.
|
||||
OpenType `palt` feature; glyphs the font doesn't re-space keep their full width. In vertical type, upright glyphs take
|
||||
the proportional heights of the font's `vpal` feature (the glyphs are shaped top to bottom for it) and move with them;
|
||||
a font without vertical metrics keeps its cells. Line-end Punctuation Half Width then takes nothing more off the
|
||||
punctuation `palt` or `vpal` re-spaced (the opening bracket at a line's start, consecutive punctuation, the closing
|
||||
mark at a line's end); punctuation left full width is trimmed as before. Off by default and saved only when on. One
|
||||
undo step.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"proportionalMetrics":true}}}
|
||||
|
||||
+3
-2
@@ -85,8 +85,9 @@ The rules are in craftrules `standards/fonts.md`, the build option in
|
||||
(`entitlements.plist`) are deliberately empty.
|
||||
- **Notarization:** the app is zipped and sent with `xcrun notarytool submit --wait`, then the
|
||||
ticket is stapled and checked with `stapler validate` and `spctl`. The app goes on a DMG
|
||||
(`hdiutil makehybrid` and `convert`, with an `Applications` link to drag onto), which is signed,
|
||||
notarized and stapled too. Its Finder window (background, icon size and positions) comes from
|
||||
(`hdiutil create -srcfolder`, retried when a CI runner's device is busy, with an `Applications` link
|
||||
to drag onto), which is signed, notarized and stapled too; the app mounted from it must pass
|
||||
`codesign --verify --strict --deep` (#1085). Its Finder window (background, icon size and positions) comes from
|
||||
[`packaging/macos/dmg/`](../packaging/macos/dmg/README.md), and its volume is named `VectorCraft`
|
||||
without the version, which the window's background needs; the DMG file name keeps the version.
|
||||
- **CLI:** the universal `vectorcraft-cli` is signed the same way, zipped, and the zip is
|
||||
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
# Roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (M7: accurate character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G11 and Current focus: #893 fixed, #937 open; M7: accurate character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
|
||||
@@ -17,7 +17,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
|
||||
|
||||
| Core workflow | Works end to end? | Evidence | Hours to pass |
|
||||
|---|---|---|---|
|
||||
| Draw and edit paths precisely: Pen, Curvature, Direct Selection on anchors and handles, smart guides and snapping, Pathfinder | yes | `crates/tools/src/pen.rs`, `direct.rs`, `guides.rs` and their tests; 10 exact Pathfinder booleans; [ui-parity.md](ui-parity.md). Shape Builder misses regions in some files (#937, #893), Pathfinder covers the same results | 0 |
|
||||
| Draw and edit paths precisely: Pen, Curvature, Direct Selection on anchors and handles, smart guides and snapping, Pathfinder | yes | `crates/tools/src/pen.rs`, `direct.rs`, `guides.rs` and their tests; 10 exact Pathfinder booleans; [ui-parity.md](ui-parity.md). Shape Builder misses regions in some files (#937), Pathfinder covers the same results | 0 |
|
||||
| Paint and style: fills and strokes, global and spot swatches, gradients, Appearance, transparency, graphic styles | yes | M3 done (92 tasks); area scores 92–97% in [target-app-parity.md](target-app-parity.md#feature-areas) | 0 |
|
||||
| Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); hyphenation and justification options missing (not blocking) | 0 |
|
||||
| Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; ghost borders in some Asset Export output (#983) | 0 |
|
||||
@@ -30,7 +30,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
|
||||
Selection, smart guides and snapping first. 60–90 h, parallel by tool.
|
||||
2. **`.ai` files from real users** (G9): layer structure (#951, #868), guides (#779). 10–20 h.
|
||||
3. **Launch and stability** (G10): the Windows and Linux start-up reports. 20–35 h.
|
||||
4. **Core-tool bugs** (G11): Shape Builder regions (#937, #893), Asset Export borders (#983). 8–15 h.
|
||||
4. **Core-tool bugs** (G11): Shape Builder regions (#937), Asset Export borders (#983). 8–15 h.
|
||||
5. **Raster effects** (G2) continue alongside: Artistic, Sketch, then the Effect Gallery. 24–40 h.
|
||||
|
||||
## Next
|
||||
@@ -73,12 +73,13 @@ Milestone estimates predate the 2026-10-10 re-measure for M1–M14; the area tab
|
||||
|
||||
The ROADMAP's "last updated" line as of 2026-10-10, kept as the record of what landed up to then:
|
||||
|
||||
After placed documents (File › Place of a `.vectorcraft` file, linked), 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, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groLine truncated
|
||||
After placed documents (File › Place of a `.vectorcraft` file, linked), 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, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groLine truncated
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | #893 (Shape Builder seams on grids of lines) fixed; G11 keeps #937 and its 8–15 h |
|
||||
| 2026-10-10 | minor | M7: document-point character sizes, leading and baseline shift, and resettable horizontal stretching after object/group scaling (#1034). Optional group font-size preservation remains separate; scores/hours unchanged. |
|
||||
| 2026-10-10 | minor | M9 row from main: initial Revolve |
|
||||
| 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (#1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
|
||||
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
|
||||
@@ -69,7 +69,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
|
||||
|---|---:|---:|---|---:|---|
|
||||
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances | 4–6 | estimated |
|
||||
| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool, Live Corners on curved corners; see [ui-parity.md](ui-parity.md) | 8–13 | estimated |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937, #893), its gap detection and options; Live Paint Gap Options | 4–8 | estimated (lowered from 86%: open issues) |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937), its gap detection and options; Live Paint Gap Options | 4–8 | estimated (lowered from 86%: open issues) |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither, perceptual blending and gradient presets; freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated (lowered from 96%: target moved) |
|
||||
| Strokes, brushes, width profiles | 5 | 88% | Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated |
|
||||
@@ -77,7 +77,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
|
||||
| Raster effects (Effect Gallery) | 4 | 48% | 29 Photoshop-style filters (Artistic, Sketch), the Effect Gallery dialog, Load Texture | 24–40 | measured count 28/57 |
|
||||
| 3D and Materials (and Turntable) | 4 | ~8% | initial live Revolve done (flat shading, one light, no caps, approximate visibility for intersecting surfaces); Extrude & Bevel, Inflate, Rotate, materials, Turntable views, 3D export | 50–85 | estimated (Revolve landed after the first measure, #846) |
|
||||
| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated |
|
||||
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi, proportional vertical metrics), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated |
|
||||
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 85% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape | 6–10 | estimated (lowered from 88%: target moved) |
|
||||
| Image Trace, graphs, image tools | 3 | 75% | graph Design/Column/Marker, Image Trace Create Strokes depth (Create Object Mosaic is done) | 5–8 | estimated (raised from 72%) |
|
||||
| Layers, artboards, document setup | 5 | 78% | 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export | 5–8 | estimated (lowered from 82%: target moved) |
|
||||
@@ -191,7 +191,7 @@ Depth here is "does the basic use work", not the area's full score.
|
||||
| Type: point and area text, font, size | 8 | 88% | font menu with previews; hyphenation options missing (rarely touched) |
|
||||
| Align, group, arrange | 6 | 95% | |
|
||||
| Layers, basic | 4 | 85% | Layers panel rework; no row context menus |
|
||||
| Shape Builder | 5 | 70% | beginners lean on it; region detection fails in some files (#937, #893) |
|
||||
| Shape Builder | 5 | 70% | beginners lean on it; region detection fails in some files (#937) |
|
||||
| Pathfinder | 4 | 95% | exact booleans |
|
||||
| Place images, Image Trace | 4 | 85% | 13 trace presets |
|
||||
| Export PNG/JPG/SVG/PDF, print | 8 | 88% | ghost borders in some Asset Export output (#983) |
|
||||
@@ -306,7 +306,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 |
|
||||
| 3D and Materials | 4 | ~8% | Initial Revolve only, with simple lighting and Revolve-specific visible-surface expansion; Extrude & Bevel, Inflate, Rotate, caps, materials, shadows and more complete visibility remain | 45–75 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034) | 14–19 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional 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 (beyond parity, not scored: inline symbols in text) | 20–28 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, 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 (beyond parity, not scored: inline symbols in text) | 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 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
|
||||
| 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 preset polish (Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
|
||||
@@ -381,10 +381,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
|
||||
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
|
||||
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
|
||||
- Swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped on the window, as File › Open opens them.
|
||||
- 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, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). About 17 stored options still do nothing yet (the rest of #394: Highlight Alternate Glyphs, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
|
||||
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, and Align and Distribute) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). About 17 stored options still do nothing yet (the rest of #394: Highlight Alternate Glyphs, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
|
||||
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
|
||||
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
|
||||
@@ -395,6 +396,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Tools:**
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
A key object needs another selected object outside it. A Shift-click or any other change that leaves one object selected clears the key, and `select.key` sets none on one object or on a group selected with only its own members. With one object selected, the Align panel, the Control bar and the Properties panel align it to the artboard whatever Align To is set to.
|
||||
Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784).
|
||||
Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself.
|
||||
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
|
||||
@@ -403,6 +405,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come). A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded.
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
@@ -414,17 +417,19 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **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, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel.
|
||||
- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock.
|
||||
- **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, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), 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) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), 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, each with a chip of its colour, 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. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).
|
||||
`object.setProps` with `visible: false` or `locked: true` deselects every selected object that is then hidden or locked, the key object included, as the eye and lock columns do.
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), 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. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them).
|
||||
Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then.
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
|
||||
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)).
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boLine truncated
|
||||
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type, and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (vertical type's `vpal` not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
|
||||
@@ -437,6 +442,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`).
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
|
||||
@@ -454,6 +460,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Type advanced: Proportional Metrics in vertical type (`vpal`, #966) leaves the missing list; score and hours unchanged at this rounding (estimated) |
|
||||
| 2026-10-11 | minor | Shape Builder #893 fixed (planar-map faces share their corners exactly, so merges leave no seams); Path operations stays 80% and Shape Builder 70% while #937 is open (estimated) |
|
||||
| 2026-10-10 | minor | Merged main: initial live Revolve landed (#846). 3D 0% → ~8% (50–85 h), menu stubs 28 → 27; depth, readiness and totals unchanged within rounding |
|
||||
| 2026-10-10 | minor | Method aligned with the standard, no new evidence: the full ready-for-real-work number is the additive weighted sum over the dimensions (~62%). The multiplicative 55% from earlier today is withdrawn, and the 50% before it was an unbroken-down judgement. Added hours to ~95% for each audience (full 340–560 h, mainstream 230–380 h, essentials 60–105 h). Beta distance is now ~13 points |
|
||||
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%): 16 core features weighted, depth ~91%, discounted for launch, discoverability and files people send |
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (document-point character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Proportional Metrics in vertical type, `vpal`, #966; document-point character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
|
||||
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
|
||||
@@ -43,12 +43,14 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Middle Eastern: Character Direction, digit types, kashidas, Middle Eastern composers, split caret | missing | |
|
||||
| CJK: input methods (marked text, candidate window) | done | |
|
||||
| CJK: kinsoku (Hard, Soft, None), Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, the Mojikumi Settings dialog and the other mojikumi sets missing |
|
||||
| CJK: ruby, proportional vertical metrics (`vpal`/`palt`), manual tate-chu-yoko | missing | #966, #633 |
|
||||
| CJK: Proportional Metrics (`palt` in horizontal type, `vpal` in vertical type) | done | #966 |
|
||||
| CJK: ruby, manual tate-chu-yoko | missing | #633 |
|
||||
| Inline symbols in text | beyond Illustrator | not scored |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Proportional Metrics done: `palt` in horizontal type, `vpal` in vertical type (#966, #1082); ruby and manual tate-chu-yoko still missing |
|
||||
| 2026-10-10 | minor | #1034: transformed character controls and regression coverage; preserving group font sizes remains a separate enhancement. Scores/hours unchanged at this rounding. |
|
||||
| 2026-10-10 | major | First checklist, split out of the parity table's Type core and Type advanced rows |
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist: pen, anchors, handles, snapping, nudging, modifiers, panels) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Direct Selection: whole-path drags by an anchor and Alt-drag copies, #1104; earlier: first checklist) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
|
||||
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
|
||||
@@ -63,6 +63,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
|---|---|---|
|
||||
| Click/Shift-click anchors; marquee toggles with Shift | done | #483 |
|
||||
| Drag a curved segment to bend it, a straight one to move its anchors | done | |
|
||||
| Drag by an anchor of a path selected whole moves the whole path; Alt-drag by an anchor or a segment copies it, Alt toggles the copy during the drag, and the copy snaps by the anchor pressed, onto the original too | done | #1104 |
|
||||
| Handles shown are the handles that drag; tolerance from Selection & Anchor Display | done | #494 |
|
||||
| Drag a handle with Shift (45° about its anchor), Alt (move one handle alone) | done | handles snap to smart guides |
|
||||
| Show handles when multiple anchors are selected; Highlight anchors on mouse over; anchor and handle size | done | Preferences › Selection & Anchor Display |
|
||||
@@ -166,4 +167,5 @@ re-measure of the panels added since.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Direct Selection: dragging a path selected whole by an anchor moves it whole; Alt-drag copies by an anchor too, toggles during the drag and snaps onto the original (#1104); score unchanged (estimated) |
|
||||
| 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation |
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
|
||||
`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
|
||||
generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no
|
||||
Finder scripting on CI).
|
||||
generated at build time, so the DMG builds with a plain `hdiutil create -srcfolder` (no Finder
|
||||
scripting on CI, nothing extra installed in the signing job).
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
|
||||
@@ -135,15 +135,39 @@ ln -s /Applications "$STAGE/Applications"
|
||||
mkdir -p "$STAGE/.background"
|
||||
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
|
||||
cp "$HERE/dmg/DS_Store" "$STAGE/.DS_Store"
|
||||
rm -f "$DMG" "$WORK/raw.dmg"
|
||||
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
|
||||
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
|
||||
# The volume name has no version: .DS_Store finds the background through an alias that includes it.
|
||||
hdiutil makehybrid -hfs -hfs-volume-name "VectorCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
|
||||
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
|
||||
rm -f "$WORK/raw.dmg"
|
||||
rm -f "$DMG"
|
||||
# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a
|
||||
# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or
|
||||
# similar detritus" on the app in the image and on a copy installed from it (#1085, as PdfCraft's
|
||||
# storytold/pdfcraft#823). `create` attaches a device while it copies, which fails now and then on
|
||||
# CI runners ("Resource busy"), so it gets a few tries. The volume name has no version: .DS_Store
|
||||
# finds the background through an alias that includes it.
|
||||
# Usage: hdiutil_retry <what> <hdiutil args…>; tries five times with growing pauses.
|
||||
hdiutil_retry() {
|
||||
local what="$1" attempt
|
||||
shift
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if hdiutil "$@"; then
|
||||
return 0
|
||||
fi
|
||||
[ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
|
||||
echo "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" >&2
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
}
|
||||
hdiutil_retry create create -srcfolder "$STAGE" -volname "VectorCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG"
|
||||
sign "$DMG"
|
||||
codesign --verify --strict --verbose=2 "$DMG"
|
||||
# The app inside the image must still pass --strict, so a makehybrid-style regression can't ship.
|
||||
MOUNT="$WORK/dmg-check"
|
||||
rm -rf "$MOUNT"
|
||||
mkdir -p "$MOUNT"
|
||||
hdiutil_retry attach attach -nobrowse -readonly -mountpoint "$MOUNT" "$DMG" >/dev/null
|
||||
if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/VectorCraft.app"; then
|
||||
hdiutil_retry detach detach "$MOUNT" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
hdiutil_retry detach detach "$MOUNT" >/dev/null
|
||||
if [ "$NOTARIZE" = 1 ]; then
|
||||
notarize "$DMG"
|
||||
xcrun stapler staple "$DMG"
|
||||
|
||||
Reference in new issue
Block a user