Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 2afd0b659c Icons: the Selection arrow is black and Direct Selection's white in every theme
Icons are drawn in the theme's colour. The Selection tool's filled arrow
therefore came out light in the dark themes and the Direct Selection
tool's hollow one dark, so they looked swapped to anyone who knows the
black Selection and white Direct Selection arrows (reported by a user).
Both arrows now paint a fill in a fixed colour (black, white;
dc-arrow-fill.svg, original) under their outline in the theme's colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:30:24 +07:00
BFlat 0089e7bb8b Merge pull request #1149 from storytold/fix/1018-edit-toolbar-drawer
#1018: Edit Toolbar opens a drawer beside the toolbar; tools drag in and out
2026-10-11 15:30:18 +07:00
bflatasticandClaude Opus 5.5 539759429c #1018: Edit Toolbar opens a drawer beside the toolbar; tools drag in and out
The ••• button at the bottom of the toolbar no longer opens All Tools as a
centred dialog. It opens the All Tools drawer beside the toolbar, next to the
button. Escape or a click outside closes it; a click in the toolbar doesn't.
Tools that are already in the toolbar show dimmed in the drawer.

While the drawer is open the toolbar can be edited, as the public
documentation describes:
- drag a tool from the drawer onto a toolbar button's top or bottom edge
  (left or right edge in two columns) to put it in its own slot there;
- drag it onto a button's middle to add it to that button's group;
- drag a toolbar button into the drawer to take its tool out;
- drag a toolbar button to another place in the toolbar to move its tool.
A line or outline shows where the tool will go, and the dragged tool follows
the pointer. While the drawer is closed the toolbar can't be edited.

The Basic and Advanced toolbars each keep their own edits in
UiState::toolbar_edits, which is saved with the preferences. It is serde
default, so older preferences still load, and a hand-edited file is cleaned
up when it loads. An edit that brings back the default toolbar clears the
saved edits. New UI commands toolbar.addTool {tool, slot?, place?},
toolbar.removeTool {tool, group?}, toolbar.reset and
window.editToolbar {open?} let MCP and the control channel make the same
edits. The drawer also has a Reset button.

The drawer moved from dialogs/all_tools.rs into the new edit_toolbar.rs. It
is tested with headless egui frame tests driven by pointer events (placement,
Escape, click outside, dimming, drag and drop in each direction, the locked
toolbar), along with command, persistence and drop-zone tests. The three new
command labels are translated in every complete catalog, and the error
messages reuse existing translated templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:30:09 +07:00
BFlat 08ab1676b0 Merge pull request #1146 from storytold/fix/812-star-radius-modifiers
Part of #812: Ctrl/Cmd and Alt shape a star while it's dragged
2026-10-11 14:59:21 +07:00
bflatasticandClaude Opus 5.5 eb510f4194 Part of #812: Ctrl/Cmd and Alt shape a star while it's dragged
A star's inner radius was always half its outer one while drawing, so
its ratio couldn't be changed. As Illustrator documents for the Star
tool: Ctrl/Cmd during the drag keeps the inner radius where it is while
the outer one follows the pointer (the new ratio stays once the key is
let go, for the rest of the drag and the next stars), and Alt keeps the
star's shoulders straight (each point's sides in line with the next
points', a ratio of cos(2π/n)/cos(π/n); five points or more).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:59:05 +07:00
BFlat cb9951918a Merge pull request #1145 from storytold/fix/788-svg-place-viewbox
#788: file.place frames an SVG by its viewBox when crop is given
2026-10-11 14:58:52 +07:00
bflatasticandClaude Opus 5.5 82b22af43c #788: file.place frames an SVG by its viewBox when crop is given
Placing an SVG fitted and centred it by its art's bounds whatever `crop`
said, so art drawn on a shared canvas lost its place and scale, and
`crop: "crop"` and `crop: "bounding"` gave the same result without a
warning. With `crop: "crop"` given, an SVG is now framed and clipped by
its viewBox (its artboard, as File › Open has it), as a PDF is by its
page: `rect` and `at` fit and centre that frame. By default, as
Illustrator places an SVG, and with "bounding", it is still its art's
bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:58:33 +07:00
BFlat 9bcac8f1e6 Merge pull request #1135 from storytold/fix/1089-paste-image-markup
#1089: an image copied in a browser pastes as the picture, also from its markup
2026-10-11 13:51:51 +07:00
30 changed files with 1090 additions and 162 deletions

