Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed80181d70 | ||
|
|
24eaf5e037 | ||
|
|
4603ebec7a | ||
|
|
8d881cc0bc | ||
|
|
2ecc83e700 | ||
|
|
731c30ff6d | ||
|
|
71b7ae3d56 | ||
|
|
6eaaccb717 | ||
|
|
805d30e284 | ||
|
|
4505ccb20d | ||
|
|
433c8d5aa7 | ||
|
|
c3719b2e79 | ||
|
|
9aed6191d4 | ||
|
|
9b12fff215 | ||
|
|
e428f38767 | ||
|
|
da735914b6 | ||
|
|
2adf4465d6 | ||
|
|
d3c997329d | ||
|
|
1637165600 | ||
|
|
1f5073e4d1 | ||
|
|
288e93c11f | ||
|
|
a809f270eb | ||
|
|
22ff29167e | ||
|
|
171c382618 | ||
|
|
e3e6869043 | ||
|
|
c7522b7f0c | ||
|
|
4eaa5f0b56 | ||
|
|
f68f66a579 | ||
|
|
0c6814e760 | ||
|
|
87a9fb06cd | ||
|
|
dea33fe5b4 | ||
|
|
d9c48bddef | ||
|
|
3d4caa917a | ||
|
|
fbc1c494b2 | ||
|
|
b08482d640 | ||
|
|
255fefe71f | ||
|
|
f5fab833d0 | ||
|
|
423cb18f40 | ||
|
|
65a4847eed | ||
|
|
6daba37d3b | ||
|
|
6fc725d2fa | ||
|
|
34df89e8e0 | ||
|
|
b192e2cf30 | ||
|
|
93277ef87a | ||
|
|
58c874b128 | ||
|
|
f4035c98d1 | ||
|
|
369948f816 | ||
|
|
1726c600d2 | ||
|
|
5aa1605c17 | ||
|
|
d79aa607cc | ||
|
|
3bd04a082e | ||
|
|
ae9cd2a03c | ||
|
|
3ce08507c1 | ||
|
|
1f3d469c33 | ||
|
|
eeb78520e1 | ||
|
|
eb189a36c3 | ||
|
|
22690c3565 | ||
|
|
fad7248e7d | ||
|
|
2afd0b659c | ||
|
|
0089e7bb8b | ||
|
|
539759429c | ||
|
|
08ab1676b0 | ||
|
|
eb510f4194 | ||
|
|
cb9951918a | ||
|
|
82b22af43c | ||
|
|
9bcac8f1e6 | ||
|
|
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
@@ -98,6 +98,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-extend.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | The Selection and Direct Selection arrows' fill, painted black or white under their outline |
|
||||
| `assets/icons/dc-arrow-tip.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-up.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-artboard-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
||||
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",
|
||||
|
||||
@@ -170,12 +170,13 @@ next one. To pick one when starting the app, set `WGPU_POWER_PREF=high` (or `low
|
||||
its name, such as `WGPU_ADAPTER_NAME=nvidia` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
|
||||
|
||||
On native Wayland, winit 0.30 does not deliver dropped files. To open an SVG, use File › Open;
|
||||
to place artwork in the current document, copy and paste the file in a file manager; or run
|
||||
under XWayland (`WAYLAND_DISPLAY= vectorcraft`) for file drag-and-drop
|
||||
to place artwork in the current document, copy and paste the file in a file manager. For file
|
||||
drag-and-drop, set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart, or start
|
||||
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
|
||||
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
|
||||
|
||||
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
|
||||
respond to it yet (#491). Start the app under XWayland instead: `WAYLAND_DISPLAY= vectorcraft` (see
|
||||
respond to it yet (#491). Open the window through XWayland instead, with the same preference or `WAYLAND_DISPLAY= vectorcraft` (see
|
||||
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
|
||||
|
||||
### Use it from Claude Code and other agents
|
||||
|
||||
+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 |
|
||||
|
||||
@@ -94,6 +94,15 @@ fn mcp_automation_roots() {
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
|
||||
std::fs::write(base.join("in/a.svg"), svg).unwrap();
|
||||
std::fs::write(base.join("outside/b.svg"), svg).unwrap();
|
||||
std::fs::write(base.join("outside/victim.png"), b"keep this file").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
symlink(base.join("outside/b.svg"), base.join("in/escape.svg")).unwrap();
|
||||
symlink(base.join("outside"), base.join("out/escape")).unwrap();
|
||||
symlink(base.join("outside/victim.png"), base.join("out/victim.png")).unwrap();
|
||||
symlink(base.join("outside/missing.png"), base.join("out/dangling.png")).unwrap();
|
||||
}
|
||||
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
|
||||
let fails = |args: &[&str], why: &str| {
|
||||
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
@@ -114,15 +123,32 @@ fn mcp_automation_roots() {
|
||||
.unwrap();
|
||||
let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
|
||||
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
|
||||
let mut calls = vec![
|
||||
call(1, "open_file", json!({"path": file("in", "a.svg")})),
|
||||
call(2, "open_file", json!({"path": file("outside", "b.svg")})),
|
||||
call(3, "export", json!({"path": file("out", "a.png")})),
|
||||
call(4, "export", json!({"path": file("in", "a.png")})),
|
||||
call(5, "save_file", json!({"path": file("outside", "a.vectorcraft")})),
|
||||
call(6, "open_file", json!({"path": file("in", "../outside/b.svg")})),
|
||||
call(7, "export", json!({"path": file("out", "../outside/traversal.png")})),
|
||||
];
|
||||
let mut expected_errors = vec![false, true, false, true, true, true, true];
|
||||
#[cfg(unix)]
|
||||
{
|
||||
calls.extend([
|
||||
call(8, "open_file", json!({"path": file("in", "escape.svg")})),
|
||||
call(9, "export", json!({"path": file("out", "escape/escaped.png")})),
|
||||
call(10, "export", json!({"path": file("out", "victim.png")})),
|
||||
call(11, "export", json!({"path": file("out", "dangling.png")})),
|
||||
]);
|
||||
expected_errors.extend([true; 4]);
|
||||
}
|
||||
// An access error is per request: the server continues and can still save inside its root.
|
||||
calls.push(call(12, "save_file", json!({"path": file("out", "allowed.vectorcraft")})));
|
||||
expected_errors.push(false);
|
||||
{
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
for m in [
|
||||
call(1, "open_file", json!({"path": file("in", "a.svg")})),
|
||||
call(2, "open_file", json!({"path": file("outside", "b.svg")})),
|
||||
call(3, "export", json!({"path": file("out", "a.png")})),
|
||||
call(4, "export", json!({"path": file("in", "a.png")})),
|
||||
call(5, "save_file", json!({"path": file("outside", "a.vectorcraft")})),
|
||||
] {
|
||||
for m in calls {
|
||||
writeln!(stdin, "{m}").unwrap();
|
||||
}
|
||||
}
|
||||
@@ -130,9 +156,14 @@ fn mcp_automation_roots() {
|
||||
assert!(out.status.success());
|
||||
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
|
||||
assert_eq!(errors, [false, true, false, true, true], "{replies:?}");
|
||||
assert_eq!(errors, expected_errors, "{replies:?}");
|
||||
assert!(replies[1]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root"));
|
||||
assert!(base.join("out/a.png").is_file() && !base.join("in/a.png").exists() && !base.join("outside/a.vectorcraft").exists());
|
||||
assert!(base.join("out/allowed.vectorcraft").is_file());
|
||||
for denied in ["traversal.png", "escaped.png", "missing.png"] {
|
||||
assert!(!base.join("outside").join(denied).exists(), "{denied}");
|
||||
}
|
||||
assert_eq!(std::fs::read(base.join("outside/victim.png")).unwrap(), b"keep this file");
|
||||
}
|
||||
|
||||
/// End-to-end protocol sweep over stdio against the compiled binary. The in-process
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Parse automation roots before starting any desktop services. A malformed flag must not
|
||||
//! silently remove a root and leave the control channel unconfined.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. CLI values override environment defaults
|
||||
/// once; a second occurrence is an error, including when the two spellings are mixed.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
value
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_override_environment_once_and_leave_other_arguments() {
|
||||
let mut roots = AutomationArgs::new(Some("environment".into()), None);
|
||||
let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--in-window-menus", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(args.next().as_deref(), Some("drawing.svg"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_preserve_environment_roots() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--control"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
}
|
||||
}
|
||||
+206
-55
@@ -21,6 +21,7 @@ compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinar
|
||||
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
|
||||
compile_error!("the win7 target requires --no-default-features --features windows7");
|
||||
|
||||
mod automation_args;
|
||||
mod clipboard;
|
||||
mod control_server;
|
||||
#[cfg(feature = "wgpu")]
|
||||
@@ -39,6 +40,7 @@ mod printing;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
mod system_fonts;
|
||||
mod window;
|
||||
mod xwayland;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
@@ -80,8 +82,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 +117,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 +266,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 +394,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.
|
||||
@@ -462,7 +587,36 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
}
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// First, so every start-up warning is recorded (`logging`).
|
||||
// Validate roots before logging, font scans, preferences or the window: a missing value
|
||||
// must not clear an environment root and start the control channel without confinement.
|
||||
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
let mut roots_args =
|
||||
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
|
||||
let mut files = Vec::new();
|
||||
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match roots_args.consume(&a, &mut args) {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
eprintln!("vectorcraft: {e}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--version" => {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args;
|
||||
// Before desktop services, so every start-up warning is recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
|
||||
// Before the first font scan (the app's start): the fonts font services load (#579).
|
||||
@@ -472,28 +626,6 @@ fn main() -> std::process::ExitCode {
|
||||
mac_fonts::install();
|
||||
// VectorCraft's own Fonts folder, which text.addFontFiles copies fonts into.
|
||||
prefs_dir::install_fonts();
|
||||
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
let mut read_root = root_env("VECTORCRAFT_AUTOMATION_READ_ROOT");
|
||||
let mut write_root = root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT");
|
||||
let mut files = Vec::new();
|
||||
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--automation-read-root" => read_root = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-write-root" => write_root = args.next().map(std::path::PathBuf::from),
|
||||
_ if a.starts_with("--automation-read-root=") => read_root = a.split_once('=').map(|(_, d)| d.into()),
|
||||
_ if a.starts_with("--automation-write-root=") => write_root = a.split_once('=').map(|(_, d)| d.into()),
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--version" => {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
// The log file lives in the settings directory, next to the preferences; opened after the
|
||||
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
|
||||
// first. Runs without preferences (agents' test runs) log to standard error only, so they
|
||||
@@ -551,6 +683,25 @@ fn main() -> std::process::ExitCode {
|
||||
let startup = std::sync::Arc::new(gpu::Startup::default());
|
||||
let custom_titlebar = custom_titlebar(saved.as_ref());
|
||||
let options = native_options(custom_titlebar);
|
||||
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
|
||||
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151).
|
||||
#[cfg(target_os = "linux")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
|
||||
if xwayland::wanted(pref, |name| std::env::var_os(name)) {
|
||||
if xwayland::x11_libraries_found() {
|
||||
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
|
||||
use winit::platform::x11::EventLoopBuilderExtX11 as _;
|
||||
options.event_loop_builder = Some(Box::new(|builder| {
|
||||
builder.with_x11();
|
||||
}));
|
||||
} else {
|
||||
log::warn!("Linux Display Server is X11, but the X11 libraries aren't all installed: opening the window on Wayland");
|
||||
}
|
||||
}
|
||||
options
|
||||
};
|
||||
#[cfg(feature = "wgpu")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
@@ -584,7 +735,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() {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
|
||||
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
|
||||
//! #1151). Only when XWayland can run the window: an X display is set and the X11 libraries winit
|
||||
//! loads at runtime are installed. Otherwise the window opens on Wayland, so a missing library
|
||||
//! never keeps the app from starting.
|
||||
//!
|
||||
//! The library check comes from PhotoCraft's `linux_libs.rs`: no `dlopen` (that needs `unsafe`),
|
||||
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories.
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// What winit 0.30 `dlopen`s for an X11 window: x11-dl opens Xlib, Xlib-xcb, Xcursor and XInput2,
|
||||
/// x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11.
|
||||
const X11_LIBS: &[&str] =
|
||||
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
|
||||
|
||||
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, a
|
||||
/// Wayland session (winit picks Wayland whenever `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set)
|
||||
/// and an X display to open.
|
||||
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>) -> bool {
|
||||
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
|
||||
pref == Some("x11") && (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY")
|
||||
}
|
||||
|
||||
/// Are all the libraries an X11 window needs found on this system? Libraries the check can't find
|
||||
/// count as missing.
|
||||
pub fn x11_libraries_found() -> bool {
|
||||
let cache = read_ldconfig();
|
||||
let dirs = search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref());
|
||||
X11_LIBS
|
||||
.iter()
|
||||
.all(|lib| cache.as_deref().is_some_and(|out| ldconfig_has(out, lib, ldconfig_arch_tag())) || dirs.iter().any(|d| d.join(lib).exists()))
|
||||
}
|
||||
|
||||
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
|
||||
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
|
||||
fn ldconfig_arch_tag() -> Option<&'static str> {
|
||||
if cfg!(target_arch = "x86_64") {
|
||||
Some("x86-64")
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
Some("AArch64")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
|
||||
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
|
||||
fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
|
||||
output.lines().any(|line| {
|
||||
let Some((name, rest)) = line.trim_start().split_once(' ') else { return false };
|
||||
if name != soname {
|
||||
return false;
|
||||
}
|
||||
match arch_tag {
|
||||
None => true,
|
||||
Some(tag) => {
|
||||
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
|
||||
flags.split(',').any(|f| f.trim() == tag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian). A
|
||||
/// real listing starts with "N libs found in cache".
|
||||
fn read_ldconfig() -> Option<String> {
|
||||
["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"].into_iter().find_map(|cmd| {
|
||||
let out = std::process::Command::new(cmd).arg("-p").output().ok().filter(|out| out.status.success())?;
|
||||
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
text.lines().next().is_some_and(|l| l.contains("libs found in cache")).then_some(text)
|
||||
})
|
||||
}
|
||||
|
||||
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
|
||||
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
|
||||
let triplet = if cfg!(target_arch = "aarch64") {
|
||||
"aarch64-linux-gnu"
|
||||
} else if cfg!(target_arch = "riscv64") {
|
||||
"riscv64-linux-gnu"
|
||||
} else {
|
||||
"x86_64-linux-gnu"
|
||||
};
|
||||
dirs.extend(["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"].map(PathBuf::from));
|
||||
dirs.push(Path::new("/usr/lib").join(triplet));
|
||||
dirs.push(Path::new("/lib").join(triplet));
|
||||
dirs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<OsString> {
|
||||
move |k| pairs.iter().find(|(name, _)| *name == k).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xwayland_only_when_chosen_on_wayland_with_an_x_display() {
|
||||
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
|
||||
assert!(wanted(Some("x11"), &wayland));
|
||||
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")])));
|
||||
assert!(!wanted(Some("automatic"), &wayland));
|
||||
assert!(!wanted(None, &wayland));
|
||||
assert!(!wanted(Some("wayland"), &wayland));
|
||||
// An X11 session already opens on X11; without an X display there is no XWayland to use.
|
||||
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")])));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")])));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ldconfig_parsing_matches_name_and_architecture() {
|
||||
let cache = "12 libs found in cache `/etc/ld.so.cache'\n\
|
||||
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
|
||||
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n";
|
||||
assert!(ldconfig_has(cache, "libX11.so.6", Some("x86-64")));
|
||||
assert!(!ldconfig_has(cache, "libX11.so.6", Some("AArch64")));
|
||||
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
|
||||
assert!(!ldconfig_has(cache, "libxkbcommon-x11.so.0", Some("x86-64")));
|
||||
assert!(ldconfig_has(cache, "libxkbcommon-x11.so.0", None));
|
||||
assert!(!ldconfig_has(cache, "libX11.so", None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
|
||||
|
||||
/// Invalid roots must exit before desktop initialization on every platform, including a CI
|
||||
/// machine with no display. A regression must fail rather than hang with a window open.
|
||||
fn rejects(args: &[&str], message: &str) {
|
||||
let mut child = Command::new(BIN)
|
||||
.args(args)
|
||||
.env_remove("VECTORCRAFT_CONTROL_PORT")
|
||||
.env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT")
|
||||
.env_remove("VECTORCRAFT_AUTOMATION_WRITE_ROOT")
|
||||
.env("VECTORCRAFT_NO_PREFS", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let timed_out = loop {
|
||||
if child.try_wait().unwrap().is_some() {
|
||||
break false;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
break true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(!timed_out, "{args:?}: desktop started or did not exit promptly");
|
||||
assert!(!out.status.success(), "{args:?}");
|
||||
assert!(out.stdout.is_empty(), "{args:?}: {:?}", out.stdout);
|
||||
// Exact stderr excludes unrelated startup warnings or display errors that could otherwise
|
||||
// make a malformed invocation fail for an unrelated reason.
|
||||
assert_eq!(String::from_utf8(out.stderr).unwrap(), format!("vectorcraft: {message}\n"), "{args:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_empty_or_option_operands_fail_before_startup() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
rejects(&["--control", "7979", flag], &format!("{flag} needs a folder"));
|
||||
rejects(&[flag], &format!("{flag} needs a folder"));
|
||||
rejects(&["--control", "7979", &format!("{flag}=")], &format!("{flag} needs a non-empty folder"));
|
||||
rejects(&[flag, ""], &format!("{flag} needs a non-empty folder"));
|
||||
for next in ["--version", "--control", "--automation-write-root", "-folder"] {
|
||||
rejects(&[flag, next], &format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_roots_fail_for_both_spellings() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
rejects(&[flag, ".", &format!("{flag}=.")], &format!("{flag} given twice"));
|
||||
rejects(&[&format!("{flag}=."), flag, "."], &format!("{flag} given twice"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_does_not_consume_version() {
|
||||
let out = Command::new(BIN).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
|
||||
assert!(out.stderr.is_empty());
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 173 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="currentColor" stroke-width="1"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3 L6 19 L10 15 L13 21 L15.5 20 L12.6 14 L18 14 Z" fill="none"/></svg>
|
||||
|
Before Width: | Height: | Size: 290 B After Width: | Height: | Size: 265 B |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,6 +375,20 @@ mod tests {
|
||||
assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some());
|
||||
}
|
||||
|
||||
/// #1103: a marquee drawn over a background picks what is on it, not the background, whose
|
||||
/// outline it doesn't touch.
|
||||
#[test]
|
||||
fn a_marquee_inside_a_background_leaves_the_background() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (back, small) = (d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap();
|
||||
d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
|
||||
assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]);
|
||||
// Reaching the background's edge takes it too.
|
||||
assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_top_object() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -825,20 +825,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Reshape", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if seg >= sp.segment_count() {
|
||||
if !sp.bend_segment(seg, t, dv) {
|
||||
return Err(EngineError::Other("no such segment".into()));
|
||||
}
|
||||
let n = sp.anchors.len();
|
||||
// Moving both inner control points by v moves B(t) by 3t(1-t)·v. A smooth anchor stays
|
||||
// smooth: its other handle turns with the moved one.
|
||||
let v = dv / (3.0 * t * (1.0 - t));
|
||||
let (i0, i1) = (seg % n, (seg + 1) % n);
|
||||
if let Some(a) = sp.anchors.get_mut(i0) {
|
||||
a.set_handle(true, a.h_out + v, false);
|
||||
}
|
||||
if let Some(b) = sp.anchors.get_mut(i1) {
|
||||
b.set_handle(false, b.h_in + v, false);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
|
||||
@@ -39,6 +39,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!(
|
||||
"edit.undoGroup.begin",
|
||||
"Begin Undo Group",
|
||||
[],
|
||||
None,
|
||||
"{label?: string} until edit.undoGroup.end, the edits made in the active document are one undo step, named `label` (else after its first edit): for automation, such as MCP command_batch with `atomic` (#1163)",
|
||||
has_doc,
|
||||
undo_group_begin
|
||||
),
|
||||
cmd!(
|
||||
"edit.undoGroup.end",
|
||||
"End Undo Group",
|
||||
[],
|
||||
None,
|
||||
"{cancel?: bool} close the undo group: its edits stay one undo step or, with `cancel`, are undone",
|
||||
has_doc,
|
||||
undo_group_end
|
||||
),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{} (the Artboard tool chosen: artboard.cut)", has_selection_or_artboard_tool, cut),
|
||||
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} (the Artboard tool chosen: artboard.copy)", has_selection_or_artboard_tool, copy),
|
||||
cmd!(
|
||||
@@ -167,6 +185,22 @@ pub(crate) fn typing_in_progress(s: &Session) -> bool {
|
||||
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
|
||||
}
|
||||
|
||||
fn undo_group_begin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.undoGroup.begin";
|
||||
let label = match p.get("label") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(l)) if !l.trim().is_empty() && l.chars().count() <= 200 => Some(l.trim().to_string()),
|
||||
Some(_) => return Err(bad(C, "`label` must be a non-empty name of at most 200 characters")),
|
||||
};
|
||||
s.begin_named_undo_group(label);
|
||||
ok()
|
||||
}
|
||||
|
||||
fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.end_undo_group(bool_or(p, "cancel", false));
|
||||
ok()
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
|
||||
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -129,7 +129,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
artboard_new
|
||||
),
|
||||
cmd!("artboard.delete", "Delete Artboard", ["Window", "Artboards"], None, "{index}", has_doc, artboard_delete),
|
||||
cmd!(
|
||||
"artboard.delete",
|
||||
"Delete Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left",
|
||||
has_doc,
|
||||
artboard_delete
|
||||
),
|
||||
cmd!(
|
||||
"artboard.setProps",
|
||||
"Artboard Options…",
|
||||
@@ -551,11 +559,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()
|
||||
@@ -753,17 +757,16 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.delete", "missing index"))? as usize;
|
||||
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?;
|
||||
s.edit("Delete Artboard", |d, sel| {
|
||||
if d.artboards.len() <= 1 {
|
||||
if indices.len() >= d.artboards.len() {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
}
|
||||
if i >= d.artboards.len() {
|
||||
return Err(EngineError::Other("no such artboard".into()));
|
||||
// The last first, so the others keep their places; their guides go with them.
|
||||
for &i in indices.iter().rev() {
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(id));
|
||||
}
|
||||
// Its guides go with it.
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(id));
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
|
||||
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Hide Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer). One undo step → {count}",
|
||||
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer), which show. One undo step → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Visible, false)
|
||||
),
|
||||
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Outline Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
|
||||
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer), which preview → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Preview, false)
|
||||
),
|
||||
@@ -114,7 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Lock Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
|
||||
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer), which unlock → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Locked, true)
|
||||
),
|
||||
@@ -176,27 +176,23 @@ impl Flag {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `flag` to `v` on `ids` (layers that differ) in one undo step labelled `label` → how many
|
||||
/// Set `flag` on each of `changes` (id, value; layers that differ) in one undo step labelled `label` → how many
|
||||
/// changed.
|
||||
fn set_flag(s: &mut Session, ids: Vec<NodeId>, flag: Flag, v: bool, label: &str) -> Result<Value> {
|
||||
fn set_flag(s: &mut Session, changes: Vec<(NodeId, bool)>, flag: Flag, label: &str) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| flag.get(n) != v)).collect();
|
||||
if !ids.is_empty() {
|
||||
let changes: Vec<(NodeId, bool)> = changes.into_iter().filter(|(id, v)| d.node(*id).is_some_and(|n| flag.get(n) != *v)).collect();
|
||||
if !changes.is_empty() {
|
||||
s.edit(label, |d, sel| {
|
||||
for id in &ids {
|
||||
for (id, v) in &changes {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
flag.set(n, v);
|
||||
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(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
Ok(json!({ "count": changes.len() }))
|
||||
}
|
||||
|
||||
/// The top-level layers holding the rows a command acts on.
|
||||
@@ -206,20 +202,22 @@ fn kept_layers(s: &Session, p: &Value) -> Result<HashSet<NodeId>> {
|
||||
Ok(ids.iter().filter_map(|id| d.layer_of(*id)).collect())
|
||||
}
|
||||
|
||||
/// Every top-level layer but the kept ones set to `v`, and the kept ones to the opposite: Alt-click
|
||||
/// on a hidden layer's eye shows just that layer (#1158).
|
||||
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool) -> Result<Value> {
|
||||
let keep = kept_layers(s, p)?;
|
||||
let ids: Vec<NodeId> = s.doc()?.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect();
|
||||
set_flag(s, ids, flag, v, flag.label(v))
|
||||
let changes = s.doc()?.doc.layers.iter().map(|l| (l.id, !keep.contains(&l.id) == v)).collect();
|
||||
set_flag(s, changes, flag, flag.label(v))
|
||||
}
|
||||
|
||||
fn all(s: &mut Session, flag: Flag, v: bool) -> Result<Value> {
|
||||
let mut ids = vec![];
|
||||
s.doc()?.doc.walk(|n| {
|
||||
if n.is_layer() {
|
||||
ids.push(n.id);
|
||||
ids.push((n.id, v));
|
||||
}
|
||||
});
|
||||
set_flag(s, ids, flag, v, flag.label(v))
|
||||
set_flag(s, ids, flag, flag.label(v))
|
||||
}
|
||||
|
||||
fn template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -14,7 +14,7 @@ use vectorcraft_doc::live::{
|
||||
PreserveShape, Spine,
|
||||
};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Affine, PathData, Point};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::expand::gradient_sampler;
|
||||
@@ -95,6 +95,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
spine_move_anchor
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.spine.reshapeSegment",
|
||||
"Reshape Spine Segment",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's: a straight spine turns into a curve (#1111)",
|
||||
has_doc,
|
||||
spine_reshape_segment
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.spine.addAnchor",
|
||||
"Add Spine Point",
|
||||
@@ -640,6 +649,18 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.spine.reshapeSegment";
|
||||
let seg = index_param(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
|
||||
return Err(bad(C, "t, dx and dy must be finite"));
|
||||
}
|
||||
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| if sp.bend_segment(seg, t, dv) { Ok(()) } else { Err(bad(C, "no such spine segment")) })?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn spine_add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.spine.addAnchor";
|
||||
let q = finite_point(p, C)?;
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
+121
-71
@@ -9,7 +9,7 @@ use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::corners::set_corners;
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, Node, NodeId, NodeKind, OrientedBox, Scaling};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2};
|
||||
|
||||
use super::edit::{duplicate_in, selected_roots};
|
||||
use super::opacitymask::percent;
|
||||
@@ -106,7 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("object.hide", "Selection", ["Object", "Hide"], Some("Cmd+3"), "{}", has_selection, hide),
|
||||
cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all),
|
||||
cmd!("object.compoundPath.make", "Make", ["Object", "Compound Path"], Some("Cmd+8"), "{}", has_selection, compound_make),
|
||||
cmd!("object.compoundPath.release", "Release", ["Object", "Compound Path"], Some("Cmd+Alt+Shift+8"), "{}", has_selection, compound_release),
|
||||
cmd!(
|
||||
"object.compoundPath.release",
|
||||
"Release",
|
||||
["Object", "Compound Path"],
|
||||
Some("Cmd+Alt+Shift+8"),
|
||||
"{} release the selected compound paths into their paths, each with the compound path's paint; a path of several subpaths (an SVG `d` with several `M`s) splits into one path per subpath",
|
||||
has_selection,
|
||||
compound_release
|
||||
),
|
||||
cmd!(
|
||||
"object.clippingMask.make",
|
||||
"Make",
|
||||
@@ -150,7 +158,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 +167,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 +176,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 +185,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
|
||||
),
|
||||
@@ -196,7 +204,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Shape Properties",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
|
||||
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
|
||||
has_selection,
|
||||
set_live_shape
|
||||
),
|
||||
@@ -363,7 +371,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 +385,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)
|
||||
}
|
||||
|
||||
@@ -653,26 +666,52 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// Is `n` a compound path to release: a compound path, or a path of several subpaths (an SVG `d`
|
||||
/// with several `M`s, a PDF path), which is one in Illustrator too (#1163)?
|
||||
fn releases(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Compound { .. } => true,
|
||||
NodeKind::Path { path, .. } => path.subpaths.len() > 1,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no compound path selected, the document, the selection and the undo history stay as
|
||||
// they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))) {
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(releases)) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Release Compound Path", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let NodeKind::Compound { children, .. } = &n.kind else { continue };
|
||||
let Some(n) = d.node(*id).cloned().filter(releases) else { continue };
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
d.remove(*id)?;
|
||||
for (k, c) in children.iter().enumerate() {
|
||||
let mut c = (**c).clone();
|
||||
c.appearance = n.appearance.clone();
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
match &n.kind {
|
||||
NodeKind::Compound { children, .. } => {
|
||||
for (k, c) in children.iter().enumerate() {
|
||||
let mut c = (**c).clone();
|
||||
c.appearance = n.appearance.clone();
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
}
|
||||
}
|
||||
// Each subpath becomes a path of its own with the path's paint, in the same
|
||||
// stacking order; the first keeps the path's id.
|
||||
NodeKind::Path { path, .. } => {
|
||||
for (k, sp) in path.subpaths.iter().enumerate() {
|
||||
let mut c = if k == 0 { n.clone() } else { d.reid(&n) };
|
||||
if let NodeKind::Path { path, live, .. } = &mut c.kind {
|
||||
(*path, *live) = (PathData::single(sp.clone()), None);
|
||||
}
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sel.set(out);
|
||||
@@ -849,6 +888,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 +908,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 +960,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 +984,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 +1040,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 +1067,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 +1096,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 +1138,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 +1153,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 +1177,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
|
||||
),
|
||||
@@ -78,6 +78,21 @@ fn board_and_art(s: &Session, p: &Value) -> (usize, bool) {
|
||||
|
||||
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
|
||||
/// ones only with prefs moveLockedWithArtboard), in paint order.
|
||||
/// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`,
|
||||
/// in order and once each.
|
||||
pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
let listed = match (p.get("indices"), p.get("index")) {
|
||||
(Some(v), _) => point_indices(v),
|
||||
(None, Some(i)) => point_indices(&json!([i])),
|
||||
(None, None) => return Err(bad(cmd, "missing index")),
|
||||
};
|
||||
let mut indices =
|
||||
listed.filter(|v| !v.is_empty() && v.iter().all(|i| *i < d.artboards.len())).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
Ok(indices)
|
||||
}
|
||||
|
||||
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -216,7 +216,12 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
|
||||
let layers = std::mem::take(&mut doc.layers);
|
||||
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf") {
|
||||
(art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten())), None)
|
||||
// An SVG with `crop: "crop"` asked for is framed and clipped by its viewBox (its artboard),
|
||||
// as a PDF by its page: art drawn on a shared canvas keeps its place and scale (#788). By
|
||||
// default, as Illustrator places one, it is its art's bounds.
|
||||
let framed = matches!(format.id, "svg" | "svgz") && crop && p.get("crop").is_some();
|
||||
let frame = doc.artboards.first().map(|a| a.rect).filter(|_| framed);
|
||||
(art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten())), frame)
|
||||
} else {
|
||||
// The page placed is a PDF import's only one.
|
||||
let index = if pdf { 0 } else { page - 1 };
|
||||
|
||||
@@ -87,6 +87,10 @@ const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
|
||||
/// `automatic` (#502).
|
||||
pub const GPU_PREFERENCES: &[(&str, &str)] =
|
||||
&[("automatic", "Automatic"), ("lowPower", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
|
||||
/// Performance › Linux Display Server (`linuxDisplayServer`), read by the desktop app at startup:
|
||||
/// `x11` opens the window through XWayland on a Wayland session, where dropped files arrive
|
||||
/// (winit 0.30 has no Wayland drag and drop, #1151).
|
||||
const LINUX_DISPLAY_SERVERS: &[(&str, &str)] = &[("automatic", "Automatic"), ("x11", "X11 (XWayland)")];
|
||||
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
|
||||
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
|
||||
|
||||
@@ -290,6 +294,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("historyStates", "Performance", "", "History States", int(5, 1000)),
|
||||
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
|
||||
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
|
||||
p!("linuxDisplayServer", "Performance", "", "Linux Display Server", choice(LINUX_DISPLAY_SERVERS)),
|
||||
// File Handling
|
||||
p!("backgroundSave", "File Handling", "", "Enable Background Save", bool),
|
||||
p!("backgroundExport", "File Handling", "", "Enable Background Export", bool),
|
||||
|
||||
@@ -26,6 +26,9 @@ use vectorcraft_render::{AntiAlias, RenderOptions};
|
||||
|
||||
use super::*;
|
||||
|
||||
mod report;
|
||||
use report::FlattenReport;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"document.rasterEffectsSettings",
|
||||
@@ -409,9 +412,9 @@ fn needs(n: &Node, which: Which) -> bool {
|
||||
}
|
||||
|
||||
/// The art of `n`'s opacity mask with its raster effects as images.
|
||||
fn walk_mask(out: &mut Document, n: &mut Node, which: Which) {
|
||||
fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) {
|
||||
if let Some(m) = n.mask.as_mut()
|
||||
&& let Some(art) = walk(out, &m.art, which)
|
||||
&& let Some(art) = walk(out, &m.art, report)
|
||||
{
|
||||
m.art = Arc::new(art);
|
||||
}
|
||||
@@ -420,15 +423,23 @@ fn walk_mask(out: &mut Document, n: &mut Node, which: Which) {
|
||||
/// `n` with its raster effects (and its members' and its opacity mask's) as images, `None` when it
|
||||
/// has none. An object whose effects all paint below it keeps its vector art over an image of
|
||||
/// them; one whose effects change it (blur, feather, inner glow) becomes the image.
|
||||
fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
|
||||
fn walk(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option<Node> {
|
||||
let checkpoint = report.checkpoint();
|
||||
let result = walk_inner(out, n, report);
|
||||
report.attribute_since(checkpoint, n);
|
||||
result
|
||||
}
|
||||
|
||||
fn walk_inner(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option<Node> {
|
||||
let which = report.which;
|
||||
if !needs(n, which) {
|
||||
return None;
|
||||
}
|
||||
if !imaged(n, which) {
|
||||
let mut m = n.clone();
|
||||
walk_mask(out, &mut m, which);
|
||||
walk_mask(out, &mut m, report);
|
||||
if is_container(&m)
|
||||
&& let Some(ch) = walk_all(out, n.children()?, which)
|
||||
&& let Some(ch) = walk_all(out, n.children()?, report)
|
||||
&& let Some(slot) = m.children_mut()
|
||||
{
|
||||
*slot = ch;
|
||||
@@ -436,33 +447,41 @@ fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
|
||||
return Some(m);
|
||||
}
|
||||
if let Some(g) = placed_symbol(out, n) {
|
||||
return Some(walk(out, &g, which).unwrap_or(g));
|
||||
return Some(walk(out, &g, report).unwrap_or(g));
|
||||
}
|
||||
let below = below_only(n);
|
||||
if !below && let Some(g) = split_items(out, n) {
|
||||
return Some(walk(out, &g, which).unwrap_or(g));
|
||||
return Some(walk(out, &g, report).unwrap_or(g));
|
||||
}
|
||||
// An image that can't be made leaves the object as it is (the writer reports its effects).
|
||||
let mut v = n.clone();
|
||||
strip_raster(&mut v, false);
|
||||
let mut m = if below {
|
||||
let images = below_images(out, n)?;
|
||||
let mut m = walk(out, &v, which).unwrap_or(v);
|
||||
for image in &images {
|
||||
report.record(n, image);
|
||||
}
|
||||
let mut m = walk(out, &v, report).unwrap_or(v);
|
||||
// Each goes in first: the last one put is the lowest.
|
||||
for image in images.into_iter().rev() {
|
||||
put_below(out, &mut m, image);
|
||||
}
|
||||
m
|
||||
} else {
|
||||
replace_with_image(v, raster_image(out, n, false, false)?)
|
||||
let image = raster_image(out, n, false, false)?;
|
||||
report.record(n, &image);
|
||||
replace_with_image(v, image)
|
||||
};
|
||||
walk_mask(out, &mut m, which);
|
||||
walk_mask(out, &mut m, report);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// `nodes` with their raster effects as images, `None` when none has any.
|
||||
fn walk_all(out: &mut Document, nodes: &[Arc<Node>], which: Which) -> Option<Vec<Arc<Node>>> {
|
||||
nodes.iter().any(|n| needs(n, which)).then(|| nodes.iter().map(|n| walk(out, n, which).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
|
||||
fn walk_all(out: &mut Document, nodes: &[Arc<Node>], report: &mut FlattenReport) -> Option<Vec<Arc<Node>>> {
|
||||
nodes
|
||||
.iter()
|
||||
.any(|n| needs(n, report.which))
|
||||
.then(|| nodes.iter().map(|n| walk(out, n, report).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
|
||||
}
|
||||
|
||||
/// A copy of `doc` with raster effects turned into images at the document's raster effects
|
||||
@@ -470,17 +489,18 @@ fn walk_all(out: &mut Document, nodes: &[Arc<Node>], which: Which) -> Option<Vec
|
||||
/// type, images, symbol instances, live objects) and on single fills and strokes, in the layers,
|
||||
/// opacity masks, symbol definitions and pattern tiles.
|
||||
pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
|
||||
flatten(doc, Which::All)
|
||||
flatten(doc, &mut FlattenReport::new(Which::All, doc))
|
||||
}
|
||||
|
||||
/// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp
|
||||
/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the
|
||||
/// other raster effects live), or `None` when there are none.
|
||||
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
|
||||
flatten(doc, Which::Pixel)
|
||||
flatten(doc, &mut FlattenReport::new(Which::Pixel, doc))
|
||||
}
|
||||
|
||||
fn flatten(doc: &Document, which: Which) -> Option<Document> {
|
||||
fn flatten(doc: &Document, report: &mut FlattenReport) -> Option<Document> {
|
||||
let which = report.which;
|
||||
let any = |nodes: &[Arc<Node>]| nodes.iter().any(|n| needs(n, which));
|
||||
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art, which)) && !doc.patterns.iter().any(|p| any(&p.art)) {
|
||||
return None;
|
||||
@@ -489,18 +509,18 @@ fn flatten(doc: &Document, which: Which) -> Option<Document> {
|
||||
let baked = effects::bake_document(doc);
|
||||
let src = baked.as_ref().unwrap_or(doc);
|
||||
let mut out = src.clone();
|
||||
if let Some(layers) = walk_all(&mut out, &src.layers, which) {
|
||||
if let Some(layers) = walk_all(&mut out, &src.layers, report) {
|
||||
out.layers = layers;
|
||||
}
|
||||
for i in 0..out.symbols.len() {
|
||||
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a, which))
|
||||
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a, report))
|
||||
&& let Some(s) = out.symbols.get_mut(i)
|
||||
{
|
||||
s.art = Arc::new(art);
|
||||
}
|
||||
}
|
||||
for i in 0..out.patterns.len() {
|
||||
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a, which))
|
||||
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a, report))
|
||||
&& let Some(p) = out.patterns.get_mut(i)
|
||||
{
|
||||
p.art = art;
|
||||
@@ -520,10 +540,11 @@ pub fn export_pdf_with_report(
|
||||
doc: &Document,
|
||||
opts: &vectorcraft_pdf::PdfOptions,
|
||||
) -> std::result::Result<vectorcraft_pdf::ExportReport, vectorcraft_pdf::PdfError> {
|
||||
match flatten_raster_effects(doc) {
|
||||
Some(d) => vectorcraft_pdf::export_with_report(&d, opts),
|
||||
None => vectorcraft_pdf::export_with_report(doc, opts),
|
||||
}
|
||||
let mut report = FlattenReport::new(Which::All, doc);
|
||||
let flat = flatten(doc, &mut report);
|
||||
let mut exported = vectorcraft_pdf::export_with_report(flat.as_ref().unwrap_or(doc), opts)?;
|
||||
exported.warnings.extend(report.warnings());
|
||||
Ok(exported)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! The PDF export warnings for raster effects whose images the 64 million pixel cap rendered below
|
||||
//! the document's raster effects resolution, one per object of the document.
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::Which;
|
||||
|
||||
pub(super) struct FlattenReport {
|
||||
pub which: Which,
|
||||
requested_ppi: f64,
|
||||
reductions: Vec<Reduction>,
|
||||
/// Ids from here on are the flattening's own (split appearance items, groups under shadows).
|
||||
first_new_id: u64,
|
||||
}
|
||||
|
||||
struct Reduction {
|
||||
id: NodeId,
|
||||
name: Option<String>,
|
||||
achieved_ppi: f64,
|
||||
}
|
||||
|
||||
impl FlattenReport {
|
||||
pub fn new(which: Which, doc: &Document) -> Self {
|
||||
Self { which, requested_ppi: super::effects_scale(doc) * 72.0, reductions: vec![], first_new_id: doc.peek_next_id() }
|
||||
}
|
||||
|
||||
/// Note `image` (made for the raster effects of `original`) when the cap lowered its resolution.
|
||||
pub fn record(&mut self, original: &Node, image: &Node) {
|
||||
if let NodeKind::Image(image) = &image.kind {
|
||||
let [pixel_size, ..] = image.xf.as_coeffs();
|
||||
let achieved_ppi = 72.0 / pixel_size;
|
||||
if achieved_ppi + 1e-6 < self.requested_ppi {
|
||||
self.reductions.push(Reduction { id: original.id, name: original.name.clone(), achieved_ppi });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checkpoint(&self) -> usize {
|
||||
self.reductions.len()
|
||||
}
|
||||
|
||||
/// Give the reductions recorded since `checkpoint` on pieces the flattening made to `original`,
|
||||
/// a document object (the innermost one, as the walk returns from it).
|
||||
pub fn attribute_since(&mut self, checkpoint: usize, original: &Node) {
|
||||
let first_new_id = self.first_new_id;
|
||||
if original.id.0 >= first_new_id {
|
||||
return;
|
||||
}
|
||||
for reduction in self.reductions.iter_mut().skip(checkpoint) {
|
||||
if reduction.id.0 >= first_new_id {
|
||||
reduction.id = original.id;
|
||||
reduction.name.clone_from(&original.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn warnings(self) -> Vec<String> {
|
||||
let mut lowest: Vec<Reduction> = vec![];
|
||||
for reduction in self.reductions {
|
||||
if let Some(existing) = lowest.iter_mut().find(|r| r.id == reduction.id) {
|
||||
existing.achieved_ppi = existing.achieved_ppi.min(reduction.achieved_ppi);
|
||||
} else {
|
||||
lowest.push(reduction);
|
||||
}
|
||||
}
|
||||
lowest
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let name = r.name.map(|name| format!(" ({name})")).unwrap_or_default();
|
||||
format!(
|
||||
"raster effects on object {}{name}: requested {:.1} ppi, achieved {:.1} ppi due to the 64 million pixel cap",
|
||||
r.id.0, self.requested_ppi, r.achieved_ppi
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_doc::ImageObject;
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
fn image(ppi: f64) -> Node {
|
||||
Node::new(
|
||||
NodeId(999),
|
||||
NodeKind::Image(ImageObject {
|
||||
key: "effect".into(),
|
||||
width: 8000,
|
||||
height: 8000,
|
||||
xf: Affine::scale(72.0 / ppi),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowest_resolution_is_reported_when_multiple_images_belong_to_one_object() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
doc.raster_effects_ppi = 2400.0;
|
||||
let original = &doc.layers[0];
|
||||
let mut report = FlattenReport::new(Which::All, &doc);
|
||||
|
||||
for ppi in [1800.0, 1200.0, 1500.0] {
|
||||
report.record(original, &image(ppi));
|
||||
}
|
||||
|
||||
let warnings = report.warnings();
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains("achieved 1200.0 ppi"), "{:?}", warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_identity_is_reported_when_generated_pieces_are_attributed() {
|
||||
let doc = Document::new(100.0, 100.0);
|
||||
let original = &doc.layers[0];
|
||||
let generated = Node::group(NodeId(998), vec![]);
|
||||
let mut report = FlattenReport::new(Which::All, &doc);
|
||||
|
||||
report.record(&generated, &image(36.0));
|
||||
report.attribute_since(0, original);
|
||||
|
||||
let warnings = report.warnings();
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains(&format!("object {}", original.id.0)), "{:?}", warnings);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index, dx, dy, moveArt?: bool, copy?: bool} move an artboard (and the art fully inside it; locked and hidden art only with prefs moveLockedWithArtboard); `copy` (Alt-drag) leaves them and moves copies → {index, moved: the art moved or the copies}",
|
||||
"{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool} move artboards (and the art fully inside them; locked and hidden art only with prefs moveLockedWithArtboard), several in one step; `copy` (Alt-drag) leaves them and moves copies → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}",
|
||||
has_doc,
|
||||
artboard_move
|
||||
),
|
||||
@@ -366,26 +366,41 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// ---------- artboards ----------
|
||||
|
||||
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.move", "missing index"))? as usize;
|
||||
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.move")?;
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let art = super::panelcmds::artboard_art(s, i, bool_or(p, "moveArt", false))?;
|
||||
let move_art = bool_or(p, "moveArt", false);
|
||||
// Each artboard's art, an object counted once (artboards can overlap).
|
||||
let mut arts: Vec<(usize, Vec<NodeId>)> = vec![];
|
||||
for &i in &indices {
|
||||
let art = super::panelcmds::artboard_art(s, i, move_art)?;
|
||||
let art = art.into_iter().filter(|id| !arts.iter().any(|(_, a)| a.contains(id))).collect();
|
||||
arts.push((i, art));
|
||||
}
|
||||
let scale_strokes = s.prefs.scale_strokes;
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
|
||||
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
|
||||
if copy {
|
||||
return super::panelcmds::copy_artboard(d, i, dv, &art, scale_strokes);
|
||||
}
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = a.rect + dv;
|
||||
let id = a.id;
|
||||
d.move_artboard_guides(id, dv);
|
||||
for id in &art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(dv), scale_strokes);
|
||||
let (mut index, mut moved) = (vec![], vec![]);
|
||||
for (i, art) in &arts {
|
||||
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
|
||||
if copy {
|
||||
let (at, copies) = super::panelcmds::copy_artboard(d, *i, dv, art, scale_strokes)?;
|
||||
index.push(at);
|
||||
moved.extend(copies);
|
||||
continue;
|
||||
}
|
||||
let a = d.artboards.get_mut(*i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = a.rect + dv;
|
||||
let id = a.id;
|
||||
d.move_artboard_guides(id, dv);
|
||||
for id in art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(dv), scale_strokes);
|
||||
}
|
||||
}
|
||||
index.push(*i);
|
||||
moved.extend(art.iter().copied());
|
||||
}
|
||||
Ok((i, art.clone()))
|
||||
Ok((index, moved))
|
||||
})?;
|
||||
Ok(json!({ "index": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
Ok(json!({ "index": index.first(), "indices": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
@@ -100,6 +100,8 @@ pub struct UndoGroup {
|
||||
first: Option<Arc<Document>>,
|
||||
/// The journal's length when the group began: a cancelled group drops the entries after it.
|
||||
journal: usize,
|
||||
/// The undo step's name; else the first edit's.
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-document editing state.
|
||||
@@ -285,13 +287,16 @@ impl DocState {
|
||||
}
|
||||
/// Record undo step `e` (the document before an edit). In an undo group only the group's first
|
||||
/// edit records one: the edits after it extend that step.
|
||||
fn push_undo(&mut self, e: HistoryEntry) {
|
||||
fn push_undo(&mut self, mut e: HistoryEntry) {
|
||||
if let Some(g) = &mut self.undo_group {
|
||||
let recorded = |first: &Arc<Document>| self.history.undo.last().is_some_and(|l| Arc::ptr_eq(&l.doc, first));
|
||||
if g.first.as_ref().is_some_and(recorded) {
|
||||
return;
|
||||
}
|
||||
g.first = Some(e.doc.clone());
|
||||
if let Some(l) = &g.label {
|
||||
e.label = l.clone();
|
||||
}
|
||||
}
|
||||
self.history.undo.push(e);
|
||||
if self.history.undo.len() > self.history.limit {
|
||||
@@ -369,10 +374,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) {
|
||||
@@ -547,6 +569,9 @@ pub struct Prefs {
|
||||
pub real_time_drawing: bool,
|
||||
/// Rasterizer worker threads; -1 = automatic.
|
||||
pub render_threads: i32,
|
||||
/// Linux: `automatic` opens the window on the session's display server; `x11` opens it through
|
||||
/// XWayland on a Wayland session, where dropped files arrive (#1151). Read when the app starts.
|
||||
pub linux_display_server: String,
|
||||
// File Handling
|
||||
pub background_save: bool,
|
||||
pub background_export: bool,
|
||||
@@ -752,6 +777,7 @@ impl Default for Prefs {
|
||||
history_states: 500,
|
||||
real_time_drawing: true,
|
||||
render_threads: -1,
|
||||
linux_display_server: s("automatic"),
|
||||
background_save: true,
|
||||
background_export: true,
|
||||
autosave_recovery: true,
|
||||
@@ -1349,10 +1375,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;
|
||||
@@ -1389,11 +1422,16 @@ impl Session {
|
||||
/// applies each value it passes as its own command, as a typed value is applied; the drag is
|
||||
/// one step.
|
||||
pub fn begin_undo_group(&mut self) {
|
||||
self.begin_named_undo_group(None);
|
||||
}
|
||||
|
||||
/// [`Session::begin_undo_group`] with the undo step named `label` (else after its first edit).
|
||||
pub fn begin_named_undo_group(&mut self, label: Option<String>) {
|
||||
let journal = self.journal.len();
|
||||
if let Some(st) = self.active_mut()
|
||||
&& st.undo_group.is_none()
|
||||
{
|
||||
st.undo_group = Some(UndoGroup { first: None, journal });
|
||||
st.undo_group = Some(UndoGroup { first: None, journal, label });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,90 @@ fn clipping_and_compound() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
|
||||
}
|
||||
|
||||
/// #1163: a path of several subpaths (an SVG `d` with several `M`s) is a compound path to Release:
|
||||
/// one path per subpath, each with the path's paint, in its stacking order, in one undo step.
|
||||
#[test]
|
||||
fn release_splits_a_path_of_several_subpaths() {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute("path.create", &json!({"d": "M0 0 L10 0 L10 10 Z M20 0 L30 0 L30 10 Z M40 0 L50 0 L50 10 Z"})).unwrap()["id"].as_u64().unwrap(),
|
||||
);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("object.compoundPath.release", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
let sel = st.selection.objects.clone();
|
||||
assert_eq!((sel.len(), sel[0]), (3, id), "the first keeps its id");
|
||||
let layer = st.doc.layers[0].children().unwrap();
|
||||
for (k, n) in sel.iter().map(|i| st.doc.node(*i).unwrap()).enumerate() {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { panic!("a path") };
|
||||
assert_eq!(path.subpaths.len(), 1);
|
||||
assert_eq!(path.bounds().unwrap().x0, 20.0 * k as f64, "in order");
|
||||
assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#ff0000", "the path's paint");
|
||||
assert_eq!(layer[k].id, n.id, "stacked bottom to top");
|
||||
}
|
||||
}
|
||||
|
||||
/// `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 +475,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.
|
||||
|
||||
@@ -187,3 +187,20 @@ fn key_anchors_round_trip() {
|
||||
let old: BlendSpec = serde_json::from_value(json!({"spine": {"subpaths": []}})).unwrap();
|
||||
assert!(old.key_anchors.is_empty());
|
||||
}
|
||||
|
||||
/// #1111: a straight spine's segment bends into a curve, its middle moving where it's dragged, and
|
||||
/// the steps follow it; junk segments and deltas are refused.
|
||||
#[test]
|
||||
fn a_straight_spine_segment_bends_into_a_curve() {
|
||||
let mut s = session();
|
||||
let (_, _, g) = blend(&mut s);
|
||||
s.execute("object.blend.spine.reshapeSegment", &json!({"segment": 0, "t": 0.5, "dx": 0, "dy": 40})).unwrap();
|
||||
let spine = spec(&s, g).spine.unwrap();
|
||||
let seg = spine.subpaths[0].segment(0);
|
||||
use vectorcraft_geom::kurbo::ParamCurve;
|
||||
assert!(seg.eval(0.5).distance(Point::new(55.0, 45.0)) < 1e-9, "{seg:?}");
|
||||
assert!(center(&steps(&s, g)[1]).y > 20.0, "the step follows the curve");
|
||||
for bad in [json!({"segment": 1, "dy": 10}), json!({"segment": 0, "dx": f64::MAX, "dy": f64::MAX}), json!({"segment": -1})] {
|
||||
assert!(s.execute("object.blend.spine.reshapeSegment", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -460,6 +543,23 @@ fn hide_lock_and_outline_others_and_their_all_counterparts() {
|
||||
assert_eq!(run(&mut s, "layer.previewAll", json!({}))["count"], 0, "nothing to do: no undo step");
|
||||
}
|
||||
|
||||
/// #1158: Alt-click on a hidden (locked) layer's eye (lock) isolates it: it shows (unlocks) as the
|
||||
/// others hide (lock), in one undo step.
|
||||
#[test]
|
||||
fn hide_and_lock_others_show_and_unlock_the_layer_kept() {
|
||||
let mut s = session();
|
||||
let l1 = s.doc().unwrap().doc.layers[0].id;
|
||||
let l2 = id(&run(&mut s, "layer.new", json!({})));
|
||||
let flags = |s: &Session, f: fn(&Node) -> bool| [l1, l2].map(|l| f(&node(s, l)));
|
||||
run(&mut s, "layer.setProps", json!({"ids": [l2.0], "visible": false, "locked": true}));
|
||||
assert_eq!(run(&mut s, "layer.hideOthers", json!({"ids": [l2.0]}))["count"], 2);
|
||||
assert_eq!(flags(&s, |n| n.visible), [false, true]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(flags(&s, |n| n.visible), [true, false], "one undo step");
|
||||
run(&mut s, "layer.lockOthers", json!({"ids": [l2.0]}));
|
||||
assert_eq!(flags(&s, |n| n.locked), [true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_object_highlights_its_row() {
|
||||
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();
|
||||
|
||||
@@ -182,3 +182,42 @@ fn shadows_and_glows_keep_their_blend_modes() {
|
||||
flat.layers[0].children().unwrap()[0].children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.blend).collect();
|
||||
assert_eq!(modes, [vectorcraft_color::BlendMode::Multiply, vectorcraft_color::BlendMode::Screen]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_resolution_cap_warns_once_for_original_object_when_appearance_items_split() {
|
||||
let mut d = Document::new(300.0, 300.0);
|
||||
d.raster_effects_ppi = 2400.0;
|
||||
d.raster_effects.anti_alias = false;
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(20.0, 20.0, 270.0, 270.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 20.0),
|
||||
);
|
||||
let id = n.id;
|
||||
n.name = Some("Large original".into());
|
||||
for item in &mut n.appearance.items {
|
||||
item.effects_mut().push(Effect { params: json!({"radius": 0.0}), ..blur() });
|
||||
}
|
||||
add(&mut d, n);
|
||||
|
||||
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
|
||||
|
||||
assert!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"));
|
||||
assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
|
||||
let warning = &r.warnings[0];
|
||||
assert!(warning.contains(&format!("object {}", id.0)) && warning.contains("Large original"), "{warning}");
|
||||
assert!(warning.contains("requested 2400.0 ppi") && warning.contains("achieved 1268.7 ppi"), "{warning}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_resolution_cap_stays_quiet_when_small_effect_meets_requested_ppi() {
|
||||
let mut d = Document::new(50.0, 50.0);
|
||||
d.raster_effects_ppi = 300.0;
|
||||
let n = with(rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), Color::BLACK), blur());
|
||||
add(&mut d, n);
|
||||
|
||||
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
|
||||
|
||||
assert!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
@@ -346,3 +346,32 @@ fn bad_place_params_are_errors() {
|
||||
assert_eq!(s.tool_id(), "selection", "a failed queue leaves the tool alone");
|
||||
assert!(top_children(&s).is_empty());
|
||||
}
|
||||
|
||||
/// An SVG placed with `crop: "crop"` given is framed and clipped by its viewBox, as a PDF by its
|
||||
/// page, so art drawn on a shared canvas keeps its place and scale; by default (and with
|
||||
/// "bounding") it is fitted by its art's bounds (#788).
|
||||
#[test]
|
||||
fn an_svg_placed_with_crop_is_framed_by_its_viewbox() {
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path d="M200 300 L600 300 L600 800 L200 800 Z"/></svg>"#;
|
||||
let path_bounds = |s: &Session, id: &Value| {
|
||||
let mut b = None;
|
||||
node(s, id).walk(&mut |n| {
|
||||
if let NodeKind::Path { clipping: false, .. } = n.kind {
|
||||
b = n.visual_bounds();
|
||||
}
|
||||
});
|
||||
b.unwrap()
|
||||
};
|
||||
let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6);
|
||||
let mut s = session();
|
||||
let r = place(&mut s, "glyph.svg", svg, json!({"rect": [100, 100, 200, 200], "crop": "crop"}));
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(200.0)), "{r}");
|
||||
let id = &r["ids"][0];
|
||||
assert!(near(path_bounds(&s, id), Rect::new(140.0, 160.0, 220.0, 260.0)), "{:?}", path_bounds(&s, id));
|
||||
assert!(matches!(node(&s, id).kind, NodeKind::Group { clip: true, .. }), "clipped to the viewBox");
|
||||
for extra in [json!({"rect": [100, 100, 200, 200]}), json!({"rect": [100, 100, 200, 200], "crop": "bounding"})] {
|
||||
let mut s = session();
|
||||
let r = place(&mut s, "glyph.svg", svg, extra);
|
||||
assert!(near(path_bounds(&s, &r["ids"][0]), Rect::new(120.0, 100.0, 280.0, 300.0)), "{r}");
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,23 @@ fn gpu_preference_defaults_to_automatic_and_validates() {
|
||||
assert_eq!(s.prefs.gpu_preference, "automatic");
|
||||
}
|
||||
|
||||
/// Performance › Linux Display Server (#1151): automatic by default, X11 by value or label, and
|
||||
/// anything else is an error that changes nothing.
|
||||
#[test]
|
||||
fn linux_display_server_defaults_to_automatic_and_validates() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.prefs.linux_display_server, "automatic");
|
||||
s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": "X11 (XWayland)"})).unwrap();
|
||||
assert_eq!(s.prefs.linux_display_server, "x11");
|
||||
for bad in [json!("wayland"), json!(""), json!(11), json!(null)] {
|
||||
assert!(s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": bad})).is_err(), "{bad}");
|
||||
assert_eq!(s.prefs.linux_display_server, "x11");
|
||||
}
|
||||
assert_eq!(s.prefs.to_json()["linuxDisplayServer"], "x11");
|
||||
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
|
||||
assert_eq!(s.prefs.linux_display_server, "automatic");
|
||||
}
|
||||
|
||||
/// Selection & Anchor Display › Tolerance, Object Selection by Path Only and Command Click to
|
||||
/// Select Objects Behind, and General › Double Click To Isolate, as the Selection tool sees them
|
||||
/// (#394).
|
||||
@@ -452,6 +469,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();
|
||||
@@ -596,3 +627,32 @@ fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() {
|
||||
s.execute("object.distort", &json!({"corners": corners, "from": [0, 0, 100, 100], "ids": [a.0]})).unwrap();
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
}
|
||||
|
||||
/// `artboard.move` and `artboard.delete` take several artboards at once (#1139): one undo step
|
||||
/// each, the art on each moving with it, and one artboard always left.
|
||||
#[test]
|
||||
fn several_artboards_move_and_delete_together() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 1400, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
let r = rect(&mut s, 1050.0, 50.0, 50.0, 50.0);
|
||||
let before: Vec<Rect> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let out = s.execute("artboard.move", &json!({"indices": [2, 1], "dx": 10, "dy": 20, "moveArt": true})).unwrap();
|
||||
assert_eq!(out["indices"], json!([1, 2]));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.artboards[0].rect, before[0]);
|
||||
assert_eq!(
|
||||
(d.artboards[1].rect, d.artboards[2].rect),
|
||||
(before[1] + vectorcraft_geom::Vec2::new(10.0, 20.0), before[2] + vectorcraft_geom::Vec2::new(10.0, 20.0))
|
||||
);
|
||||
assert_eq!(d.node(r).unwrap().geometric_bounds().unwrap().x0, 1060.0, "the art on artboard 2 moved with it");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[2].rect, before[2], "one undo step");
|
||||
for bad in [json!({"indices": [1, 9]}), json!({"indices": []}), json!({"indices": [-1]}), json!({"indices": "1"})] {
|
||||
assert!(s.execute("artboard.move", &json!({"indices": bad["indices"], "dx": 1})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("artboard.delete", &json!({"indices": [0, 1, 2]})).is_err(), "one is always left");
|
||||
s.execute("artboard.delete", &json!({"indices": [2, 0]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(), [before[1]]);
|
||||
}
|
||||
@@ -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})),
|
||||
@@ -364,6 +367,7 @@ fn structured_junk() {
|
||||
("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})),
|
||||
("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})),
|
||||
("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})),
|
||||
("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})),
|
||||
("object.blend.spine.removeAnchor", json!({"anchor": -1})),
|
||||
("object.puppetWarp", json!({"rest": true, "pins": [], "moved": []})),
|
||||
("object.puppetWarp", json!({"rest": true, "pins": [[1e308, 0]], "moved": [[0, 0]], "expand": -5})),
|
||||
|
||||
@@ -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.
|
||||
|
||||
+183
-44
@@ -1,7 +1,8 @@
|
||||
//! Live Corners on any path: the anchors where two straight sides meet at an angle, and the path
|
||||
//! with those corners cut round, inverted round or chamfered. Rectangles cut theirs the same way.
|
||||
//! Live Corners on any path: the anchors where two sides, straight or curved, meet at an angle,
|
||||
//! and the path with those corners cut round, inverted round or chamfered (a curved side trimmed
|
||||
//! to what the cut leaves of it). Rectangles cut theirs the same way.
|
||||
|
||||
use kurbo::{Point, Vec2};
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv, Point, Vec2};
|
||||
|
||||
use crate::path::{Anchor, AnchorKind, PathData, SubPath};
|
||||
use crate::shapes::{CornerKind, KAPPA};
|
||||
@@ -11,39 +12,91 @@ const EPS: f64 = 1e-9;
|
||||
/// Sides closer than this to straight on (or folded back), as the sine of their angle, make no corner.
|
||||
const MIN_SINE: f64 = 1e-6;
|
||||
|
||||
/// A corner Live Corners can cut: an anchor without handles between two straight sides that
|
||||
/// meet at an angle.
|
||||
/// How closely arc lengths along curved sides are measured (points).
|
||||
const ARCLEN_ACCURACY: f64 = 1e-6;
|
||||
|
||||
/// A side of a corner: the segment from the corner's anchor to its neighbour, as a cubic leaving
|
||||
/// the corner (a straight side has its handles at its ends).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Side {
|
||||
pub curve: CubicBez,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// Is the side straight, its handles on its ends?
|
||||
fn straight(&self) -> bool {
|
||||
self.curve.p1.distance(self.curve.p0) <= EPS && self.curve.p2.distance(self.curve.p3) <= EPS
|
||||
}
|
||||
|
||||
/// The unit direction the side leaves the corner in: towards its first control point not on
|
||||
/// the corner. `None` when the side has no length.
|
||||
fn direction(&self) -> Option<Vec2> {
|
||||
let c = self.curve;
|
||||
let d = [c.p1, c.p2, c.p3].into_iter().map(|q| q - c.p0).find(|d| d.hypot() > EPS)?;
|
||||
let d = d / d.hypot();
|
||||
d.x.is_finite().then_some(d)
|
||||
}
|
||||
|
||||
/// The side's length.
|
||||
fn length(&self) -> f64 {
|
||||
if self.straight() { self.curve.p0.distance(self.curve.p3) } else { self.curve.arclen(ARCLEN_ACCURACY) }
|
||||
}
|
||||
|
||||
/// The parameter `s` along the side from the corner, the point there and the direction the
|
||||
/// side runs on in (away from the corner).
|
||||
fn at_length(&self, s: f64, u: Vec2) -> (f64, Point, Vec2) {
|
||||
let c = self.curve;
|
||||
if self.straight() {
|
||||
let len = c.p0.distance(c.p3);
|
||||
let t = if len > EPS { (s / len).clamp(0.0, 1.0) } else { 0.0 };
|
||||
return (t, c.p0 + u * s, u);
|
||||
}
|
||||
let t = c.inv_arclen(s, ARCLEN_ACCURACY).clamp(0.0, 1.0);
|
||||
let d = c.deriv().eval(t).to_vec2();
|
||||
let dir = if d.hypot() > EPS { d / d.hypot() } else { u };
|
||||
(t, c.eval(t), dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// A corner Live Corners can cut: an anchor that isn't smooth, where two sides (straight or
|
||||
/// curved) meet at an angle (#1144).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Corner {
|
||||
/// The anchor's index in its path, counting the anchors of every subpath in order.
|
||||
pub index: usize,
|
||||
/// Where the sides meet.
|
||||
pub at: Point,
|
||||
/// Unit directions along the sides: towards the previous anchor, then towards the next one.
|
||||
/// Unit directions the sides leave the corner in: towards the previous anchor, then towards
|
||||
/// the next one.
|
||||
pub u: Vec2,
|
||||
pub v: Vec2,
|
||||
/// The sides' lengths, in the same order.
|
||||
/// The sides' lengths (along curves), in the same order.
|
||||
pub lu: f64,
|
||||
pub lv: f64,
|
||||
/// The sides themselves, leaving the corner, in the same order.
|
||||
pub side_u: Side,
|
||||
pub side_v: Side,
|
||||
}
|
||||
|
||||
impl Corner {
|
||||
/// The corner of anchor `i` of `sp`, whose index in the path is `index`; `None` when the
|
||||
/// anchor is an end of an open subpath, has handles or is smooth, when a side curves or has
|
||||
/// no length, or when the sides run straight on.
|
||||
/// anchor is an end of an open subpath or is smooth, when a side has no length, or when the
|
||||
/// sides leave it straight on.
|
||||
fn of(sp: &SubPath, i: usize, index: usize) -> Option<Self> {
|
||||
let n = sp.anchors.len();
|
||||
let (prev, next) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) };
|
||||
let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev)?, sp.anchors.get(next)?);
|
||||
if a.kind == AnchorKind::Smooth || a.has_in() || a.has_out() || prev.has_out() || next.has_in() {
|
||||
if a.kind == AnchorKind::Smooth {
|
||||
return None;
|
||||
}
|
||||
let (du, dv) = (prev.p - a.p, next.p - a.p);
|
||||
let (lu, lv) = (du.hypot(), dv.hypot());
|
||||
let side_u = Side { curve: CubicBez::new(a.p, a.h_in, prev.h_out, prev.p) };
|
||||
let side_v = Side { curve: CubicBez::new(a.p, a.h_out, next.h_in, next.p) };
|
||||
let (u, v) = (side_u.direction()?, side_v.direction()?);
|
||||
let (lu, lv) = (side_u.length(), side_v.length());
|
||||
if !(lu > EPS && lv > EPS && lu.is_finite() && lv.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let c = Self { index, at: a.p, u: du / lu, v: dv / lv, lu, lv };
|
||||
let c = Self { index, at: a.p, u, v, lu, lv, side_u, side_v };
|
||||
(c.sin() > MIN_SINE).then_some(c)
|
||||
}
|
||||
|
||||
@@ -92,32 +145,47 @@ impl Corner {
|
||||
d.dot(self.u + self.v) * self.sin() / (2.0 + 2.0 * self.cos())
|
||||
}
|
||||
|
||||
/// The two anchors replacing the corner when cut with `radius` (> 0, fitted): where the cut
|
||||
/// meets the arriving side, then the leaving one.
|
||||
fn cut(&self, radius: f64, kind: CornerKind) -> [Anchor; 2] {
|
||||
let t = self.setback(radius);
|
||||
let (start, end) = (self.at + self.u * t, self.at + self.v * t);
|
||||
/// The cut of radius `radius` (> 0, fitted): where it meets each side, `setback` along it,
|
||||
/// and the cut's own curve between them, its handles leaving the sides smoothly.
|
||||
fn cut(&self, radius: f64, kind: CornerKind) -> Cut {
|
||||
let s = self.setback(radius);
|
||||
let (tu, start, du) = self.side_u.at_length(s, self.u);
|
||||
let (tv, end, dv) = self.side_v.at_length(s, self.v);
|
||||
let cos = du.dot(dv);
|
||||
let (h_out, h_in) = match kind {
|
||||
// An arc of `radius` tangent to both sides, turning by 180° less the angle.
|
||||
// An arc tangent to both sides, turning by 180° less the angle between them there.
|
||||
CornerKind::Round => {
|
||||
let h = radius * arc_handle(-self.cos());
|
||||
(start - self.u * h, end - self.v * h)
|
||||
let h = radius * arc_handle(-cos);
|
||||
(start - du * h, end - dv * h)
|
||||
}
|
||||
// An arc centred on the corner through both ends, spanning the angle.
|
||||
CornerKind::InvertedRound => {
|
||||
let h = t * arc_handle(self.cos());
|
||||
(start + square_to(self.u, self.v) * h, end + square_to(self.v, self.u) * h)
|
||||
let h = s * arc_handle(cos);
|
||||
(start + square_to(du, dv) * h, end + square_to(dv, du) * h)
|
||||
}
|
||||
CornerKind::Chamfer => (start, end),
|
||||
};
|
||||
[Anchor { p: start, h_in: start, h_out, kind: AnchorKind::Corner }, Anchor { p: end, h_in, h_out: end, kind: AnchorKind::Corner }]
|
||||
Cut { tu, tv, start, h_out, h_in, end }
|
||||
}
|
||||
}
|
||||
|
||||
/// A corner cut: the parameters where it meets its sides (from the corner), its two new anchors
|
||||
/// and the handles between them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Cut {
|
||||
tu: f64,
|
||||
tv: f64,
|
||||
start: Point,
|
||||
h_out: Point,
|
||||
h_in: Point,
|
||||
end: Point,
|
||||
}
|
||||
|
||||
/// The unit direction square to unit `a`, on `b`'s side.
|
||||
fn square_to(a: Vec2, b: Vec2) -> Vec2 {
|
||||
let w = b - a * a.dot(b);
|
||||
w / w.hypot()
|
||||
let l = w.hypot();
|
||||
if l > EPS { w / l } else { Vec2::ZERO }
|
||||
}
|
||||
|
||||
/// The handle length, per unit of radius, of a cubic drawing a circular arc that spans an angle
|
||||
@@ -126,6 +194,7 @@ fn arc_handle(cos: f64) -> f64 {
|
||||
if cos.abs() < 1e-12 {
|
||||
return KAPPA;
|
||||
}
|
||||
let cos = cos.clamp(-1.0, 1.0);
|
||||
let (sin_half, cos_half) = (((1.0 - cos) / 2.0).max(0.0).sqrt(), ((1.0 + cos) / 2.0).max(0.0).sqrt());
|
||||
4.0 / 3.0 * sin_half / (1.0 + cos_half)
|
||||
}
|
||||
@@ -143,24 +212,55 @@ pub fn path_corners(path: &PathData) -> Vec<Corner> {
|
||||
|
||||
/// `path` with each of its corners ([`path_corners`]) cut by the radius in `radii` and the kind in
|
||||
/// `kinds` at its anchor index (missing: 0 and round), each radius no larger than fits
|
||||
/// ([`Corner::fitted`]). A cut corner becomes two anchors; a closed subpath whose first corner
|
||||
/// is cut starts where that cut ends, so the cut closes it. Also returns, per subpath, the
|
||||
/// anchor index in `path` each anchor comes from.
|
||||
/// ([`Corner::fitted`]). A cut corner becomes two anchors, and a curved side between corners is
|
||||
/// trimmed to what is left of it; a closed subpath whose first corner is cut starts where that
|
||||
/// cut ends, so the cut closes it. Also returns, per subpath, the anchor index in `path` each
|
||||
/// anchor comes from.
|
||||
pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (PathData, Vec<Vec<usize>>) {
|
||||
let corners = path_corners(path);
|
||||
let mut corners = corners.iter().peekable();
|
||||
let (mut subpaths, mut sources) = (Vec::with_capacity(path.subpaths.len()), Vec::with_capacity(path.subpaths.len()));
|
||||
let mut first = 0;
|
||||
for sp in &path.subpaths {
|
||||
let (mut anchors, mut from) = (Vec::with_capacity(sp.anchors.len()), Vec::with_capacity(sp.anchors.len()));
|
||||
let n = sp.anchors.len();
|
||||
// The cut of each anchor, if any.
|
||||
let cuts: Vec<Option<Cut>> = (0..n)
|
||||
.map(|i| {
|
||||
let index = first + i;
|
||||
let c = corners.next_if(|c| c.index == index)?;
|
||||
let radius = c.fitted(radii.get(index).copied().unwrap_or(0.0));
|
||||
(radius > EPS).then(|| c.cut(radius, kinds.get(index).copied().unwrap_or_default()))
|
||||
})
|
||||
.collect();
|
||||
// Each segment from anchor j, trimmed to the part the cuts at its ends leave: its handles
|
||||
// (out of its first anchor, into its last), `None` when it is untouched.
|
||||
let trimmed: Vec<Option<(Point, Point)>> = (0..n)
|
||||
.map(|j| {
|
||||
let k = (j + 1) % n;
|
||||
let (a, b) = (sp.anchors.get(j)?, sp.anchors.get(k)?);
|
||||
let (from, to) = (cuts.get(j).copied().flatten(), cuts.get(k).copied().flatten());
|
||||
if (from.is_none() && to.is_none()) || (k == 0 && !sp.closed) {
|
||||
return None;
|
||||
}
|
||||
// A straight segment stays straight, its handles on its ends.
|
||||
if !a.has_out() && !b.has_in() {
|
||||
return Some((from.map_or(a.p, |c| c.end), to.map_or(b.p, |c| c.start)));
|
||||
}
|
||||
let (s0, s1) = (from.map_or(0.0, |c| c.tv), to.map_or(1.0, |c| 1.0 - c.tu));
|
||||
let sub = CubicBez::new(a.p, a.h_out, b.h_in, b.p).subsegment(s0..s1.max(s0));
|
||||
Some((sub.p1, sub.p2))
|
||||
})
|
||||
.collect();
|
||||
let (mut anchors, mut from) = (Vec::with_capacity(n), Vec::with_capacity(n));
|
||||
let mut closing = None;
|
||||
for (i, a) in sp.anchors.iter().enumerate() {
|
||||
let index = first + i;
|
||||
let corner = corners.next_if(|c| c.index == index);
|
||||
let radius = corner.map_or(0.0, |c| c.fitted(radii.get(index).copied().unwrap_or(0.0)));
|
||||
match corner {
|
||||
Some(c) if radius > EPS => {
|
||||
let [start, end] = c.cut(radius, kinds.get(index).copied().unwrap_or_default());
|
||||
let h_in = trimmed.get((i + n - 1) % n).copied().flatten().map_or(a.h_in, |(_, h)| h);
|
||||
let h_out = trimmed.get(i).copied().flatten().map_or(a.h_out, |(h, _)| h);
|
||||
match cuts.get(i).copied().flatten() {
|
||||
Some(c) => {
|
||||
let start = Anchor { p: c.start, h_in, h_out: c.h_out, kind: AnchorKind::Corner };
|
||||
let end = Anchor { p: c.end, h_in: c.h_in, h_out, kind: AnchorKind::Corner };
|
||||
if i == 0 && sp.closed {
|
||||
closing = Some(start);
|
||||
} else {
|
||||
@@ -170,8 +270,8 @@ pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (Pat
|
||||
anchors.push(end);
|
||||
from.push(index);
|
||||
}
|
||||
_ => {
|
||||
anchors.push(*a);
|
||||
None => {
|
||||
anchors.push(Anchor { h_in, h_out, ..*a });
|
||||
from.push(index);
|
||||
}
|
||||
}
|
||||
@@ -180,7 +280,7 @@ pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (Pat
|
||||
anchors.push(a);
|
||||
from.push(first);
|
||||
}
|
||||
first += sp.anchors.len();
|
||||
first += n;
|
||||
subpaths.push(SubPath::new(anchors, sp.closed));
|
||||
sources.push(from);
|
||||
}
|
||||
@@ -246,11 +346,7 @@ mod tests {
|
||||
false,
|
||||
));
|
||||
assert_eq!(path_corners(&open).iter().map(|c| c.index).collect::<Vec<_>>(), [2, 3]);
|
||||
// A curve on either side, or handles on the anchor, leave it alone.
|
||||
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
|
||||
sp.anchors[1].h_out = Point::new(110.0, 10.0);
|
||||
let p = PathData::single(sp);
|
||||
assert_eq!(path_corners(&p).iter().map(|c| c.index).collect::<Vec<_>>(), [0, 3]);
|
||||
// Smooth anchors aren't corners.
|
||||
assert!(path_corners(&shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0))).is_empty());
|
||||
// A second subpath counts on from the first one's anchors.
|
||||
let two = PathData::new(vec![
|
||||
@@ -262,6 +358,49 @@ mod tests {
|
||||
assert_eq!(cut.anchor_count(), 7);
|
||||
}
|
||||
|
||||
/// The square `0..100` with its right side bulging out as a curve (#1144).
|
||||
fn bulging_square() -> PathData {
|
||||
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
|
||||
sp.anchors[1].h_out = Point::new(140.0, 30.0);
|
||||
sp.anchors[2].h_in = Point::new(140.0, 70.0);
|
||||
PathData::single(sp)
|
||||
}
|
||||
|
||||
/// Corners where a curve meets a line are cut too: the cut ends on the path, leaves both
|
||||
/// sides smoothly, and what is left of the curve still follows it (#1144).
|
||||
#[test]
|
||||
fn corners_next_to_curves_are_cut_along_them() {
|
||||
let path = bulging_square();
|
||||
let corners = path_corners(&path);
|
||||
assert_eq!(corners.iter().map(|c| c.index).collect::<Vec<_>>(), [0, 1, 2, 3]);
|
||||
let side = kurbo::CubicBez::new(Point::new(100.0, 0.0), Point::new(140.0, 30.0), Point::new(140.0, 70.0), Point::new(100.0, 100.0));
|
||||
// The curved side is longer than its chord, and limits its corners by its length.
|
||||
assert!(corners[1].lv > 100.0 + 1.0);
|
||||
let (cut, from) = cut_corners(&path, &[10.0; 4], &[]);
|
||||
assert_eq!(from, [vec![0, 1, 1, 2, 2, 3, 3, 0]]);
|
||||
let sp = &cut.subpaths[0];
|
||||
let on_side = |p: Point| (0..=1000).map(|i| side.eval(f64::from(i) / 1000.0).distance(p)).fold(f64::MAX, f64::min);
|
||||
// Corner 1's cut ends on the curve and corner 2's starts on it, and the trimmed curve
|
||||
// between them stays on the original one.
|
||||
let (a, b) = (sp.anchors[2], sp.anchors[3]);
|
||||
assert!(on_side(a.p) < 0.1 && on_side(b.p) < 0.1);
|
||||
let trimmed = kurbo::CubicBez::new(a.p, a.h_out, b.h_in, b.p);
|
||||
for t in [0.25, 0.5, 0.75] {
|
||||
assert!(on_side(trimmed.eval(t)) < 0.1);
|
||||
}
|
||||
// Every cut leaves its sides smoothly: each anchor's handles are in line.
|
||||
for (i, x) in sp.anchors.iter().enumerate() {
|
||||
let (into, out) = if x.has_in() && x.has_out() {
|
||||
(x.p - x.h_in, x.h_out - x.p)
|
||||
} else if x.has_out() {
|
||||
(x.p - sp.anchors[(i + sp.anchors.len() - 1) % sp.anchors.len()].p, x.h_out - x.p)
|
||||
} else {
|
||||
(x.h_in - x.p, x.p - sp.anchors[(i + 1) % sp.anchors.len()].p)
|
||||
};
|
||||
assert!(into.normalize().cross(out.normalize()).abs() < 1e-6 && into.dot(out) > 0.0, "anchor {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverted round and chamfer cuts end where the round one does, at any angle.
|
||||
#[test]
|
||||
fn kinds_share_their_ends() {
|
||||
@@ -272,8 +411,8 @@ mod tests {
|
||||
assert_eq!(ends(CornerKind::Chamfer), round);
|
||||
// The inverted arc stays on the circle about the corner.
|
||||
let c = path_corners(&star)[0];
|
||||
let [s, e] = c.cut(3.0, CornerKind::InvertedRound);
|
||||
let bez = kurbo::CubicBez::new(s.p, s.h_out, e.h_in, e.p);
|
||||
let cut = c.cut(3.0, CornerKind::InvertedRound);
|
||||
let bez = kurbo::CubicBez::new(cut.start, cut.h_out, cut.h_in, cut.end);
|
||||
let r = c.setback(3.0);
|
||||
for t in [0.25, 0.5, 0.75] {
|
||||
use kurbo::ParamCurve;
|
||||
|
||||
+9
-13
@@ -66,7 +66,10 @@ pub fn stroke_contains(path: &BezPath, width: f64, tol: f64, p: Point) -> bool {
|
||||
quick.contains(p) && distance_to_outline(path, p) <= width / 2.0 + tol
|
||||
}
|
||||
|
||||
/// Does the path intersect (or lie inside) the rect? Used for marquee selection.
|
||||
/// Does the path touch the rect: an anchor inside it, or a segment crossing its edges? Used for
|
||||
/// marquee selection, which (as in Illustrator) needs the path itself: a rect wholly inside a
|
||||
/// filled shape doesn't touch it, so a marquee drawn over a background picks only what is on it
|
||||
/// (#1103).
|
||||
pub fn intersects_rect(path: &PathData, r: Rect) -> bool {
|
||||
let Some(b) = path.bounds() else { return false };
|
||||
if b.intersect(r).area() <= 0.0 && !(b.width() == 0.0 || b.height() == 0.0) {
|
||||
@@ -83,16 +86,7 @@ pub fn intersects_rect(path: &PathData, r: Rect) -> bool {
|
||||
kurbo::Line::new((r.x1, r.y1), (r.x0, r.y1)),
|
||||
kurbo::Line::new((r.x0, r.y1), (r.x0, r.y0)),
|
||||
];
|
||||
let bp = path.to_bezpath();
|
||||
for seg in bp.segments() {
|
||||
for e in &edges {
|
||||
if !seg.intersect_line(*e).is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rect entirely inside a closed shape counts as touching it.
|
||||
path.is_closed() && fill_contains(&bp, FillRule::NonZero, r.center())
|
||||
path.to_bezpath().segments().any(|seg| edges.iter().any(|e| !seg.intersect_line(*e).is_empty()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -152,8 +146,10 @@ mod tests {
|
||||
assert!(!intersects_rect(&p, Rect::new(20.0, 20.0, 30.0, 30.0)));
|
||||
// corner region of the bbox that misses the ellipse outline
|
||||
assert!(!intersects_rect(&p, Rect::new(0.0, 0.0, 0.5, 0.5)));
|
||||
// fully inside
|
||||
assert!(intersects_rect(&p, Rect::new(4.0, 4.0, 6.0, 6.0)));
|
||||
// Wholly inside the shape, touching neither its anchors nor its outline (#1103).
|
||||
assert!(!intersects_rect(&p, Rect::new(4.0, 4.0, 6.0, 6.0)));
|
||||
// Around the whole shape.
|
||||
assert!(intersects_rect(&p, Rect::new(-1.0, -1.0, 11.0, 11.0)));
|
||||
// crossing a line
|
||||
let l = shapes::line(Point::new(0.0, 0.0), Point::new(10.0, 10.0));
|
||||
assert!(intersects_rect(&l, Rect::new(4.0, 0.0, 6.0, 10.0)));
|
||||
|
||||
@@ -252,6 +252,24 @@ impl SubPath {
|
||||
_ => a,
|
||||
}
|
||||
}
|
||||
/// Bend segment `seg` so its point at `t` moves by `dv`: both inner control points move by
|
||||
/// dv / 3t(1-t), since B(t) moves by 3t(1-t) times that. A smooth anchor stays smooth: its other
|
||||
/// handle turns with the moved one. False when there is no such segment.
|
||||
pub fn bend_segment(&mut self, seg: usize, t: f64, dv: kurbo::Vec2) -> bool {
|
||||
let n = self.anchors.len();
|
||||
if seg >= self.segment_count() || n == 0 {
|
||||
return false;
|
||||
}
|
||||
let v = dv / (3.0 * t * (1.0 - t));
|
||||
if let Some(a) = self.anchors.get_mut(seg % n) {
|
||||
a.set_handle(true, a.h_out + v, false);
|
||||
}
|
||||
if let Some(b) = self.anchors.get_mut((seg + 1) % n) {
|
||||
b.set_handle(false, b.h_in + v, false);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Split segment `seg` at parameter `t`, inserting a new anchor. Returns the new anchor index.
|
||||
pub fn insert_anchor(&mut self, seg: usize, t: f64) -> usize {
|
||||
let n = self.anchors.len();
|
||||
|
||||
@@ -55,6 +55,18 @@ fn conventions_commands_batch_and_bounded_preview() {
|
||||
assert_eq!(p["failed"], 1);
|
||||
assert_eq!(p["completed"], total - 1);
|
||||
}
|
||||
// atomic: the whole batch is one undo step, named by `label` (#1163).
|
||||
let undo_steps = |s: &mut Server| payload(&call(s, "doc_inspect", json!({"depth":0})))["history"].as_array().map_or(0, Vec::len);
|
||||
let before = undo_steps(&mut s);
|
||||
let two = json!([{"id":"shape.rectangle","params":{"x":0,"y":0,"width":5,"height":5}}, {"id":"shape.ellipse","params":{"x":9,"y":0,"width":5,"height":5}}]);
|
||||
let r = call(&mut s, "command_batch", json!({"steps":two,"atomic":true,"label":"Agent build"}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
assert_eq!(undo_steps(&mut s), before + 1);
|
||||
assert_eq!(payload(&call(&mut s, "run_command", json!({"command":"edit.undo"})))["undone"], "Agent build");
|
||||
assert_eq!(undo_steps(&mut s), before);
|
||||
for bad in [json!({"steps":two,"label":"x"}), json!({"steps":two,"atomic":"yes"}), json!({"steps":two,"atomic":true,"label":""})] {
|
||||
assert_eq!(call(&mut s, "command_batch", bad.clone())["isError"], true, "{bad}");
|
||||
}
|
||||
let before = call(&mut s, "doc_inspect", json!({}));
|
||||
let r = call(&mut s, "render_preview", json!({"max_side":64}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
@@ -65,6 +77,17 @@ fn conventions_commands_batch_and_bounded_preview() {
|
||||
for max in [json!(0), json!(4097), json!("64")] {
|
||||
assert_eq!(call(&mut s, "render_preview", json!({"max_side":max}))["isError"], true);
|
||||
}
|
||||
// Another artboard, three times wider than tall, previews by its index (#1163).
|
||||
let r = call(&mut s, "run_command", json!({"command":"artboard.new","params":{"x":2000,"y":0,"width":300,"height":100}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let r = call(&mut s, "render_preview", json!({"max_side":60,"artboard":1}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let png = vectorcraft_format::base64_decode(r["content"][0]["data"].as_str().unwrap()).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (60, 20));
|
||||
for board in [json!(2), json!(-1), json!("1")] {
|
||||
assert_eq!(call(&mut s, "render_preview", json!({"artboard":board}))["isError"], true, "{board}");
|
||||
}
|
||||
for name in ["ui_inspect", "ui_screenshot"] {
|
||||
assert_eq!(call(&mut s, name, json!({}))["isError"], true);
|
||||
}
|
||||
|
||||
+34
-12
@@ -96,9 +96,9 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"command_batch",
|
||||
"Run several commands",
|
||||
"Run steps in order; each edit is its own undo step. A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed and results. Stops on the first error by default.",
|
||||
"Run steps in order; each edit is its own undo step, or with atomic:true the whole batch is one (named `label`, else after its first edit). A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed and results. Stops on the first error by default.",
|
||||
obj(
|
||||
json!({"steps": {"type":"array", "items":obj(json!({"id":string("Command id"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}}),
|
||||
json!({"steps": {"type":"array", "items":obj(json!({"id":string("Command id"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}, "atomic":{"type":"boolean", "default":false, "description":"Make the batch's edits one undo step (needs an open document)"}, "label":string("The undo step's name with atomic")}),
|
||||
&["steps"],
|
||||
),
|
||||
false,
|
||||
@@ -113,8 +113,11 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"render_preview",
|
||||
"Render a preview",
|
||||
"Render the first artboard as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
|
||||
obj(json!({"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[]),
|
||||
"Render an artboard (default the first) as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
|
||||
obj(
|
||||
json!({"max_side":{"type":"integer","minimum":1,"maximum":4096},"artboard":{"type":"integer","minimum":0,"description":"0-based artboard (default 0)"}}),
|
||||
&[],
|
||||
),
|
||||
true,
|
||||
),
|
||||
tool("ui_inspect", "Inspect the interface", "Inspect the connected desktop interface; returns a tool error in headless mode.", empty(), true),
|
||||
@@ -138,7 +141,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"],
|
||||
@@ -200,7 +203,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"draw_path",
|
||||
"Draw path",
|
||||
"Create a path from points ([[x,y],…] or [{x,y,in?:[x,y],out?:[x,y]},…] with Bézier handles) or SVG path data `d`, then apply optional paint. Returns the new object id.",
|
||||
"Create a path from points ([[x,y],…] or [{x,y,in?:[x,y],out?:[x,y]},…] with Bézier handles) or SVG path data `d`, then apply optional paint. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke). For a filled path without an outline, pass an explicit fill and stroke:\"none\". Returns the new object id.",
|
||||
obj(
|
||||
json!({
|
||||
"points": {"type": "array", "description": "Anchors: [x,y] pairs or {x, y, in?, out?, smooth?} objects", "items": {"anyOf": [{"type": "array", "items": {"type": "number"}, "minItems": 2}, {"type": "object"}]}},
|
||||
@@ -217,7 +220,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"draw_shape",
|
||||
"Draw shape",
|
||||
"Create a live shape and apply optional paint. rectangle/ellipse: x, y, width, height (rectangle: radius = corner radius). polygon: cx, cy, radius, sides. star: cx, cy, radius1, radius2, points. line: x1, y1, x2, y2. Returns the new object id.",
|
||||
"Create a live shape and apply optional paint. rectangle/ellipse: x, y, width, height (rectangle: radius = corner radius). polygon: cx, cy, radius, sides. star: cx, cy, radius1, radius2, points. line: x1, y1, x2, y2. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke); lines start with no fill and a visible stroke. For a filled shape without an outline, pass an explicit fill and stroke:\"none\". Returns the new object id.",
|
||||
obj(
|
||||
json!({
|
||||
"shape": {"type": "string", "enum": ["rectangle", "ellipse", "polygon", "star", "line"]},
|
||||
@@ -329,7 +332,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf (one page per artboard: all, or `artboard` / `range`), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
|
||||
"Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf (one page per artboard: all, or `artboard` / `range`), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. For SVG/SVGZ, top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options.advanced.outlineText: true`, default false (live text). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
|
||||
obj(
|
||||
json!({
|
||||
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
|
||||
@@ -338,8 +341,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF default: all)"},
|
||||
"range": string("1-based artboards such as \"1-3, 5\" (PDF: one page each; other formats take one)"),
|
||||
"selection": {"type": "boolean", "description": "Export only the selection, cropped to it"},
|
||||
"outlineText": {"type": "boolean", "description": "SVG: text as outlines (viewable without the fonts)"},
|
||||
"options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg"},
|
||||
"outlineText": {"type": "boolean", "description": "SVG/SVGZ: true writes text as outlines (viewable without the fonts). DXF takes it too (type outlined anyway unless preserve: editability). PDF uses options.advanced.outlineText instead (default false: live text)"},
|
||||
"options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg or {\"advanced\": {\"outlineText\": true}} for PDF text as outlines (default false: live text)"},
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
@@ -902,6 +905,16 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
None => true,
|
||||
Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?,
|
||||
};
|
||||
let atomic = match a.get("atomic") {
|
||||
None => false,
|
||||
Some(v) => v.as_bool().ok_or("`atomic` must be a boolean")?,
|
||||
};
|
||||
if atomic {
|
||||
let label = a.get("label").cloned().unwrap_or(Value::Null);
|
||||
b.call("engine.execute", json!({"command":"edit.undoGroup.begin","params":{"label":label}}))?;
|
||||
} else if a.get("label").is_some() {
|
||||
return Err("`label` names the undo step of an atomic batch: give atomic:true too".into());
|
||||
}
|
||||
let (mut completed, mut failed, mut results) = (0, 0, Vec::new());
|
||||
// Each step's result (null for a failed one), for later steps' `"$N.path"` references.
|
||||
let mut values: Vec<Value> = vec![];
|
||||
@@ -936,6 +949,10 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The steps that ran stay, as one undo step.
|
||||
if atomic {
|
||||
b.call("engine.execute", json!({"command":"edit.undoGroup.end","params":{}}))?;
|
||||
}
|
||||
let mut r = ToolResult::json(&json!({"completed":completed,"failed":failed,"results":results}));
|
||||
r.is_error = failed > 0;
|
||||
Ok(r)
|
||||
@@ -946,8 +963,13 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
None => 1024,
|
||||
Some(v) => v.as_u64().filter(|v| (1..=4096).contains(v)).ok_or("`max_side` must be an integer in 1..4096")?,
|
||||
};
|
||||
let index = match a.get("artboard") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().ok_or("`artboard` must be a 0-based artboard index")?,
|
||||
};
|
||||
let doc = b.call("document.inspect", json!({"depth":0}))?;
|
||||
let board = doc.get("artboards").and_then(Value::as_array).and_then(|v| v.first()).ok_or("no such artboard")?;
|
||||
let board =
|
||||
doc.get("artboards").and_then(Value::as_array).and_then(|v| v.get(usize::try_from(index).ok()?)).ok_or(format!("no artboard {index}"))?;
|
||||
let width = board.get("width").and_then(Value::as_f64).ok_or("artboard has no width")?;
|
||||
let height = board.get("height").and_then(Value::as_f64).ok_or("artboard has no height")?;
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
||||
@@ -959,7 +981,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
if width.max(height) * scale > 4096.0 {
|
||||
return Err("artboard is too large for a bounded preview".into());
|
||||
}
|
||||
let r = b.call("ui.render", json!({"scale":scale}))?;
|
||||
let r = b.call("ui.render", json!({"scale":scale, "artboard": index}))?;
|
||||
let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
|
||||
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
|
||||
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?;
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -1186,6 +1186,9 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn draw_shape(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) {
|
||||
if !drawable(bp, f.view, n) {
|
||||
return;
|
||||
}
|
||||
if fx::has_fx(n) {
|
||||
return self.draw_shape_fx(ctx, f, n, bp, rule);
|
||||
}
|
||||
@@ -1700,6 +1703,21 @@ fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 {
|
||||
(l.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
|
||||
}
|
||||
|
||||
/// Most pixels from the origin that a shape handed to the rasterizer may reach, or a stroke be
|
||||
/// wide. A document's art stays within 4 million points, 2.6 billion pixels at the closest zoom.
|
||||
/// Geometry far beyond that (from a malformed file) makes vello_cpu panic on a worker thread or ask
|
||||
/// for more memory than there is, either of which ends the app: it is left undrawn.
|
||||
const MAX_DEVICE: f64 = 1e10;
|
||||
|
||||
/// Can shape `bp` of `n` be drawn through `view`: its control points (finite) within
|
||||
/// [`MAX_DEVICE`] pixels, and its visible strokes no wider?
|
||||
fn drawable(bp: &BezPath, view: Affine, n: &Node) -> bool {
|
||||
let b = view.transform_rect_bbox(bp.control_box());
|
||||
let scale = view.determinant().abs().sqrt();
|
||||
let widest = n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st.width) } else { None }).fold(0.0, f64::max);
|
||||
[b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_DEVICE) && (widest * scale).is_finite() && widest * scale <= MAX_DEVICE
|
||||
}
|
||||
|
||||
/// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a
|
||||
/// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur).
|
||||
pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext {
|
||||
|
||||
@@ -43,7 +43,9 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
|
||||
peniko::Gradient::new_linear(s, e).with_stops(stops.as_slice())
|
||||
}
|
||||
};
|
||||
ctx.set_paint(grad);
|
||||
// Colour and opacity blend each on its own, as the model samples them and as the PDF
|
||||
// and SVG exports draw them (peniko's default premultiplies).
|
||||
ctx.set_paint(grad.with_interpolation_alpha_space(peniko::InterpolationAlphaSpace::Unpremultiplied));
|
||||
true
|
||||
}
|
||||
Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, f),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Gradient stops with opacity: colour and opacity blend each on its own (not premultiplied), as
|
||||
//! the model samples them ([`Gradient::sample`]) and as the SVG and PDF exports draw them. The colour
|
||||
//! of a transparent stop counts: red fading into transparent black darkens as it fades.
|
||||
|
||||
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
use vectorcraft_testkit::fixtures::DocBuilder;
|
||||
use vectorcraft_testkit::raster::{Image, render_view};
|
||||
|
||||
/// A 100 pt square filled with `gradient` on `geom`.
|
||||
fn filled(gradient: &Gradient, geom: GradientGeom) -> Document {
|
||||
let mut gp = GradientPaint::new(gradient.clone());
|
||||
gp.geom = Some(geom);
|
||||
let mut b = DocBuilder::new(100.0, 100.0);
|
||||
b.path(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0), |_| {});
|
||||
b.build()
|
||||
}
|
||||
|
||||
fn stop(offset: f32, color: Color, opacity: f32, midpoint: f32) -> GradientStop {
|
||||
GradientStop { opacity, midpoint, ..GradientStop::new(offset, color) }
|
||||
}
|
||||
|
||||
/// Every 7th pixel is the model's colour at its opacity over white (pixel centres).
|
||||
fn assert_follows_model(img: &Image, gradient: &Gradient, geom: &GradientGeom) {
|
||||
for y in (3..100).step_by(7) {
|
||||
for x in (3..100).step_by(7) {
|
||||
let t = geom.param_at(gradient.kind, Point::new(x as f64 + 0.5, y as f64 + 0.5)).clamp(0.0, 1.0);
|
||||
let (c, o) = gradient.sample(t as f32);
|
||||
let want = c.to_rgb().map(|v| ((v * o + 1.0 - o) * 255.0).round() as u8);
|
||||
let got = img.over_white(x, y);
|
||||
let off = (0..3).map(|k| got[k].abs_diff(want[k])).max().unwrap_or(0);
|
||||
assert!(off <= 4, "{:?} ({x},{y}) t={t:.3}: got {got:?}, want {want:?}", gradient.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_stops_blend_colour_and_opacity_apart() {
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
let fades = [
|
||||
// Transparent white into black: mid-grey at half opacity halfway, light over white.
|
||||
vec![stop(0.0, Color::WHITE, 0.0, 0.5), stop(1.0, Color::BLACK, 1.0, 0.5)],
|
||||
// Red into transparent black: darker red as it fades.
|
||||
vec![stop(0.0, red, 1.0, 0.5), stop(1.0, Color::BLACK, 0.0, 0.5)],
|
||||
// A midpoint off centre (drawn as an extra stop) follows the same rule.
|
||||
vec![stop(0.0, red, 1.0, 0.3), stop(1.0, Color::BLACK, 0.0, 0.5)],
|
||||
];
|
||||
for stops in fades {
|
||||
for (kind, geom) in [
|
||||
(GradientKind::Linear, GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }),
|
||||
(GradientKind::Radial, GradientGeom { start: Point::new(50.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }),
|
||||
] {
|
||||
let gradient = Gradient { kind, stops: stops.clone() };
|
||||
assert_follows_model(&render_view(&filled(&gradient, geom), 100, 100), &gradient, &geom);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,3 +181,30 @@ fn a_head_past_the_end_counts_in_the_visual_bounds_and_is_never_culled() {
|
||||
let img = render_region(&d, Rect::new(83.0, 40.0, 100.0, 60.0), S);
|
||||
assert!(img.ink() > 0, "the head is drawn");
|
||||
}
|
||||
|
||||
/// A shape far beyond any document's reach (from a malformed file) is left undrawn instead of
|
||||
/// making the rasterizer panic on a worker thread or ask for terabytes, which would end the app;
|
||||
/// the art next to it still draws.
|
||||
#[test]
|
||||
fn shapes_far_beyond_the_page_are_left_undrawn_without_crashing() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let mut far = vectorcraft_geom::BezPath::new();
|
||||
far.move_to((13607718712.4, 13.2));
|
||||
far.curve_to((-0.03, 720.96), (-3.08e17, -8.36e20), (-3.35e20, -1.0));
|
||||
far.close_path();
|
||||
let black =
|
||||
|| Appearance { items: vec![AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(Color::BLACK)))], ..Default::default() };
|
||||
for (path, app) in [
|
||||
(vectorcraft_geom::PathData::from_bezpath(&far), black()),
|
||||
(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), black()),
|
||||
(shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), stroked(1e30, |_| {})),
|
||||
] {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, vectorcraft_doc::Node::path(id, path, app)).unwrap();
|
||||
}
|
||||
let img = Renderer::new().render_region(&d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true);
|
||||
let px = |x: usize, y: usize| img.pixels.get((y * img.width as usize + x) * 4).copied().unwrap();
|
||||
assert_eq!(px(20, 20), 0, "the rectangle next to them draws");
|
||||
assert_eq!(px(90, 5), 255, "nothing of the far shape or the huge stroke");
|
||||
}
|
||||
@@ -965,8 +965,9 @@ impl Importer {
|
||||
nodes = rest;
|
||||
}
|
||||
let mut children: Vec<Arc<Node>> = nodes.iter().filter_map(|c| self.node(c, ts)).map(Arc::new).collect();
|
||||
// Mask art that is all hidden draws nothing: that is an empty mask, not no mask (black all
|
||||
// over when clipped, white when not, inverted when the mask is).
|
||||
let art = match children.len() {
|
||||
0 => return None,
|
||||
1 => Arc::unwrap_or_clone(children.pop()?),
|
||||
_ => self.named("", NodeKind::Group { children, clip: false }),
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, OpacityMask};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, export, import_with_report};
|
||||
use vectorcraft_testkit::raster::{Image, render_artboard};
|
||||
|
||||
/// A red square masked by white art over its left half.
|
||||
fn masked(clip: bool, invert: bool) -> Document {
|
||||
@@ -67,3 +68,37 @@ fn foreign_masks_keep_their_art() {
|
||||
assert!(m.clip && !m.invert);
|
||||
assert_eq!(m.art.children().map(|c| c.len()), Some(2));
|
||||
}
|
||||
|
||||
/// `svg` drawn by resvg, `w`×`h` on white.
|
||||
fn resvg_render(svg: &str, w: u32, h: u32) -> Image {
|
||||
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse");
|
||||
let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap();
|
||||
pm.fill(resvg::tiny_skia::Color::WHITE);
|
||||
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut());
|
||||
Image { width: w, height: h, rgba: pm.data().to_vec() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mask_whose_art_is_all_hidden_hides_its_object() {
|
||||
// Hidden mask art draws nothing: the mask is black all over and the red square doesn't show,
|
||||
// as resvg draws the file. The square keeps its (empty) mask, so it can still be edited.
|
||||
for art in [
|
||||
r#"<rect width="100" height="100" fill="white" visibility="hidden"/>"#,
|
||||
r#"<g visibility="hidden"><rect width="100" height="100" fill="white"/></g>"#,
|
||||
] {
|
||||
let svg = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">{art}</mask>
|
||||
<g mask="url(#m)"><rect width="100" height="100" fill="red"/></g></svg>"#
|
||||
);
|
||||
let (d, _) = import_with_report(&svg).unwrap();
|
||||
let [m] = &masks(&d)[..] else { panic!("the square keeps its mask: {svg}") };
|
||||
assert!(m.clip && !m.invert);
|
||||
assert!(m.art.geometric_bounds().is_none(), "no visible art: {:?}", m.art.kind);
|
||||
let (canvas, reference) = (render_artboard(&d), resvg_render(&svg, 100, 100));
|
||||
for (x, y) in [(10, 10), (50, 50), (90, 90)] {
|
||||
assert_eq!(reference.over_white(x, y), [255, 255, 255], "resvg ({x},{y}): {svg}");
|
||||
assert_eq!(canvas.over_white(x, y), [255, 255, 255], "canvas ({x},{y}): {svg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Live Corners: the widgets inside the corners of the selected paths. The Selection tool shows
|
||||
//! them on live rectangles and polygons, Direct Selection on any path (a star, a pen path) at each
|
||||
//! corner anchor: one without handles between two straight sides. Dragging one rounds (or
|
||||
//! corner anchor: one that isn't smooth, where two sides, straight or curved, meet at an angle. Dragging one rounds (or
|
||||
//! sharpens) the corners whose widgets show together, on every selected path (#938): every corner
|
||||
//! of a path selected whole, those with a selected anchor when Direct Selection picked some. Alt-clicking a widget cycles their corner
|
||||
//! kind (round, inverted round, chamfer); double-clicking one opens the Corners dialog. The
|
||||
@@ -701,7 +701,7 @@ mod tests {
|
||||
assert!(red, "six arcs at the limit");
|
||||
}
|
||||
|
||||
/// A pen path's middle corner shows a widget; its ends and curved corners don't.
|
||||
/// A pen path's corners show widgets, curved ones too (#1144); its ends don't.
|
||||
#[test]
|
||||
fn a_pen_path_shows_widgets_on_its_corners_only() {
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(
|
||||
@@ -713,7 +713,7 @@ mod tests {
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let w = CornerWidgets::for_tool(&cx(&d, &s, &p), true).unwrap();
|
||||
assert_eq!(w.corners(), [1]);
|
||||
assert_eq!(pts(&w), [Point::new(290.0, 110.0)]);
|
||||
assert_eq!(w.corners(), [1, 2]);
|
||||
assert_eq!(pts(&w)[0], Point::new(290.0, 110.0));
|
||||
}
|
||||
}
|
||||
+232
-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`).
|
||||
@@ -86,6 +90,15 @@ enum State {
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
/// Dragging a curved segment of a selected blend's spine: it follows the pointer
|
||||
/// (`object.blend.spine.reshapeSegment`, #1111).
|
||||
SpineSegment {
|
||||
id: NodeId,
|
||||
seg: usize,
|
||||
t: f64,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
/// Dragging a handle of the clicked spine point.
|
||||
SpineHandle {
|
||||
id: NodeId,
|
||||
@@ -113,6 +126,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 +147,7 @@ impl DirectSelectionTool {
|
||||
mesh: MeshEdit::default(),
|
||||
guides: vec![],
|
||||
anchor_snap: None,
|
||||
copy_snap: None,
|
||||
handle_snap: HandleSnap::default(),
|
||||
move_snap: None,
|
||||
hover: None,
|
||||
@@ -162,6 +179,15 @@ fn hit_spine_point(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usiz
|
||||
})
|
||||
}
|
||||
|
||||
/// The segment of a selected blend's spine passing within `tol` of `p`: (blend, segment, t, whether
|
||||
/// it is straight).
|
||||
pub(crate) fn hit_spine_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, f64, bool)> {
|
||||
spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)).find_map(|(id, path)| {
|
||||
let (si, seg, t, _, dist) = path.nearest(p)?;
|
||||
(dist <= tol).then(|| (id, seg, t, path.subpaths.get(si).is_some_and(|sp| sp.segment_is_line(seg))))
|
||||
})
|
||||
}
|
||||
|
||||
/// The spine point `sel` and whether `p` is on its outgoing handle's end (`Some(true)`) or its
|
||||
/// incoming one's.
|
||||
fn hit_spine_handle(cx: &ToolContext, sel: (NodeId, usize), p: Point, tol: f64) -> Option<bool> {
|
||||
@@ -319,7 +345,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 +394,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"}))];
|
||||
}
|
||||
@@ -381,6 +420,11 @@ impl Tool for DirectSelectionTool {
|
||||
if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) {
|
||||
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
|
||||
}
|
||||
// A curved segment of a selected blend's spine reshapes, as a path's does.
|
||||
if let Some((id, seg, t, false)) = hit_spine_segment(cx, p, tol) {
|
||||
self.state = State::SpineSegment { id, seg, t, start: p, began: false };
|
||||
return vec![];
|
||||
}
|
||||
if let Some(out) = self.guide.press(cx, ev) {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
@@ -409,7 +453,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 +503,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(),
|
||||
@@ -513,6 +569,22 @@ impl Tool for DirectSelectionTool {
|
||||
out.push(Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y})));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::SpineSegment { id, seg, t, start, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Reshape Spine".into()));
|
||||
self.state = State::SpineSegment { id, seg, t, start, began: true };
|
||||
}
|
||||
let d = p - start;
|
||||
out.push(Action::Preview(
|
||||
"object.blend.spine.reshapeSegment".into(),
|
||||
json!({"id": id.0, "segment": seg, "t": t, "dx": d.x, "dy": d.y}),
|
||||
));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::SpineHandle { id, anchor, out }) => vec![Action::Preview(
|
||||
"object.blend.spine.moveAnchor".into(),
|
||||
json!({"id": id.0, "anchor": anchor, "handle": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
|
||||
@@ -550,11 +622,12 @@ impl Tool for DirectSelectionTool {
|
||||
| State::MoveObject { began, .. }
|
||||
| State::Segment { began, .. }
|
||||
| State::SpinePoint { began, .. }
|
||||
| State::SpineSegment { began, .. }
|
||||
| State::TypeArea { began, .. },
|
||||
) => {
|
||||
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 { .. }) => {
|
||||
@@ -781,6 +854,45 @@ mod tests {
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows");
|
||||
}
|
||||
|
||||
/// #1111: the Anchor Point tool bends a selected blend's straight spine; Direct Selection
|
||||
/// reshapes a curved spine segment and leaves a straight one alone, as it does a path's.
|
||||
#[test]
|
||||
fn spine_segments_bend_with_the_anchor_point_tool_and_direct_selection() {
|
||||
let (mut d, g, _) = blend_doc();
|
||||
let mut s = Selection::default();
|
||||
s.set([g]);
|
||||
let p = paint();
|
||||
let drag = |t: &mut dyn crate::Tool, cx: &ToolContext| {
|
||||
t.pointer(cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0));
|
||||
let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, 160.0, 351.0));
|
||||
t.pointer(cx, &PointerEvent::new(PointerKind::Up, 160.0, 351.0));
|
||||
a
|
||||
};
|
||||
let straight = cx(&d, &s, &p);
|
||||
let a = drag(crate::create("anchorPoint").as_mut(), &straight);
|
||||
assert!(
|
||||
matches!(a.last(), Some(Action::Preview(c, v)) if c == "object.blend.spine.reshapeSegment" && v["id"] == g.0 && v["segment"] == 0 && v["dy"] == 40.0),
|
||||
"{a:?}"
|
||||
);
|
||||
let a = drag(&mut DirectSelectionTool::new(false), &straight);
|
||||
assert!(!a.iter().any(|x| matches!(x, Action::Preview(c, _) if c == "object.blend.spine.reshapeSegment")), "straight: {a:?}");
|
||||
// The spine bent upward: Direct Selection takes it from where it passes.
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false);
|
||||
sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0));
|
||||
let Some(NodeKind::Blend { spec, .. }) = d.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") };
|
||||
(spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let curved = cx(&d, &s, &p);
|
||||
t.pointer(&curved, &PointerEvent::new(PointerKind::Down, 160.0, 330.0));
|
||||
let a = t.pointer(&curved, &PointerEvent::new(PointerKind::Drag, 160.0, 345.0));
|
||||
assert_eq!(a[0], Action::Begin("Reshape Spine".into()));
|
||||
assert!(
|
||||
matches!(&a[1], Action::Preview(c, v) if c == "object.blend.spine.reshapeSegment" && v["segment"] == 0 && v["dx"] == 0.0 && v["dy"] == 15.0),
|
||||
"{a:?}"
|
||||
);
|
||||
assert_eq!(t.pointer(&curved, &PointerEvent::new(PointerKind::Up, 160.0, 345.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_and_group_selection_pick_blend_keys() {
|
||||
let (d, g, k1) = blend_doc();
|
||||
@@ -838,6 +950,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 +1188,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]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Delete: click an anchor to remove it, re-fitting the neighbouring curve (Alt = add).
|
||||
//! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; drag a
|
||||
//! handle → move it independently (Shift: at 45° steps round its anchor); drag a segment → reshape
|
||||
//! the curve.
|
||||
//! the curve, a selected blend's spine's too, straight or curved (#1111).
|
||||
//! Scissors: click a path to split it there.
|
||||
|
||||
use serde_json::json;
|
||||
@@ -19,9 +19,30 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum State {
|
||||
Idle,
|
||||
Convert { id: NodeId, si: usize, ai: usize, start: Point, began: bool },
|
||||
Handle { id: NodeId, si: usize, ai: usize, out: bool, began: bool },
|
||||
Reshape { id: NodeId, si: usize, seg: usize, t: f64, start: Point, began: bool },
|
||||
Convert {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
ai: usize,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
Handle {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
ai: usize,
|
||||
out: bool,
|
||||
began: bool,
|
||||
},
|
||||
/// Bending a segment: a path's, or (`spine`) a selected blend's spine's.
|
||||
Reshape {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
seg: usize,
|
||||
t: f64,
|
||||
start: Point,
|
||||
began: bool,
|
||||
spine: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct AnchorTool {
|
||||
@@ -94,7 +115,9 @@ impl Tool for AnchorTool {
|
||||
} else if let Some((id, si, ai)) = hit_anchor(cx, p, point) {
|
||||
self.state = State::Convert { id, si, ai, start: p, began: false };
|
||||
} else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) {
|
||||
self.state = State::Reshape { id, si, seg, t, start: p, began: false };
|
||||
self.state = State::Reshape { id, si, seg, t, start: p, began: false, spine: false };
|
||||
} else if let Some((id, seg, t, _)) = crate::direct::hit_spine_segment(cx, p, tol) {
|
||||
self.state = State::Reshape { id, si: 0, seg, t, start: p, began: false, spine: true };
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
@@ -128,6 +151,10 @@ impl Tool for AnchorTool {
|
||||
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": q.x, "y": q.y, "independent": true}),
|
||||
)
|
||||
}
|
||||
State::Reshape { id, seg, t, start, spine: true, .. } => Action::Preview(
|
||||
"object.blend.spine.reshapeSegment".into(),
|
||||
json!({"id": id.0, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
|
||||
),
|
||||
State::Reshape { id, si, seg, t, start, .. } => Action::Preview(
|
||||
"path.reshapeSegment".into(),
|
||||
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! Drag draws (Shift constrains to square/circle/45°, Alt draws from the centre, Space held moves
|
||||
//! the shape being drawn); a click without a drag asks the UI for the size dialog (like
|
||||
//! Illustrator). ↑/↓ during a polygon/star drag change the side/point count. The start point and
|
||||
//! Illustrator). ↑/↓ during a polygon/star drag change the side/point count; during a star drag,
|
||||
//! Ctrl/Cmd keeps the inner radius where it is while the outer one follows the pointer, and Alt
|
||||
//! keeps the star's shoulders straight (#812). The start point and
|
||||
//! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -22,9 +24,19 @@ pub struct ShapeTool {
|
||||
pub corner_radius: f64,
|
||||
/// Star inner radius as a fraction of the outer radius.
|
||||
pub star_ratio: f64,
|
||||
/// The star's inner radius held while Ctrl/Cmd is down in a drag.
|
||||
inner: Option<f64>,
|
||||
snap: DrawSnap,
|
||||
}
|
||||
|
||||
/// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each
|
||||
/// point's sides in line with the next points' sides: a regular star polygon {n/2}); none for
|
||||
/// fewer than five points, whose shoulders can't line up.
|
||||
fn straight_ratio(points: u32) -> Option<f64> {
|
||||
let n = f64::from(points);
|
||||
(points >= 5).then(|| (2.0 * std::f64::consts::PI / n).cos() / (std::f64::consts::PI / n).cos())
|
||||
}
|
||||
|
||||
impl ShapeTool {
|
||||
pub fn new(id: &str) -> Self {
|
||||
let id: &'static str = match id {
|
||||
@@ -45,10 +57,28 @@ impl ShapeTool {
|
||||
points: 5,
|
||||
corner_radius: 12.0,
|
||||
star_ratio: 0.5,
|
||||
inner: None,
|
||||
snap: DrawSnap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl/Cmd in a star drag from `start` to `p`: the inner radius stays as it was when the key
|
||||
/// went down, so the ratio follows the outer radius (and stays so once the key is let go).
|
||||
fn hold_inner(&mut self, start: Point, p: Point, m: Mods) {
|
||||
let outer = (p - start).hypot();
|
||||
match (m.cmd, self.inner) {
|
||||
(true, None) => self.inner = Some(outer * self.star_ratio),
|
||||
(false, Some(_)) => self.inner = None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(inner) = self.inner
|
||||
&& outer > 0.0
|
||||
&& (inner / outer).is_finite()
|
||||
{
|
||||
self.star_ratio = (inner / outer).clamp(0.01, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
match self.id {
|
||||
"roundedRectangle" => "Rounded Rectangle",
|
||||
@@ -81,9 +111,10 @@ impl ShapeTool {
|
||||
if self.id == "polygon" {
|
||||
("shape.polygon".into(), json!({ "cx": start.x, "cy": start.y, "radius": r, "sides": self.sides, "rotation": rot }))
|
||||
} else {
|
||||
let ratio = if m.alt { straight_ratio(self.points).unwrap_or(self.star_ratio) } else { self.star_ratio };
|
||||
(
|
||||
"shape.star".into(),
|
||||
json!({ "cx": start.x, "cy": start.y, "radius1": r, "radius2": r * self.star_ratio, "points": self.points, "rotation": rot }),
|
||||
json!({ "cx": start.x, "cy": start.y, "radius1": r, "radius2": r * ratio, "points": self.points, "rotation": rot }),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -147,6 +178,9 @@ impl Tool for ShapeTool {
|
||||
space_moves(&mut s, &mut self.last, ev, self.began);
|
||||
self.start = Some(s);
|
||||
self.mods = ev.mods;
|
||||
if self.id == "star" {
|
||||
self.hold_inner(s, ev.pos, ev.mods);
|
||||
}
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
if ev.pos.distance(s) < cx.tol(2.0) {
|
||||
@@ -162,6 +196,7 @@ impl Tool for ShapeTool {
|
||||
PointerKind::Up => {
|
||||
let Some(s) = self.start.take() else { return vec![] };
|
||||
self.snap.clear();
|
||||
self.inner = None;
|
||||
if self.began {
|
||||
self.began = false;
|
||||
vec![Action::Commit]
|
||||
@@ -290,6 +325,38 @@ mod tests {
|
||||
assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["cx"] == 60.0 && v["cy"] == 60.0), "{a:?}");
|
||||
}
|
||||
|
||||
/// During a star drag, Ctrl/Cmd keeps the inner radius while the outer one follows the pointer
|
||||
/// (the new ratio stays once the key is let go), and Alt keeps the shoulders straight (#812).
|
||||
#[test]
|
||||
fn a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let radii = |a: &[Action]| match a.last() {
|
||||
Some(Action::Preview(_, v)) => (v["radius1"].as_f64().unwrap(), v["radius2"].as_f64().unwrap()),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let ctrl = Mods { cmd: true, ..Default::default() };
|
||||
let mut t = ShapeTool::new("star");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0));
|
||||
let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0)));
|
||||
assert_eq!((r1, r2), (40.0, 20.0));
|
||||
// Ctrl: the inner radius stays 20 as the outer one grows to 80.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0).with_mods(ctrl));
|
||||
let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl)));
|
||||
assert!((r1 - 80.0).abs() < 1e-9 && (r2 - 20.0).abs() < 1e-9, "{r1} {r2}");
|
||||
// Let go: the ratio (a quarter) stays.
|
||||
let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 50.0)));
|
||||
assert!((r2 / r1 - 0.25).abs() < 1e-9, "{r1} {r2}");
|
||||
// Alt: straight shoulders, about 0.382 for five points.
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 50.0).with_mods(alt)));
|
||||
assert!((r2 / r1 - 0.381_966).abs() < 1e-5, "{}", r2 / r1);
|
||||
assert_eq!(straight_ratio(4), None, "four points can't line up");
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 50.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_square_alt_center() {
|
||||
let t = ShapeTool::new("ellipse");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Artboard tool (Shift+O).
|
||||
//!
|
||||
//! Click an artboard to make it active (dashed bounds, 8 handles and its name). Drag inside moves
|
||||
//! Click an artboard to make it active (dashed bounds, 8 handles and its name); Shift-click adds
|
||||
//! artboards to the selection or takes them out (dashed bounds), and a drag or Delete then acts on
|
||||
//! all of them (#1139). Drag inside moves
|
||||
//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and
|
||||
//! leaves the artboard where it was), drag a handle resizes it
|
||||
//! (Shift proportional, Alt from centre; with the `scaleArt` option on, Scale Artwork with Artboard,
|
||||
@@ -26,6 +28,8 @@ enum Drag {
|
||||
/// last drag event (the step under way is a duplicate).
|
||||
Move {
|
||||
index: usize,
|
||||
/// Several artboards move together (the selected ones, `index` among them).
|
||||
many: bool,
|
||||
start: Point,
|
||||
rect: Rect,
|
||||
began: bool,
|
||||
@@ -48,6 +52,8 @@ enum Drag {
|
||||
pub struct ArtboardTool {
|
||||
/// Index of the active artboard.
|
||||
pub active: usize,
|
||||
/// The other selected artboards (Shift-click), in order.
|
||||
pub others: Vec<usize>,
|
||||
/// Move artwork with the artboard.
|
||||
pub move_art: bool,
|
||||
/// Scale artwork with the artboard: a resize takes the art fully inside it along, and keeps the
|
||||
@@ -69,6 +75,7 @@ impl Default for ArtboardTool {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: 0,
|
||||
others: vec![],
|
||||
move_art: true,
|
||||
scale_art: false,
|
||||
drag: None,
|
||||
@@ -90,6 +97,31 @@ impl ArtboardTool {
|
||||
cx.doc.artboards.get(self.active).map(|a| a.rect)
|
||||
}
|
||||
|
||||
/// The selected artboards that are still in the document: the active one and the others,
|
||||
/// in order.
|
||||
fn selected(&self, cx: &ToolContext) -> Vec<usize> {
|
||||
let mut all: Vec<usize> = std::iter::once(self.active).chain(self.others.iter().copied()).filter(|i| *i < cx.doc.artboards.len()).collect();
|
||||
all.sort_unstable();
|
||||
all.dedup();
|
||||
all
|
||||
}
|
||||
|
||||
/// Shift-click on artboard `i`: into the selection, active, or out of it (another becomes
|
||||
/// active).
|
||||
fn toggle(&mut self, cx: &ToolContext, i: usize) {
|
||||
let mut sel = self.selected(cx);
|
||||
if let Some(at) = sel.iter().position(|j| *j == i) {
|
||||
if sel.len() > 1 {
|
||||
sel.remove(at);
|
||||
self.active = if i == self.active { sel.first().copied().unwrap_or(0) } else { self.active };
|
||||
}
|
||||
} else {
|
||||
sel.push(i);
|
||||
self.active = i;
|
||||
}
|
||||
self.others = sel.into_iter().filter(|j| *j != self.active).collect();
|
||||
}
|
||||
|
||||
/// Gather the Smart Guide targets for dragging artboard `index` (call before the first preview,
|
||||
/// while the document still shows where everything started).
|
||||
fn begin_snapping(&mut self, cx: &ToolContext, index: usize, art_moves: bool) {
|
||||
@@ -199,15 +231,24 @@ impl Tool for ArtboardTool {
|
||||
if let Some(i) = cx.doc.artboard_at(p)
|
||||
&& let Some(a) = cx.doc.artboards.get(i)
|
||||
{
|
||||
self.active = i;
|
||||
self.drag = Some(Drag::Move { index: i, start: p, rect: a.rect, began: false, copy: false });
|
||||
if m.shift {
|
||||
self.toggle(cx, i);
|
||||
} else if !self.selected(cx).contains(&i) {
|
||||
// A plain click on an artboard that isn't selected selects just it; on a
|
||||
// selected one, the selection stays, so the drag moves them all.
|
||||
self.others.clear();
|
||||
self.active = i;
|
||||
}
|
||||
let many = self.selected(cx).len() > 1 && self.selected(cx).contains(&i);
|
||||
self.drag = Some(Drag::Move { index: i, many, start: p, rect: a.rect, began: false, copy: false });
|
||||
} else {
|
||||
self.others.clear();
|
||||
let p = self.draw.press(cx, p, &[], None);
|
||||
self.drag = Some(Drag::Create { start: p, cur: p });
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, Some(Drag::Move { index, start, rect, began, copy })) => {
|
||||
(PointerKind::Drag, Some(Drag::Move { index, many, start, rect, began, copy })) => {
|
||||
let mut out = vec![];
|
||||
let label = |copy: bool| if copy { "Duplicate Artboard" } else { "Move Artboard" };
|
||||
if !began {
|
||||
@@ -221,13 +262,14 @@ impl Tool for ArtboardTool {
|
||||
out.push(Action::Cancel);
|
||||
out.push(Action::Begin(label(m.alt).into()));
|
||||
}
|
||||
self.drag = Some(Drag::Move { index, start, rect, began: true, copy: m.alt });
|
||||
self.drag = Some(Drag::Move { index, many, start, rect, began: true, copy: m.alt });
|
||||
let d = self.snap_move(cx, rect, move_delta(start, p, m.shift), m.alt);
|
||||
self.preview = Some(rect + d);
|
||||
out.push(Action::Preview(
|
||||
"artboard.move".into(),
|
||||
json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt }),
|
||||
));
|
||||
let mut v = json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt });
|
||||
if many {
|
||||
v["indices"] = json!(self.selected(cx));
|
||||
}
|
||||
out.push(Action::Preview("artboard.move".into(), v));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, Some(Drag::Resize { index, handle, rect, grab, began })) => {
|
||||
@@ -259,10 +301,14 @@ impl Tool for ArtboardTool {
|
||||
(PointerKind::Up, Some(d)) => {
|
||||
self.end_drag();
|
||||
match d {
|
||||
Drag::Move { began: true, copy, .. } => {
|
||||
// The copy, last in the previewed document, becomes the active artboard.
|
||||
Drag::Move { began: true, copy, many, .. } => {
|
||||
// The copies, last in the previewed document, become the selection (the
|
||||
// last one active).
|
||||
if copy {
|
||||
self.active = cx.doc.artboards.len().saturating_sub(1);
|
||||
let n = cx.doc.artboards.len();
|
||||
let k = if many { self.selected(cx).len().min(n) } else { 1 };
|
||||
self.active = n.saturating_sub(1);
|
||||
self.others = (n.saturating_sub(k)..n.saturating_sub(1)).collect();
|
||||
}
|
||||
vec![Action::Commit]
|
||||
}
|
||||
@@ -300,7 +346,7 @@ impl Tool for ArtboardTool {
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
matches!(key, ToolKey::Delete | ToolKey::Backspace)
|
||||
&& self.drag.is_none()
|
||||
&& cx.doc.artboards.len() > 1
|
||||
&& cx.doc.artboards.len() > self.selected(cx).len()
|
||||
&& self.active < cx.doc.artboards.len()
|
||||
}
|
||||
|
||||
@@ -310,9 +356,14 @@ impl Tool for ArtboardTool {
|
||||
if !self.claims_key(cx, key) {
|
||||
return vec![];
|
||||
}
|
||||
let i = self.active;
|
||||
self.active = i.saturating_sub(1);
|
||||
vec![Action::Exec("artboard.delete".into(), json!({ "index": i }))]
|
||||
let sel = self.selected(cx);
|
||||
self.active = sel.first().copied().unwrap_or(0).saturating_sub(1);
|
||||
self.others.clear();
|
||||
let params = match sel.as_slice() {
|
||||
[i] => json!({ "index": i }),
|
||||
_ => json!({ "indices": sel }),
|
||||
};
|
||||
vec![Action::Exec("artboard.delete".into(), params)]
|
||||
}
|
||||
ToolKey::Escape => {
|
||||
let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. }));
|
||||
@@ -326,6 +377,7 @@ impl Tool for ArtboardTool {
|
||||
fn notify(&mut self, cx: &ToolContext, what: &str) {
|
||||
if what == "created" {
|
||||
self.active = cx.doc.artboards.len().saturating_sub(1);
|
||||
self.others.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +392,10 @@ impl Tool for ArtboardTool {
|
||||
}
|
||||
return o;
|
||||
}
|
||||
// The other selected artboards: dashed bounds (in a drag the document shows them moved).
|
||||
for r in self.others.iter().filter_map(|i| cx.doc.artboards.get(*i)).map(|a| a.rect) {
|
||||
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true });
|
||||
}
|
||||
let Some(ab) = cx.doc.artboards.get(self.active) else { return o };
|
||||
let r = self.preview.unwrap_or(ab.rect);
|
||||
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true });
|
||||
@@ -377,12 +433,24 @@ impl Tool for ArtboardTool {
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
json!({ "active": self.active, "moveArt": self.move_art, "scaleArt": self.scale_art })
|
||||
json!({ "active": self.active, "selected": std::iter::once(self.active).chain(self.others.iter().copied()).collect::<Vec<_>>(), "moveArt": self.move_art, "scaleArt": self.scale_art })
|
||||
}
|
||||
|
||||
fn set_option(&mut self, key: &str, value: &Value) {
|
||||
match key {
|
||||
"active" => self.active = value.as_u64().unwrap_or(0) as usize,
|
||||
"active" => {
|
||||
self.active = value.as_u64().unwrap_or(0) as usize;
|
||||
self.others.clear();
|
||||
}
|
||||
// The selected artboards, the first active.
|
||||
"selected" => {
|
||||
let all: Vec<usize> =
|
||||
value.as_array().into_iter().flatten().filter_map(Value::as_u64).filter_map(|i| usize::try_from(i).ok()).collect();
|
||||
if let Some((first, rest)) = all.split_first() {
|
||||
self.active = *first;
|
||||
self.others = rest.iter().copied().filter(|i| i != first).collect();
|
||||
}
|
||||
}
|
||||
"moveArt" => self.move_art = value.as_bool().unwrap_or(true),
|
||||
"scaleArt" => self.scale_art = value.as_bool().unwrap_or(false),
|
||||
_ => {}
|
||||
@@ -523,6 +591,42 @@ mod tests {
|
||||
assert_eq!(v["width"].as_f64(), Some(210.0));
|
||||
}
|
||||
|
||||
/// Shift-click selects several artboards (#1139): a drag moves them all, Shift-click takes one
|
||||
/// out again, a plain click on an unselected one selects just it, and Delete removes all the
|
||||
/// selected ones (one artboard is always left).
|
||||
#[test]
|
||||
fn shift_click_selects_several_artboards_that_move_and_delete_together() {
|
||||
let mut d = two_boards();
|
||||
let mut third = d.artboards[0].clone();
|
||||
third.rect = Rect::new(0.0, 600.0, 200.0, 800.0);
|
||||
d.artboards.push(third);
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let click = |t: &mut ArtboardTool, x: f64, y: f64, m: Mods| {
|
||||
t.pointer(&c, &ev(PointerKind::Down, x, y).with_mods(m));
|
||||
t.pointer(&c, &ev(PointerKind::Up, x, y).with_mods(m));
|
||||
};
|
||||
let mut t = ArtboardTool::default();
|
||||
click(&mut t, 450.0, 450.0, Mods::default());
|
||||
click(&mut t, 700.0, 100.0, shift);
|
||||
assert_eq!(t.options()["selected"], json!([1, 0]), "the Shift-clicked one is active");
|
||||
// A drag from either moves both.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0));
|
||||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0)));
|
||||
assert_eq!((v["indices"].clone(), v["dx"].as_f64()), (json!([0, 1]), Some(20.0)));
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)), vec![Action::Commit]);
|
||||
// Delete removes both; with all three selected it can't, so the key isn't taken.
|
||||
assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [0, 1]}))]);
|
||||
t.set_option("selected", &json!([0, 1, 2]));
|
||||
assert!(!t.claims_key(&c, ToolKey::Delete));
|
||||
// Shift-click takes one out; a plain click on an unselected one selects just it.
|
||||
click(&mut t, 100.0, 700.0, shift);
|
||||
assert_eq!(t.options()["selected"], json!([0, 1]));
|
||||
click(&mut t, 100.0, 700.0, Mods::default());
|
||||
assert_eq!(t.options()["selected"], json!([2]));
|
||||
}
|
||||
|
||||
/// Scale Artwork with Artboard (#602): the resize asks for the art to scale, keeps the artboard's
|
||||
/// proportions, and never snaps to the art scaling with it.
|
||||
#[test]
|
||||
|
||||
@@ -376,10 +376,26 @@ fn refine(used: &[Used], mut centers: Vec<[f64; 3]>) -> Vec<[f64; 3]> {
|
||||
|
||||
/// Merge 4-connected same-label components smaller than `min_area` pixels into their most common
|
||||
/// neighbouring label. Transparent pixels are never relabelled.
|
||||
///
|
||||
/// A pass can merge a speck into a small region that it merges away later, leaving the speck behind
|
||||
/// as a new island of that region's old label (#819: more noise removal gave more paths). Passes
|
||||
/// repeat until one changes nothing: each merge joins two components, so they always end.
|
||||
pub fn denoise(labels: &mut [u16], w: usize, h: usize, min_area: usize) {
|
||||
if min_area <= 1 || w == 0 || h == 0 {
|
||||
if min_area <= 1 || w == 0 || h == 0 || labels.len() < w.saturating_mul(h) {
|
||||
return;
|
||||
}
|
||||
// A backstop only: real images settle in a few passes.
|
||||
const MAX_PASSES: usize = 64;
|
||||
for _ in 0..MAX_PASSES {
|
||||
if !denoise_pass(labels, w, h, min_area) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One [`denoise`] pass over every component; true when it relabelled any.
|
||||
fn denoise_pass(labels: &mut [u16], w: usize, h: usize, min_area: usize) -> bool {
|
||||
let mut changed = false;
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
let mut comp: Vec<usize> = Vec::new();
|
||||
@@ -433,6 +449,8 @@ pub fn denoise(labels: &mut [u16], w: usize, h: usize, min_area: usize) {
|
||||
for &i in &comp {
|
||||
labels[i] = to;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
@@ -557,3 +557,121 @@ fn create_strokes_survives_noise_and_tiny_images() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 4-connected same-label components of `labels` (transparent pixels left out): each one's size
|
||||
/// and whether it touches a pixel of another opaque label.
|
||||
fn components(labels: &[u16], w: usize, h: usize) -> Vec<(usize, bool)> {
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut out = vec![];
|
||||
for start in 0..w * h {
|
||||
if seen[start] || labels[start] == TRANSPARENT {
|
||||
continue;
|
||||
}
|
||||
let l = labels[start];
|
||||
let (mut size, mut touches, mut stack) = (0, false, vec![start]);
|
||||
seen[start] = true;
|
||||
while let Some(i) = stack.pop() {
|
||||
size += 1;
|
||||
let (x, y) = (i % w, i / w);
|
||||
let near = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)];
|
||||
for j in near.into_iter().flatten() {
|
||||
if labels[j] == l {
|
||||
if !seen[j] {
|
||||
seen[j] = true;
|
||||
stack.push(j);
|
||||
}
|
||||
} else if labels[j] != TRANSPARENT {
|
||||
touches = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push((size, touches));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #819: a speck merged into a small region that is itself merged later was left behind as a new
|
||||
/// island of the small region's old label.
|
||||
#[test]
|
||||
fn denoise_leaves_no_island_behind_a_merged_region() {
|
||||
// One row: a 1-pixel speck, a 2-pixel region, then a large one; nothing under 3 pixels may stay.
|
||||
let mut labels = vec![0, 1, 1, 2, 2, 2, 2, 2];
|
||||
denoise(&mut labels, 8, 1, 3);
|
||||
assert_eq!(labels, vec![2; 8]);
|
||||
}
|
||||
|
||||
/// On noise, Remove Noise leaves no region under the minimum that could be merged, and a larger
|
||||
/// minimum never leaves more regions (#819: more noise removal gave more paths).
|
||||
#[test]
|
||||
fn denoise_merges_every_small_region_and_more_noise_never_means_more_regions() {
|
||||
let (w, h) = (48, 40);
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed
|
||||
};
|
||||
for colors in [2u16, 3, 5, 8] {
|
||||
// Blocky noise: random 1-4 pixel runs, so regions of every size touch each other.
|
||||
let mut base = vec![0u16; w * h];
|
||||
let mut i = 0;
|
||||
while i < w * h {
|
||||
let (l, run) = ((next() % u32::from(colors)) as u16, 1 + next() as usize % 4);
|
||||
for p in base.iter_mut().skip(i).take(run) {
|
||||
*p = l;
|
||||
}
|
||||
i += run;
|
||||
}
|
||||
let mut last = usize::MAX;
|
||||
for min_area in [1usize, 2, 4, 8, 16, 32, 64, 128] {
|
||||
let mut labels = base.clone();
|
||||
denoise(&mut labels, w, h, min_area);
|
||||
let comps = components(&labels, w, h);
|
||||
if min_area > 1 {
|
||||
assert!(
|
||||
comps.iter().all(|&(size, touches)| size >= min_area || !touches),
|
||||
"{colors} colours, min {min_area}: a region under the minimum is left: {comps:?}"
|
||||
);
|
||||
}
|
||||
assert!(comps.len() <= last, "{colors} colours: min {min_area} leaves {} regions, more than the {last} a smaller one left", comps.len());
|
||||
last = comps.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #819: on noisy scan-like images, raising Noise never gives more paths. Before the fix, these
|
||||
/// two went from 70 paths to 126 to 206 as Noise rose from 50 to 200 (and 22 to 25).
|
||||
#[test]
|
||||
fn more_noise_removal_never_gives_more_paths() {
|
||||
for (seed0, colors) in [(15u32, 2usize), (9, 6)] {
|
||||
let mut seed = seed0.wrapping_mul(0x9e37_79b9) | 1;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed
|
||||
};
|
||||
let ink: Vec<[u8; 4]> = (0..colors)
|
||||
.map(|_| {
|
||||
let v = next();
|
||||
[(v & 255) as u8, (v >> 8 & 255) as u8, (v >> 16 & 255) as u8, 255]
|
||||
})
|
||||
.collect();
|
||||
// Diagonal bands of ink with a quarter of the pixels speckled at random.
|
||||
let (w, h) = (121, 101);
|
||||
let px: Vec<[u8; 4]> = (0..w * h)
|
||||
.map(|i| {
|
||||
let band = ((i % w) / 7 + (i / w) / 5) % colors;
|
||||
if next() % 4 == 0 { ink[next() as usize % colors] } else { ink[band] }
|
||||
})
|
||||
.collect();
|
||||
let img = Raster::from_fn(120, 100, |x, y| px[y as usize * w + x as usize]);
|
||||
let mut last = (0, usize::MAX);
|
||||
for noise in [1, 4, 8, 12, 25, 50, 100, 200] {
|
||||
let n = trace(&img, &TraceParams { noise, ..TraceParams::default() }).paths.len();
|
||||
assert!(n <= last.1, "image {seed0}: Noise {noise} gives {n} paths, more than the {} of Noise {}", last.1, last.0);
|
||||
last = (noise, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -1235,6 +1238,8 @@ fn node_outline(n: &Node) -> BezPath {
|
||||
[b, c, d].into_iter().for_each(|q| bp.line_to(q));
|
||||
bp.close_path();
|
||||
}
|
||||
// A gradient mesh shows its mesh lines (#1111).
|
||||
NodeKind::Mesh(m) => bp.extend(m.lines().to_bezpath()),
|
||||
// An envelope shows its mesh (or top object), not its content.
|
||||
NodeKind::Envelope { .. } => {
|
||||
if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) {
|
||||
@@ -1629,9 +1634,15 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let partial = st.selection.partial(*id);
|
||||
// Path outlines.
|
||||
stroke_path(p, &node_outline(n), xf, Stroke::new(1.0, color));
|
||||
// Anchors (and handles for selected anchors in direct mode); a mesh envelope's points.
|
||||
// Anchors (and handles for selected anchors in direct mode); a gradient mesh's or a mesh
|
||||
// envelope's points.
|
||||
walk_drawn(n, &mut |c| {
|
||||
if let Some((_, Some(grid))) = vectorcraft_doc::live::envelope_overlay(c) {
|
||||
let envelope = vectorcraft_doc::live::envelope_overlay(c).and_then(|(_, grid)| grid);
|
||||
let grid = match &c.kind {
|
||||
NodeKind::Mesh(m) => Some(m),
|
||||
_ => envelope.as_ref(),
|
||||
};
|
||||
if let Some(grid) = grid {
|
||||
for q in &grid.points {
|
||||
anchor_square(p, xf.to_screen(q.p), color, false, anchor(direct));
|
||||
}
|
||||
@@ -1752,16 +1763,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 +2203,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 +2282,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 +2516,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);
|
||||
@@ -2511,6 +2556,34 @@ mod tests {
|
||||
assert!(points > 200 && squares > 200, "{points} outline points, {squares} squares");
|
||||
}
|
||||
|
||||
/// #1111: a selected gradient mesh shows its mesh lines and points to the selection tools, not
|
||||
/// only to the Mesh tool.
|
||||
#[test]
|
||||
fn a_selected_gradient_mesh_shows_its_lines_and_points() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 200, "height": 100})).unwrap();
|
||||
app.session.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let drawn = |app: &mut VectorcraftApp| {
|
||||
frame(app, &ctx, vec![]);
|
||||
let shapes = shapes(app, &ctx);
|
||||
let lines = shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.points.len() > 1)).count();
|
||||
(lines, shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count())
|
||||
};
|
||||
let (none_lines, none_points) = {
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
drawn(&mut app)
|
||||
};
|
||||
for tool in ["selection", "directSelection"] {
|
||||
app.select_tool(tool);
|
||||
app.session.execute("select.all", &json!({})).unwrap();
|
||||
let (lines, points) = drawn(&mut app);
|
||||
// The outline, two inner lines each way; the 3 × 3 points.
|
||||
assert!(lines >= none_lines + 3 && points >= none_points + 9, "{tool}: {lines} lines, {points} points");
|
||||
}
|
||||
}
|
||||
|
||||
/// #541: the key object's outline is drawn thicker than the rest of the selection's.
|
||||
#[test]
|
||||
fn the_key_object_has_a_thicker_outline() {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
//! The All Tools drawer: every tool with its icon and name, under the headings of the toolbar's
|
||||
//! groups (Select, Shapes, Draw, Modify, Type, Navigate, Color) and of the tools the Basic toolbar
|
||||
//! leaves out (#807). The active tool is highlighted; picking one selects it and closes the drawer.
|
||||
|
||||
use egui::{Sense, vec2};
|
||||
use vectorcraft_tools::{TOOL_GROUPS, ToolInfo};
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, icons, widgets};
|
||||
|
||||
/// A compact drawer two tools wide, however wide the window (#1018); the dialog frame narrows it
|
||||
/// further in a narrow window.
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: WIDTH, max_width: Some(WIDTH), ..DialogSpec::FORM };
|
||||
|
||||
/// A tool's cell: its icon and name.
|
||||
const CELL: egui::Vec2 = vec2(176.0, 28.0);
|
||||
const ICON: f32 = 20.0;
|
||||
/// The drawer's width: two cells, the gap between them and room for the scroll bar.
|
||||
const WIDTH: f32 = 2.0 * CELL.x + 24.0;
|
||||
|
||||
/// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the
|
||||
/// Advanced toolbar shows.
|
||||
fn heading(id: &str) -> &'static str {
|
||||
if let Some((cat, _)) = crate::toolbar::BASIC.iter().find(|(_, slots)| slots.iter().any(|s| s.contains(&id))) {
|
||||
return cat;
|
||||
}
|
||||
match id {
|
||||
_ if id.starts_with("symbol") => "Symbols",
|
||||
_ if id.ends_with("Graph") => "Graph",
|
||||
_ if id.starts_with("perspective") => "Perspective",
|
||||
"artboard" => "Artboards",
|
||||
"slice" | "sliceSelection" => "Slices",
|
||||
_ => "More",
|
||||
}
|
||||
}
|
||||
|
||||
/// The headings in order, each with its tools in the toolbar's order.
|
||||
fn sections() -> Vec<(&'static str, Vec<&'static ToolInfo>)> {
|
||||
let mut out: Vec<(&'static str, Vec<&'static ToolInfo>)> = crate::toolbar::BASIC.iter().map(|(cat, _)| (*cat, vec![])).collect();
|
||||
for tool in TOOL_GROUPS.iter().flat_map(|g| g.iter()) {
|
||||
let h = heading(tool.id);
|
||||
match out.iter_mut().find(|(c, _)| *c == h) {
|
||||
Some((_, tools)) => tools.push(tool),
|
||||
None => out.push((h, vec![tool])),
|
||||
}
|
||||
}
|
||||
out.retain(|(_, tools)| !tools.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let active = app.session.tool_id();
|
||||
let mut picked = None;
|
||||
let room = (ui.ctx().content_rect().height() - 180.0).max(200.0);
|
||||
egui::ScrollArea::vertical().max_height(room).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (cat, tools) in sections() {
|
||||
widgets::subheader(ui, tl!(cat));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
|
||||
for tool in tools {
|
||||
let (rect, resp) = ui.allocate_exact_size(CELL, Sense::click());
|
||||
let on = tool.id == active;
|
||||
if on {
|
||||
ui.painter().rect_filled(rect, 3.0, t.selection);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
let icon = egui::Rect::from_center_size(rect.left_center() + vec2(6.0 + ICON / 2.0, 0.0), vec2(ICON, ICON));
|
||||
icons::paint(ui, icons::tool_icon(tool.icon), icon, if on { t.text_strong } else { t.icon });
|
||||
let name = tl!(tool.label);
|
||||
let name = if crate::i18n::current() == crate::i18n::Lang::EN { name.trim_end_matches(" Tool") } else { name };
|
||||
let text_at = egui::pos2(icon.right() + 8.0, rect.center().y);
|
||||
ui.painter().with_clip_rect(rect).text(
|
||||
text_at,
|
||||
egui::Align2::LEFT_CENTER,
|
||||
name,
|
||||
egui::FontId::proportional(12.0),
|
||||
if on { t.text_strong } else { t.text },
|
||||
);
|
||||
if resp.on_hover_text(crate::toolbar::tip(tool)).clicked() {
|
||||
picked = Some(tool.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
if let Some(id) = picked {
|
||||
app.select_tool(id);
|
||||
}
|
||||
picked.is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order.
|
||||
#[test]
|
||||
fn every_tool_is_listed_once_under_a_heading() {
|
||||
let s = sections();
|
||||
let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect();
|
||||
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
|
||||
assert_eq!(listed.len(), all.len());
|
||||
assert!(all.iter().all(|id| listed.contains(id)));
|
||||
let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]);
|
||||
assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}");
|
||||
assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}");
|
||||
}
|
||||
|
||||
/// The drawer stays two tools wide in a wide window and fits a narrow one (#1018).
|
||||
#[test]
|
||||
fn all_tools_dialog_has_a_bounded_width() {
|
||||
use egui::{Pos2, RawInput, Rect};
|
||||
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.ui.dialog = Some(Dialog::new("allTools", serde_json::json!({})));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
for width in [1280.0, 430.0] {
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 900.0));
|
||||
for _ in 0..6 {
|
||||
let mut out = ctx.run_ui(RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
let rect = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "allTools")))).expect("drawer is shown");
|
||||
assert!(rect.width() <= width, "drawer exceeds viewport: {rect:?}");
|
||||
assert!(rect.width() < 3.0 * CELL.x, "drawer wider than two tools: {rect:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
};
|
||||
// Every corner given is one of the path's: none is left out of the edit.
|
||||
if corners.is_empty() || !corners.iter().all(|k| live.corner(*k).is_some()) {
|
||||
return Err("give corners of the path: anchors between two straight sides".into());
|
||||
return Err("give corners of the path: anchors where its sides meet at an angle".into());
|
||||
}
|
||||
let (radius, kind) = live.style(&corners);
|
||||
let mut fields = json!({"id": id.0, "corners": corners});
|
||||
|
||||
@@ -138,6 +138,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
|
||||
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
|
||||
None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()),
|
||||
None if id == ZIG_ZAG => zig_zag_fields(ui, d, app.session.general_unit()),
|
||||
None => {
|
||||
let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default();
|
||||
let unit = app.session.general_unit();
|
||||
@@ -313,6 +314,55 @@ fn transform_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un
|
||||
values_changed(d)
|
||||
}
|
||||
|
||||
/// The Zig Zag effect (Effect › Distort & Transform › Zig Zag…).
|
||||
const ZIG_ZAG: &str = "distort.zigZag";
|
||||
|
||||
/// Zig Zag's dialog (#1133): Size and Ridges per segment as sliders with their fields, Relative or
|
||||
/// Absolute and the Points as radio buttons. Returns whether the values differ from those last
|
||||
/// previewed.
|
||||
fn zig_zag_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool {
|
||||
const LABEL_W: f32 = 124.0;
|
||||
const FIELD_W: f32 = 70.0;
|
||||
let rail = Tokens::get(ui.ctx()).input_border;
|
||||
let track = move |_| rail;
|
||||
let relative = d.bool("relative");
|
||||
widgets::subheader(ui, tl!("Options"));
|
||||
// Size is a share of the object's size (Relative) or a distance (Absolute).
|
||||
widgets::label_row(ui, tl!("Size:"), LABEL_W, |ui| {
|
||||
form::slider_rail(ui, d, "size", 0.0..=100.0, &track);
|
||||
if !relative {
|
||||
form::length(ui, d, "size", unit, FIELD_W);
|
||||
} else if let Some(n) = widgets::range_field(ui, ("zz", "size"), d.f64("size", 10.0), 0.0..=100.0, "%", 0, FIELD_W) {
|
||||
d.fields.insert("size".into(), json!(n));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(LABEL_W + ui.spacing().item_spacing.x);
|
||||
for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] {
|
||||
if widgets::radio(ui, label, relative == on, true) {
|
||||
d.fields.insert("relative".into(), json!(on));
|
||||
}
|
||||
}
|
||||
});
|
||||
widgets::label_row(ui, tl!("Ridges per segment:"), LABEL_W, |ui| {
|
||||
form::slider_rail(ui, d, "ridges", 0.0..=100.0, &track);
|
||||
if let Some(n) = widgets::range_field(ui, ("zz", "ridges"), d.f64("ridges", 4.0), 0.0..=100.0, "", 0, FIELD_W) {
|
||||
d.fields.insert("ridges".into(), json!(n));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::subheader(ui, crate::i18n::tr_ctx(crate::i18n::current(), "effect", "Points"));
|
||||
let points = d.str("points");
|
||||
ui.horizontal(|ui| {
|
||||
for (label, value) in choices(ZIG_ZAG, "points").unwrap_or_default() {
|
||||
if widgets::radio(ui, crate::i18n::tr_ctx(crate::i18n::current(), "effect", label), points == *value, true) {
|
||||
d.fields.insert("points".into(), json!(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
values_changed(d)
|
||||
}
|
||||
|
||||
/// Curves' graph of its `points` (input across, output up, 0..255): drag a point to move it
|
||||
/// between its neighbours, press on the graph away from the points to add one there, drag one
|
||||
/// off the graph to remove it (two always stay).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -66,6 +66,7 @@ fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
|
||||
|
||||
/// The same offer for linked files another app changed while the document is open
|
||||
/// ([`vectorcraft_engine::link_watch`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn ask_update_changed(app: &mut VectorcraftApp, ids: Vec<Value>) {
|
||||
ask(app, ids, tl!("{count} linked image(s) changed on disk. Show the new versions?"));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//! Shortcuts, Workspaces, Find Font) draw their own window.
|
||||
|
||||
mod about;
|
||||
mod all_tools;
|
||||
pub mod artboard_options;
|
||||
pub mod blend_options;
|
||||
mod brush_art_options;
|
||||
@@ -211,7 +210,6 @@ registry! {
|
||||
PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC,
|
||||
DocumentSetup: ["documentSetup"] => document_setup::SPEC,
|
||||
ArtboardOptions: [artboard_options::KIND] => artboard_options::SPEC,
|
||||
AllTools: ["allTools"] => all_tools::SPEC,
|
||||
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
|
||||
Recolor: [recolor::KIND] => recolor::SPEC,
|
||||
Command: ["command"] => command::SPEC,
|
||||
|
||||
@@ -40,5 +40,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let (id, params) = command(d);
|
||||
if d.kind == "offsetPath" { form::commit_preview(app, id, params) } else { run_and_close(app, id, params) }
|
||||
if d.kind != "offsetPath" {
|
||||
return run_and_close(app, id, params);
|
||||
}
|
||||
let r = form::commit_preview(app, id, params)?;
|
||||
// The fields as the dialog held them, so they show as typed when it opens again.
|
||||
let kept = ["offset", "joins", "miterLimit"].into_iter().filter_map(|k| Some((k.to_string(), d.fields.get(k)?.clone())));
|
||||
app.ui.offset_path = Value::Object(kept.collect());
|
||||
Ok(r)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -52,7 +52,6 @@ fn kinds_map_to_their_dialogs() {
|
||||
assert_eq!(DialogKind::of(kind), Some(want), "{kind}");
|
||||
}
|
||||
assert_eq!(DialogKind::of("nope"), None);
|
||||
assert!(spec("allTools").ok.is_none());
|
||||
assert!(spec("effect").preview && spec("recolor").preview && !spec("move").preview);
|
||||
assert!(spec("preferences").window.is_some() && spec("rectangle").window.is_none());
|
||||
}
|
||||
@@ -89,7 +88,6 @@ fn every_dialog_draws() {
|
||||
for (kind, fields) in [
|
||||
("move", json!({"dx": "0 pt", "dy": "0 pt"})),
|
||||
("average", json!({"axis": "both"})),
|
||||
("allTools", json!({})),
|
||||
(crate::unsaved::KIND, json!({"index": 0, "name": "Untitled-1", "then": "close"})),
|
||||
("newWorkspace", json!({"name": "Mine"})),
|
||||
("shortcuts", json!({})),
|
||||
@@ -107,10 +105,6 @@ fn enter_confirms_through_the_dialog_spec() {
|
||||
frame(&mut app, enter());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(objects(&app), 1);
|
||||
// All Tools has no OK: Enter leaves it open.
|
||||
open(&mut app, "allTools", json!({}));
|
||||
frame(&mut app, enter());
|
||||
assert!(app.ui.dialog.is_some());
|
||||
// An unregistered kind still closes on OK without running anything.
|
||||
open(&mut app, "unregistered", json!({}));
|
||||
assert_eq!(confirm(&mut app), Ok(serde_json::Value::Null));
|
||||
@@ -546,3 +540,18 @@ fn artboard_options_position_moves_art_only_with_the_tool_option_on() {
|
||||
assert_eq!(*app.session.doc().unwrap().doc, *before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Zig Zag's dialog has Size and Ridges per segment sliders, and Relative / Absolute and Smooth /
|
||||
/// Corner as radio buttons, not dropdowns (#1133).
|
||||
#[test]
|
||||
fn zig_zag_has_sliders_and_radio_buttons() {
|
||||
let mut app = app();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect": "distort.zigZag"})).unwrap();
|
||||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
|
||||
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
for want in ["Size:", "Relative", "Absolute", "Ridges per segment:", "Points", "Smooth", "Corner"] {
|
||||
assert!(lines.contains(&want), "no {want:?} in {lines:?}");
|
||||
}
|
||||
assert!(!lines.contains(&"Ridges:"), "{lines:?}");
|
||||
}
|
||||
@@ -201,3 +201,19 @@ fn offset_path_previews() {
|
||||
cancel(&mut app);
|
||||
assert_eq!(count(&app), 1, "rolled back");
|
||||
}
|
||||
|
||||
/// Offset Path opens on the values it was last confirmed with (#1152).
|
||||
#[test]
|
||||
fn offset_path_remembers_its_last_values() {
|
||||
let mut app = app_with_selection();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("offset".into(), json!(-4.5));
|
||||
d.fields.insert("joins".into(), json!("round"));
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.f64("offset", 0.0), d.str("joins"), d.f64("miterLimit", 0.0)), (-4.5, "round".to_string(), 4.0));
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
//! Edit Toolbar (#1018): the All Tools drawer that the ••• button at the bottom of the toolbar
|
||||
//! opens beside it, and the toolbar edits it makes. The drawer lists every tool under the Basic
|
||||
//! toolbar's group headings (and headings for the tools only the Advanced toolbar shows); the tools
|
||||
//! already in the toolbar are dimmed. While it is open the toolbar can be edited: a tool dragged
|
||||
//! from the drawer onto the toolbar is added there (dropped between two tools, or onto one to join
|
||||
//! its group), one dragged from the toolbar into the drawer is removed, and the toolbar's tools
|
||||
//! drag to new places. Escape or a click outside closes it. Picking a tool in it selects the tool.
|
||||
//!
|
||||
//! Behaviour from the public documentation: <https://helpx.adobe.com/illustrator/using/tools.html>
|
||||
//! (Edit Toolbar, the All Tools drawer; tools are added, removed or rearranged only while the drawer
|
||||
//! is open, and the ones already in the toolbar show dimmed in it).
|
||||
//!
|
||||
//! Each toolbar (Basic, Advanced) keeps its own edits in `UiState::toolbar_edits`, saved with the
|
||||
//! preferences; `toolbar.addTool`, `toolbar.removeTool` and `toolbar.reset` make them.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use egui::{DragAndDrop, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_tools::{TOOL_GROUPS, ToolInfo, tool_info};
|
||||
|
||||
use crate::state::{ToolbarCategory, ToolbarEdits};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, icons, widgets};
|
||||
|
||||
/// A tool's cell in the drawer: its icon and name.
|
||||
const CELL: egui::Vec2 = vec2(176.0, 28.0);
|
||||
const ICON: f32 = 20.0;
|
||||
/// The drawer's width: two cells, the gap between them and room for the scroll bar.
|
||||
const WIDTH: f32 = 2.0 * CELL.x + 24.0;
|
||||
/// Gap between the toolbar and the drawer, and the drawer's least distance from the window's edges.
|
||||
const GAP: f32 = 4.0;
|
||||
/// Size of the tool following the pointer while it is dragged.
|
||||
const GHOST: f32 = 30.0;
|
||||
|
||||
/// A tool dragged between the drawer and the toolbar (egui's drag-and-drop payload).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ToolDrag {
|
||||
pub tool: &'static str,
|
||||
/// Dragged from the toolbar (dropping it in the drawer removes it).
|
||||
pub from_toolbar: bool,
|
||||
}
|
||||
|
||||
/// Where a tool dropped on a toolbar slot goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Place {
|
||||
/// Its own slot, before the slot (on the divider above it, or left of it in two columns).
|
||||
Before,
|
||||
/// Its own slot, after the slot.
|
||||
After,
|
||||
/// Into the slot's group (its flyout), last.
|
||||
Group,
|
||||
}
|
||||
|
||||
impl Place {
|
||||
const ALL: [Self; 3] = [Self::Before, Self::After, Self::Group];
|
||||
|
||||
/// The `place` param (omitted: before).
|
||||
fn parse(p: &Value) -> Result<Self, String> {
|
||||
let key = "place";
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(Self::Before),
|
||||
Some(v) => Self::ALL
|
||||
.into_iter()
|
||||
.find(|place| v.as_str() == Some(place.name()))
|
||||
.ok_or_else(|| format!("`{key}` must be one of {}", Self::ALL.map(Self::name).join(", "))),
|
||||
}
|
||||
}
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Before => "before",
|
||||
Self::After => "after",
|
||||
Self::Group => "group",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the model ----------
|
||||
|
||||
/// A toolbar as it ships: the Basic toolbar's categories, or the Advanced toolbar's tool groups.
|
||||
fn shipped(advanced: bool) -> Vec<ToolbarCategory> {
|
||||
let strings = |s: &[&str]| s.iter().map(|id| id.to_string()).collect::<Vec<_>>();
|
||||
if advanced {
|
||||
let slots = TOOL_GROUPS.iter().map(|g| g.iter().map(|t| t.id.to_string()).collect()).collect();
|
||||
vec![ToolbarCategory { name: None, slots }]
|
||||
} else {
|
||||
crate::toolbar::BASIC
|
||||
.iter()
|
||||
.map(|(cat, slots)| ToolbarCategory { name: Some(cat.to_string()), slots: slots.iter().map(|s| strings(s)).collect() })
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn current(edits: &mut ToolbarEdits, advanced: bool) -> &mut Option<Vec<ToolbarCategory>> {
|
||||
if advanced { &mut edits.advanced } else { &mut edits.basic }
|
||||
}
|
||||
|
||||
/// The Basic toolbar category named `name`.
|
||||
fn category(name: &str) -> Option<&'static str> {
|
||||
crate::toolbar::BASIC.iter().map(|(c, _)| *c).find(|c| *c == name)
|
||||
}
|
||||
|
||||
/// The current toolbar's slots as Edit Toolbar left them, laid out as `toolbar::slots` lays them
|
||||
/// out (the category name on each category's first slot); `None` while it is as it ships.
|
||||
pub(crate) fn edited_slots(app: &VectorcraftApp) -> Option<Vec<(Option<&'static str>, Vec<&'static str>)>> {
|
||||
let edits = &app.ui.toolbar_edits;
|
||||
let cats = if app.ui.toolbar_advanced { &edits.advanced } else { &edits.basic };
|
||||
let mut out = vec![];
|
||||
for c in cats.as_ref()? {
|
||||
let mut name = c.name.as_deref().and_then(category);
|
||||
for s in &c.slots {
|
||||
let tools: Vec<&'static str> = s.iter().filter_map(|id| tool_info(id).map(|t| t.id)).collect();
|
||||
if !tools.is_empty() {
|
||||
out.push((name.take(), tools));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Drop the empty slots, then the empty categories.
|
||||
fn prune(cats: &mut Vec<ToolbarCategory>) {
|
||||
for c in cats.iter_mut() {
|
||||
c.slots.retain(|s| !s.is_empty());
|
||||
}
|
||||
cats.retain(|c| !c.slots.is_empty());
|
||||
}
|
||||
|
||||
/// Keep known tools, each once, and known category names (a hand-edited preferences file).
|
||||
pub(crate) fn sanitize(edits: &mut ToolbarEdits) {
|
||||
for cats in [&mut edits.basic, &mut edits.advanced].into_iter().flatten() {
|
||||
let mut seen = BTreeSet::new();
|
||||
for c in cats.iter_mut() {
|
||||
c.name = c.name.as_deref().and_then(category).map(str::to_string);
|
||||
for s in &mut c.slots {
|
||||
s.retain(|id| tool_info(id).is_some() && seen.insert(id.clone()));
|
||||
}
|
||||
}
|
||||
prune(cats);
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the current toolbar with `f`: an edit that gives back the toolbar as it ships forgets
|
||||
/// the edits. Returns the toolbar's slots.
|
||||
fn edit(app: &mut VectorcraftApp, f: impl FnOnce(&mut Vec<ToolbarCategory>) -> Result<(), String>) -> Result<Value, String> {
|
||||
let advanced = app.ui.toolbar_advanced;
|
||||
let slot = current(&mut app.ui.toolbar_edits, advanced);
|
||||
let mut cats = slot.clone().unwrap_or_else(|| shipped(advanced));
|
||||
f(&mut cats)?;
|
||||
prune(&mut cats);
|
||||
*slot = (cats != shipped(advanced)).then_some(cats);
|
||||
Ok(slots_json(app))
|
||||
}
|
||||
|
||||
/// The current toolbar's slots, each a list of tool ids (`toolbar.*` commands return it).
|
||||
fn slots_json(app: &VectorcraftApp) -> Value {
|
||||
json!(crate::toolbar::slots(app).into_iter().map(|(_, s)| s).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Put `tool` by the slot numbered `slot` (over the toolbar's slots, 0 = the first; none or null:
|
||||
/// at the end), moving it if it is in the toolbar already. A tool appended to a toolbar left without
|
||||
/// categories starts one (`named`: under its own heading).
|
||||
fn add(cats: &mut Vec<ToolbarCategory>, tool: &str, slot: Option<&Value>, place: Place, named: bool) -> Result<(), String> {
|
||||
let at: Vec<(usize, usize)> = cats.iter().enumerate().flat_map(|(c, cat)| (0..cat.slots.len()).map(move |s| (c, s))).collect();
|
||||
let bad_slot = || {
|
||||
let (key, min, max, x) = ("slot", 0, at.len().saturating_sub(1), slot.unwrap_or(&Value::Null));
|
||||
format!("`{key}` must be a whole number between {min} and {max} (got {x})")
|
||||
};
|
||||
let target = match slot {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(*v.as_u64().and_then(|k| usize::try_from(k).ok()).and_then(|k| at.get(k)).ok_or_else(bad_slot)?),
|
||||
};
|
||||
let holds =
|
||||
|cats: &[ToolbarCategory], (c, s): (usize, usize)| cats.get(c).and_then(|cat| cat.slots.get(s)).is_some_and(|s| s.iter().any(|t| t == tool));
|
||||
if place == Place::Group && target.is_some_and(|t| holds(cats, t)) {
|
||||
// Onto its own group: nothing to do.
|
||||
return Ok(());
|
||||
}
|
||||
// Out of where it was, leaving its slot in place (emptied ones go after) so `target` holds.
|
||||
for c in cats.iter_mut() {
|
||||
for s in &mut c.slots {
|
||||
s.retain(|t| t != tool);
|
||||
}
|
||||
}
|
||||
let new = vec![tool.to_string()];
|
||||
let Some((c, s)) = target else {
|
||||
match cats.last_mut() {
|
||||
Some(cat) => cat.slots.push(new),
|
||||
None => cats.push(ToolbarCategory { name: named.then(|| heading(tool)).and_then(category).map(str::to_string), slots: vec![new] }),
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let cat = cats.get_mut(c).ok_or_else(bad_slot)?;
|
||||
match place {
|
||||
Place::Group => cat.slots.get_mut(s).ok_or_else(bad_slot)?.push(tool.to_string()),
|
||||
Place::Before => cat.slots.insert(s.min(cat.slots.len()), new),
|
||||
Place::After => cat.slots.insert((s + 1).min(cat.slots.len()), new),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take `tool` out of the toolbar (`group`: with the rest of its group); one that isn't in it
|
||||
/// stays out.
|
||||
fn remove(cats: &mut [ToolbarCategory], tool: &str, group: bool) {
|
||||
if let Some(slot) = cats.iter_mut().flat_map(|c| c.slots.iter_mut()).find(|s| s.iter().any(|t| t == tool)) {
|
||||
if group {
|
||||
slot.clear();
|
||||
} else {
|
||||
slot.retain(|t| t != tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A known tool's id from the `tool` param.
|
||||
fn tool_param(p: &Value) -> Result<&'static str, String> {
|
||||
let tool = p.get("tool").and_then(Value::as_str).ok_or("tool (a tool id) is required")?;
|
||||
tool_info(tool).map(|t| t.id).ok_or_else(|| format!("unknown tool `{tool}`"))
|
||||
}
|
||||
|
||||
/// The Edit Toolbar commands; `None` for other ids.
|
||||
pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
Some(match id {
|
||||
"window.editToolbar" => crate::menus::opt_bool(p, "open").map(|open| {
|
||||
let open = open.unwrap_or(!app.ui.tools_drawer);
|
||||
app.ui.tools_drawer = open;
|
||||
if open {
|
||||
app.ui.flyout = None;
|
||||
}
|
||||
json!(open)
|
||||
}),
|
||||
"toolbar.addTool" => (|| {
|
||||
let tool = tool_param(p)?;
|
||||
let place = Place::parse(p)?;
|
||||
let named = !app.ui.toolbar_advanced;
|
||||
edit(app, |cats| add(cats, tool, p.get("slot"), place, named))
|
||||
})(),
|
||||
"toolbar.removeTool" => (|| {
|
||||
let tool = tool_param(p)?;
|
||||
let group = crate::menus::opt_bool(p, "group")?.unwrap_or(false);
|
||||
edit(app, |cats| {
|
||||
remove(cats, tool, group);
|
||||
Ok(())
|
||||
})
|
||||
})(),
|
||||
"toolbar.reset" => {
|
||||
*current(&mut app.ui.toolbar_edits, app.ui.toolbar_advanced) = None;
|
||||
Ok(slots_json(app))
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- dropping on the toolbar ----------
|
||||
|
||||
/// Where a tool dropped at `at` goes, given the toolbar's slot buttons (slot index, rect) in a
|
||||
/// toolbar `cols` columns wide: onto a button's middle joins its group, onto its edge (top or
|
||||
/// bottom; left or right in two columns) goes beside it; elsewhere it goes beside the nearest
|
||||
/// button. `(None, _)`: at the end (no buttons).
|
||||
pub(crate) fn drop_target(slots: &[(usize, Rect)], at: Pos2, cols: usize) -> (Option<usize>, Place) {
|
||||
let Some(&(k, r)) = slots.iter().min_by(|a, b| a.1.distance_sq_to_pos(at).total_cmp(&b.1.distance_sq_to_pos(at))) else {
|
||||
return (None, Place::Before);
|
||||
};
|
||||
if r.contains(at) {
|
||||
let f = if cols == 1 { (at.y - r.top()) / r.height().max(1.0) } else { (at.x - r.left()) / r.width().max(1.0) };
|
||||
let place = if f < 0.3 {
|
||||
Place::Before
|
||||
} else if f > 0.7 {
|
||||
Place::After
|
||||
} else {
|
||||
Place::Group
|
||||
};
|
||||
return (Some(k), place);
|
||||
}
|
||||
let before = at.y < r.top() || (at.y <= r.bottom() && at.x < r.center().x);
|
||||
(Some(k), if before { Place::Before } else { Place::After })
|
||||
}
|
||||
|
||||
/// While a tool is dragged over the toolbar (`bounds`): mark where it would go, and on the release
|
||||
/// put it there (`toolbar.addTool`).
|
||||
pub(crate) fn toolbar_drop(app: &mut VectorcraftApp, ui: &Ui, slots: &[(usize, Rect)], cols: usize, bounds: Rect) {
|
||||
let Some(drag) = DragAndDrop::payload::<ToolDrag>(ui.ctx()) else { return };
|
||||
let Some(at) = ui.ctx().pointer_interact_pos().filter(|p| bounds.contains(*p)) else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (slot, place) = drop_target(slots, at, cols);
|
||||
if let Some(r) = slot.and_then(|k| slots.iter().find(|s| s.0 == k)).map(|s| s.1) {
|
||||
let line = Stroke::new(2.0, t.accent);
|
||||
match (place, cols == 1) {
|
||||
(Place::Group, _) => {
|
||||
ui.painter().rect_stroke(r.shrink(1.0), 2.0, line, StrokeKind::Inside);
|
||||
}
|
||||
(Place::Before, true) => {
|
||||
ui.painter().hline(r.x_range(), r.top(), line);
|
||||
}
|
||||
(Place::After, true) => {
|
||||
ui.painter().hline(r.x_range(), r.bottom(), line);
|
||||
}
|
||||
(Place::Before, false) => {
|
||||
ui.painter().vline(r.left(), r.y_range(), line);
|
||||
}
|
||||
(Place::After, false) => {
|
||||
ui.painter().vline(r.right(), r.y_range(), line);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.input(|i| i.pointer.any_released()) {
|
||||
DragAndDrop::clear_payload(ui.ctx());
|
||||
let p = json!({ "tool": drag.tool, "slot": slot, "place": place.name() });
|
||||
if let Err(e) = app.run("toolbar.addTool", p) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the drawer ----------
|
||||
|
||||
/// Where the toolbar leaves its ••• button's rect and its own for the drawer, each frame.
|
||||
pub(crate) fn button_id() -> egui::Id {
|
||||
egui::Id::new("edit-toolbar-button")
|
||||
}
|
||||
|
||||
pub(crate) fn toolbar_id() -> egui::Id {
|
||||
egui::Id::new("edit-toolbar-toolbar")
|
||||
}
|
||||
|
||||
/// The drawer's area.
|
||||
pub(crate) fn drawer_id() -> egui::Id {
|
||||
egui::Id::new("tools-drawer")
|
||||
}
|
||||
|
||||
/// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the
|
||||
/// Advanced toolbar shows.
|
||||
fn heading(id: &str) -> &'static str {
|
||||
if let Some((cat, _)) = crate::toolbar::BASIC.iter().find(|(_, slots)| slots.iter().any(|s| s.contains(&id))) {
|
||||
return cat;
|
||||
}
|
||||
match id {
|
||||
_ if id.starts_with("symbol") => "Symbols",
|
||||
_ if id.ends_with("Graph") => "Graph",
|
||||
_ if id.starts_with("perspective") => "Perspective",
|
||||
"artboard" => "Artboards",
|
||||
"slice" | "sliceSelection" => "Slices",
|
||||
_ => "More",
|
||||
}
|
||||
}
|
||||
|
||||
/// The headings in order, each with its tools in the toolbar's order.
|
||||
fn sections() -> &'static [(&'static str, Vec<&'static ToolInfo>)] {
|
||||
static SECTIONS: OnceLock<Vec<(&'static str, Vec<&'static ToolInfo>)>> = OnceLock::new();
|
||||
SECTIONS.get_or_init(|| {
|
||||
let mut out: Vec<(&'static str, Vec<&'static ToolInfo>)> = crate::toolbar::BASIC.iter().map(|(cat, _)| (*cat, vec![])).collect();
|
||||
for tool in TOOL_GROUPS.iter().flat_map(|g| g.iter()) {
|
||||
let h = heading(tool.id);
|
||||
match out.iter_mut().find(|(c, _)| *c == h) {
|
||||
Some((_, tools)) => tools.push(tool),
|
||||
None => out.push((h, vec![tool])),
|
||||
}
|
||||
}
|
||||
out.retain(|(_, tools)| !tools.is_empty());
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// The All Tools drawer beside the toolbar, its top by the ••• button (higher when that would put
|
||||
/// it past the window's bottom), while `tools_drawer` is on.
|
||||
pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if !app.ui.tools_drawer {
|
||||
return;
|
||||
}
|
||||
let (Some(button), Some(bar)) = ctx.data(|d| (d.get_temp::<Rect>(button_id()), d.get_temp::<Rect>(toolbar_id()))) else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
let id = drawer_id();
|
||||
let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(WIDTH, 200.0), |r| r.size());
|
||||
let x = (bar.right() + GAP).min(screen.right() - size.x - GAP).max(screen.left());
|
||||
let y = button.top().min(screen.bottom() - size.y - GAP).max(screen.top() + GAP);
|
||||
let in_toolbar: BTreeSet<&str> = crate::toolbar::slots(app).into_iter().flat_map(|(_, s)| s).collect();
|
||||
let active = app.session.tool_id();
|
||||
let dragged = DragAndDrop::payload::<ToolDrag>(ctx).map(|d| *d);
|
||||
let pointer = ctx.pointer_interact_pos();
|
||||
let over = |r: Rect| pointer.is_some_and(|p| r.contains(p));
|
||||
let removing = dragged.is_some_and(|d| d.from_toolbar) && ctx.memory(|m| m.area_rect(id)).is_some_and(over);
|
||||
let mut picked = None;
|
||||
let mut reset = false;
|
||||
let resp = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| {
|
||||
crate::toolbar::flyout_frame(&t, removing).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
|
||||
ui.set_width(WIDTH.min(screen.width() - 2.0 * GAP - 16.0).max(CELL.x));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("All Tools")).size(13.0).color(t.text_strong));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| reset = widgets::flat_button(ui, "Reset", 64.0).clicked());
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
// As tall as its tools, up to the window's height (it scrolls then); the Area's size
|
||||
// from the last frame mustn't cap it.
|
||||
let room = (screen.height() - 2.0 * GAP - 68.0).max(120.0);
|
||||
egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (cat, tools) in sections() {
|
||||
widgets::subheader(ui, tl!(cat));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
|
||||
for tool in tools {
|
||||
if cell(ui, &t, tool, tool.id == active, in_toolbar.contains(tool.id)) {
|
||||
picked = Some(tool.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let rect = resp.response.rect;
|
||||
// A tool dragged from the toolbar and dropped in the drawer leaves the toolbar.
|
||||
if let Some(d) = dragged.filter(|d| d.from_toolbar && over(rect))
|
||||
&& ctx.input(|i| i.pointer.any_released())
|
||||
{
|
||||
DragAndDrop::clear_payload(ctx);
|
||||
if let Err(e) = app.run("toolbar.removeTool", json!({ "tool": d.tool })) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
if reset && let Err(e) = app.run("toolbar.reset", json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
if let Some(id) = picked {
|
||||
app.select_tool(id);
|
||||
app.ui.tools_drawer = false;
|
||||
} else if resp.response.clicked_elsewhere() && !pointer.is_some_and(|p| bar.contains(p)) {
|
||||
// A click in the toolbar (picking a tool, or the ••• button that toggles the drawer) leaves
|
||||
// it open.
|
||||
app.ui.tools_drawer = false;
|
||||
}
|
||||
ghost(ctx, &t);
|
||||
}
|
||||
|
||||
/// A tool's cell in the drawer: icon and name, dimmed when the tool is in the toolbar already (it
|
||||
/// can't be dragged in again). Returns whether it was clicked.
|
||||
fn cell(ui: &mut Ui, t: &Tokens, tool: &'static ToolInfo, on: bool, dimmed: bool) -> bool {
|
||||
let (rect, resp) = ui.allocate_exact_size(CELL, if dimmed { Sense::click() } else { Sense::click_and_drag() });
|
||||
if !dimmed {
|
||||
resp.dnd_set_drag_payload(ToolDrag { tool: tool.id, from_toolbar: false });
|
||||
}
|
||||
if on {
|
||||
ui.painter().rect_filled(rect, 3.0, t.selection);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
// The active tool's cell is lit, dimmed or not.
|
||||
let color = if on {
|
||||
t.text_strong
|
||||
} else if dimmed {
|
||||
t.text_disabled
|
||||
} else {
|
||||
t.text
|
||||
};
|
||||
let icon = Rect::from_center_size(rect.left_center() + vec2(6.0 + ICON / 2.0, 0.0), vec2(ICON, ICON));
|
||||
icons::paint(ui, icons::tool_icon(tool.icon), icon, if dimmed || on { color } else { t.icon });
|
||||
let name = tl!(tool.label);
|
||||
let name = if crate::i18n::current() == crate::i18n::Lang::EN { name.trim_end_matches(" Tool") } else { name };
|
||||
let text_at = pos2(icon.right() + 8.0, rect.center().y);
|
||||
ui.painter().with_clip_rect(rect).text(text_at, egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), color);
|
||||
resp.on_hover_text(crate::toolbar::tip(tool)).clicked()
|
||||
}
|
||||
|
||||
/// The tool being dragged, by the pointer (below and right of it, clear of the drop marker).
|
||||
fn ghost(ctx: &egui::Context, t: &Tokens) {
|
||||
let Some(d) = DragAndDrop::payload::<ToolDrag>(ctx) else { return };
|
||||
let (Some(at), Some(tool)) = (ctx.pointer_interact_pos(), tool_info(d.tool)) else { return };
|
||||
let r = Rect::from_min_size(at + vec2(8.0, 8.0), vec2(GHOST, GHOST));
|
||||
egui::Area::new(egui::Id::new("tool-drag-ghost")).order(egui::Order::Tooltip).fixed_pos(r.min).interactable(false).show(ctx, |ui| {
|
||||
let (r, _) = ui.allocate_exact_size(r.size(), Sense::hover());
|
||||
ui.painter().rect(r, 3.0, t.panel, Stroke::new(1.0, t.accent), StrokeKind::Inside);
|
||||
icons::paint(ui, icons::tool_icon(tool.icon), Rect::from_center_size(r.center(), vec2(18.0, 18.0)), t.text_strong);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{Color32, Event, PointerButton, RawInput};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Tall enough for the whole Basic toolbar and the whole drawer.
|
||||
const SCREEN: egui::Vec2 = vec2(1000.0, 2400.0);
|
||||
/// A toolbar tool button.
|
||||
const BUTTON: egui::Vec2 = vec2(36.0, 29.0);
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn slots(app: &VectorcraftApp) -> Vec<Vec<&'static str>> {
|
||||
crate::toolbar::slots(app).into_iter().map(|(_, s)| s).collect()
|
||||
}
|
||||
|
||||
/// The slot holding `tool`.
|
||||
fn slot_of(app: &VectorcraftApp, tool: &str) -> Option<usize> {
|
||||
slots(app).iter().position(|s| s.contains(&tool))
|
||||
}
|
||||
|
||||
/// One headless frame of the toolbar, its keys handled first as in the app; returns the texts
|
||||
/// painted: (text, a point inside it, colour).
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<Event>) -> Vec<(String, Pos2, Color32)> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2, Color32)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => {
|
||||
let color = t.galley.job.sections.first().map_or(t.fallback_color, |s| s.format.color);
|
||||
out.push((t.galley.text().to_string(), t.pos + vec2(4.0, t.galley.size().y / 2.0), color));
|
||||
}
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let input = RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
crate::shortcuts::handle(app, ui.ctx());
|
||||
crate::toolbar::show(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mut shown = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
shown
|
||||
}
|
||||
|
||||
fn ctx() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
ctx
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) {
|
||||
frame(app, ctx, time, vec![Event::PointerMoved(at), button(at, true)]);
|
||||
frame(app, ctx, time + 0.05, vec![button(at, false)]);
|
||||
frame(app, ctx, time + 0.1, vec![]);
|
||||
}
|
||||
|
||||
/// A press at `from` dragged to `to` in steps, one frame each, then released there.
|
||||
fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: Pos2) {
|
||||
frame(app, ctx, time, vec![Event::PointerMoved(from), button(from, true)]);
|
||||
for k in 1..=6 {
|
||||
frame(app, ctx, time + 0.05 * f64::from(k), vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]);
|
||||
}
|
||||
frame(app, ctx, time + 0.4, vec![button(to, false)]);
|
||||
frame(app, ctx, time + 0.45, vec![]);
|
||||
}
|
||||
|
||||
fn drawer_rect(ctx: &egui::Context) -> Option<Rect> {
|
||||
ctx.memory(|m| m.area_rect(drawer_id()))
|
||||
}
|
||||
|
||||
fn drawer_shown(ctx: &egui::Context) -> bool {
|
||||
ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Foreground, drawer_id())))
|
||||
}
|
||||
|
||||
/// A point inside the drawer's cell of the tool named `name` (as the drawer shows it), and the
|
||||
/// colour of its name.
|
||||
fn cell_at(shown: &[(String, Pos2, Color32)], drawer: Rect, name: &str) -> (Pos2, Color32) {
|
||||
shown
|
||||
.iter()
|
||||
.find(|(s, p, _)| s == name && drawer.contains(*p))
|
||||
.map(|(_, p, c)| (*p, *c))
|
||||
.unwrap_or_else(|| panic!("no {name} in the drawer {drawer:?}"))
|
||||
}
|
||||
|
||||
/// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order.
|
||||
#[test]
|
||||
fn every_tool_is_listed_once_under_a_heading() {
|
||||
let s = sections();
|
||||
let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect();
|
||||
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
|
||||
assert_eq!(listed.len(), all.len());
|
||||
assert!(all.iter().all(|id| listed.contains(id)));
|
||||
let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]);
|
||||
assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}");
|
||||
assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commands_add_move_remove_and_reset_tools() {
|
||||
let mut app = app();
|
||||
let shipped = slots(&app);
|
||||
// At the end; the toolbar's slots come back.
|
||||
let r = app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
|
||||
assert_eq!(r, json!(slots(&app)));
|
||||
assert_eq!(slots(&app).last().unwrap(), &["artboard"]);
|
||||
// Before the first slot, moved out of its group.
|
||||
app.run("toolbar.addTool", json!({"tool": "magicWand", "slot": 0})).unwrap();
|
||||
assert_eq!(&slots(&app)[..3], [&["magicWand"][..], &["selection"], &["directSelection", "groupSelection"]]);
|
||||
assert_eq!(slots(&app)[3], ["lasso"]);
|
||||
// After a slot, and into a group (last), moved out of the Rectangle group.
|
||||
app.run("toolbar.addTool", json!({"tool": "slice", "slot": 1, "place": "after"})).unwrap();
|
||||
assert_eq!(slots(&app)[2], ["slice"]);
|
||||
let ellipse = slot_of(&app, "ellipse").unwrap();
|
||||
app.run("toolbar.addTool", json!({"tool": "star", "slot": ellipse, "place": "group"})).unwrap();
|
||||
assert_eq!(slots(&app)[ellipse], ["ellipse", "star"]);
|
||||
assert!(!slots(&app)[slot_of(&app, "rectangle").unwrap()].contains(&"star"));
|
||||
// Onto its own group: nothing changes.
|
||||
let before = slots(&app);
|
||||
app.run("toolbar.addTool", json!({"tool": "ellipse", "slot": ellipse, "place": "group"})).unwrap();
|
||||
assert_eq!(slots(&app), before);
|
||||
// The category label still names the first slot of its category.
|
||||
assert_eq!(crate::toolbar::slots(&app)[0].0, Some("Select"));
|
||||
// Out: one tool, or its whole group; an emptied slot goes.
|
||||
app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap();
|
||||
assert_eq!(slots(&app)[ellipse], ["star"]);
|
||||
app.run("toolbar.removeTool", json!({"tool": "addAnchor", "group": true})).unwrap();
|
||||
assert!(slot_of(&app, "pen").is_none() && slot_of(&app, "addAnchor").is_none());
|
||||
// Bad params change nothing.
|
||||
let before = slots(&app);
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"tool": "nope"}),
|
||||
json!({"tool": "pen", "slot": 999}),
|
||||
json!({"tool": "pen", "slot": -1}),
|
||||
json!({"tool": "pen", "place": "inside"}),
|
||||
] {
|
||||
assert!(app.run("toolbar.addTool", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(app.run("toolbar.removeTool", json!({"tool": "pen"})).unwrap(), json!(before), "not in the toolbar: stays out");
|
||||
assert!(app.run("toolbar.addTool", json!({"tool": "pen", "slot": "first"})).unwrap_err().contains("between 0 and"));
|
||||
assert!(app.run("toolbar.removeTool", json!({"tool": "star", "group": "yes"})).is_err());
|
||||
assert_eq!(slots(&app), before);
|
||||
// The Advanced toolbar keeps its own edits.
|
||||
app.run("window.toolbarAdvanced", json!({})).unwrap();
|
||||
assert_eq!(slots(&app).len(), TOOL_GROUPS.len());
|
||||
app.run("toolbar.removeTool", json!({"tool": "selection"})).unwrap();
|
||||
assert!(app.ui.toolbar_edits.advanced.is_some() && slot_of(&app, "selection").is_none());
|
||||
app.run("window.toolbarAdvanced", json!({})).unwrap();
|
||||
assert_eq!(slots(&app)[0], ["magicWand"]);
|
||||
// Reset puts the current one back as it ships.
|
||||
assert_eq!(app.run("toolbar.reset", json!({})).unwrap(), json!(shipped));
|
||||
assert!(app.ui.toolbar_edits.basic.is_none() && app.ui.toolbar_edits.advanced.is_some());
|
||||
// An edit that gives back the shipped toolbar forgets the edits.
|
||||
app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
|
||||
app.run("toolbar.removeTool", json!({"tool": "artboard"})).unwrap();
|
||||
assert!(app.ui.toolbar_edits.basic.is_none());
|
||||
// Everything out, then a tool back: it starts its own category.
|
||||
for tool in shipped.iter().flatten() {
|
||||
app.run("toolbar.removeTool", json!({"tool": tool})).unwrap();
|
||||
}
|
||||
assert!(slots(&app).is_empty());
|
||||
app.run("toolbar.addTool", json!({"tool": "pen"})).unwrap();
|
||||
assert_eq!(crate::toolbar::slots(&app), [(Some("Draw"), vec!["pen"])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_edited_toolbar_is_saved_and_loads_sanitized() {
|
||||
let mut app = app();
|
||||
app.run("toolbar.addTool", json!({"tool": "artboard", "slot": 0})).unwrap();
|
||||
let saved = serde_json::to_value(&app.ui).unwrap();
|
||||
let back: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap();
|
||||
assert_eq!(back.toolbar_edits, app.ui.toolbar_edits);
|
||||
// Preferences saved before Edit Toolbar load the shipped toolbars.
|
||||
let mut old = saved;
|
||||
old.as_object_mut().unwrap().remove("toolbar_edits");
|
||||
assert_eq!(serde_json::from_value::<crate::state::UiState>(old).unwrap().toolbar_edits, ToolbarEdits::default());
|
||||
// A hand-edited file: unknown tools and names go, each tool once, emptied slots go.
|
||||
let cat = |name: Option<&str>, slots: &[&[&str]]| ToolbarCategory {
|
||||
name: name.map(str::to_string),
|
||||
slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(),
|
||||
};
|
||||
let ui = crate::state::UiState {
|
||||
toolbar_edits: ToolbarEdits {
|
||||
basic: Some(vec![cat(Some("Nope"), &[&["pen", "nope"], &["pen"], &["nope"]]), cat(Some("Draw"), &[&[]])]),
|
||||
advanced: None,
|
||||
},
|
||||
tools_drawer: true,
|
||||
..Default::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(ui.toolbar_edits.basic, Some(vec![cat(None, &[&["pen"]])]));
|
||||
assert!(!ui.tools_drawer, "the drawer starts closed");
|
||||
}
|
||||
|
||||
/// #1018: the ••• button opens the drawer beside the toolbar, by the button, not a centred
|
||||
/// dialog; Escape, a click outside and the button close it, a click in the toolbar doesn't.
|
||||
#[test]
|
||||
fn edit_toolbar_opens_a_drawer_beside_the_toolbar() {
|
||||
let mut app = app();
|
||||
let ctx = ctx();
|
||||
frame(&mut app, &ctx, 0.0, vec![]);
|
||||
let edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).last().copied().unwrap();
|
||||
click(&mut app, &ctx, 1.0, edit.center());
|
||||
frame(&mut app, &ctx, 1.2, vec![]);
|
||||
assert!(app.ui.tools_drawer && app.ui.dialog.is_none());
|
||||
assert_eq!(crate::menus::checked(&app, "window.editToolbar", &json!({})), Some(true));
|
||||
let drawer = drawer_rect(&ctx).expect("the drawer is shown");
|
||||
let bar = ctx.data(|d| d.get_temp::<Rect>(toolbar_id())).unwrap();
|
||||
assert!((drawer.left() - bar.right() - GAP).abs() < 1.0, "beside the toolbar: {drawer:?} {bar:?}");
|
||||
assert!((drawer.top() - edit.top()).abs() < 1.0 || drawer.bottom() >= SCREEN.y - GAP - 1.0, "by the button: {drawer:?} {edit:?}");
|
||||
assert!(drawer.y_range().contains(edit.center().y), "by the button: {drawer:?} {edit:?}");
|
||||
assert!(drawer.width() < 3.0 * CELL.x, "two tools wide: {drawer:?}");
|
||||
// A click on a toolbar tool picks it and leaves the drawer open.
|
||||
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
|
||||
let ellipse = tools[slot_of(&app, "ellipse").unwrap()].center();
|
||||
click(&mut app, &ctx, 2.0, ellipse);
|
||||
assert!(app.ui.tools_drawer && app.session.tool_id() == "ellipse");
|
||||
// Escape closes it.
|
||||
let esc = |pressed| Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, 3.0, vec![esc(true), esc(false)]);
|
||||
assert!(!app.ui.tools_drawer);
|
||||
frame(&mut app, &ctx, 3.1, vec![]);
|
||||
assert!(!drawer_shown(&ctx));
|
||||
// A click outside closes it, and so does the ••• button.
|
||||
app.run("window.editToolbar", json!({"open": true})).unwrap();
|
||||
frame(&mut app, &ctx, 4.0, vec![]);
|
||||
click(&mut app, &ctx, 4.5, pos2(SCREEN.x - 20.0, 300.0));
|
||||
assert!(!app.ui.tools_drawer);
|
||||
click(&mut app, &ctx, 5.0, edit.center());
|
||||
assert!(app.ui.tools_drawer);
|
||||
click(&mut app, &ctx, 6.0, edit.center());
|
||||
assert!(!app.ui.tools_drawer);
|
||||
// Picking a tool in the drawer selects it and closes the drawer.
|
||||
app.run("window.editToolbar", json!({})).unwrap();
|
||||
frame(&mut app, &ctx, 7.0, vec![]);
|
||||
let shown = frame(&mut app, &ctx, 7.1, vec![]);
|
||||
let (at, _) = cell_at(&shown, drawer_rect(&ctx).unwrap(), "Artboard");
|
||||
click(&mut app, &ctx, 8.0, at);
|
||||
assert!(!app.ui.tools_drawer && app.session.tool_id() == "artboard");
|
||||
}
|
||||
|
||||
/// The drawer dims the tools the toolbar holds.
|
||||
#[test]
|
||||
fn tools_in_the_toolbar_are_dimmed_in_the_drawer() {
|
||||
let mut app = app();
|
||||
let ctx = ctx();
|
||||
app.run("window.editToolbar", json!({"open": true})).unwrap();
|
||||
frame(&mut app, &ctx, 0.0, vec![]);
|
||||
let shown = frame(&mut app, &ctx, 0.1, vec![]);
|
||||
let drawer = drawer_rect(&ctx).unwrap();
|
||||
let dim = Tokens::get(&ctx).text_disabled;
|
||||
assert_eq!(cell_at(&shown, drawer, "Ellipse").1, dim);
|
||||
assert_ne!(cell_at(&shown, drawer, "Artboard").1, dim);
|
||||
app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
|
||||
app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap();
|
||||
let shown = frame(&mut app, &ctx, 0.2, vec![]);
|
||||
assert_ne!(cell_at(&shown, drawer, "Ellipse").1, dim);
|
||||
assert_eq!(cell_at(&shown, drawer, "Artboard").1, dim);
|
||||
}
|
||||
|
||||
/// #1018: tools drag from the drawer onto the toolbar (between two tools, or onto one to join
|
||||
/// its group), from the toolbar into the drawer, and within the toolbar, only while the drawer
|
||||
/// is open.
|
||||
#[test]
|
||||
fn tools_drag_between_the_drawer_and_the_toolbar() {
|
||||
let mut app = app();
|
||||
let ctx = ctx();
|
||||
app.run("window.editToolbar", json!({"open": true})).unwrap();
|
||||
frame(&mut app, &ctx, 0.0, vec![]);
|
||||
let shown = frame(&mut app, &ctx, 0.1, vec![]);
|
||||
let drawer = drawer_rect(&ctx).unwrap();
|
||||
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
|
||||
// Onto the divider under the Selection tool: its own slot, second.
|
||||
let (artboard, _) = cell_at(&shown, drawer, "Artboard");
|
||||
drag(&mut app, &ctx, 1.0, artboard, tools[0].center_bottom() - vec2(0.0, 3.0));
|
||||
assert_eq!(&slots(&app)[..2], [&["selection"][..], &["artboard"]]);
|
||||
assert!(app.ui.tools_drawer, "the drawer stays open");
|
||||
// Onto the middle of the Ellipse tool: into its group.
|
||||
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
|
||||
let ellipse = slot_of(&app, "ellipse").unwrap();
|
||||
let (slice, _) = cell_at(&shown, drawer, "Slice");
|
||||
drag(&mut app, &ctx, 2.0, slice, tools[ellipse].center());
|
||||
assert_eq!(slots(&app)[ellipse], ["ellipse", "slice"]);
|
||||
// A dimmed tool (in the toolbar already) doesn't drag.
|
||||
let before = slots(&app);
|
||||
let (rectangle, _) = cell_at(&shown, drawer, "Rectangle");
|
||||
drag(&mut app, &ctx, 3.0, rectangle, tools[0].center_bottom() - vec2(0.0, 3.0));
|
||||
assert_eq!(slots(&app), before);
|
||||
// From the toolbar into the drawer: out of the toolbar.
|
||||
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
|
||||
drag(&mut app, &ctx, 4.0, tools[1].center(), drawer.center());
|
||||
assert!(slot_of(&app, "artboard").is_none(), "{:?}", slots(&app));
|
||||
// Within the toolbar: Selection onto the divider over the Lasso.
|
||||
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
|
||||
drag(&mut app, &ctx, 5.0, tools[0].center(), tools[2].center_top() + vec2(0.0, 3.0));
|
||||
assert_eq!(&slots(&app)[..3], [&["directSelection", "groupSelection"][..], &["selection"], &["lasso", "magicWand"]]);
|
||||
// The drawer closed, the toolbar is locked: the same drag moves nothing.
|
||||
app.run("window.editToolbar", json!({"open": false})).unwrap();
|
||||
frame(&mut app, &ctx, 6.0, vec![]);
|
||||
let before = slots(&app);
|
||||
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
|
||||
drag(&mut app, &ctx, 7.0, tools[0].center(), tools[2].center_bottom() - vec2(0.0, 3.0));
|
||||
assert_eq!(slots(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_targets_follow_the_buttons_edges() {
|
||||
let r = |y: f32| Rect::from_min_size(pos2(6.0, y), BUTTON);
|
||||
let one = [(0, r(0.0)), (1, r(30.0))];
|
||||
assert_eq!(drop_target(&one, pos2(20.0, 2.0), 1), (Some(0), Place::Before));
|
||||
assert_eq!(drop_target(&one, pos2(20.0, 14.0), 1), (Some(0), Place::Group));
|
||||
assert_eq!(drop_target(&one, pos2(20.0, 27.0), 1), (Some(0), Place::After));
|
||||
// Past the last button, or beside them: next to the nearest one.
|
||||
assert_eq!(drop_target(&one, pos2(20.0, 200.0), 1), (Some(1), Place::After));
|
||||
assert_eq!(drop_target(&one, pos2(44.5, 40.0), 1), (Some(1), Place::After));
|
||||
// Two columns: the left and right edges.
|
||||
let two = [(0, Rect::from_min_size(pos2(2.0, 0.0), BUTTON)), (1, Rect::from_min_size(pos2(38.0, 0.0), BUTTON))];
|
||||
assert_eq!(drop_target(&two, pos2(40.0, 10.0), 2), (Some(1), Place::Before));
|
||||
assert_eq!(drop_target(&two, pos2(36.0, 10.0), 2), (Some(0), Place::After));
|
||||
assert_eq!(drop_target(&[], pos2(0.0, 0.0), 1), (None, Place::Before));
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,13 @@ impl Row {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep keyboard/pointer focus on the same font after filtering, loading new fonts or
|
||||
/// expanding a family's styles. A row index on its own can now name a different font.
|
||||
fn remap_highlight(before: &[Row], selected: Option<usize>, after: &[Row]) -> Option<usize> {
|
||||
let row = before.get(selected?)?;
|
||||
after.iter().position(|candidate| candidate == row)
|
||||
}
|
||||
|
||||
/// What the rows were listed for: the query, the script, kind and favourites filters, Show Font
|
||||
/// Names in English, the expanded families, the favourites and the font list's generation.
|
||||
type RowsKey = (String, usize, Option<FontClass>, bool, bool, Vec<String>, Vec<String>, u64);
|
||||
@@ -285,6 +292,10 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
|
||||
rows.extend(db.styles(f).into_iter().map(|s| Row::Style(f.clone(), s)));
|
||||
}
|
||||
}
|
||||
// Filter changes (including un-starring a favourite) may remove rows; expanding
|
||||
// another family or a background font scan can insert rows before the highlight.
|
||||
// Never preview an unrelated family because the old numeric index now points to it.
|
||||
st.highlight = remap_highlight(&st.rows, st.highlight, &rows);
|
||||
st.rows = rows.into();
|
||||
st.rows_key = Some(key);
|
||||
}
|
||||
@@ -320,7 +331,9 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
|
||||
}
|
||||
if rows.is_empty() {
|
||||
widgets::dim_label(ui, tl!("No matching fonts"));
|
||||
return None;
|
||||
// A filter or a font rescan can remove the row whose font was previewed.
|
||||
// Do not leave that font applied while the list has no candidate to highlight.
|
||||
return st.previewed.take().map(|_| FontPick::EndPreview);
|
||||
}
|
||||
// The rows: only those in view are laid out (there are thousands of families). The list is
|
||||
// the menu's one scrolling part, under the search field and filters (#555): it fills the
|
||||
@@ -438,6 +451,7 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
|
||||
st.previewed = Some(pick.clone());
|
||||
Some(FontPick::Preview(pick.0, pick.1))
|
||||
}
|
||||
None => st.previewed.take().map(|_| FontPick::EndPreview),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -731,3 +745,35 @@ pub(crate) fn sample_text(app: &VectorcraftApp) -> Option<String> {
|
||||
let line = text.lines().map(str::trim).find(|l| !l.is_empty())?;
|
||||
Some(line.chars().take(12).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Row, remap_highlight};
|
||||
|
||||
fn family(name: &str) -> Row {
|
||||
Row::Family(name.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_same_family_when_other_rows_are_inserted_or_removed() {
|
||||
let before = [family("Alpha"), family("Bravo"), family("Charlie")];
|
||||
assert_eq!(remap_highlight(&before, Some(2), &[family("Bravo"), family("Charlie")]), Some(1));
|
||||
assert_eq!(remap_highlight(&before, Some(2), &[family("Alpha"), family("New"), family("Bravo"), family("Charlie")]), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clears_the_highlight_when_its_family_is_filtered_out() {
|
||||
let before = [family("Alpha"), family("Bravo"), family("Charlie")];
|
||||
assert_eq!(remap_highlight(&before, Some(1), &[family("Alpha"), family("Charlie")]), None);
|
||||
assert_eq!(remap_highlight(&before, Some(10), &before), None);
|
||||
assert_eq!(remap_highlight(&before, None, &before), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_style_rows_do_not_retarget_the_highlight() {
|
||||
let before = [family("Alpha"), family("Bravo")];
|
||||
let after = [family("Alpha"), Row::Style("Alpha".into(), "Bold".into()), family("Bravo")];
|
||||
assert_eq!(remap_highlight(&before, Some(1), &after), Some(2));
|
||||
assert_eq!(remap_highlight(&after, Some(1), &before), None);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user