diff --git a/ROADMAP.md b/ROADMAP.md
index 1ae56880..d5f17e77 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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 Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). The toolbar's Fill/Stroke proxy and Color, Gradient and None buttons grow in two columns, where the three drawing modes sit side by side (#1129). An image copied in a browser that puts its `
` markup on the clipboard as text pastes as the picture, not the markup (#1089). `file.place` of an SVG with `crop: "crop"` frames and clips it by its viewBox, so art drawn on a shared canvas keeps its place and scale (#788). While a star is dragged, Ctrl/Cmd keeps its inner radius as the outer one grows and Alt keeps its shoulders straight (#812). |
+| 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 Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). The toolbar's Fill/Stroke proxy and Color, Gradient and None buttons grow in two columns, where the three drawing modes sit side by side (#1129). An image copied in a browser that puts its `
` markup on the clipboard as text pastes as the picture, not the markup (#1089). `file.place` of an SVG with `crop: "crop"` frames and clips it by its viewBox, so art drawn on a shared canvas keeps its place and scale (#788). While a star is dragged, Ctrl/Cmd keeps its inner radius as the outer one grows and Alt keeps its shoulders straight (#812). Edit Toolbar opens the All Tools drawer beside the toolbar, dims the tools already in it, and adds, removes and moves tools by drag and drop while it is open (#1018). |
| 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 |
diff --git a/crates/ui-egui/src/dialogs/all_tools.rs b/crates/ui-egui/src/dialogs/all_tools.rs
deleted file mode 100644
index 30584b5a..00000000
--- a/crates/ui-egui/src/dialogs/all_tools.rs
+++ /dev/null
@@ -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:?}");
- }
- }
-}
diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs
index 09b9c290..a02abf87 100644
--- a/crates/ui-egui/src/dialogs/mod.rs
+++ b/crates/ui-egui/src/dialogs/mod.rs
@@ -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,
diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs
index f0df5c69..925692ad 100644
--- a/crates/ui-egui/src/dialogs/tests.rs
+++ b/crates/ui-egui/src/dialogs/tests.rs
@@ -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));
diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs
new file mode 100644
index 00000000..121641ba
--- /dev/null
+++ b/crates/ui-egui/src/edit_toolbar.rs
@@ -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:
+//! (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 {
+ 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 {
+ let strings = |s: &[&str]| s.iter().map(|id| id.to_string()).collect::>();
+ 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> {
+ 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<&'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) {
+ 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) -> Result<(), String>) -> Result {
+ 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::>())
+}
+
+/// 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, 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> {
+ 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, 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::(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)>> = 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::(button_id()), d.get_temp::(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::(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::(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> {
+ crate::toolbar::slots(app).into_iter().map(|(_, s)| s).collect()
+ }
+
+ /// The slot holding `tool`.
+ fn slot_of(app: &VectorcraftApp, tool: &str) -> Option {
+ 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) -> 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 {
+ 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::(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::(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));
+ }
+}
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 76e8c87e..c3c1aad0 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -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
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 860895aa..7558aa59 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -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
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index f2d4bf1d..d0261b11 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -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
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index 762e22b6..480b48e8 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -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
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index d5902eb7..ed142e06 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -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 文字タッチツール
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index e336a260..dfbbf5ec 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -2899,6 +2899,9 @@
Tool Options… Параметры инструмента…
Toolbar: Advanced / Basic Панель инструментов: расширенная / основная
Toolbar: Single/Double Column Панель инструментов: один/два столбца
+ Toolbar: Add Tool Панель инструментов: добавить инструмент
+ Toolbar: Remove Tool Панель инструментов: убрать инструмент
+ Toolbar: Reset Панель инструментов: сбросить
Toolbars Панели инструментов
Tools Инструменты
Top Вверху
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index 47e7a373..be5fa557 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -2895,6 +2895,9 @@
Tool Options… Параметри інструменту…
Toolbar: Advanced / Basic Панель інструментів: розширена/основна
Toolbar: Single/Double Column Панель інструментів: один/два стовпці
+ Toolbar: Add Tool Панель інструментів: додати інструмент
+ Toolbar: Remove Tool Панель інструментів: вилучити інструмент
+ Toolbar: Reset Панель інструментів: скинути
Toolbars Панелі інструментів
Tools Інструменти
Top Вгорі
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 62e14437..d61b2623 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -2790,6 +2790,9 @@
Tool Options… 工具选项…
Toolbar: Advanced / Basic 工具栏:高级 / 基本
Toolbar: Single/Double Column 工具列:单列/双列
+ Toolbar: Add Tool 工具栏:添加工具
+ Toolbar: Remove Tool 工具栏:移除工具
+ Toolbar: Reset 工具栏:重置
Toolbars 工具栏
Tools 工具
Top 上
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index b68f0f28..802614ec 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -2881,6 +2881,9 @@
Tool Options… 工具選項…
Toolbar: Advanced / Basic 工具列:進階 / 基本
Toolbar: Single/Double Column 工具列:單欄/雙欄
+ Toolbar: Add Tool 工具列:新增工具
+ Toolbar: Remove Tool 工具列:移除工具
+ Toolbar: Reset 工具列:重設
Toolbars 工具列
Tools 工具
Top 上
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 8c93463a..06b74537 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -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;
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index c5f8d59f..86c15522 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -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 Option {
"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))
diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs
index 320cc11e..a6765659 100644
--- a/crates/ui-egui/src/shortcuts.rs
+++ b/crates/ui-egui/src/shortcuts.rs
@@ -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();
diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs
index cd0fb4cf..aa72dd85 100644
--- a/crates/ui-egui/src/state.rs
+++ b/crates/ui-egui/src/state.rs
@@ -274,6 +274,12 @@ pub struct UiState {
/// Tool flyouts torn off the toolbar into floating panels.
#[serde(default)]
pub floating_flyouts: Vec,
+ /// 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,
@@ -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,
+ #[serde(default)]
+ pub slots: Vec>,
+}
+
+/// 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>,
+ #[serde(default)]
+ pub advanced: Option>,
+}
+
/// 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(),
diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs
index bea966b0..7c73ca98 100644
--- a/crates/ui-egui/src/toolbar.rs
+++ b/crates/ui-egui/src/toolbar.rs
@@ -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(held_id));
}
let held: Option = 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 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 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) })
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index 599cf223..3c564457 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -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
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 3321d984..12535604 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -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 |