No files matched your search

+1
View File
@@ -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
View File
@@ -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 |
+1
View File
@@ -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
View File
@@ -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

+7 -2
View File
@@ -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 };
+29
View File
@@ -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}");
}
}
+69 -2
View File
@@ -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");
-137
View File
@@ -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:?}");
}
}
}
-2
View File
@@ -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,
-6
View File
@@ -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));
+812
View File
@@ -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));
}
}
+3
View File
@@ -2835,6 +2835,9 @@
Tool Options… Werkzeugoptionen…
Toolbar: Advanced / Basic Werkzeugleiste: Erweitert / Einfach
Toolbar: Single/Double Column Werkzeugleiste: ein-/zweispaltig
Toolbar: Add Tool Werkzeugleiste: Werkzeug hinzufügen
Toolbar: Remove Tool Werkzeugleiste: Werkzeug entfernen
Toolbar: Reset Werkzeugleiste: Zurücksetzen
Toolbars Werkzeugleisten
Tools Werkzeuge
Top Oben
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2903,6 +2903,9 @@
Tool Options… Opciones de herramienta…
Toolbar: Advanced / Basic Barra de herramientas: avanzada / básica
Toolbar: Single/Double Column Barra de herramientas: columna simple/doble
Toolbar: Add Tool Barra de herramientas: añadir herramienta
Toolbar: Remove Tool Barra de herramientas: quitar herramienta
Toolbar: Reset Barra de herramientas: restablecer
Toolbars Barras de herramientas
Tools Herramientas
Top Arriba
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2900,6 +2900,9 @@
Tool Options… Options de l’outil…
Toolbar: Advanced / Basic Barre d’outils : avancée / de base
Toolbar: Single/Double Column Barre d’outils : une/deux colonnes
Toolbar: Add Tool Barre d’outils : ajouter un outil
Toolbar: Remove Tool Barre d’outils : retirer un outil
Toolbar: Reset Barre d’outils : réinitialiser
Toolbars Barres d’outils
Tools Outils
Top Haut
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2903,6 +2903,9 @@
Tool Options… Opzioni strumento…
Toolbar: Advanced / Basic Barra degli strumenti: avanzata / di base
Toolbar: Single/Double Column Barra degli strumenti: colonna singola/doppia
Toolbar: Add Tool Barra degli strumenti: aggiungi strumento
Toolbar: Remove Tool Barra degli strumenti: rimuovi strumento
Toolbar: Reset Barra degli strumenti: ripristina
Toolbars Barre degli strumenti
Tools Strumenti
Top In alto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2640,6 +2640,9 @@
Tool Options… ツールオプション…
Toolbar: Advanced / Basic ツールバー: 詳細/基本
Toolbar: Single/Double Column ツールバー: 1 列/2 列
Toolbar: Add Tool ツールバー: ツールを追加
Toolbar: Remove Tool ツールバー: ツールを削除
Toolbar: Reset ツールバー: リセット
Top: 上:
Total Width: 合計幅:
Touch Type Tool 文字タッチツール
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2899,6 +2899,9 @@
Tool Options… Параметры инструмента…
Toolbar: Advanced / Basic Панель инструментов: расширенная / основная
Toolbar: Single/Double Column Панель инструментов: один/два столбца
Toolbar: Add Tool Панель инструментов: добавить инструмент
Toolbar: Remove Tool Панель инструментов: убрать инструмент
Toolbar: Reset Панель инструментов: сбросить
Toolbars Панели инструментов
Tools Инструменты
Top Вверху
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2895,6 +2895,9 @@
Tool Options… Параметри інструменту…
Toolbar: Advanced / Basic Панель інструментів: розширена/основна
Toolbar: Single/Double Column Панель інструментів: один/два стовпці
Toolbar: Add Tool Панель інструментів: додати інструмент
Toolbar: Remove Tool Панель інструментів: вилучити інструмент
Toolbar: Reset Панель інструментів: скинути
Toolbars Панелі інструментів
Tools Інструменти
Top Вгорі
Can't render this file because it contains an unexpected character in line 659 and column 40.
+3
View File
@@ -2790,6 +2790,9 @@
Tool Options… 工具选项…
Toolbar: Advanced / Basic 工具栏:高级 / 基本
Toolbar: Single/Double Column 工具列:单列/双列
Toolbar: Add Tool 工具栏:添加工具
Toolbar: Remove Tool 工具栏:移除工具
Toolbar: Reset 工具栏:重置
Toolbars 工具栏
Tools 工具
Top 上
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2881,6 +2881,9 @@
Tool Options… 工具選項…
Toolbar: Advanced / Basic 工具列:進階 / 基本
Toolbar: Single/Double Column 工具列:單欄/雙欄
Toolbar: Add Tool 工具列:新增工具
Toolbar: Remove Tool 工具列:移除工具
Toolbar: Reset 工具列:重設
Toolbars 工具列
Tools 工具
Top 上
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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")),
+41 -1
View File
@@ -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]
+1
View File
@@ -24,6 +24,7 @@ pub mod credits;
pub mod cursors;
pub mod dialogs;
pub mod dock;
mod edit_toolbar;
pub mod find_font;
pub mod floating;
pub mod font_menu;
+26
View File
@@ -256,6 +256,30 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{tool: id, floating?: bool} float the toolbar group holding `tool` (in the current Basic or Advanced layout) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state",
),
(
"window.editToolbar",
"Edit Toolbar",
"",
"{open?: bool} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar, as the toolbar's ••• button does; while it is open, tools drag from it onto the toolbar and from the toolbar into it (Escape or a click outside closes it); returns the new state",
),
(
"toolbar.addTool",
"Toolbar: Add Tool",
"",
"{tool: id, slot?: n, place?: \"before\"|\"after\"|\"group\"} put `tool` in the current (Basic or Advanced) toolbar, moving it if it is there already: before (default) or after slot `slot` (0 = the first; omitted: at the end) in its own slot, or into that slot's group (its flyout), as dropping a tool from the Edit Toolbar drawer does; saved with the preferences; returns the toolbar's slots (lists of tool ids)",
),
(
"toolbar.removeTool",
"Toolbar: Remove Tool",
"",
"{tool: id, group?: bool} take `tool` (with `group`, its whole group) out of the current toolbar (one not in it stays out), as dragging it into the Edit Toolbar drawer does; returns the toolbar's slots",
),
(
"toolbar.reset",
"Toolbar: Reset",
"",
"{} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes; returns its slots",
),
("window.taskBar", "Contextual Task Bar", "", "{}"),
(
"window.taskBar.pin",
@@ -888,6 +912,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
if let Some(r) = crate::shortcut_editor::run_command(app, id, p)
.or_else(|| crate::workspaces::run_command(app, id, p))
.or_else(|| crate::floating::command(app, id, p))
.or_else(|| crate::edit_toolbar::command(app, id, p))
{
return Some(r);
}
@@ -1469,6 +1494,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
"window.toolbar" => app.ui.toolbar,
"window.toolbarAdvanced" => app.ui.toolbar_advanced,
"window.toolbarColumns" => app.ui.toolbar_double,
"window.editToolbar" => app.ui.tools_drawer,
"window.floatTools" => {
let tool = p.get("tool").and_then(Value::as_str).unwrap_or("");
app.ui.floating_flyouts.iter().any(|f| f.tools.iter().any(|id| id == tool))
+4
View File
@@ -227,6 +227,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
return;
}
let typing = ctx.egui_wants_keyboard_input();
// Escape closes Edit Toolbar's drawer first (egui drops a drag from it on Escape already).
if app.ui.tools_drawer && !typing && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) {
app.ui.tools_drawer = false;
}
let view = app.view_info();
// While an IME composes, Enter and Escape are its own (confirm / cancel).
let composing = app.session.tool_composing();
+29
View File
@@ -274,6 +274,12 @@ pub struct UiState {
/// Tool flyouts torn off the toolbar into floating panels.
#[serde(default)]
pub floating_flyouts: Vec<FloatingFlyout>,
/// The Basic and Advanced toolbars as Edit Toolbar left them (none: as they ship).
#[serde(default)]
pub toolbar_edits: ToolbarEdits,
/// Edit Toolbar's All Tools drawer is open beside the toolbar (`window.editToolbar`).
#[serde(skip)]
pub tools_drawer: bool,
/// Panels dragged out of the dock: each group floats as its own stack of tabs.
#[serde(default)]
pub floating_panels: Vec<FloatingPanels>,
@@ -426,6 +432,25 @@ pub struct FloatingFlyout {
pub pos: [f32; 2],
}
/// A category of an edited toolbar: its name (one of the Basic toolbar's; none in the Advanced
/// toolbar) and its slots, each a tool group (the first tool is the slot's default).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ToolbarCategory {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub slots: Vec<Vec<String>>,
}
/// The toolbars Edit Toolbar changed: each one is `None` while it is as it ships.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ToolbarEdits {
#[serde(default)]
pub basic: Option<Vec<ToolbarCategory>>,
#[serde(default)]
pub advanced: Option<Vec<ToolbarCategory>>,
}
/// Where the Contextual Task Bar sits once its handle has moved it (`window.taskBar.pin`,
/// `window.taskBar.reset`).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
@@ -531,6 +556,7 @@ impl UiState {
pub fn sanitized(mut self) -> Self {
self.dialog = None;
self.flyout = None;
self.tools_drawer = false;
self.palette_open = false;
self.status.clear();
self.about = false;
@@ -545,6 +571,7 @@ impl UiState {
f.tools.first().is_some_and(|k| seen.insert(k.clone()))
});
FloatingPanels::sanitize(&mut self.floating_panels, &mut self.toolbar_pos);
crate::edit_toolbar::sanitize(&mut self.toolbar_edits);
self.docking = self.docking.filter(crate::panel_docking::valid);
self.docking_icons.retain(|id| ICON_PANELS.iter().any(|entry| entry.0 == id));
// Overrides that can't fire (modifier-only chords recorded by older versions, #487) give
@@ -570,6 +597,8 @@ impl Default for UiState {
task_bar_place: TaskBarPlace::default(),
slot_tool: Default::default(),
floating_flyouts: vec![],
toolbar_edits: ToolbarEdits::default(),
tools_drawer: false,
floating_panels: vec![],
docking: None,
docking_hidden: Default::default(),
+29 -8
View File
@@ -2,7 +2,8 @@
//! Draw, Modify, Type, Navigate, Color). Window → Toolbars → Advanced shows every tool group; the
//! double arrow at the top (or Window → Toolbars → Double Column, `window.toolbarColumns`) lays the
//! tools out in one or two columns.
//! Bottom: fill/stroke proxy, colour/gradient/none, drawing modes, screen mode, Edit Toolbar.
//! Bottom: fill/stroke proxy, colour/gradient/none, drawing modes, screen mode, Edit Toolbar (its
//! All Tools drawer and the toolbar edits it makes: `edit_toolbar`).
use egui::{Color32, CornerRadius, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
@@ -69,9 +70,12 @@ pub(crate) fn tip(t: &ToolInfo) -> String {
}
}
/// Slots of the current layout: (category label for the first slot of a category, tool ids).
fn slots(app: &VectorcraftApp) -> Vec<(Option<&'static str>, Vec<&'static str>)> {
if app.ui.toolbar_advanced {
/// Slots of the current layout, as Edit Toolbar left it: (category label for the first slot of a
/// category, tool ids).
pub(crate) fn slots(app: &VectorcraftApp) -> Vec<(Option<&'static str>, Vec<&'static str>)> {
if let Some(edited) = crate::edit_toolbar::edited_slots(app) {
edited
} else if app.ui.toolbar_advanced {
TOOL_GROUPS.iter().map(|g| (None, g.iter().map(|t| t.id).collect())).collect()
} else {
BASIC.iter().flat_map(|(cat, ss)| ss.iter().enumerate().map(move |(i, s)| (if i == 0 { Some(*cat) } else { None }, s.to_vec()))).collect()
@@ -122,6 +126,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// layers in one frame).
floating(app, ui.ctx());
flyout(app, ui.ctx());
crate::edit_toolbar::drawer(app, ui.ctx());
}
/// The Tools panel's contents, docked or floating at `floating`.
@@ -129,6 +134,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
let t = Tokens::get(ui.ctx());
let all = slots(app);
let bounds = ui.max_rect();
// The drawer opens beside it; a click in it leaves the drawer open.
ui.data_mut(|d| d.insert_temp(crate::edit_toolbar::toolbar_id(), bounds));
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
// Title bar with » and a grip strip: a drag on either floats the panel or moves it.
let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::click_and_drag());
@@ -164,6 +171,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
}
let held: Option<egui::Id> = ui.data(|d| d.get_temp(held_id));
// While Edit Toolbar's drawer is open, the tools drag (to new places or into the drawer)
// instead of opening their flyouts.
let editing = app.ui.tools_drawer;
let mut buttons: Vec<(usize, egui::Rect)> = vec![];
let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None;
// A group torn off into a floating flyout is raised instead of opening its flyout.
let mut raise: Option<&'static str> = None;
@@ -215,7 +226,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
let c = rect.center() + vec2(12.5, 10.0);
ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)], t.icon, Stroke::NONE));
}
let press = if slot.len() > 1 { flyout_press(ui, &resp, rect) } else { None };
if editing {
buttons.push((k, rect));
resp.dnd_set_drag_payload(crate::edit_toolbar::ToolDrag { tool: shown.id, from_toolbar: true });
}
let press = if slot.len() > 1 && !editing { flyout_press(ui, &resp, rect) } else { None };
let alt = ui.input(|inp| inp.modifiers.alt);
if press.is_some() {
if is_floating(app, slot[0]) {
@@ -242,6 +257,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
});
i += row.len();
}
if editing {
crate::edit_toolbar::toolbar_drop(app, ui, &buttons, cols, bounds);
}
if let Some(key) = raise {
raise_floating(ui.ctx(), key);
}
@@ -475,8 +493,11 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, cols: usiz
if widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() {
app.run("view.screenMode", json!({})).ok();
}
if widgets::icon_button(ui, "ellipsis", "Edit Toolbar", false, 26.0).clicked() {
app.ui.dialog = Some(crate::state::Dialog::new("allTools", json!({})));
// Edit Toolbar: the All Tools drawer opens beside the toolbar, by this button.
let edit = widgets::icon_button(ui, "ellipsis", "Edit Toolbar", app.ui.tools_drawer, 26.0);
ui.data_mut(|d| d.insert_temp(crate::edit_toolbar::button_id(), edit.rect));
if edit.clicked() {
app.run("window.editToolbar", json!({})).ok();
}
});
let _ = t;
@@ -526,7 +547,7 @@ fn raise_floating(ctx: &egui::Context, key: &str) {
}
/// A flyout's frame: drop shadow and border (the accent while it flashes).
fn flyout_frame(t: &Tokens, flash: bool) -> egui::Frame {
pub(crate) fn flyout_frame(t: &Tokens, flash: bool) -> egui::Frame {
egui::Frame::NONE
.fill(t.panel)
.stroke(if flash { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.input_border) })
+7
View File
@@ -246,6 +246,13 @@ that open its flyout raise the strip instead. The strips (`ui.floating_flyouts`
and the strip's top-left corner) are saved with the preferences and in user workspaces; the built-in workspaces
float none.
Edit Toolbar: the ••• button at the bottom of the toolbar (or `window.editToolbar {open?}`) opens the All Tools
drawer beside the toolbar; Escape or a click outside closes it. While it is open, `ui.drag` a tool from it onto the
toolbar to add it (onto a button's top or bottom edge: its own slot before or after; onto its middle: into its
group), from the toolbar into it to remove it, or within the toolbar to move it. `toolbar.addTool {tool, slot?,
place?}`, `toolbar.removeTool {tool, group?}` and `toolbar.reset` do the same for the current Basic or Advanced
toolbar and return its slots; the edits (`ui.toolbar_edits`) are saved with the preferences.
The Contextual Task Bar: its handle (the grip at its left end) drags it anywhere on the canvas, which always holds
it whole, also when the window shrinks. Unpinned, the bar keeps that offset as it follows the selection, until
another document becomes active. Its More Options (…) menu has Hide Bar (`window.taskBar`); Pin Bar Position
+1 -1
View File
@@ -1775,7 +1775,7 @@ passed straight back to `file.new`. Print presets and sizes without `units` star
`file.place` puts another file's art into the active document as one undo step without touching the clipboard:
a raster image at 100% of its physical size (the resolution its file declares, else 72 ppi; linked to its `path`
unless `link: false`; a Photoshop document as its merged image), an SVG as one group, a PDF/.ai page as one clipped group, with the images, symbols, patterns
unless `link: false`; a Photoshop document as its merged image), an SVG as one group (fitted by its art's bounds; with `crop: "crop"` given, framed and clipped by its viewBox, #788), a PDF/.ai page as one clipped group, with the images, symbols, patterns
and swatches it uses. A VectorCraft document read from `path` is a placed document: one locked object showing its
artboard `page` (or, with `crop: "bounding"`, its art's bounds), linked to the file and read again when it changes
(see Linked images), and vectors in every output; with `link: false` (or from `dataBase64`) it is an editable copy
+3 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Direct Selection: whole-path drags by an anchor and Alt-drag copies, #1104; earlier: first checklist) · **Target:** Adobe Illustrator 2026 (30.x)
> **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)
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,
@@ -147,6 +147,7 @@ Layers panel.
| Panels float inside the window, stack, dock back | done | #495 |
| Floating groups stack into sets; sets dock as extra dock columns (a multi-column dock); groups drop anywhere in a set or column, or onto its tabs; a column taller than the window scrolls | done | `window.panel.float {below, above}`, `window.panel.dock {column}` |
| Panels outside the app window (another monitor) | missing | single OS window |
| Edit Toolbar (•••): the All Tools drawer opens beside the toolbar by its button (Escape or a click outside closes it), dims the tools already in the toolbar; while it is open, tools drag from it onto the toolbar (between two tools, or onto one to join its group), from the toolbar into it, and within the toolbar; each toolbar keeps its edits | done | #1018; `window.editToolbar`, `toolbar.addTool`/`removeTool`/`reset`; the drawer's Reset (Illustrator has it in a drawer menu); dragging a toolbar button moves the tool it shows, not its whole group; New/Manage Toolbars missing |
| Properties panel per context (every selection type's Quick Actions) | partial | |
| Contextual task bar | done | |
| Control bar with Transform and Stroke popovers | done | |
@@ -167,5 +168,6 @@ re-measure of the panels added since.
| Date | Change | Summary |
|---|---|---|
| 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 |