Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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 | |
|
||||
|
||||
+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 |
|
||||
|
||||
@@ -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 |
@@ -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…",
|
||||
@@ -749,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()
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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<_>>() }))
|
||||
}
|
||||
@@ -627,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]]);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,7 @@ pub fn lang_from_tag(tag: &str) -> Option<Lang> {
|
||||
|
||||
/// True when `tag` names the generic `C` / `POSIX` locale (or a variant like `C.UTF-8`), which on
|
||||
/// macOS means "no particular language" rather than English: the system language list decides.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_c_locale(tag: &str) -> bool {
|
||||
let base = tag.split(['.', '@']).next().unwrap_or("").to_ascii_lowercase();
|
||||
matches!(base.as_str(), "c" | "posix")
|
||||
|
||||
@@ -66,6 +66,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||
("dc-dir-off", include_bytes!("../../../assets/icons/dc-dir-off.svg")),
|
||||
("dc-dir-on", include_bytes!("../../../assets/icons/dc-dir-on.svg")),
|
||||
("dc-direct", include_bytes!("../../../assets/icons/dc-direct.svg")),
|
||||
("dc-arrow-fill", include_bytes!("../../../assets/icons/dc-arrow-fill.svg")),
|
||||
("dc-dist-bottom", include_bytes!("../../../assets/icons/dc-dist-bottom.svg")),
|
||||
("dc-dist-hcenter", include_bytes!("../../../assets/icons/dc-dist-hcenter.svg")),
|
||||
("dc-dist-hspace", include_bytes!("../../../assets/icons/dc-dist-hspace.svg")),
|
||||
|
||||
@@ -105,8 +105,19 @@ pub fn source(name: &str) -> ImageSource<'static> {
|
||||
table().source(name)
|
||||
}
|
||||
|
||||
/// Paint icon `name` into `rect` tinted with `tint`.
|
||||
/// Paint icon `name` into `rect` tinted with `tint`. The Selection and Direct Selection arrows are
|
||||
/// black and white whatever the theme, as users know them (a light theme colour would make the
|
||||
/// Selection arrow look like Direct Selection's): their fill is painted first, then their outline
|
||||
/// in `tint`.
|
||||
pub fn paint(ui: &Ui, name: &str, rect: Rect, tint: Color32) {
|
||||
let fill = match name {
|
||||
"dc-selection" => Some(Color32::BLACK),
|
||||
"dc-direct" => Some(Color32::WHITE),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(fill) = fill {
|
||||
egui::Image::new(source("dc-arrow-fill")).tint(fill).fit_to_exact_size(rect.size()).paint_at(ui, rect);
|
||||
}
|
||||
egui::Image::new(source(name)).tint(tint).fit_to_exact_size(rect.size()).paint_at(ui, rect);
|
||||
}
|
||||
|
||||
@@ -121,6 +132,35 @@ pub fn icon(ui: &mut Ui, name: &str, size: f32, tint: Color32) -> egui::Response
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The tints of the images `name` paints, bottom first.
|
||||
fn tints(name: &str, tint: Color32) -> Vec<Color32> {
|
||||
let ctx = egui::Context::default();
|
||||
egui_extras::install_image_loaders(&ctx);
|
||||
let draw = |ui: &mut Ui| paint(ui, name, Rect::from_min_size(egui::Pos2::ZERO, Vec2::splat(24.0)), tint);
|
||||
// The first frame loads the images; the second draws them.
|
||||
ctx.run_ui(egui::RawInput::default(), draw).textures_delta.clear();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), draw);
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Mesh(m) => m.vertices.first().map(|v| v.color),
|
||||
egui::Shape::Rect(r) if r.brush.is_some() => Some(r.fill),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The Selection arrow is black and Direct Selection's white in every theme (a reported swap
|
||||
/// in the dark themes), under an outline in the theme's colour; other icons are one layer.
|
||||
#[test]
|
||||
fn the_selection_arrows_are_black_and_white_under_the_theme_outline() {
|
||||
let light = Color32::from_gray(220);
|
||||
assert_eq!(tints("dc-selection", light), [Color32::BLACK, light]);
|
||||
assert_eq!(tints("dc-direct", light), [Color32::WHITE, light]);
|
||||
assert_eq!(tints("dc-group-select", light), [light]);
|
||||
}
|
||||
|
||||
/// Characterize the pre-extraction loader against every embedded asset, including custom
|
||||
/// icons. A shared-crate update must not silently change our stroke weight or cache keys.
|
||||
#[test]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! fits it in the window.
|
||||
|
||||
use egui::{Sense, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
@@ -40,6 +40,46 @@ pub(crate) fn selected(app: &VectorcraftApp, n: usize) -> usize {
|
||||
app.view().map_or(0, |v| v.artboard).min(n.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// The selected artboards (of `n`): while the Artboard tool is in use, those it has selected
|
||||
/// (Shift-click, #1139), else the active one.
|
||||
pub(crate) fn picked(app: &VectorcraftApp, n: usize) -> Vec<usize> {
|
||||
let tool = (app.session.tool_id() == "artboard").then(|| app.session.tool_options()["selected"].clone());
|
||||
let mut all: Vec<usize> = tool
|
||||
.iter()
|
||||
.filter_map(Value::as_array)
|
||||
.flatten()
|
||||
.filter_map(Value::as_u64)
|
||||
.filter_map(|i| usize::try_from(i).ok())
|
||||
.filter(|i| *i < n)
|
||||
.collect();
|
||||
if all.is_empty() {
|
||||
all.push(selected(app, n));
|
||||
}
|
||||
all.sort_unstable();
|
||||
all.dedup();
|
||||
all
|
||||
}
|
||||
|
||||
/// Shift- or Ctrl/Cmd-click on artboard `i`'s row while the Artboard tool is in use: into its
|
||||
/// selection or out of it (the active one stays the navigator's).
|
||||
fn toggle(app: &mut VectorcraftApp, i: usize, n: usize) {
|
||||
let mut all = picked(app, n);
|
||||
match all.iter().position(|j| *j == i) {
|
||||
Some(at) if all.len() > 1 => {
|
||||
all.remove(at);
|
||||
}
|
||||
Some(_) => return,
|
||||
None => all.insert(0, i),
|
||||
}
|
||||
let active = if all.contains(&i) { i } else { all.first().copied().unwrap_or(0) };
|
||||
all.retain(|j| *j != active);
|
||||
all.insert(0, active);
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.artboard = active;
|
||||
}
|
||||
app.session.set_tool_option("selected", &json!(all));
|
||||
}
|
||||
|
||||
/// Make artboard `i` the active one, leaving the view where it is: the navigator's and, while it
|
||||
/// is the tool, the Artboard tool's.
|
||||
pub(crate) fn select(app: &mut VectorcraftApp, i: usize) {
|
||||
@@ -72,6 +112,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
return;
|
||||
}
|
||||
let sel = selected(app, abs.len());
|
||||
let all = picked(app, abs.len());
|
||||
let editing: Option<usize> = pstate(ui.ctx(), "ab-edit");
|
||||
widgets::list_box(ui, |ui| {
|
||||
ui.set_min_height(110.0);
|
||||
@@ -82,7 +123,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if resp.drag_started() {
|
||||
egui::DragAndDrop::set_payload(ui.ctx(), RowDrag(i));
|
||||
}
|
||||
if i == sel {
|
||||
if all.contains(&i) {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
@@ -120,7 +161,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.select_tool("artboard");
|
||||
select(app, i);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let several = ui.input(|inp| inp.modifiers.shift || inp.modifiers.command);
|
||||
if resp.clicked() && several && app.session.tool_id() == "artboard" {
|
||||
toggle(app, i, abs.len());
|
||||
} else if resp.clicked() {
|
||||
select(app, i);
|
||||
}
|
||||
// Double-clicking the number goes to the artboard (fits it in the window); the name
|
||||
@@ -159,8 +203,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
} else if new.clicked() && app.run("artboard.new", json!({})).is_ok() {
|
||||
select(app, n);
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > 1, 24.0).clicked() {
|
||||
app.run("artboard.delete", json!({"index": sel})).ok();
|
||||
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > all.len(), 24.0).clicked()
|
||||
&& app.run("artboard.delete", json!({ "indices": all })).is_ok()
|
||||
{
|
||||
select(app, all.first().copied().unwrap_or(0).saturating_sub(1));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -255,6 +301,49 @@ mod tests {
|
||||
assert_eq!(picked(&mut app, json!({})), json!([a]));
|
||||
assert_eq!(picked(&mut app, json!({"artboard": 1})), json!([b]));
|
||||
}
|
||||
/// With the Artboard tool, Shift-click on a row adds its artboard to the tool's selection or
|
||||
/// takes it out (#1139), and every selected row is highlighted.
|
||||
#[test]
|
||||
fn shift_clicking_rows_selects_several_artboards() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
|
||||
for x in [400, 800] {
|
||||
app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap();
|
||||
}
|
||||
app.select_tool("artboard");
|
||||
select(&mut app, 0);
|
||||
let ctx = egui::Context::default();
|
||||
let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0)));
|
||||
let frame = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
||||
let raw = egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
};
|
||||
let shapes = frame(&mut app, 0.0, vec![]);
|
||||
let third = shapes
|
||||
.iter()
|
||||
.find_map(|c| match &c.shape {
|
||||
egui::Shape::Text(t) if t.galley.text() == "Artboard 3" => Some(t.pos + vec2(4.0, 4.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let click = |at: egui::Pos2| {
|
||||
let b =
|
||||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::SHIFT };
|
||||
vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT), egui::Event::PointerMoved(at), b(true), b(false)]
|
||||
};
|
||||
frame(&mut app, 1.0, click(third));
|
||||
assert_eq!(picked(&app, 3), [0, 2]);
|
||||
let rows = frame(&mut app, 2.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)])
|
||||
.iter()
|
||||
.filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected))
|
||||
.count();
|
||||
assert_eq!(rows, 2, "both rows highlighted");
|
||||
frame(&mut app, 3.0, click(third));
|
||||
assert_eq!(picked(&app, 3), [0]);
|
||||
}
|
||||
|
||||
/// Double-clicking a row's number goes to its artboard and its name renames it; a row's
|
||||
/// Options button edits that artboard with the Artboard tool, and the tool's artboard is the
|
||||
/// active one.
|
||||
|
||||
@@ -224,6 +224,57 @@ fn right_arrow_lists_a_familys_styles_and_they_preview() {
|
||||
assert_eq!(picked, Some(FontPick::Chosen("Source Sans 3".into(), Some(styles[1].clone()))));
|
||||
}
|
||||
|
||||
/// #1116: styles listed above the highlight (a click on another family's triangle) leave it on its
|
||||
/// family; it doesn't land on one of those styles and preview it.
|
||||
#[test]
|
||||
fn styles_listed_above_the_highlight_leave_it_on_its_family() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let frame = |events: Vec<egui::Event>| {
|
||||
let input =
|
||||
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() };
|
||||
let (mut at, mut picked) = (egui::Pos2::ZERO, None);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
at = ui.next_widget_position();
|
||||
picked = font_menu(ui, "test-rebuild", "Source Sans 3", 260.0, None, MenuLook::default());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
// The family's row: the lowest text naming it (the menu's button above names it too).
|
||||
let row = out
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Text(t) if t.galley.text() == "Source Sans 3" => Some(t.pos + egui::vec2(0.0, t.galley.size().y * 0.5)),
|
||||
_ => None,
|
||||
})
|
||||
.max_by(|a, b| a.y.total_cmp(&b.y));
|
||||
(at, picked, row)
|
||||
};
|
||||
let (at, ..) = frame(vec![]);
|
||||
let click = at + egui::vec2(40.0, 10.0);
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(vec![egui::Event::PointerMoved(click), button(click, true)]);
|
||||
frame(vec![button(click, false)]);
|
||||
let mut row = None;
|
||||
for _ in 0..5 {
|
||||
row = frame(vec![]).2;
|
||||
}
|
||||
// The family's disclosure triangle, left of its name: hovered, it highlights the family.
|
||||
let triangle = row.expect("the family is listed") - egui::vec2(7.0, 0.0);
|
||||
frame(vec![egui::Event::PointerMoved(triangle)]);
|
||||
let (_, picked, _) = frame(key(egui::Key::ArrowDown));
|
||||
let Some(FontPick::Preview(next, None)) = picked else { panic!("↓ previews the next family: {picked:?}") };
|
||||
// A click on the triangle, the pointer still: the styles are listed between the two families.
|
||||
frame(vec![button(triangle, true)]);
|
||||
frame(vec![button(triangle, false)]);
|
||||
for _ in 0..3 {
|
||||
let (_, picked, _) = frame(vec![]);
|
||||
assert_eq!(picked, None, "{next} stays highlighted and previewed");
|
||||
}
|
||||
let (_, picked, _) = frame(key(egui::Key::Enter));
|
||||
assert_eq!(picked, Some(FontPick::Chosen(next, None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stars_mark_favourites_and_the_preferences_size_the_rows() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), crate::Services::default());
|
||||
|
||||
+6
-5
@@ -116,11 +116,12 @@ to the target triple, so host build scripts aren't affected.
|
||||
candidates replace each other.
|
||||
- Double-clicking the MSI opens a setup wizard (WixUI_InstallDir without the licence page):
|
||||
Welcome, install folder (remembered for upgrades in `HKLM\Software\VectorCraft\InstallDir`),
|
||||
Ready, a progress page, and a Finish page that says where to find the app, with a ticked "Launch
|
||||
VectorCraft" box. The pages name the full version (`vectorcraft.en-us.wxl`). `package.ps1` draws
|
||||
the wizard's banner and side bitmaps from the app icon, so no WiX stock art ships. `msiexec /qn`
|
||||
still installs silently. Building needs the WiX UI and Util extensions:
|
||||
`wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`.
|
||||
Shortcuts (a ticked "Create a desktop shortcut" box), Ready, a progress page, and a Finish page
|
||||
that says where to find the app, with a ticked "Launch VectorCraft" box. The pages name the full
|
||||
version (`vectorcraft.en-us.wxl`). `package.ps1` draws the wizard's banner and side bitmaps from
|
||||
the app icon, so no WiX stock art ships. `msiexec /qn` still installs silently, with the desktop
|
||||
shortcut unless `INSTALLDESKTOPSHORTCUT=""` is passed. Building needs the WiX UI and Util
|
||||
extensions: `wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`.
|
||||
- The ARM64 build is cross-compiled on the x64 runner. `.github/workflows/windows-arm64.yml`
|
||||
installs that MSI on a Windows 11 ARM64 runner, checks that both installed programs are ARM64,
|
||||
runs `vectorcraft-cli --version` natively and uninstalls.
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Edit Toolbar: the All Tools drawer beside the toolbar, tools dragged in and out, #1018; earlier: Direct Selection whole-path drags and Alt-drag copies, #1104) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (several artboards selected at once, #1139; earlier: the Edit Toolbar drawer, #1018) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
|
||||
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
|
||||
@@ -126,6 +126,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Space: Hand; Cmd+Space / Cmd+Alt+Space: zoom in/out; middle button pans | done | |
|
||||
| Cmd/Ctrl: last-used selection tool; Cmd+Alt: Group Selection inside Direct Selection | partial | |
|
||||
| Alt-drag duplicates (objects, artboards with their art) | done | |
|
||||
| Shift-click selects several artboards (Artboard tool, Artboards panel); they move, Alt-copy and delete together | done | `artboard.move`/`artboard.delete` `indices` (#1139) |
|
||||
| Shape tools: Shift constrains, Alt from centre, Space moves while dragging, arrows change sides/points/rows | done | |
|
||||
| Tool double-click opens its options | done | |
|
||||
| Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | |
|
||||
@@ -168,6 +169,7 @@ re-measure of the panels added since.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Artboards: Shift-click selects several, which move, copy and delete together (#1139); score unchanged (estimated) |
|
||||
| 2026-10-11 | minor | Edit Toolbar opens the All Tools drawer beside the toolbar instead of a centred dialog, dims the tools the toolbar holds, and edits the toolbar by drag and drop while it is open (#1018); score unchanged (estimated) |
|
||||
| 2026-10-11 | minor | Direct Selection: dragging a path selected whole by an anchor moves it whole; Alt-drag copies by an anchor too, toggles during the drag and snaps onto the original (#1104); score unchanged (estimated) |
|
||||
| 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation |
|
||||
@@ -7,5 +7,9 @@
|
||||
<String Id="WelcomeDlgTitle" Value="{\WixUI_Font_Bigger}Welcome to the [ProductName] [VectorcraftVersion] Setup Wizard" />
|
||||
<String Id="WelcomeDlgDescription" Value="The Setup Wizard will install [ProductName] version [VectorcraftVersion] on your computer. Click Next to continue or Cancel to exit the Setup Wizard." />
|
||||
<String Id="VerifyReadyDlgInstallTitle" Value="{\WixUI_Font_Title}Ready to install [ProductName] [VectorcraftVersion]" />
|
||||
<String Id="ShortcutsDlgTitle" Value="{\WixUI_Font_Title}Shortcuts" />
|
||||
<String Id="ShortcutsDlgDescription" Value="Choose where you can start [ProductName] from." />
|
||||
<String Id="ShortcutsDlgDesktop" Value="Create a &desktop shortcut" />
|
||||
<String Id="ShortcutsDlgStartMenu" Value="[ProductName] is always added to the Start menu." />
|
||||
<String Id="ExitDialogTitle" Value="{\WixUI_Font_Bigger}Completed the [ProductName] [VectorcraftVersion] Setup Wizard" />
|
||||
</WixLocalization>
|
||||
@@ -10,7 +10,9 @@
|
||||
Per-machine install with a setup wizard that names the version: Welcome, install folder (Program Files\VectorCraft by
|
||||
default, remembered for upgrades), Ready, a progress page, and a Finish page with a ticked
|
||||
"Launch VectorCraft" box. Installs a Start Menu shortcut and an App Paths entry (so `vectorcraft`
|
||||
starts from the Run dialog), plus vectorcraft-cli. `msiexec /qn` still installs silently.
|
||||
starts from the Run dialog), plus vectorcraft-cli. A Shortcuts page after the install folder offers
|
||||
a desktop shortcut (ticked). `msiexec /qn` still installs silently, with the desktop shortcut
|
||||
unless INSTALLDESKTOPSHORTCUT="" is passed.
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
||||
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||
@@ -39,6 +41,7 @@
|
||||
<Directory Id="INSTALLFOLDER" Name="VectorCraft" />
|
||||
</StandardDirectory>
|
||||
<StandardDirectory Id="ProgramMenuFolder" />
|
||||
<StandardDirectory Id="DesktopFolder" />
|
||||
|
||||
<!-- An upgrade installs where the user put the previous version (written by VectorcraftApp). -->
|
||||
<Property Id="INSTALLFOLDER">
|
||||
@@ -47,14 +50,43 @@
|
||||
|
||||
<Feature Id="Main" Title="VectorCraft" Level="1">
|
||||
<ComponentGroupRef Id="VectorcraftFiles" />
|
||||
<ComponentRef Id="DesktopShortcut" />
|
||||
</Feature>
|
||||
|
||||
<!-- A desktop shortcut: the Shortcuts page's box (ticked), or INSTALLDESKTOPSHORTCUT="" on the
|
||||
msiexec command line for none. Secure, so the wizard's choice reaches the install. -->
|
||||
<Property Id="INSTALLDESKTOPSHORTCUT" Value="1" Secure="yes" />
|
||||
|
||||
<!-- The setup wizard: WixUI_InstallDir without its licence page (the code is MIT OR
|
||||
Apache-2.0; there is nothing to accept). A later NewDialog on the same button wins. -->
|
||||
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
|
||||
<UI>
|
||||
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg" Order="2" Condition="NOT Installed" />
|
||||
<Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2" />
|
||||
<!-- The Shortcuts page sits between the install folder and Ready (once the folder is valid). -->
|
||||
<Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="ShortcutsDlg" Order="5"
|
||||
Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"" />
|
||||
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="ShortcutsDlg" Order="5" Condition="NOT Installed" />
|
||||
|
||||
<Dialog Id="ShortcutsDlg" Width="370" Height="270" Title="!(loc.InstallDirDlg_Title)">
|
||||
<Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="!(loc.WixUINext)">
|
||||
<Publish Event="NewDialog" Value="VerifyReadyDlg" />
|
||||
</Control>
|
||||
<Control Id="Back" Type="PushButton" X="180" Y="243" Width="56" Height="17" Text="!(loc.WixUIBack)">
|
||||
<Publish Event="NewDialog" Value="InstallDirDlg" />
|
||||
</Control>
|
||||
<Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="!(loc.WixUICancel)">
|
||||
<Publish Event="SpawnDialog" Value="CancelDlg" />
|
||||
</Control>
|
||||
<Control Id="BannerBitmap" Type="Bitmap" X="0" Y="0" Width="370" Height="44" TabSkip="no" Text="!(loc.InstallDirDlgBannerBitmap)" />
|
||||
<Control Id="BannerLine" Type="Line" X="0" Y="44" Width="370" Height="0" />
|
||||
<Control Id="BottomLine" Type="Line" X="0" Y="234" Width="370" Height="0" />
|
||||
<Control Id="Title" Type="Text" X="15" Y="6" Width="200" Height="15" Transparent="yes" NoPrefix="yes" Text="!(loc.ShortcutsDlgTitle)" />
|
||||
<Control Id="Description" Type="Text" X="25" Y="23" Width="280" Height="15" Transparent="yes" NoPrefix="yes" Text="!(loc.ShortcutsDlgDescription)" />
|
||||
<Control Id="DesktopShortcutCheckBox" Type="CheckBox" X="20" Y="60" Width="330" Height="18"
|
||||
Property="INSTALLDESKTOPSHORTCUT" CheckBoxValue="1" Text="!(loc.ShortcutsDlgDesktop)" />
|
||||
<Control Id="StartMenuNote" Type="Text" X="20" Y="84" Width="330" Height="30" NoPrefix="yes" Text="!(loc.ShortcutsDlgStartMenu)" />
|
||||
</Dialog>
|
||||
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchVectorcraft"
|
||||
Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed" />
|
||||
</UI>
|
||||
@@ -92,5 +124,14 @@
|
||||
<File Id="VectorcraftCliExe" Source="$(var.BinDir)\vectorcraft-cli.exe" KeyPath="yes" />
|
||||
</Component>
|
||||
</ComponentGroup>
|
||||
|
||||
<!-- Installed only with INSTALLDESKTOPSHORTCUT set (the Shortcuts page's box); removed with the
|
||||
app. A per-machine install puts it on the common desktop. `wix msi validate` reports ICE38,
|
||||
ICE43 and ICE57 for it: they take a desktop shortcut for per-user data, which it isn't here. -->
|
||||
<Component Id="DesktopShortcut" Directory="DesktopFolder" Guid="498F2D4C-E655-47F6-B406-2D4C449B8D26" Condition="INSTALLDESKTOPSHORTCUT">
|
||||
<Shortcut Id="DesktopShortcut" Name="VectorCraft" Description="VectorCraft vector illustration app"
|
||||
Target="[#VectorcraftExe]" WorkingDirectory="INSTALLFOLDER" Icon="VectorcraftIcon.ico" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\VectorCraft" Name="DesktopShortcut" Value="1" Type="integer" KeyPath="yes" />
|
||||
</Component>
|
||||
</Fragment>
|
||||
</Wix>
|
||||
Reference in new issue
Block a user