Browse / github.com/storytold/vectorcraft / patch
Toolbars: New Toolbar makes a named toolbar filled from the All Tools drawer
Toolbars: New Toolbar makes a named toolbar filled from the All Tools drawer Part of #1759. ## What and why The reference app's help page on the toolbar (https://web.archive.org/web/20261006071910/https://helpx.adobe.com/illustrator/using/tools.html, "Create and manage toolbars") makes a toolbar from New Toolbar in the drawer's flyout menu or from Window › Toolbars › New Toolbar: "After you provide a name and select OK, a blank toolbar is created. Select the Edit Toolbar button to open the drawer and add tools to the toolbar." It also says "A toolbar is persistent in the workspace it is created in." For the main toolbar, the same page says "You can move the toolbar b
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #4370, open)
- Base
8622d0c71c18a166d666314058be7f0e3e9b290f- Size
- +1578 −106 in 43 files
- Review
- pending
- Shared
- 2026-10-28
- Id
693fe263864d63949803dd6e
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/app.rs crates/ui-egui/src/custom_toolbars.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/edit_toolbar.rs crates/ui-egui/src/edit_toolbar/tests.rs crates/ui-egui/src/i18n/bg.tsv crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/da.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/el.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fi.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/id.tsv crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/ka.tsv crates/ui-egui/src/i18n/ko.tsv crates/ui-egui/src/i18n/nl.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-br.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/sv.tsv crates/ui-egui/src/i18n/tr.tsv crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/i18n/vi.tsv crates/ui-egui/src/i18n/zh-hans.tsv crates/ui-egui/src/i18n/zh-hant.tsv crates/ui-egui/src/lib.rs crates/ui-egui/src/menus/labels.rs crates/ui-egui/src/menus/run.rs crates/ui-egui/src/menus/state.rs crates/ui-egui/src/menus/tree.rs crates/ui-egui/src/menus/ui_commands.rs crates/ui-egui/src/shortcut_editor.rs crates/ui-egui/src/state.rs crates/ui-egui/src/toolbar.rs crates/ui-egui/src/workspaces.rs docs/control-protocol.md docs/gaps.md docs/target-app-parity.md docs/ui-parity.md
@@ -471,6 +471,10 @@ impl VectorcraftApp { } if self.ui.toolbar && self.ui.screen_mode < 3 { toolbar::show(self, ui);+ } else if self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() {+ // Window › Tools hides the Tools panel alone; Tab hides the toolbars made with New+ // Toolbar too.+ toolbar::show_custom(self, ui.ctx()); } panels::library_panel::tidy(self); // The canvas and the panels: one docking layout. Tab hides the panels but not the canvas.new file mode 100644@@ -0,0 +1,945 @@+//! Window › Toolbars › New Toolbar (#1759): toolbars the user makes and names. New Toolbar asks+//! for a name and makes a blank toolbar that floats on its own; the ••• button at its foot opens the+//! All Tools drawer beside it, and tools dragged from the drawer fill it (`edit_toolbar`, with the+//! `toolbar` param of its commands). Its title bar moves it, its × hides it and its double arrow+//! shows its tools in one column or two. Each toolbar is listed under Window › Toolbars, where its+//! item shows or hides it, and is kept with the workspace it was made in (`Workspace::toolbars`).+//!+//! Behavior from the public documentation:+//! <https://helpx.adobe.com/illustrator/using/tools.html> (Create and manage toolbars: New Toolbar+//! from Window › Toolbars or the drawer's flyout menu, a name and OK make a blank toolbar, its Edit+//! Toolbar button opens the drawer to add tools, and a toolbar stays in the workspace it is made in;+//! a toolbar moves by its title bar, whose double arrow toggles one column or two and whose close+//! button hides it).++use std::sync::atomic::Ordering;++use egui::{CornerRadius, Sense, Stroke, pos2, vec2};+use serde_json::{Value, json};+use vectorcraft_tools::tool_info;++use crate::state::{CustomToolbar, Dialog, UiState};+use crate::theme::Tokens;+use crate::{VectorcraftApp, icons, widgets};++/// The most toolbars a workspace keeps.+pub(crate) const MAX: usize = 64;+/// Width of a toolbar of one column of tool buttons, inside its border.+const WIDTH: f32 = 44.0;+/// Width a second column of tool buttons adds.+const BUTTON: f32 = 36.0;+/// Height of the title bar at a toolbar's top, which moves it.+const GRIP: f32 = 14.0;+/// Width of the × and the double arrow at the ends of the title bar.+const TITLE_BUTTON: f32 = 12.0;+/// Where the first toolbar made floats, right of the Tools panel; the next ones go lower.+const NEW_AT: [f32; 2] = [96.0, 120.0];++/// A toolbar's name without leading and trailing spaces, in lowercase. Names with the same key are+/// the same name: lookups, the check that a name is free, sanitizing and each toolbar's area+/// compare keys.+pub(crate) fn key(name: &str) -> String {+ name.trim().to_lowercase()+}++/// The area a toolbar is drawn in.+pub(crate) fn area_id(name: &str) -> egui::Id {+ egui::Id::unique(("custom-toolbar", key(name)))+}++/// The toolbar named `name`, whatever its case.+pub(crate) fn find<'a>(ui: &'a UiState, name: &str) -> Option<&'a CustomToolbar> {+ let k = key(name);+ ui.custom_toolbars.iter().find(|b| key(&b.name) == k)+}++pub(crate) fn find_mut<'a>(ui: &'a mut UiState, name: &str) -> Option<&'a mut CustomToolbar> {+ let k = key(name);+ ui.custom_toolbars.iter_mut().find(|b| key(&b.name) == k)+}++/// The `toolbar` param: the name of a toolbar made with New Toolbar, as it was given (none or+/// null: the Tools panel's toolbar).+pub(crate) fn target(ui: &UiState, p: &Value) -> Result<Option<String>, String> {+ match p.get("toolbar") {+ None | Some(Value::Null) => Ok(None),+ Some(v) => {+ let name = v.as_str().ok_or("toolbar (a toolbar's name) must be a string")?;+ find(ui, name).map(|b| Some(b.name.clone())).ok_or_else(|| format!("no toolbar named `{name}`"))+ }+ }+}++/// The toolbar the open All Tools drawer edits: one made with New Toolbar, or none for the Tools+/// panel's toolbar (and while the drawer is closed).+pub(crate) fn drawer_target(ui: &UiState) -> Option<&str> {+ ui.drawer_toolbar.as_deref().filter(|_| ui.tools_drawer)+}++/// Make a blank toolbar named `name`, shown; returns its name.+pub(crate) fn create(ui: &mut UiState, name: &str) -> Result<String, String> {+ let name = name.trim();+ if name.is_empty() {+ return Err("toolbar name can't be empty".into());+ }+ if find(ui, name).is_some() {+ return Err(format!("there is already a toolbar named `{name}`"));+ }+ if ui.custom_toolbars.len() >= MAX {+ return Err(format!("a workspace keeps at most {MAX} toolbars"));+ }+ let n = ui.custom_toolbars.len() as f32;+ let pos = [NEW_AT[0] + 16.0 * n, NEW_AT[1] + 24.0 * n];+ ui.custom_toolbars.push(CustomToolbar {+ name: name.to_string(),+ slots: vec![],+ shown: true,+ pos,+ slot_tool: Default::default(),+ double: false,+ floating: vec![],+ });+ Ok(name.to_string())+}++/// Keep named toolbars, each name once whatever its case, of known tools, each once in its+/// toolbar, at a position that is a number, with one strip per group torn off them (a hand-edited+/// preferences file or workspace).+pub(crate) fn sanitize(bars: &mut Vec<CustomToolbar>) {+ let mut names = std::collections::BTreeSet::new();+ bars.retain_mut(|b| {+ b.name = b.name.trim().to_string();+ let mut seen = std::collections::BTreeSet::new();+ for s in &mut b.slots {+ s.retain(|id| tool_info(id).is_some() && seen.insert(id.clone()));+ }+ b.slots.retain(|s| !s.is_empty());+ crate::state::FloatingFlyout::sanitize(&mut b.floating);+ if !b.pos.iter().all(|v| v.is_finite()) {+ b.pos = NEW_AT;+ }+ !b.name.is_empty() && names.insert(key(&b.name))+ });+ bars.truncate(MAX);+}++/// The commands of the toolbars made with New Toolbar; `None` for other ids.+pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {+ Some(match id {+ "window.toolbar.new" => match p.get("name").filter(|v| !v.is_null()) {+ Some(name) => match name.as_str() {+ Some(name) => create(&mut app.ui, name).map(|name| json!({ "toolbar": name })),+ None => Err("name (a toolbar's name) must be a string".into()),+ },+ None => {+ let name = (1..).map(|i| format!("Toolbar {i}")).find(|n| find(&app.ui, n).is_none()).unwrap_or_default();+ app.ui.dialog = Some(Dialog::new(KIND, json!({ "name": name })));+ Ok(Value::Null)+ }+ },+ "window.toolbar.custom" => (|| {+ let show = crate::menus::opt_bool(p, "show")?;+ let name = p.get("name").and_then(Value::as_str).ok_or("name (a toolbar's name) is required")?;+ let bar = find_mut(&mut app.ui, name).ok_or_else(|| format!("no toolbar named `{name}`"))?;+ bar.shown = show.unwrap_or(!bar.shown);+ let (shown, name) = (bar.shown, bar.name.clone());+ // The drawer beside a toolbar that is hidden closes with it.+ if !shown && drawer_target(&app.ui) == Some(name.as_str()) {+ app.ui.tools_drawer = false;+ app.ui.drawer_toolbar = None;+ }+ Ok(json!(shown))+ })(),+ "window.toolbar.list" => Ok(json!(+ app.ui+ .custom_toolbars+ .iter()+ .map(|b| json!({"name": b.name, "shown": b.shown, "double": b.double, "slots": b.slots}))+ .collect::<Vec<_>>()+ )),+ _ => return None,+ })+}++/// New Toolbar's dialog.+pub(crate) const KIND: &str = "newToolbar";++/// OK in New Toolbar.+pub(crate) fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {+ let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };+ let name = create(&mut app.ui, &d.str("name"))?;+ app.ui.dialog = None;+ Ok(json!({ "toolbar": name }))+}++/// The New Toolbar dialog: the name, OK and Cancel.+pub(crate) fn show_dialog(app: &mut VectorcraftApp, ctx: &egui::Context) {+ let Some(mut d) = app.ui.dialog.clone() else { return };+ let t = Tokens::get(ctx);+ let (mut ok, mut cancel) = (false, false);+ crate::dialogs::modal::show(ctx, "New Toolbar", egui::Id::unique("dialog-new-toolbar"), -40.0, 22, |ui| {+ ui.set_width(320.0);+ crate::dialogs::modal::heading(ui, tl!("New Toolbar"));+ ui.add_space(12.0);+ ui.horizontal(|ui| {+ ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim));+ let mut name = d.str("name");+ if ui.add(egui::TextEdit::singleline(&mut name).desired_width(220.0)).changed() {+ d.fields.insert("name".into(), json!(name));+ }+ });+ ui.add_space(16.0);+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+ if widgets::primary_button(ui, tl!("OK")).clicked() {+ ok = true;+ }+ ui.add_space(8.0);+ if widgets::secondary_button(ui, tl!("Cancel")).clicked() {+ cancel = true;+ }+ });+ });+ if ctx.input(|i| i.key_pressed(egui::Key::Enter)) {+ ok = true;+ }+ app.ui.dialog = Some(d);+ if cancel {+ app.ui.dialog = None;+ } else if ok && let Err(e) = confirm(app) {+ app.status(e);+ }+}++// ---------- the menu ----------++crate::shortcut_editor::ui_mirror!(fn names_store() -> Vec<&'static str>);++/// Mirror the toolbars' names for the (static) menu tree.+pub fn sync(ui: &UiState) {+ let same = names_store()+ .read()+ .map(|v| v.len() == ui.custom_toolbars.len() && v.iter().zip(&ui.custom_toolbars).all(|(a, b)| *a == b.name))+ .unwrap_or(true);+ if !same {+ if let Ok(mut v) = names_store().write() {+ *v = ui.custom_toolbars.iter().map(|b| crate::shortcut_editor::intern(&b.name)).collect();+ }+ crate::shortcut_editor::GENERATION.fetch_add(1, Ordering::Relaxed);+ }+}++/// Window › Toolbars: Advanced and Double Column, the toolbars made with New Toolbar (each checked+/// while it shows), and New Toolbar….+pub fn menu_items() -> Vec<crate::menus::Item> {+ use crate::menus::Item;+ let c = |label: &'static str, id: &'static str| Item::Cmd(label, id, Value::Null);+ let mut v = vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns"), Item::Sep];+ let names = names_store().read().map(|v| v.clone()).unwrap_or_default();+ if !names.is_empty() {+ v.extend(names.into_iter().map(|n| Item::Cmd(n, "window.toolbar.custom", json!({ "name": n }))));+ v.push(Item::Sep);+ }+ v.push(c("New Toolbar…", "window.toolbar.new"));+ v+}++// ---------- the toolbars ----------++/// What a press on a toolbar did, applied once every toolbar is drawn.+enum Action {+ Select(&'static str),+ Options(&'static str),+ /// A group's flyout: its tools, its button, and the toolbar's name.+ Flyout(Vec<&'static str>, egui::Rect, String),+ EditToolbar(String),+ /// The title bar's ×.+ Hide(String),+ /// The title bar's double arrow: two columns (true) or one.+ Columns(String, bool),+}++/// The toolbars made with New Toolbar that show, each floating where its title bar was dragged: the+/// title bar's × hides it and its double arrow lays the tools out in one column or two; then its+/// tool buttons (a group's flyout opens as the Tools panel's do) and the ••• button that opens the+/// All Tools drawer beside it.+pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {+ if app.ui.custom_toolbars.iter().all(|b| !b.shown) {+ return;+ }+ let t = Tokens::get(ctx);+ let screen = ctx.content_rect();+ let active = app.session.tool_id();+ let editing = drawer_target(&app.ui).map(str::to_string);+ let held_id = egui::Id::unique("custom-toolbar-held");+ if ctx.input(|i| i.pointer.any_pressed()) {+ ctx.data_mut(|d| d.remove::<egui::Id>(held_id));+ }+ let held: Option<egui::Id> = ctx.data(|d| d.get_temp(held_id));+ let mut actions = vec![];+ // Where the drawer opens (the toolbar's bounds and its ••• button): beside the toolbar it+ // edits, or beside the one whose ••• button was just clicked.+ let (mut anchor, mut clicked_anchor) = (None, None);+ for k in 0..app.ui.custom_toolbars.len() {+ let Some(bar) = app.ui.custom_toolbars.get(k).filter(|b| b.shown).cloned() else { continue };+ let id = area_id(&bar.name);+ let cols = if bar.double { 2 } else { 1 };+ let width = WIDTH + BUTTON * (cols - 1) as f32;+ // Kept on screen by its title bar and first tools: the rest scrolls.+ let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(width, 60.0), |r| vec2(r.width(), r.height().min(160.0)));+ let pos = crate::tools_float::clamp(bar.pos, size, screen);+ let edit_this = editing.as_deref() == Some(bar.name.as_str());+ let mut moved = None;+ let mut drops = None;+ egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| {+ crate::toolbar::flyout_frame(&t, false).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).show(ui, |ui| {+ ui.set_width(width);+ // The tools and the ••• button scroll when they don't fit in the window under the+ // title bar.+ ui.set_max_height(screen.bottom() - pos.y - 12.0);+ ui.spacing_mut().item_spacing = vec2(0.0, 0.0);+ let middle = ui.max_rect().center().x;+ // The title bar: × at its left end, the double arrow at its right end, and the grip+ // between them, whose drag moves the toolbar.+ let (title, _) = ui.allocate_exact_size(vec2(ui.available_width(), GRIP), Sense::hover());+ ui.painter().rect_filled(title, 0.0, t.tab_strip);+ let close = egui::Rect::from_min_size(title.min, vec2(TITLE_BUTTON, GRIP));+ let arrows = egui::Rect::from_min_size(pos2(title.right() - TITLE_BUTTON, title.top()), vec2(TITLE_BUTTON, GRIP));+ let grip = egui::Rect::from_min_max(pos2(close.right(), title.top()), pos2(arrows.left(), title.bottom()));+ let cresp = ui.interact(close, id.with("close"), Sense::click());+ icons::paint(ui, "x", close.shrink2(vec2(1.5, 2.5)), if cresp.hovered() { t.text_strong } else { t.text });+ cresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Close")));+ if cresp.on_hover_text(tl!("Close")).clicked() {+ actions.push(Action::Hide(bar.name.clone()));+ }+ let aresp = ui.interact(arrows, id.with("columns"), Sense::click());+ let chevrons = if bar.double { "chevrons-left" } else { "chevrons-right" };+ icons::paint(ui, chevrons, arrows.shrink2(vec2(1.0, 2.0)), if aresp.hovered() { t.text_strong } else { t.text });+ aresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Toggle single/double column")));+ if aresp.on_hover_text(tl!("Toggle single/double column")).clicked() {+ actions.push(Action::Columns(bar.name.clone(), !bar.double));+ }+ let gresp = ui.interact(grip, id.with("grip"), Sense::drag());+ for n in 0..3 {+ let y = grip.center().y - 2.0 + n as f32 * 2.0;+ let half = (grip.width() / 2.0 - 2.0).clamp(0.0, 8.0);+ ui.painter().line_segment([pos2(grip.center().x - half, y), pos2(grip.center().x + half, y)], Stroke::new(0.5, t.text_disabled));+ }+ gresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &bar.name));+ // Held where the press took it, from the toolbar's corner, while it is dragged.+ let grab = id.with("grab");+ if gresp.dragged() {+ ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);+ let (origin, at) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos()));+ if let Some(at) = at {+ let off = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(grab, || origin.unwrap_or(at) - pos));+ moved = Some(at - off);+ }+ } else {+ ui.data_mut(|d| d.remove::<egui::Vec2>(grab));+ gresp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(&bar.name);+ }+ // A gap keeps a press near the grip bar's lower edge on the bar.+ ui.add_space(4.0);+ let mut buttons: Vec<(usize, egui::Rect)> = vec![];+ let edit = widgets::strip_scroll(ui, &format!("custom-toolbar-{}", key(&bar.name)), |ui| {+ let slots: Vec<(usize, &Vec<String>)> = bar.slots.iter().enumerate().collect();+ let press = Press { bar: &bar, active, edit_this, held, held_id };+ for row in slots.chunks(cols) {+ ui.horizontal(|ui| {+ ui.add_space((middle - BUTTON / 2.0 * row.len() as f32 - ui.cursor().min.x).max(0.0));+ for &(s, slot) in row {+ if let Some(rect) = slot_button(ui, &t, &press, slot, &mut actions)+ && edit_this+ {+ buttons.push((s, rect));+ }+ }+ });+ }+ ui.add_space(4.0);+ // Edit Toolbar: the All Tools drawer opens beside this toolbar, by this button.+ ui.vertical_centered(|ui| widgets::icon_button(ui, "ellipsis", "Edit Toolbar", edit_this, 26.0)).inner+ });+ if edit.clicked() {+ actions.push(Action::EditToolbar(bar.name.clone()));+ }+ // The toolbar as drawn (inside its border): where tools drop, and where a click+ // leaves the drawer open; also on the click that opens the drawer here.+ let bounds = ui.min_rect();+ if edit.clicked() {+ clicked_anchor = Some((bounds, edit.rect));+ } else if edit_this {+ anchor = Some((bounds, edit.rect));+ }+ if edit_this {+ drops = Some((buttons, bounds));+ }+ });+ if let Some((buttons, bounds)) = drops.take() {+ crate::edit_toolbar::toolbar_drop(app, ui, &buttons, cols, bounds, Some(&bar.name));+ }+ });+ if let Some(p) = moved.filter(|p| p.x.is_finite() && p.y.is_finite())+ && let Some(b) = app.ui.custom_toolbars.get_mut(k)+ {+ b.pos = [p.x, p.y];+ }+ }+ if let Some((bounds, button)) = clicked_anchor.or(anchor) {+ ctx.data_mut(|d| {+ d.insert_temp(crate::edit_toolbar::toolbar_id(), bounds);+ d.insert_temp(crate::edit_toolbar::button_id(), button);+ });+ }+ for a in actions {+ match a {+ Action::Select(id) => app.select_tool(id),+ Action::Options(id) => {+ app.select_tool(id);+ app.run("tool.options", json!({ "tool": id })).ok();+ }+ // A group that floats already (one strip per group) is raised instead, as the Tools+ // panel's buttons do.+ Action::Flyout(tools, _, name) if tools.first().is_some_and(|key| crate::toolbar::is_floating(app, Some(&name), key)) => {+ if let Some(key) = tools.first() {+ crate::toolbar::raise_floating(ctx, Some(&name), key);+ }+ }+ Action::Flyout(tools, rect, name) => {+ app.ui.flyout = Some(0);+ ctx.data_mut(|d| {+ d.insert_temp(egui::Id::unique("flyout-anchor"), rect);+ d.insert_temp(egui::Id::unique("flyout-tools"), tools.iter().map(|s| s.to_string()).collect::<Vec<String>>());+ d.insert_temp(crate::toolbar::flyout_toolbar_id(), Some(name));+ });+ }+ Action::EditToolbar(name) => {+ if let Err(e) = app.run("window.editToolbar", json!({ "toolbar": name })) {+ app.status(e);+ }+ }+ Action::Hide(name) => {+ if let Err(e) = app.run("window.toolbar.custom", json!({ "name": name, "show": false })) {+ app.status(e);+ }+ }+ Action::Columns(name, double) => {+ if let Err(e) = app.run("window.toolbarColumns", json!({ "toolbar": name, "double": double })) {+ app.status(e);+ }+ }+ }+ }+}++/// What a toolbar's tool buttons need from the frame: the toolbar, the active tool, whether the+/// drawer edits the toolbar, and the button whose long press opened its flyout.+struct Press<'a> {+ bar: &'a CustomToolbar,+ active: &'static str,+ edit_this: bool,+ held: Option<egui::Id>,+ held_id: egui::Id,+}++/// One slot's tool button, as the Tools panel's: it shows the tool chosen last from its group, a+/// press selects it, and a long or right press opens the group's flyout; while the drawer edits the+/// toolbar, it drags instead. Returns where it is drawn.+fn slot_button(ui: &mut egui::Ui, t: &Tokens, p: &Press, slot: &[String], actions: &mut Vec<Action>) -> Option<egui::Rect> {+ let tools: Vec<&'static str> = slot.iter().filter_map(|id| tool_info(id).map(|t| t.id)).collect();+ let &first = tools.first()?;+ let is_active = tools.contains(&p.active);+ let shown_id = if is_active { p.active } else { p.bar.slot_tool.get(first).and_then(|id| tool_info(id)).map_or(first, |t| t.id) };+ let shown = tool_info(shown_id).filter(|t| tools.contains(&t.id)).or_else(|| tool_info(first))?;+ let (rect, resp) = ui.allocate_exact_size(vec2(BUTTON, crate::toolbar::PITCH - 1.0), Sense::click_and_drag());+ let well = egui::Rect::from_center_size(rect.center(), vec2(30.0, 27.5));+ if is_active {+ ui.painter().rect_filled(well, CornerRadius::same(6), t.tool_active);+ } else if resp.hovered() {+ ui.painter().rect_filled(well, CornerRadius::same(6), t.hover);+ }+ let ir = egui::Rect::from_center_size(rect.center(), vec2(18.0, 18.0));+ icons::paint(ui, icons::tool_icon(shown.icon), ir, if is_active { t.text_strong } else { t.icon });+ if tools.len() > 1 {+ 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));+ }+ if p.edit_this {+ resp.dnd_set_drag_payload(crate::edit_toolbar::ToolDrag { tool: shown.id, from_toolbar: true });+ }+ let press = if tools.len() > 1 && !p.edit_this { crate::toolbar::flyout_press(ui, &resp, rect) } else { None };+ let alt = ui.input(|i| i.modifiers.alt);+ if press.is_some() {+ actions.push(Action::Flyout(tools.clone(), rect, p.bar.name.clone()));+ if press == Some(crate::toolbar::FlyoutPress::Primary) {+ ui.data_mut(|d| d.insert_temp(p.held_id, resp.id));+ }+ } else if resp.clicked() && p.held == Some(resp.id) {+ // Releasing the long press that opened the flyout leaves it open.+ } else if resp.clicked() && alt && tools.len() > 1 {+ let i = tools.iter().position(|x| *x == shown.id).unwrap_or(0);+ actions.push(Action::Select(tools[(i + 1) % tools.len()]));+ } else if resp.double_clicked() {+ actions.push(Action::Options(shown.id));+ } else if resp.clicked() {+ actions.push(Action::Select(shown.id));+ }+ let tip = crate::toolbar::tip(shown);+ resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), is_active, &tip));+ resp.on_hover_text(tip);+ Some(rect)+}++/// Remember `id` as the tool shown by each slot of a toolbar made with New Toolbar that holds it,+/// however it was chosen (`toolbar::remember` calls it).+pub(crate) fn remember(ui: &mut UiState, id: &str) {+ for bar in &mut ui.custom_toolbars {+ let firsts: Vec<String> = bar.slots.iter().filter(|s| s.len() > 1 && s.iter().any(|t| t == id)).filter_map(|s| s.first().cloned()).collect();+ for first in firsts {+ bar.slot_tool.insert(first, id.to_string());+ }+ }+}++#[cfg(test)]+mod tests {+ use vectorcraft_engine::Session;++ use super::*;+ use crate::menus::Item;++ fn app() -> VectorcraftApp {+ VectorcraftApp::new(Session::new(), Default::default())+ }++ /// The items of Window › Toolbars: (label, command id, params).+ fn toolbars_menu(app: &VectorcraftApp) -> Vec<(&'static str, &'static str, Value)> {+ crate::shortcut_editor::sync(&app.ui);+ let window = crate::menus::menu_tree().into_iter().find(|(m, _)| *m == "Window").map(|(_, items)| items).unwrap();+ let Some(Item::Sub(_, items)) = window.into_iter().find(|i| matches!(i, Item::Sub("Toolbars", _))) else { panic!("no Toolbars submenu") };+ items.into_iter().filter_map(|i| if let Item::Cmd(l, id, p) = i { Some((l, id, p)) } else { None }).collect()+ }++ /// New Toolbar named "Mine" is listed under Window › Toolbars, checked while it shows; its item+ /// hides and shows it. Without a name, a dialog asks for one.+ #[test]+ fn a_new_toolbar_is_listed_under_window_toolbars() {+ let mut app = app();+ assert_eq!(toolbars_menu(&app).last().map(|(l, id, _)| (*l, *id)), Some(("New Toolbar…", "window.toolbar.new")));+ assert_eq!(app.run("window.toolbar.new", json!({"name": " Mine "})).unwrap(), json!({"toolbar": "Mine"}));+ let menu = toolbars_menu(&app);+ let item = menu.iter().find(|(l, ..)| *l == "Mine").expect("Mine is listed");+ assert_eq!((item.1, &item.2), ("window.toolbar.custom", &json!({"name": "Mine"})));+ assert_eq!(crate::menus::checked(&app, item.1, &item.2), Some(true));+ assert_eq!(crate::menus::display_label(&app, item.1, item.0), "Mine", "listed by its own name");+ // A blank toolbar, floating, shown.+ let bar = find(&app.ui, "mine").unwrap();+ assert!(bar.slots.is_empty() && bar.shown);+ // Its item hides it and shows it again.+ assert_eq!(app.run("window.toolbar.custom", json!({"name": "Mine"})).unwrap(), json!(false));+ assert_eq!(crate::menus::checked(&app, item.1, &item.2), Some(false));+ assert_eq!(app.run("window.toolbar.custom", json!({"name": "MINE", "show": true})).unwrap(), json!(true));+ // Names: not empty, not taken whatever the case.+ assert!(app.run("window.toolbar.new", json!({"name": " "})).unwrap_err().contains("empty"));+ assert!(app.run("window.toolbar.new", json!({"name": "mine"})).unwrap_err().contains("already"));+ // Whatever the case outside ASCII too: one name, one toolbar.+ app.run("window.toolbar.new", json!({"name": "É"})).unwrap();+ assert!(app.run("window.toolbar.new", json!({"name": "é"})).unwrap_err().contains("already"));+ assert!(app.run("window.toolbar.custom", json!({"name": "é", "show": true})).is_ok());+ app.ui.custom_toolbars.retain(|b| b.name != "É");+ assert!(app.run("window.toolbar.custom", json!({"name": "Nope"})).unwrap_err().contains("Nope"));+ // A name is looked up without its leading and trailing spaces, as it was made.+ assert_eq!(app.run("window.toolbar.custom", json!({"name": " Mine ", "show": true})).unwrap(), json!(true));+ assert!(app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine "})).is_ok());+ assert!(app.run("toolbar.removeTool", json!({"tool": "pen", "toolbar": "Mine"})).is_ok());+ // A name that is not a string is reported as one.+ assert!(app.run("window.toolbar.new", json!({"name": 5})).unwrap_err().contains("must be a string"));+ assert!(app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": 5})).unwrap_err().contains("must be a string"));+ // Without a name: the dialog, its OK makes the toolbar.+ assert_eq!(app.run("window.toolbar.new", json!({})).unwrap(), Value::Null);+ let d = app.ui.dialog.clone().unwrap();+ assert_eq!((d.kind.as_str(), d.str("name").as_str()), (KIND, "Toolbar 1"));+ crate::dialogs::confirm(&mut app).unwrap();+ assert!(app.ui.dialog.is_none());+ let names: Vec<&str> = toolbars_menu(&app).into_iter().filter(|(_, id, _)| *id == "window.toolbar.custom").map(|(l, ..)| l).collect();+ assert_eq!(names, ["Mine", "Toolbar 1"]);+ let list = app.run("window.toolbar.list", json!({})).unwrap();+ assert_eq!(+ list,+ json!([{"name": "Mine", "shown": true, "double": false, "slots": []}, {"name": "Toolbar 1", "shown": true, "double": false, "slots": []}])+ );+ }++ /// The New Toolbar dialog: a name typed in it and Enter make the toolbar; a name that is taken is+ /// reported in the status line and the dialog stays open; OK makes it once the name is free, and+ /// Cancel closes the dialog without a toolbar.+ #[test]+ fn the_new_toolbar_dialog_takes_the_name_typed() {+ let mut app = app();+ let ctx = egui::Context::default();+ crate::theme::install_fonts(&ctx);+ let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {+ let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0));+ let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| show_dialog(app, ui.ctx()));+ out.textures_delta.clear();+ out.shapes+ .iter()+ .filter_map(+ |c| if let egui::Shape::Text(t) = &c.shape { Some((t.galley.text().to_string(), t.visual_bounding_rect())) } else { None },+ )+ .collect::<Vec<_>>()+ };+ let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };+ let button =+ |at, pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };+ let at =+ |texts: &[(String, egui::Rect)], s: &str| texts.iter().find(|(t, _)| t == s).unwrap_or_else(|| panic!("no {s} in the dialog")).1.center();+ // Typed into the Name field, then Enter. The dialog is sized in its first frame and drawn+ // from the second.+ app.run("window.toolbar.new", json!({})).unwrap();+ frame(&mut app, vec![]);+ let texts = frame(&mut app, vec![]);+ let field = at(&texts, "Toolbar 1");+ frame(&mut app, vec![egui::Event::PointerMoved(field), button(field, true)]);+ frame(&mut app, vec![button(field, false)]);+ frame(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("Mine".into())]);+ assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("name")).as_deref(), Some("Mine"));+ frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);+ assert!(app.ui.dialog.is_none(), "Enter makes the toolbar");+ assert!(find(&app.ui, "Mine").is_some());+ // A name that is taken: the status line says so, and the dialog stays open.+ app.run("window.toolbar.new", json!({})).unwrap();+ app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("MINE"));+ frame(&mut app, vec![]);+ let texts = frame(&mut app, vec![]);+ let ok = at(&texts, "OK");+ frame(&mut app, vec![egui::Event::PointerMoved(ok), button(ok, true)]);+ frame(&mut app, vec![button(ok, false)]);+ assert!(app.ui.status.contains("already"), "{}", app.ui.status);+ assert!(app.ui.dialog.is_some(), "the dialog stays open");+ // OK with a free name makes it.+ app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Other"));+ frame(&mut app, vec![egui::Event::PointerMoved(ok), button(ok, true)]);+ frame(&mut app, vec![button(ok, false)]);+ assert!(app.ui.dialog.is_none() && find(&app.ui, "Other").is_some());+ // Cancel makes none.+ app.run("window.toolbar.new", json!({})).unwrap();+ frame(&mut app, vec![]);+ let texts = frame(&mut app, vec![]);+ let cancel = at(&texts, "Cancel");+ frame(&mut app, vec![egui::Event::PointerMoved(cancel), button(cancel, true)]);+ frame(&mut app, vec![button(cancel, false)]);+ assert!(app.ui.dialog.is_none());+ assert_eq!(app.ui.custom_toolbars.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Mine", "Other"]);+ }++ /// Switching workspaces while the drawer edits a toolbar of the workspace left closes the+ /// drawer, so it names no toolbar that the new workspace lacks.+ #[test]+ fn switching_workspaces_closes_the_drawer_on_a_toolbar_left() {+ let mut app = app();+ app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap();+ app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();+ app.run("window.editToolbar", json!({"toolbar": "Mine", "open": true})).unwrap();+ assert_eq!(drawer_target(&app.ui), Some("Mine"));+ app.run("window.workspace", json!({"name": "Essentials"})).unwrap();+ assert!(!app.ui.tools_drawer && app.ui.drawer_toolbar.is_none());+ assert!(app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).is_err(), "Mine is not in Essentials");+ }++ /// Tools added to a toolbar made with New Toolbar leave the Tools panel's toolbar as it was, and+ /// the toolbar comes back with them after switching workspaces and after a restart.+ #[test]+ fn tools_added_to_a_new_toolbar_survive_a_workspace_reload() {+ let mut app = app();+ let shipped = crate::toolbar::slots(&app);+ app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap();+ app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();+ app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();+ let r = app.run("toolbar.addTool", json!({"tool": "curvature", "toolbar": "mine", "slot": 0, "place": "group"})).unwrap();+ assert_eq!(r, json!([["pen", "curvature"]]));+ let r = app.run("toolbar.addTool", json!({"tool": "artboard", "toolbar": "Mine", "slot": 0})).unwrap();+ assert_eq!(r, json!([["artboard"], ["pen", "curvature"]]));+ assert_eq!(crate::toolbar::slots(&app), shipped, "the Tools panel's toolbar is untouched");+ assert!(app.ui.toolbar_edits.basic.is_none());+ assert!(app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Nope"})).is_err());+ let r = app.run("toolbar.removeTool", json!({"tool": "artboard", "toolbar": "Mine"})).unwrap();+ assert_eq!(r, json!([["pen", "curvature"]]));+ let mine = find(&app.ui, "Mine").cloned().unwrap();+ // Another workspace has its own toolbars (none); coming back brings Mine back.+ app.run("window.workspace", json!({"name": "Essentials"})).unwrap();+ assert!(app.ui.custom_toolbars.is_empty());+ assert!(toolbars_menu(&app).iter().all(|(l, ..)| *l != "Mine"));+ app.run("window.workspace", json!({"name": "Drawing"})).unwrap();+ assert_eq!(find(&app.ui, "Mine"), Some(&mine));+ // Saved under a workspace and in the preferences, it loads again.+ app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap();+ let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();+ let mut ui = prefs.sanitized();+ assert_eq!(find(&ui, "Mine"), Some(&mine));+ crate::workspaces::switch(&mut ui, "Essentials").unwrap();+ crate::workspaces::switch(&mut ui, "Drawing").unwrap();+ assert_eq!(find(&ui, "Mine"), Some(&mine));+ // Reset makes it blank again.+ assert_eq!(app.run("toolbar.reset", json!({"toolbar": "Mine"})).unwrap(), json!([]));+ // Workspaces saved before New Toolbar load without toolbars.+ let mut old = serde_json::to_value(crate::workspaces::capture(&app.ui, "Old")).unwrap();+ old.as_object_mut().unwrap().remove("toolbars");+ assert!(serde_json::from_value::<crate::workspaces::Workspace>(old).unwrap().toolbars.is_empty());+ }++ /// One frame of the whole window, `height` points tall.+ fn window_frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, height: f32) {+ let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, height));+ let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), ..Default::default() };+ ctx.run_ui(raw, |ui| {+ app.logic(ui.ctx());+ app.ui(ui);+ })+ .textures_delta+ .clear();+ }++ fn drawn(ctx: &egui::Context, id: egui::Id, order: egui::Order) -> bool {+ ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(order, id)))+ }++ /// Window › Tools hides the Tools panel alone: a toolbar made with New Toolbar still shows, and+ /// the drawer opens beside it. Tab hides it with the other panels, and a second Tab shows it.+ #[test]+ fn a_new_toolbar_shows_without_the_tools_panel_until_tab() {+ let mut app = app();+ app.run("file.new", json!({"width": 300, "height": 300})).unwrap();+ let ctx = egui::Context::default();+ app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();+ app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();+ let mut time = 0.0;+ let mut frames = |app: &mut VectorcraftApp| {+ for _ in 0..3 {+ time += 0.05;+ window_frame(app, &ctx, time, 800.0);+ }+ };+ frames(&mut app);+ assert!(drawn(&ctx, area_id("Mine"), egui::Order::Middle));+ app.run("window.toolbar", json!({})).unwrap();+ assert!(!app.ui.toolbar);+ app.run("window.editToolbar", json!({"toolbar": "Mine"})).unwrap();+ frames(&mut app);+ assert!(drawn(&ctx, area_id("Mine"), egui::Order::Middle), "shown without the Tools panel");+ assert!(drawn(&ctx, crate::edit_toolbar::drawer_id(), egui::Order::Foreground), "the drawer beside it");+ app.run("window.editToolbar", json!({"open": false})).unwrap();+ // A group torn off this toolbar shows too; one torn off the Tools panel stays hidden with it.+ let strip = |tools: &[&str]| crate::state::FloatingFlyout { tools: tools.iter().map(|s| s.to_string()).collect(), pos: [400.0, 300.0] };+ find_mut(&mut app.ui, "Mine").unwrap().floating = vec![strip(&["pen", "curvature"])];+ app.ui.floating_flyouts = vec![strip(&["rectangle", "star"])];+ frames(&mut app);+ assert!(drawn(&ctx, egui::Id::unique(("tool-floating", "pen")).with(key("Mine")), egui::Order::Middle));+ assert!(!drawn(&ctx, egui::Id::unique(("tool-floating", "rectangle")), egui::Order::Middle));+ // Hidden with the toolbar it was torn off, and shown again with it.+ app.run("window.toolbar.custom", json!({"name": "Mine", "show": false})).unwrap();+ frames(&mut app);+ assert!(!drawn(&ctx, egui::Id::unique(("tool-floating", "pen")).with(key("Mine")), egui::Order::Middle));+ app.run("window.toolbar.custom", json!({"name": "Mine", "show": true})).unwrap();+ frames(&mut app);+ assert!(drawn(&ctx, egui::Id::unique(("tool-floating", "pen")).with(key("Mine")), egui::Order::Middle));+ app.run("window.dock", json!({})).unwrap();+ frames(&mut app);+ assert!(!drawn(&ctx, area_id("Mine"), egui::Order::Middle), "Tab hides it");+ app.run("window.dock", json!({})).unwrap();+ frames(&mut app);+ assert!(drawn(&ctx, area_id("Mine"), egui::Order::Middle), "a second Tab shows it");+ }++ /// A group's button shows the tool chosen last from it however it was chosen (its flyout, a+ /// shortcut), and the Tools panel's buttons keep their own.+ #[test]+ fn a_group_shows_the_tool_chosen_last() {+ let mut app = app();+ app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();+ app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();+ app.run("toolbar.addTool", json!({"tool": "curvature", "toolbar": "Mine", "slot": 0, "place": "group"})).unwrap();+ app.select_tool("curvature");+ app.select_tool("selection");+ assert_eq!(find(&app.ui, "Mine").unwrap().slot_tool.get("pen").map(String::as_str), Some("curvature"));+ assert_ne!(app.ui.slot_tool.get("pen").map(String::as_str), Some("curvature"), "the Tools panel's Pen group holds no Curvature tool");+ // Kept with the toolbar.+ let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();+ assert_eq!(find(&prefs.sanitized(), "Mine").unwrap().slot_tool.get("pen").map(String::as_str), Some("curvature"));+ }++ /// A group of a toolbar made with New Toolbar floats on its own, apart from a Tools panel group+ /// with the same first tool: both strips show, and each toolbar finds only its own.+ #[test]+ fn groups_float_apart_from_the_tools_panels() {+ let mut app = app();+ app.run("file.new", json!({"width": 300, "height": 300})).unwrap();+ let ctx = egui::Context::default();+ app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();+ app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();+ app.run("toolbar.addTool", json!({"tool": "curvature", "toolbar": "Mine", "slot": 0, "place": "group"})).unwrap();+ app.run("window.floatTools", json!({"tool": "pen", "floating": true})).unwrap();+ assert!(crate::toolbar::is_floating(&app, None, "pen"));+ assert!(!crate::toolbar::is_floating(&app, Some("Mine"), "pen"), "the Tools panel's Pen group floats, not Mine's");+ assert_eq!(app.run("window.floatTools", json!({"tool": "curvature", "toolbar": "mine"})).unwrap(), json!(true));+ assert!(crate::toolbar::is_floating(&app, Some("Mine"), "pen"));+ assert_eq!(crate::menus::checked(&app, "window.floatTools", &json!({"tool": "curvature", "toolbar": "Mine"})), Some(true));+ assert!(app.run("window.floatTools", json!({"tool": "selection", "toolbar": "Mine"})).is_err(), "not in a group of Mine");+ assert!(app.run("window.floatTools", json!({"tool": "pen", "toolbar": "Nope"})).is_err());+ // Both kept by the preferences, Mine's with Mine: a build without New Toolbar reads the+ // Tools panel's strips alone. Both are drawn.+ let saved = serde_json::to_value(&app.ui).unwrap();+ let tools_panel = &app.ui.floating_flyouts[0].tools;+ assert_eq!(saved["floating_flyouts"], json!([{"tools": tools_panel, "pos": [90.0, 100.0]}]));+ let prefs = serde_json::from_value::<UiState>(saved).unwrap().sanitized();+ assert_eq!(prefs.floating_flyouts.len(), 1);+ assert_eq!(find(&prefs, "Mine").unwrap().floating.iter().map(|f| f.tools.clone()).collect::<Vec<_>>(), [["pen", "curvature"]]);+ for k in 0..3 {+ window_frame(&mut app, &ctx, f64::from(k) * 0.05, 800.0);+ }+ let main = egui::Id::unique(("tool-floating", "pen"));+ assert!(drawn(&ctx, main, egui::Order::Middle) && drawn(&ctx, main.with(key("Mine")), egui::Order::Middle));+ // Putting the Tools panel's group back leaves Mine's floating.+ app.run("window.floatTools", json!({"tool": "pen", "floating": false})).unwrap();+ assert!(app.ui.floating_flyouts.is_empty() && find(&app.ui, "Mine").unwrap().floating.len() == 1);+ assert_eq!(crate::menus::checked(&app, "window.floatTools", &json!({"tool": "pen"})), Some(false));+ // And Mine's goes back by its command too.+ assert_eq!(app.run("window.floatTools", json!({"tool": "pen", "toolbar": "Mine"})).unwrap(), json!(false));+ assert!(find(&app.ui, "Mine").unwrap().floating.is_empty());+ }++ /// A toolbar with more tools than the window's height holds stays in the window: its tools+ /// scroll.+ #[test]+ fn a_tall_toolbar_scrolls_inside_the_window() {+ let mut app = app();+ let ctx = egui::Context::default();+ app.run("window.toolbar.new", json!({"name": "Tall"})).unwrap();+ for tool in vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).take(30) {+ app.run("toolbar.addTool", json!({"tool": tool.id, "toolbar": "Tall"})).unwrap();+ }+ assert_eq!(find(&app.ui, "Tall").unwrap().slots.len(), 30);+ for k in 0..4 {+ window_frame(&mut app, &ctx, f64::from(k) * 0.05, 500.0);+ }+ let r = ctx.memory(|m| m.area_rect(area_id("Tall"))).unwrap();+ assert!(r.bottom() <= 500.0 && r.top() >= 0.0, "{r:?}");+ }++ /// Preferences › Devices › Enable Touch Workspace switches to Touch and back as Window ›+ /// Workspace does: the toolbars made in the workspace left come back with it.+ #[test]+ fn the_touch_workspace_brings_the_toolbars_back() {+ let mut app = app();+ app.run("window.workspace", json!({"name": "Typography"})).unwrap();+ app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();+ app.session.prefs.touch_workspace = true;+ crate::workspaces::sync_touch_workspace(&mut app, true);+ assert_eq!(app.ui.workspace, crate::workspaces::TOUCH);+ assert!(app.ui.custom_toolbars.is_empty());+ app.session.prefs.touch_workspace = false;+ crate::workspaces::sync_touch_workspace(&mut app, true);+ assert_eq!(app.ui.workspace, "Typography");+ assert!(find(&app.ui, "Mine").is_some(), "back with Typography");+ }++ /// Reset Workspace keeps the toolbars made in the workspace, in a built-in one too, and New+ /// Workspace leaves the workspace it starts from with its toolbars: the new one starts with a+ /// copy, and switching back brings the first workspace's own back.+ #[test]+ fn toolbars_stay_in_their_workspace_through_reset_and_new_workspace() {+ let mut app = app();+ app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();+ app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();+ app.run("window.toolbar", json!({})).unwrap();+ app.run("window.workspace.reset", json!({})).unwrap();+ assert!(app.ui.toolbar, "the layout is reset");+ assert_eq!(find(&app.ui, "Mine").map(|b| b.slots.clone()), Some(vec![vec!["pen".to_string()]]), "Mine stays in Essentials");+ let mine = find(&app.ui, "Mine").cloned().unwrap();+ // The layout changes go into the new workspace; Essentials keeps only its toolbars.+ app.run("window.toolbar", json!({})).unwrap();+ app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap();+ assert_eq!(find(&app.ui, "Mine"), Some(&mine), "Drawing starts with a copy");+ app.run("window.toolbar.new", json!({"name": "Only in Drawing"})).unwrap();+ app.run("window.workspace", json!({"name": "Essentials"})).unwrap();+ assert!(app.ui.toolbar, "Essentials as saved");+ assert_eq!(app.ui.custom_toolbars, std::slice::from_ref(&mine), "with its own toolbars");+ app.run("window.workspace", json!({"name": "Drawing"})).unwrap();+ assert_eq!(app.ui.custom_toolbars.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Mine", "Only in Drawing"]);+ }++ /// The title bar's × hides the toolbar, and its double arrow lays its tools out in two columns+ /// and back in one (`window.toolbarColumns` with `toolbar`).+ #[test]+ fn the_title_bar_hides_the_toolbar_and_toggles_two_columns() {+ let mut app = app();+ app.run("file.new", json!({"width": 300, "height": 300})).unwrap();+ let ctx = egui::Context::default();+ app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();+ for tool in ["pen", "rectangle", "selection"] {+ app.run("toolbar.addTool", json!({"tool": tool, "toolbar": "Mine"})).unwrap();+ }+ let mut time = 0.0;+ let mut frames = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {+ for e in std::iter::once(events).chain(std::iter::repeat_with(Vec::new)).take(3) {+ time += 0.05;+ let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0));+ let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), events: e, ..Default::default() };+ ctx.run_ui(raw, |ui| {+ app.logic(ui.ctx());+ app.ui(ui);+ })+ .textures_delta+ .clear();+ }+ };+ let click = |p: egui::Pos2| {+ let button =+ |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };+ vec![egui::Event::PointerMoved(p), button(true), button(false)]+ };+ frames(&mut app, vec![]);+ let r = ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap();+ let one = r.width();+ // The double arrow at the right end of the title bar.+ frames(&mut app, click(pos2(r.right() - 1.0 - TITLE_BUTTON / 2.0, r.top() + 1.0 + GRIP / 2.0)));+ assert!(find(&app.ui, "Mine").unwrap().double);+ assert_eq!(crate::menus::checked(&app, "window.toolbarColumns", &json!({"toolbar": "Mine"})), Some(true));+ assert!(!app.ui.toolbar_double, "the Tools panel keeps one column");+ frames(&mut app, vec![]);+ let r = ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap();+ assert!((r.width() - one - BUTTON).abs() < 0.5, "{one} then {}", r.width());+ assert_eq!(app.run("window.toolbarColumns", json!({"toolbar": "mine"})).unwrap(), json!(false));+ assert!(app.run("window.toolbarColumns", json!({"toolbar": "Nope"})).is_err());+ // The × at the left end hides it.+ frames(&mut app, vec![]);+ let r = ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap();+ frames(&mut app, click(pos2(r.left() + 1.0 + TITLE_BUTTON / 2.0, r.top() + 1.0 + GRIP / 2.0)));+ assert!(!find(&app.ui, "Mine").unwrap().shown);+ assert!(!drawn(&ctx, area_id("Mine"), egui::Order::Middle));+ }++ /// A hand-edited file: unnamed toolbars and repeated names go, unknown tools go, each tool once.+ #[test]+ fn toolbars_load_sanitized() {+ let bar = |name: &str, slots: &[&[&str]]| CustomToolbar {+ name: name.into(),+ slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(),+ shown: true,+ pos: [f32::NAN, 0.0],+ slot_tool: Default::default(),+ double: false,+ floating: ["pen", "nope", "pen"]+ .iter()+ .map(|id| crate::state::FloatingFlyout { tools: vec![id.to_string(), "curvature".into()], pos: [0.0, 0.0] })+ .collect(),+ };+ let mut bars = vec![bar("A", &[&["pen", "nope"], &["pen"], &["nope"]]), bar("a", &[]), bar(" ", &[]), bar("B", &[])];+ sanitize(&mut bars);+ assert_eq!(bars.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["A", "B"]);+ assert_eq!(bars[0].slots, [["pen"]]);+ assert_eq!(bars[0].pos, NEW_AT);+ // One strip per group, of known tools.+ assert_eq!(bars[0].floating.iter().map(|f| f.tools.join(",")).collect::<Vec<_>>(), ["pen,curvature", "curvature"]);+ }+}@@ -277,6 +277,7 @@ registry! { Preferences: ["preferences"] => DialogSpec::window(crate::prefs_dialog::show, |app, _| crate::prefs_dialog::confirm(app)), Shortcuts: ["shortcuts"] => DialogSpec::window(crate::shortcut_editor::show, |app, _| crate::shortcut_editor::confirm(app)), Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)),+ NewToolbar: [crate::custom_toolbars::KIND] => DialogSpec::window(crate::custom_toolbars::show_dialog, |app, _| crate::custom_toolbars::confirm(app)), FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)), SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC, Confirm: [confirm::KIND] => confirm::SPEC,@@ -11,7 +11,9 @@ //! 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.+//! preferences; `toolbar.addTool`, `toolbar.removeTool` and `toolbar.reset` make them. With their+//! `toolbar` param they edit a toolbar made with New Toolbar instead (`custom_toolbars`), whose •••+//! button opens the drawer beside it. use std::collections::BTreeSet; use std::sync::OnceLock;@@ -154,11 +156,40 @@ fn edit(app: &mut VectorcraftApp, f: impl FnOnce(&mut Vec<ToolbarCategory>) -> R Ok(slots_json(app)) } +/// Change the toolbar made with New Toolbar called `name` with `f`. Returns its slots.+fn edit_custom(app: &mut VectorcraftApp, name: &str, f: impl FnOnce(&mut Vec<ToolbarCategory>) -> Result<(), String>) -> Result<Value, String> {+ let bar = crate::custom_toolbars::find_mut(&mut app.ui, name).ok_or_else(|| format!("no toolbar named `{name}`"))?;+ let mut cats = vec![ToolbarCategory { name: None, slots: bar.slots.clone() }];+ f(&mut cats)?;+ prune(&mut cats);+ bar.slots = cats.into_iter().flat_map(|c| c.slots).collect();+ Ok(json!(bar.slots))+}++/// Change the toolbar the `toolbar` param names (none: the current Basic or Advanced toolbar).+fn edit_target(app: &mut VectorcraftApp, p: &Value, f: impl FnOnce(&mut Vec<ToolbarCategory>, bool) -> Result<(), String>) -> Result<Value, String> {+ match crate::custom_toolbars::target(&app.ui, p)? {+ Some(name) => edit_custom(app, &name, |cats| f(cats, false)),+ None => {+ let named = !app.ui.toolbar_advanced;+ edit(app, |cats| f(cats, named))+ }+ }+}+ /// 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<_>>()) } +/// The tools of the toolbar the drawer edits: one made with New Toolbar, or the Tools panel's.+fn target_tools(app: &VectorcraftApp) -> BTreeSet<&'static str> {+ match crate::custom_toolbars::drawer_target(&app.ui).and_then(|name| crate::custom_toolbars::find(&app.ui, name)) {+ Some(bar) => bar.slots.iter().flatten().filter_map(|id| tool_info(id).map(|t| t.id)).collect(),+ None => crate::toolbar::slots(app).into_iter().flat_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).@@ -222,24 +253,36 @@ fn tool_param(p: &Value) -> Result<&'static str, String> { /// 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);+ "window.editToolbar" => (|| {+ let open = crate::menus::opt_bool(p, "open")?;+ let target = crate::custom_toolbars::target(&app.ui, p)?;+ // Toggled: open, or open beside another toolbar, closes or moves it.+ let open = open.unwrap_or(!(app.ui.tools_drawer && app.ui.drawer_toolbar == target)); app.ui.tools_drawer = open;+ app.ui.drawer_toolbar = target.filter(|_| open); if open { app.ui.flyout = None;+ // The toolbar the drawer opens beside shows.+ match app.ui.drawer_toolbar.clone() {+ Some(name) => {+ if let Some(bar) = crate::custom_toolbars::find_mut(&mut app.ui, &name) {+ bar.shown = true;+ }+ }+ None => app.ui.toolbar = true,+ } }- json!(open)- }),+ Ok(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))+ edit_target(app, p, |cats, named| 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| {+ edit_target(app, p, |cats, _| { remove(cats, tool, group); Ok(()) })@@ -257,10 +300,17 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R } Ok(app.ui.toolbar_show.json()) })(),- "toolbar.reset" => {+ "toolbar.reset" => (|| {+ if let Some(name) = crate::custom_toolbars::target(&app.ui, p)? {+ // A toolbar made with New Toolbar goes back to blank, as it was made.+ return edit_custom(app, &name, |cats| {+ cats.clear();+ Ok(())+ });+ } *current(&mut app.ui.toolbar_edits, app.ui.toolbar_advanced) = None; Ok(slots_json(app))- }+ })(), _ => return None, }) }@@ -290,9 +340,9 @@ pub(crate) fn drop_target(slots: &[(usize, Rect)], at: Pos2, cols: usize) -> (Op (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) {+/// While a tool is dragged over the toolbar (`bounds`; `toolbar`: one made with New Toolbar): 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, toolbar: Option<&str>) { 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());@@ -319,7 +369,7 @@ pub(crate) fn toolbar_drop(app: &mut VectorcraftApp, ui: &Ui, slots: &[(usize, R } if ui.input(|i| i.pointer.any_released()) { DragAndDrop::clear_payload(ui.ctx());- let p = json!({ "tool": drag.tool, "slot": slot, "place": place.name() });+ let p = json!({ "tool": drag.tool, "slot": slot, "place": place.name(), "toolbar": toolbar }); if let Err(e) = app.run("toolbar.addTool", p) { app.ui.status = e; }@@ -417,7 +467,8 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { // buttons) and the bars under it. let top = bar.top().max(screen.top() + GAP); let y = button.top().min(screen.bottom() - size.y - GAP).max(top);- let in_toolbar: BTreeSet<&str> = crate::toolbar::slots(app).into_iter().flat_map(|(_, s)| s).collect();+ let in_toolbar = target_tools(app);+ let target = crate::custom_toolbars::drawer_target(&app.ui).map(str::to_string); let active = app.session.tool_id(); let dragged = DragAndDrop::payload::<ToolDrag>(ctx).map(|d| *d); let pointer = ctx.pointer_interact_pos();@@ -425,13 +476,15 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { 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 mut new_toolbar = false; let mut advanced = None; let mut show_toggle = None; 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| { let width = WIDTH.min(screen.width() - 2.0 * GAP - 16.0).max(160.0); ui.set_width(width);- // All Tools, and the drawer's flyout menu: Reset, and the Basic or Advanced toolbar.+ // All Tools, and the drawer's flyout menu: Reset, the Basic or Advanced toolbar, and New+ // Toolbar. 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| {@@ -450,6 +503,10 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { advanced = Some(adv); } }+ ui.separator();+ if widgets::menu_item(ui, "New Toolbar…", true, false) {+ new_toolbar = true;+ } }); }); });@@ -478,7 +535,11 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { } } });- // Show: the controls under the toolbar's tools.+ // Show: the controls under the Tools panel's tools (a toolbar made with New Toolbar has+ // none).+ if target.is_some() {+ return;+ } ui.add_space(4.0); ui.separator(); ui.horizontal(|ui| {@@ -498,16 +559,18 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { && ctx.input(|i| i.pointer.any_released()) { DragAndDrop::clear_payload(ctx);- if let Err(e) = app.run("toolbar.removeTool", json!({ "tool": d.tool })) {+ if let Err(e) = app.run("toolbar.removeTool", json!({ "tool": d.tool, "toolbar": target })) { app.ui.status = e; } }- if reset && let Err(e) = app.run("toolbar.reset", json!({})) {+ if reset && let Err(e) = app.run("toolbar.reset", json!({ "toolbar": target })) { app.ui.status = e; }- // Basic or Advanced: the menu item of the other toolbar switches to it.- if advanced.is_some_and(|a| a != app.ui.toolbar_advanced)- && let Err(e) = app.run("window.toolbarAdvanced", json!({}))+ if new_toolbar && let Err(e) = app.run("window.toolbar.new", json!({})) {+ app.ui.status = e;+ }+ if let Some(advanced) = advanced+ && let Err(e) = pick_layout(app, advanced) { app.ui.status = e; }@@ -527,6 +590,19 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { ghost(ctx, &t); } +/// The drawer menu's Basic or Advanced: the menu item of the other toolbar switches the Tools panel+/// to it, and the drawer goes on to edit the Tools panel's toolbar, also from a toolbar made with+/// New Toolbar.+fn pick_layout(app: &mut VectorcraftApp, advanced: bool) -> Result<(), String> {+ if crate::custom_toolbars::drawer_target(&app.ui).is_some() {+ app.run("window.editToolbar", json!({ "open": true }))?;+ }+ if advanced != app.ui.toolbar_advanced {+ app.run("window.toolbarAdvanced", json!({}))?;+ }+ Ok(())+}+ /// A Show section toggle: lit while its control shows under the toolbar's tools. Fill and Stroke /// and Coloring are drawn as small copies of those controls, the modes with their buttons' icons. fn show_button(ui: &mut Ui, t: &Tokens, name: &str, label: &str, icon: &str, on: bool) -> bool {@@ -356,6 +356,141 @@ fn tools_drag_between_the_drawer_and_the_toolbar() { assert_eq!(slots(&app), before); } +/// A toolbar made with New Toolbar floats blank; its ••• button opens the drawer beside it, and+/// tools dragged from the drawer fill it, leaving the Tools panel's toolbar as it was.+#[test]+fn a_new_toolbar_fills_from_the_drawer_beside_it() {+ let mut app = app();+ let ctx = ctx();+ let shipped = slots(&app);+ app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();+ frame(&mut app, &ctx, 0.0, vec![]);+ frame(&mut app, &ctx, 0.1, vec![]);+ let area = || ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id("Mine"))).expect("the toolbar is shown");+ let bar = area();+ let edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).into_iter().find(|r| bar.contains(r.center())).expect("its ••• button");+ // Open beside the Tools panel's toolbar, the drawer moves to this one on its ••• click.+ app.run("window.editToolbar", json!({})).unwrap();+ frame(&mut app, &ctx, 0.5, vec![]);+ click(&mut app, &ctx, 1.0, edit.center());+ assert!(app.ui.tools_drawer && app.ui.drawer_toolbar.as_deref() == Some("Mine"));+ assert_eq!(crate::menus::checked(&app, "window.editToolbar", &json!({"toolbar": "Mine"})), Some(true));+ assert_eq!(crate::menus::checked(&app, "window.editToolbar", &json!({})), Some(false));+ let shown = frame(&mut app, &ctx, 1.2, vec![]);+ let drawer = drawer_rect(&ctx).unwrap();+ assert!((drawer.left() - bar.right() - GAP).abs() < 2.0, "beside the new toolbar: {drawer:?} {bar:?}");+ // The Tools panel's tools aren't dimmed: they aren't in this toolbar. The Show section, for+ // the controls under the Tools panel's tools, is left out.+ assert!(!shown.iter().any(|(s, p, _)| s == "Show:" && drawer.contains(*p)));+ let dim = Tokens::get(&ctx).text_disabled;+ assert_ne!(cell_at(&shown, drawer, "Ellipse Tool").1, dim);+ // Dragged onto the blank toolbar, a tool is added to it.+ let (ellipse, _) = cell_at(&shown, drawer, "Ellipse Tool");+ drag(&mut app, &ctx, 2.0, ellipse, bar.center());+ assert_eq!(crate::custom_toolbars::find(&app.ui, "Mine").unwrap().slots, [["ellipse"]]);+ assert_eq!(slots(&app), shipped, "the Tools panel's toolbar is untouched");+ let shown = frame(&mut app, &ctx, 2.9, vec![]);+ let drawer = drawer_rect(&ctx).unwrap();+ assert_eq!(cell_at(&shown, drawer, "Ellipse Tool").1, dim, "now in the toolbar the drawer edits");+ // A second one, below the first.+ let tools: Vec<Rect> = crate::toolbar::tests::widget_rects(&ctx, BUTTON).into_iter().filter(|r| area().contains(r.center())).collect();+ let (artboard, _) = cell_at(&shown, drawer, "Artboard Tool");+ drag(&mut app, &ctx, 3.0, artboard, tools[0].center_bottom() - vec2(0.0, 3.0));+ assert_eq!(crate::custom_toolbars::find(&app.ui, "Mine").unwrap().slots, [["ellipse"], ["artboard"]]);+ // A click on its tool picks it; the drawer stays open.+ let tools: Vec<Rect> = crate::toolbar::tests::widget_rects(&ctx, BUTTON).into_iter().filter(|r| area().contains(r.center())).collect();+ click(&mut app, &ctx, 4.0, tools[1].center());+ assert_eq!(app.session.tool_id(), "artboard");+ assert!(app.ui.tools_drawer);+ // Dragged from it into the drawer, a tool leaves it.+ drag(&mut app, &ctx, 5.0, tools[0].center(), drawer.center());+ assert_eq!(crate::custom_toolbars::find(&app.ui, "Mine").unwrap().slots, [["artboard"]]);+ // The Tools panel's ••• button takes the drawer back beside its toolbar.+ let main_edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).into_iter().rfind(|r| r.right() < 60.0).unwrap();+ click(&mut app, &ctx, 5.5, main_edit.center());+ assert!(app.ui.tools_drawer && app.ui.drawer_toolbar.is_none());+ // The drawer closed, a drag on the grip bar moves the toolbar.+ app.run("window.editToolbar", json!({"open": false})).unwrap();+ frame(&mut app, &ctx, 6.0, vec![]);+ let before = area();+ drag(&mut app, &ctx, 7.0, before.center_top() + vec2(0.0, 5.0), before.center_top() + vec2(120.0, 205.0));+ frame(&mut app, &ctx, 7.5, vec![]);+ let after = area();+ assert!((after.min - before.min - vec2(120.0, 200.0)).length() < 2.0, "{before:?} {after:?}");+ // Hidden, it isn't drawn.+ app.run("window.toolbar.custom", json!({"name": "Mine", "show": false})).unwrap();+ frame(&mut app, &ctx, 8.0, vec![]);+ frame(&mut app, &ctx, 8.1, vec![]);+ assert!(!ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, crate::custom_toolbars::area_id("Mine")))));+}++/// New Toolbar… in the drawer's menu opens the New Toolbar dialog, which asks for a name.+#[test]+fn new_toolbar_in_the_drawer_menu_asks_for_a_name() {+ let mut app = app();+ let ctx = ctx();+ app.run("window.editToolbar", json!({"open": true})).unwrap();+ frame(&mut app, &ctx, 0.0, vec![]);+ frame(&mut app, &ctx, 0.1, vec![]);+ let drawer = drawer_rect(&ctx).expect("the drawer is open");+ let menu = crate::toolbar::tests::widget_rects(&ctx, vec2(18.0, 18.0))+ .into_iter()+ .find(|r| drawer.contains(r.center()))+ .expect("the drawer menu button");+ click(&mut app, &ctx, 1.0, menu.center());+ // The menu is sized in its first frame and drawn from the second.+ frame(&mut app, &ctx, 1.2, vec![]);+ let shown = frame(&mut app, &ctx, 1.3, vec![]);+ // A menu item's label is indented where a checked item has its ✓.+ let item = shown.iter().find(|(s, ..)| s.trim() == "New Toolbar…").map(|(_, p, _)| *p).expect("New Toolbar… in the drawer menu");+ assert!(app.ui.dialog.is_none());+ click(&mut app, &ctx, 2.0, item);+ let d = app.ui.dialog.clone().expect("the dialog asks for a name");+ assert_eq!((d.kind.as_str(), d.str("name").as_str()), (crate::custom_toolbars::KIND, "Toolbar 1"));+}++/// A click on another toolbar's ••• button takes the drawer there, whichever was made first.+#[test]+fn the_drawer_moves_between_new_toolbars() {+ let mut app = app();+ let ctx = ctx();+ for name in ["A", "B"] {+ app.run("window.toolbar.new", json!({"name": name})).unwrap();+ }+ app.ui.custom_toolbars[1].pos = [600.0, 900.0];+ frame(&mut app, &ctx, 0.0, vec![]);+ frame(&mut app, &ctx, 0.1, vec![]);+ let edit_of = |name: &str| {+ let bar = ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id(name))).unwrap();+ crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).into_iter().find(|r| bar.contains(r.center())).unwrap()+ };+ let (a, b) = (edit_of("A"), edit_of("B"));+ for (k, (button, name)) in [(b, "B"), (a, "A"), (b, "B")].into_iter().enumerate() {+ click(&mut app, &ctx, 1.0 + k as f64, button.center());+ frame(&mut app, &ctx, 1.5 + k as f64, vec![]);+ assert_eq!(crate::custom_toolbars::drawer_target(&app.ui), Some(name), "after the click on {name}'s ••• button");+ }+}++/// Basic or Advanced in the drawer's menu, picked while the drawer edits a toolbar made with New+/// Toolbar, switches the Tools panel and takes the drawer to it, so Reset and drops edit it.+#[test]+fn basic_or_advanced_takes_the_drawer_to_the_tools_panel() {+ let mut app = app();+ app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();+ app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();+ // With the Tools panel hidden too: it shows.+ app.run("window.toolbar", json!({})).unwrap();+ for advanced in [false, true] {+ app.run("window.editToolbar", json!({"toolbar": "Mine", "open": true})).unwrap();+ pick_layout(&mut app, advanced).unwrap();+ assert!(app.ui.tools_drawer && crate::custom_toolbars::drawer_target(&app.ui).is_none(), "{advanced}");+ assert_eq!(app.ui.toolbar_advanced, advanced);+ assert!(app.ui.toolbar, "the Tools panel shows beside the drawer");+ }+ assert_eq!(crate::custom_toolbars::find(&app.ui, "Mine").unwrap().slots, [["pen"]], "Mine as it was");+}+ #[test] fn drop_targets_follow_the_buttons_edges() { let r = |y: f32| Rect::from_min_size(pos2(6.0, y), BUTTON);@@ -1982,6 +1982,10 @@ font-technology Variable Променливи New Without Dialog Нов без диалогов прозорец New Workspace Ново работно пространство New Workspace… Ново работно пространство…+ New Toolbar… Нова лента с инструменти…+ New Toolbar Нова лента с инструменти+ Show Toolbar Показване на лента с инструменти+ List Toolbars Списък на лентите с инструменти New {kind} Style… Нов стил ({kind})… New… Нов… Next Следващ@@ -632,6 +632,10 @@ Reset Typography Obnovit Typography Reset Web Obnovit Web New Workspace… Nový pracovní prostor…+ New Toolbar… Nový panel nástrojů…+ New Toolbar Nový panel nástrojů+ Show Toolbar Zobrazit panel nástrojů+ List Toolbars Seznam panelů nástrojů Manage Workspaces… Spravovat pracovní prostory… Control Ovládací panel Contextual Task Bar Kontextový panel úloh@@ -585,6 +585,10 @@ Reset Typography Nulstil Typography Reset Web Nulstil Web New Workspace… Nyt arbejdsområde…+ New Toolbar… Ny værktøjslinje…+ New Toolbar Ny værktøjslinje+ Show Toolbar Vis værktøjslinje+ List Toolbars Liste over værktøjslinjer Manage Workspaces… Administrér arbejdsområder… Control Kontrol Contextual Task Bar Kontekstuel opgavelinje@@ -1996,6 +1996,10 @@ file-filter Templates Vorlagen New Without Dialog Neu ohne Dialogfeld New Workspace Neuer Arbeitsbereich New Workspace… Neuer Arbeitsbereich…+ New Toolbar… Neue Werkzeugleiste…+ New Toolbar Neue Werkzeugleiste+ Show Toolbar Werkzeugleiste einblenden+ List Toolbars Werkzeugleisten auflisten New {kind} Style… Neues {kind}format… New… Neu… Next Weiter@@ -1669,6 +1669,10 @@ font-technology Variable Μεταβλητές New Without Dialog Νέο χωρίς παράθυρο διαλόγου New Workspace Νέος χώρος εργασίας New Workspace… Νέος χώρος εργασίας…+ New Toolbar… Νέα γραμμή εργαλείων…+ New Toolbar Νέα γραμμή εργαλείων+ Show Toolbar Εμφάνιση γραμμής εργαλείων+ List Toolbars Λίστα γραμμών εργαλείων New… Νέο… Next Επόμενο No document Δεν υπάρχει έγγραφο@@ -2034,6 +2034,10 @@ font-technology Variable Variables New Without Dialog Nuevo sin cuadro de diálogo New Workspace Nuevo espacio de trabajo New Workspace… Nuevo espacio de trabajo…+ New Toolbar… Nueva barra de herramientas…+ New Toolbar Nueva barra de herramientas+ Show Toolbar Mostrar barra de herramientas+ List Toolbars Lista de barras de herramientas New {kind} Style… Nuevo estilo de {kind}… New… Nuevo… Next Siguiente@@ -623,6 +623,10 @@ Reset Typography Palauta Typography Reset Web Palauta Web New Workspace… Uusi työtila…+ New Toolbar… Uusi työkalurivi…+ New Toolbar Uusi työkalurivi+ Show Toolbar Näytä työkalurivi+ List Toolbars Luettele työkalurivit Manage Workspaces… Hallitse työtiloja… Control Ohjaus Contextual Task Bar Kontekstuaalinen tehtäväpalkki@@ -2031,6 +2031,10 @@ font-technology Variable Variables New Without Dialog Nouveau sans boîte de dialogue New Workspace Nouvel espace de travail New Workspace… Nouvel espace de travail…+ New Toolbar… Nouvelle barre d’outils…+ New Toolbar Nouvelle barre d’outils+ Show Toolbar Afficher la barre d’outils+ List Toolbars Liste des barres d’outils New {kind} Style… Nouveau style de {kind}… New… Nouveau… Next Suivant@@ -2489,6 +2489,10 @@ file-filter Templates Sablonok New Without Dialog Új párbeszédablak nélkül New Workspace Új munkaterület New Workspace… Új munkaterület…+ New Toolbar… Új eszköztár…+ New Toolbar Új eszköztár+ Show Toolbar Eszköztár megjelenítése+ List Toolbars Eszköztárak listája New {kind} Style… Új stílus: {kind}… New… Új… Next Következő@@ -585,6 +585,10 @@ Reset Typography Atur ulang Typography Reset Web Atur ulang Web New Workspace… Ruang kerja baru…+ New Toolbar… Bilah alat baru…+ New Toolbar Bilah alat baru+ Show Toolbar Tampilkan bilah alat+ List Toolbars Daftar bilah alat Manage Workspaces… Kelola ruang kerja… Control Kontrol Contextual Task Bar Bilah tugas kontekstual@@ -2061,6 +2061,10 @@ file-filter Templates Modelli New Without Dialog Nuovo senza finestra di dialogo New Workspace Nuova area di lavoro New Workspace… Nuova area di lavoro…+ New Toolbar… Nuova barra degli strumenti…+ New Toolbar Nuova barra degli strumenti+ Show Toolbar Mostra barra degli strumenti+ List Toolbars Elenco barre degli strumenti New {kind} Style… Nuovo stile {kind}… New… Nuovo… Next Successivo@@ -607,6 +607,10 @@ font-technology Variable バリアブル Workspace ワークスペース Reset Essentials 初期設定をリセット New Workspace… 新規ワークスペース…+ New Toolbar… 新規ツールバー…+ New Toolbar 新規ツールバー+ Show Toolbar ツールバーを表示+ List Toolbars ツールバーの一覧 Manage Workspaces… ワークスペースを管理… Control コントロール Contextual Task Bar コンテキストタスクバー@@ -529,6 +529,10 @@ Workspace სამუშაო სივრცე Reset Essentials Essentials-ის ჩამოყრა New Workspace… ახალი სამუშაო სივრცე…+ New Toolbar… ახალი ხელსაწყოთა ზოლი…+ New Toolbar ახალი ხელსაწყოთა ზოლი+ Show Toolbar ხელსაწყოთა ზოლის ჩვენება+ List Toolbars ხელსაწყოთა ზოლების სია Manage Workspaces… სამუშაო სივრცეების მართვა… Control მართვა Contextual Task Bar კონტექსტური ამოცანების ზოლი@@ -605,6 +605,10 @@ font-technology Variable 가변 Workspace 작업 영역 Reset Essentials Essentials 재설정 New Workspace… 새 작업 영역…+ New Toolbar… 새 도구 막대…+ New Toolbar 새 도구 막대+ Show Toolbar 도구 막대 표시+ List Toolbars 도구 막대 목록 Manage Workspaces… 작업 영역 관리… Control 컨트롤 Contextual Task Bar 상황별 작업 표시줄@@ -599,6 +599,10 @@ Reset Typography Typography herstellen Reset Web Web herstellen New Workspace… Nieuwe werkruimte…+ New Toolbar… Nieuwe werkbalk…+ New Toolbar Nieuwe werkbalk+ Show Toolbar Werkbalk tonen+ List Toolbars Lijst met werkbalken Manage Workspaces… Werkruimten beheren… Control Bediening Contextual Task Bar Contextuele taakbalk@@ -582,6 +582,10 @@ Workspace Obszar roboczy Reset Essentials Resetuj Essentials New Workspace… Nowy obszar roboczy…+ New Toolbar… Nowy pasek narzędzi…+ New Toolbar Nowy pasek narzędzi+ Show Toolbar Pokaż pasek narzędzi+ List Toolbars Lista pasków narzędzi Manage Workspaces… Zarządzaj obszarami roboczymi… Control Sterowanie Contextual Task Bar Kontekstowy pasek zadań@@ -1651,6 +1651,10 @@ New Without Dialog Novo sem Diálogo New Workspace Novo Espaço de Trabalho New Workspace… Novo Espaço de Trabalho…+ New Toolbar… Nova Barra de Ferramentas…+ New Toolbar Nova Barra de Ferramentas+ Show Toolbar Mostrar Barra de Ferramentas+ List Toolbars Listar Barras de Ferramentas New file Novo arquivo New from Template Novo a partir de Modelo New from Template… Novo a partir de Modelo…@@ -2057,6 +2057,10 @@ file-filter Templates Шаблоны New Without Dialog Создать без диалогового окна New Workspace Новая рабочая среда New Workspace… Новая рабочая среда…+ New Toolbar… Новая панель инструментов…+ New Toolbar Новая панель инструментов+ Show Toolbar Показать панель инструментов+ List Toolbars Список панелей инструментов New {kind} Style… Новый стиль {kind}… New… Создать… Next Далее@@ -1982,6 +1982,10 @@ font-technology Variable Variabla New Without Dialog Ny utan dialogruta New Workspace Ny arbetslayout New Workspace… Ny arbetslayout…+ New Toolbar… Nytt verktygsfält…+ New Toolbar Nytt verktygsfält+ Show Toolbar Visa verktygsfält+ List Toolbars Lista verktygsfält New {kind} Style… Ny {kind}stil… New… Ny… Next Nästa@@ -1858,6 +1858,10 @@ font-technology Variable Değişken New Window Yeni Pencere New Workspace Yeni Çalışma Alanı New Workspace… Yeni Çalışma Alanı…+ New Toolbar… Yeni Araç Çubuğu…+ New Toolbar Yeni Araç Çubuğu+ Show Toolbar Araç Çubuğunu Göster+ List Toolbars Araç Çubuklarını Listele New {kind} Style… Yeni {kind} Stili… New… Yeni… Next Sonraki@@ -2053,6 +2053,10 @@ file-filter Templates Шаблони New Without Dialog Створити без діалогового вікна New Workspace Нове робоче середовище New Workspace… Нове робоче середовище…+ New Toolbar… Нова панель інструментів…+ New Toolbar Нова панель інструментів+ Show Toolbar Показати панель інструментів+ List Toolbars Список панелей інструментів New {kind} Style… Новий стиль {kind}… New… Створити… Next Далі@@ -2297,6 +2297,10 @@ file-filter Templates Mẫu New Without Dialog Mới không qua hộp thoại New Workspace Không gian làm việc mới New Workspace… Không gian làm việc mới…+ New Toolbar… Thanh công cụ mới…+ New Toolbar Thanh công cụ mới+ Show Toolbar Hiện thanh công cụ+ List Toolbars Danh sách thanh công cụ New {kind} Style… Kiểu {kind} mới… New… Mới… Next Tiếp theo@@ -1932,6 +1932,10 @@ file-filter Templates 模板 New Without Dialog 无对话框新建 New Workspace 新建工作区 New Workspace… 新建工作区…+ New Toolbar… 新建工具栏…+ New Toolbar 新建工具栏+ Show Toolbar 显示工具栏+ List Toolbars 列出工具栏 New {kind} Style… 新建{kind}样式… New… 新建… Next 下一个@@ -2831,6 +2831,10 @@ file-filter Templates 範本 New Without Dialog 新增但不開啟對話方塊 New Workspace 新增工作區 New Workspace… 新增工作區…+ New Toolbar… 新增工具列…+ New Toolbar 新增工具列+ Show Toolbar 顯示工具列+ List Toolbars 列出工具列 New {kind} Style… 新增{kind}樣式… New… 新增… Next 下一個@@ -22,6 +22,7 @@ pub mod community; pub mod control; pub mod credits; pub mod cursors;+mod custom_toolbars; pub mod dialogs; pub mod dock; mod edit_toolbar;@@ -103,7 +103,7 @@ pub(super) fn shows_a_name(id: &str, label: &str) -> bool { ]; SLOTS.iter().any(|s| id.starts_with(s)) || saved_selection_slot(id).is_some()- || matches!(id, "text.setStyle" | "plugin.dialog")+ || matches!(id, "text.setStyle" | "plugin.dialog" | "window.toolbar.custom") || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) }@@ -71,6 +71,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::edit_toolbar::command(app, id, p))+ .or_else(|| crate::custom_toolbars::command(app, id, p)) { return Some(r); }@@ -351,11 +352,21 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R } "window.control" => flag(&mut app.ui.control_bar), "window.toolbar" => flag(&mut app.ui.toolbar),- "window.toolbarColumns" => opt_bool(p, "double").map(|on| {- let on = on.unwrap_or(!app.ui.toolbar_double);- app.ui.toolbar_double = on;- json!(on)- }),+ "window.toolbarColumns" => {+ let toolbar = match crate::custom_toolbars::target(&app.ui, p) {+ Ok(t) => t,+ Err(e) => return Some(Err(e)),+ };+ let double = match toolbar.as_deref().and_then(|name| crate::custom_toolbars::find_mut(&mut app.ui, name)) {+ Some(b) => &mut b.double,+ None => &mut app.ui.toolbar_double,+ };+ opt_bool(p, "double").map(|on| {+ let on = on.unwrap_or(!*double);+ *double = on;+ json!(on)+ })+ } "window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced), "imageTrace.preview" => opt_bool(p, "on").map(|on| { let on = on.unwrap_or(!app.ui.image_trace_preview);@@ -368,7 +379,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R Err(e) => return Some(Err(e)), }; let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) };- crate::toolbar::float_group(app, &tool, floating).map(Value::Bool)+ let toolbar = match crate::custom_toolbars::target(&app.ui, p) {+ Ok(t) => t,+ Err(e) => return Some(Err(e)),+ };+ crate::toolbar::float_group(app, &tool, floating, toolbar.as_deref()).map(Value::Bool) } "window.taskBar" => flag(&mut app.ui.task_bar), "window.taskBar.pin" => {@@ -61,11 +61,22 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> { "window.control" => app.ui.control_bar, "window.toolbar" => app.ui.toolbar, "window.toolbarAdvanced" => app.ui.toolbar_advanced,- "window.toolbarColumns" => app.ui.toolbar_double,- "window.editToolbar" => app.ui.tools_drawer,+ "window.toolbarColumns" => match p.get("toolbar").and_then(Value::as_str) {+ None => app.ui.toolbar_double,+ Some(name) => crate::custom_toolbars::find(&app.ui, name).is_some_and(|b| b.double),+ },+ "window.editToolbar" => {+ app.ui.tools_drawer+ && crate::custom_toolbars::drawer_target(&app.ui).map(crate::custom_toolbars::key)+ == p.get("toolbar").and_then(Value::as_str).map(crate::custom_toolbars::key)+ }+ "window.toolbar.custom" => {+ p.get("name").and_then(Value::as_str).and_then(|name| crate::custom_toolbars::find(&app.ui, name)).is_some_and(|b| b.shown)+ } "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))+ let toolbar = p.get("toolbar").and_then(Value::as_str);+ crate::toolbar::strips(&app.ui, toolbar).iter().any(|f| f.tools.iter().any(|id| id == tool)) } "window.taskBar" => app.ui.task_bar, "window.taskBar.pin" => app.ui.task_bar_place.pinned,@@ -607,7 +607,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> { c("Control", "window.control"), c("Contextual Task Bar", "window.taskBar"), c("Tools", "window.toolbar"),- sub("Toolbars", vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns")]),+ sub("Toolbars", crate::custom_toolbars::menu_items()), Sep, panel("Actions", "actions"), panel("Align", "align"),@@ -373,14 +373,14 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.toolbarColumns", "Toolbar: Single/Double Column", "",- "{double?: bool} show the toolbar's tools in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state",+ "{double?: bool, toolbar?: name} show the toolbar's tools (with `toolbar`, that toolbar made with New Toolbar's) in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state", ), ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), ( "window.floatTools", "Float Tool Group", "",- "{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",+ "{tool: id, floating?: bool, toolbar?: name} float the toolbar group holding `tool` (in the current Basic or Advanced layout; with `toolbar`, in that toolbar made with New Toolbar) 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", ), ( "toolbar.show",@@ -392,26 +392,39 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "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",+ "{open?: bool, toolbar?: name} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar (with `toolbar`, beside that toolbar made with New Toolbar, which shows), 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)",+ "{tool: id, slot?: n, place?: \"before\"|\"after\"|\"group\", toolbar?: name} put `tool` in the current (Basic or Advanced) toolbar (with `toolbar`, in that toolbar made with New 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",+ "{tool: id, group?: bool, toolbar?: name} take `tool` (with `group`, its whole group) out of the current toolbar (with `toolbar`, out of that toolbar made with New 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",+ "{toolbar?: name} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes (with `toolbar`, make that toolbar made with New Toolbar blank again); returns its slots", ),+ (+ "window.toolbar.new",+ "New Toolbar…",+ "",+ "{name?: string} make a blank toolbar called `name` (omitted: a dialog asks for the name), floating beside the Tools panel and listed under Window › Toolbars; its ••• button opens the All Tools drawer to fill it (window.editToolbar with `toolbar`); it is kept with the current workspace; returns {toolbar: name}",+ ),+ (+ "window.toolbar.custom",+ "Show Toolbar",+ "",+ "{name: string, show?: bool} show (true), hide (false) or toggle (omitted) the toolbar made with New Toolbar called `name`, as its item under Window › Toolbars does (its title bar's × hides it); returns the new state",+ ),+ ("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"), ("window.taskBar", "Contextual Task Bar", "", "{}"), ( "window.taskBar.pin",@@ -124,6 +124,7 @@ pub fn sync(ui: &UiState) { GENERATION.fetch_add(1, Ordering::Relaxed); } crate::workspaces::sync(ui);+ crate::custom_toolbars::sync(ui); } /// Registry default shortcut of a command (engine or UI command).@@ -368,6 +368,13 @@ pub struct UiState { /// Edit Toolbar's All Tools drawer is open beside the toolbar (`window.editToolbar`). #[serde(skip)] pub tools_drawer: bool,+ /// The toolbars made with New Toolbar, in the order they were made.+ #[serde(default, skip_serializing_if = "Vec::is_empty")]+ pub custom_toolbars: Vec<CustomToolbar>,+ /// The toolbar made with New Toolbar that the open All Tools drawer edits (none: the Tools+ /// panel's toolbar).+ #[serde(skip)]+ pub drawer_toolbar: Option<String>, /// Floating panel groups as files from before the canvas joined [`Self::docking`] saved /// them. Read only to convert those files ([`crate::layout_migration`]), never written. #[serde(default, skip_serializing)]@@ -610,6 +617,44 @@ pub struct FloatingFlyout { pub pos: [f32; 2], } +impl FloatingFlyout {+ /// Keep strips of known tools, one per group (a hand-edited preferences file).+ pub fn sanitize(strips: &mut Vec<FloatingFlyout>) {+ let mut seen = std::collections::BTreeSet::new();+ strips.retain_mut(|f| {+ f.tools.retain(|id| vectorcraft_tools::tool_info(id).is_some());+ f.tools.first().is_some_and(|k| seen.insert(k.clone()))+ });+ }+}++/// A toolbar made with Window › Toolbars › New Toolbar (`window.toolbar.new`): it floats on its+/// own, filled with tools from the All Tools drawer, and is kept with the workspace it was made in.+/// The groups torn off it are kept with it (`floating`), so the Tools panel's strips+/// (`UiState::floating_flyouts`) hold none of them.+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]+pub struct CustomToolbar {+ /// The name it is listed under in Window › Toolbars.+ pub name: String,+ /// Its slots, each a tool group (the first tool is the slot's default).+ #[serde(default)]+ pub slots: Vec<Vec<String>>,+ /// It shows (Window › Toolbars › its name toggles it).+ #[serde(default = "yes")]+ pub shown: bool,+ /// Top-left corner in screen points.+ pub pos: [f32; 2],+ /// The tool each slot of several tools shows, chosen last (keyed by the slot's first tool).+ #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]+ pub slot_tool: std::collections::BTreeMap<String, String>,+ /// Its tools show in two columns (the double arrow on its title bar toggles it).+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]+ pub double: bool,+ /// The groups torn off it, each floating as its own strip; they show while it does.+ #[serde(default, skip_serializing_if = "Vec::is_empty")]+ pub floating: Vec<FloatingFlyout>,+}+ /// 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)]@@ -749,6 +794,8 @@ impl UiState { self.dialog = None; self.flyout = None; self.tools_drawer = false;+ self.drawer_toolbar = None;+ crate::custom_toolbars::sanitize(&mut self.custom_toolbars); self.palette_open = false; self.status.clear(); self.about = false;@@ -756,12 +803,7 @@ impl UiState { if self.group_tool.len() != vectorcraft_tools::TOOL_GROUPS.len() { self.group_tool = UiState::default().group_tool; }- // One strip per group, of known tools (a hand-edited preferences file).- let mut seen = std::collections::BTreeSet::new();- self.floating_flyouts.retain_mut(|f| {- f.tools.retain(|id| vectorcraft_tools::tool_info(id).is_some());- f.tools.first().is_some_and(|k| seen.insert(k.clone()))- });+ FloatingFlyout::sanitize(&mut self.floating_flyouts); sanitize_toolbar_pos(&mut self.toolbar_pos); crate::edit_toolbar::sanitize(&mut self.toolbar_edits); // `read_preferences` converted the file's older layouts already; this covers a state read@@ -815,6 +857,8 @@ impl Default for UiState { toolbar_edits: ToolbarEdits::default(), toolbar_show: ToolbarShow::default(), tools_drawer: false,+ custom_toolbars: vec![],+ drawer_toolbar: None, floating_panels: vec![], docking: None, docking_hidden: Default::default(),@@ -15,7 +15,7 @@ use vectorcraft_tools::{Mods, TOOL_GROUPS, ToolInfo, ToolKey, tool_info}; use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, icons, widgets}; -const PITCH: f32 = 30.0;+pub(crate) const PITCH: f32 = 30.0; const WIDTH: f32 = 48.0; /// Seconds a press on a tool group's button is held before its flyout opens. const LONG_PRESS: f64 = 0.35;@@ -92,6 +92,7 @@ pub fn remember(app: &mut VectorcraftApp, id: &str) { app.ui.slot_tool.insert(s[0].to_string(), id.to_string()); } }+ crate::custom_toolbars::remember(&mut app.ui, id); } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {@@ -130,19 +131,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } // Before the flyout: one torn off this frame floats from the next (its bar mustn't be in two // layers in one frame).- floating(app, ui.ctx());+ floating(app, ui.ctx(), false); crate::dialogs::dimension::floating(app, ui.ctx());+ crate::custom_toolbars::show(app, ui.ctx()); flyout(app, ui.ctx()); crate::edit_toolbar::drawer(app, ui.ctx()); } +/// The toolbars made with New Toolbar while the Tools panel is hidden (Window › Tools off): their+/// flyouts and the groups torn off them, and the All Tools drawer beside the one it edits.+pub fn show_custom(app: &mut VectorcraftApp, ctx: &egui::Context) {+ floating(app, ctx, true);+ crate::custom_toolbars::show(app, ctx);+ flyout(app, ctx);+ if crate::custom_toolbars::drawer_target(&app.ui).is_some() {+ crate::edit_toolbar::drawer(app, ctx);+ }+}+ /// The Tools panel's contents, docked or floating at `floating`. fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egui::Pos2>) { 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));+ let editing = app.ui.tools_drawer && app.ui.drawer_toolbar.is_none(); 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());@@ -180,9 +192,8 @@ 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;+ // While Edit Toolbar's drawer edits this toolbar, the tools drag (to new places or into the+ // drawer) instead of opening their flyouts. 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.@@ -243,7 +254,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu 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]) {+ if is_floating(app, None, slot[0]) { raise = Some(slot[0]); } else { open_flyout = Some((slot.clone(), rect));@@ -269,26 +280,32 @@ 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);+ crate::edit_toolbar::toolbar_drop(app, ui, &buttons, cols, bounds, None); } if let Some(key) = raise {- raise_floating(ui.ctx(), key);+ raise_floating(ui.ctx(), None, key); } if let Some((slot, rect)) = open_flyout { app.ui.flyout = Some(0); ui.data_mut(|d| { d.insert_temp(egui::Id::unique("flyout-anchor"), rect); d.insert_temp(egui::Id::unique("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::<Vec<String>>());+ d.insert_temp(flyout_toolbar_id(), None::<String>); }); } ui.add_space(8.0); bottom_controls(app, ui, &t, cols); });+ // While the drawer edits this toolbar (also from the ••• click just made), it opens beside it; a+ // click in it leaves the drawer open.+ if app.ui.tools_drawer && app.ui.drawer_toolbar.is_none() {+ ui.data_mut(|d| d.insert_temp(crate::edit_toolbar::toolbar_id(), bounds));+ } } /// How a press on a tool group's button opens its flyout. #[derive(Clone, Copy, PartialEq, Eq, Debug)]-enum FlyoutPress {+pub(crate) enum FlyoutPress { /// A right press (it opens at once, before the release). Secondary, /// A primary press on the corner triangle, or one held [`LONG_PRESS`] seconds: its release@@ -299,7 +316,7 @@ enum FlyoutPress { /// Whether this frame's pointer state on a tool button opens its group's flyout. A primary press /// still short of a long one asks for the frame where it becomes long: egui draws only on input, /// so a still mouse would otherwise never get there.-fn flyout_press(ui: &Ui, resp: &egui::Response, rect: egui::Rect) -> Option<FlyoutPress> {+pub(crate) fn flyout_press(ui: &Ui, resp: &egui::Response, rect: egui::Rect) -> Option<FlyoutPress> { if resp.hovered() && ui.input(|inp| inp.pointer.button_pressed(egui::PointerButton::Secondary)) { return Some(FlyoutPress::Secondary); }@@ -531,11 +548,15 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, cols: usiz app.run("view.screenMode", json!({})).ok(); } // 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));+ let editing = |app: &VectorcraftApp| app.ui.tools_drawer && app.ui.drawer_toolbar.is_none();+ let edit = widgets::icon_button(ui, "ellipsis", "Edit Toolbar", editing(app), 26.0); if edit.clicked() { app.run("window.editToolbar", json!({})).ok(); }+ // After the click, which may have opened the drawer here.+ if editing(app) {+ ui.data_mut(|d| d.insert_temp(crate::edit_toolbar::button_id(), edit.rect));+ } }); let _ = t; }@@ -546,38 +567,72 @@ const FLOAT_AT: [f32; 2] = [90.0, 100.0]; /// Float the toolbar group (of the current layout) holding `tool` as a strip of tool buttons, put /// it back in the toolbar, or toggle (`floating` omitted), as a flyout's tear-off bar and the-/// strip's × do (`window.floatTools`). Returns the new state.-pub fn float_group(app: &mut VectorcraftApp, tool: &str, floating: Option<bool>) -> Result<bool, String> {- let Some((_, group)) = slots(app).into_iter().find(|(_, s)| s.len() > 1 && s.contains(&tool)) else {+/// strip's × do (`window.floatTools`; `toolbar`: the group of the toolbar made with New Toolbar+/// called that). Returns the new state.+pub fn float_group(app: &mut VectorcraftApp, tool: &str, floating: Option<bool>, toolbar: Option<&str>) -> Result<bool, String> {+ let groups: Vec<Vec<&'static str>> = match toolbar {+ None => slots(app).into_iter().map(|(_, s)| s).collect(),+ Some(name) => crate::custom_toolbars::find(&app.ui, name)+ .map(|b| b.slots.iter().map(|s| s.iter().filter_map(|id| tool_info(id).map(|t| t.id)).collect()).collect())+ .unwrap_or_default(),+ };+ let Some(group) = groups.into_iter().find(|s| s.len() > 1 && s.contains(&tool)) else { return Err(format!("{tool} isn't in a toolbar group of several tools")); }; let key = group.first().copied().unwrap_or_default();- let was = is_floating(app, key);+ let was = is_floating(app, toolbar, key); let on = floating.unwrap_or(!was);+ let Some(strips) = strips_mut(&mut app.ui, toolbar) else { return Err(format!("no toolbar named `{}`", toolbar.unwrap_or_default())) }; if on && !was {- let n = app.ui.floating_flyouts.len() as f32;+ let n = strips.len() as f32; let tools = group.iter().map(|id| id.to_string()).collect();- app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [FLOAT_AT[0], FLOAT_AT[1] + n * (PITCH + 12.0)] });+ strips.push(crate::state::FloatingFlyout { tools, pos: [FLOAT_AT[0], FLOAT_AT[1] + n * (PITCH + 12.0)] }); app.ui.flyout = None; } else if !on {- app.ui.floating_flyouts.retain(|f| f.tools.first().is_none_or(|k| k != key));+ strips.retain(|f| f.tools.first().is_none_or(|k| k != key)); } Ok(on) } -/// Whether the group whose first tool is `key` floats as its own panel.-fn is_floating(app: &VectorcraftApp, key: &str) -> bool {- app.ui.floating_flyouts.iter().any(|f| f.tools.first().is_some_and(|k| k == key))+/// The groups torn off the Tools panel's toolbar (`toolbar` none) or off the toolbar made with New+/// Toolbar called `toolbar` (none when there is no such toolbar).+pub(crate) fn strips<'a>(ui: &'a crate::state::UiState, toolbar: Option<&str>) -> &'a [crate::state::FloatingFlyout] {+ match toolbar {+ None => &ui.floating_flyouts,+ Some(name) => crate::custom_toolbars::find(ui, name).map_or(&[], |b| b.floating.as_slice()),+ }+}++fn strips_mut<'a>(ui: &'a mut crate::state::UiState, toolbar: Option<&str>) -> Option<&'a mut Vec<crate::state::FloatingFlyout>> {+ match toolbar {+ None => Some(&mut ui.floating_flyouts),+ Some(name) => crate::custom_toolbars::find_mut(ui, name).map(|b| &mut b.floating),+ }+}++/// Whether the group whose first tool is `key`, of the Tools panel's toolbar (`toolbar` none) or of+/// the toolbar made with New Toolbar called `toolbar`, floats as its own panel.+pub(crate) fn is_floating(app: &VectorcraftApp, toolbar: Option<&str>, key: &str) -> bool {+ strips(&app.ui, toolbar).iter().any(|f| f.tools.first().is_some_and(|k| k == key)) } -/// The area of the floating flyout of the group whose first tool is `key`.+/// The area of the floating flyout of the Tools panel's group whose first tool is `key`. fn floating_area(key: &str) -> egui::Id { egui::Id::unique(("tool-floating", key)) } +/// The area of the floating flyout of the group whose first tool is `key`, torn off the toolbar+/// made with New Toolbar called `toolbar` (none: the Tools panel's).+fn strip_area(toolbar: Option<&str>, key: &str) -> egui::Id {+ match toolbar {+ None => floating_area(key),+ Some(name) => floating_area(key).with(crate::custom_toolbars::key(name)),+ }+}+ /// Bring a floating flyout to the front and flash its border.-fn raise_floating(ctx: &egui::Context, key: &str) {- let id = floating_area(key);+pub(crate) fn raise_floating(ctx: &egui::Context, toolbar: Option<&str>, key: &str) {+ let id = strip_area(toolbar, key); ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); let now = ctx.input(|i| i.time); ctx.data_mut(|d| d.insert_temp(id, now));@@ -604,10 +659,12 @@ struct FlyoutInput { /// A flyout's tool rows (a floating flyout's strip of tool buttons) with the grab bar down their /// right side; a floating flyout's bar has a × at its top. The bar's id is the group's, so a drag-/// that tears a flyout off carries on moving the floating flyout it becomes.-fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool) -> FlyoutInput {+/// that tears a flyout off carries on moving the floating flyout it becomes (`toolbar`: the toolbar+/// made with New Toolbar the group is in, none for the Tools panel's).+fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool, toolbar: Option<&str>) -> FlyoutInput { let mut out = FlyoutInput::default(); let key = tools.first().map_or("", String::as_str);+ let bar_id = bar_id(toolbar, key); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); ui.horizontal(|ui| { let rows = if floating { ui.horizontal(|ui| tool_strip(ui, t, active, tools)) } else { ui.vertical(|ui| tool_rows(ui, t, active, tools)) };@@ -616,7 +673,7 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating ui.painter().rect_filled(bar, 0.0, t.tab_strip); let grip = if floating { let x = egui::Rect::from_min_size(bar.min, vec2(BAR, CLOSE));- let xr = ui.interact(x, egui::Id::unique(("tool-flyout-close", key)), Sense::click());+ let xr = ui.interact(x, bar_id.with("close"), Sense::click()); icons::paint(ui, "x", x.shrink2(vec2(1.0, 3.0)), if xr.hovered() { t.text_strong } else { t.text }); xr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Put back in the toolbar"))); out.close = xr.on_hover_text(tl!("Put back in the toolbar")).clicked();@@ -624,7 +681,7 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating } else { bar };- let resp = ui.interact(grip, bar_id(key), Sense::drag());+ let resp = ui.interact(grip, bar_id, Sense::drag()); let half = (grip.height() / 2.0 - 3.0).clamp(0.0, 9.0); for k in 0..3 { let x = grip.center().x - 2.0 + k as f32 * 2.0;@@ -697,14 +754,19 @@ fn rows_width<'a>(rows: impl Iterator<Item = &'a RowText>) -> f32 { } /// The grab bar of the group whose first tool is `key` (one id, flyout or floating, so a drag-/// that tears the flyout off carries on moving it).-fn bar_id(key: &str) -> egui::Id {- egui::Id::unique(("tool-flyout-bar", key))+/// that tears the flyout off carries on moving it), in the toolbar made with New Toolbar called+/// `toolbar` (none: the Tools panel's).+fn bar_id(toolbar: Option<&str>, key: &str) -> egui::Id {+ let id = egui::Id::unique(("tool-flyout-bar", key));+ match toolbar {+ None => id,+ Some(name) => id.with(crate::custom_toolbars::key(name)),+ } } /// Where the pointer holds a floating flyout's bar, from the flyout's corner, while it's dragged.-fn grab_id(key: &str) -> egui::Id {- bar_id(key).with("grab")+fn grab_id(toolbar: Option<&str>, key: &str) -> egui::Id {+ bar_id(toolbar, key).with("grab") } /// One icon button per tool, the active one lit, with its name and shortcut on hover. Returns the@@ -752,11 +814,12 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.data(|d| d.get_temp(egui::Id::unique("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0))); let t = Tokens::get(ctx); let active = app.session.tool_id();+ let toolbar = ctx.data(|d| d.get_temp::<Option<String>>(flyout_toolbar_id())).flatten(); let mut input = FlyoutInput::default(); let resp = egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show( ctx, |ui| {- flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false));+ flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref())); }, ); // Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the@@ -771,9 +834,12 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { let grab = vec2(1.0 + strip_width(&tools) - BAR / 2.0, 1.0 + CLOSE + 4.0); let p = at - grab; if let Some(key) = tools.first() {- ctx.data_mut(|m| m.insert_temp(grab_id(key), grab));+ ctx.data_mut(|m| m.insert_temp(grab_id(toolbar.as_deref(), key), grab));+ }+ // A toolbar made with New Toolbar that has gone since its flyout opened keeps no strip.+ if let Some(strips) = strips_mut(&mut app.ui, toolbar.as_deref()) {+ strips.push(crate::state::FloatingFlyout { tools, pos: [p.x, p.y] }); }- app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [p.x, p.y] }); app.ui.flyout = None; return; }@@ -787,20 +853,38 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { let _ = theme::semibold; } +/// The toolbar made with New Toolbar whose group the open flyout shows (none: the Tools panel's).+pub(crate) fn flyout_toolbar_id() -> egui::Id {+ egui::Id::unique("flyout-toolbar")+}+ /// The flyouts torn off the toolbar, each floating where its bar was dragged; its × puts it back.-fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {- if app.ui.floating_flyouts.is_empty() {- return;+/// `custom_only`: the Tools panel is hidden, and only those torn off toolbars made with New Toolbar+/// show.+fn floating(app: &mut VectorcraftApp, ctx: &egui::Context, custom_only: bool) {+ // A group torn off a toolbar made with New Toolbar shows while that toolbar does.+ let owners: Vec<Option<String>> = (!custom_only)+ .then_some(None)+ .into_iter()+ .chain(app.ui.custom_toolbars.iter().filter(|b| b.shown && !b.floating.is_empty()).map(|b| Some(b.name.clone())))+ .collect();+ for owner in owners {+ floating_strips(app, ctx, owner.as_deref()); }+}++/// The strips torn off one toolbar (`toolbar` none: the Tools panel's).+fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Option<&str>) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); let now = ctx.input(|i| i.time); let active = app.session.tool_id(); let mut chosen = None; let mut closed = None;- for (k, f) in app.ui.floating_flyouts.iter_mut().enumerate() {+ let Some(strips) = strips_mut(&mut app.ui, toolbar) else { return };+ for (k, f) in strips.iter_mut().enumerate() { let Some(key) = f.tools.first() else { continue };- let id = floating_area(key);+ let id = strip_area(toolbar, key); let since = ctx.data(|d| d.get_temp::<f64>(id)).map(|t0| now - t0); let flash = since.is_some_and(|s| (0.0..FLASH).contains(&s)); if let Some(s) = since.filter(|_| flash) {@@ -811,12 +895,12 @@ fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) { let pos = crate::tools_float::clamp(f.pos, size, screen); let mut input = FlyoutInput::default(); egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| {- flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true));+ flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true, toolbar)); }); // Dragging the bar keeps the flyout where the pointer holds it (by an offset rather than // each frame's motion, which a just torn-off flyout's first, unseen frame would drop).- let grab = grab_id(key);- if ctx.is_being_dragged(bar_id(key))+ let grab = grab_id(toolbar, key);+ if ctx.is_being_dragged(bar_id(toolbar, key)) && let Some(at) = ctx.input(|i| i.pointer.interact_pos()) { let off = ctx.data_mut(|m| *m.get_temp_mut_or_insert_with(grab, || at - pos));@@ -832,8 +916,8 @@ fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) { } chosen = chosen.or(input.chosen); }- if let Some(k) = closed.filter(|k| *k < app.ui.floating_flyouts.len()) {- app.ui.floating_flyouts.remove(k);+ if let Some(k) = closed.filter(|k| *k < strips.len()) {+ strips.remove(k); } if let Some(id) = chosen { app.select_tool(id);@@ -12,7 +12,7 @@ use std::sync::atomic::Ordering; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::state::{Dialog, DockTab, FloatingFlyout, FloatingPanels, UiState};+use crate::state::{CustomToolbar, Dialog, DockTab, FloatingFlyout, FloatingPanels, UiState}; use crate::theme::Tokens; use crate::{VectorcraftApp, widgets}; @@ -54,6 +54,9 @@ pub struct Workspace { /// Main dock column width in screen points while `docking` is none (#1753). #[serde(default, skip_serializing_if = "Option::is_none")] pub dock_width: Option<f32>,+ /// The toolbars made with New Toolbar in this workspace (built-in workspaces: none).+ #[serde(default, skip_serializing_if = "Vec::is_empty")]+ pub toolbars: Vec<CustomToolbar>, } impl Default for Workspace {@@ -139,7 +142,8 @@ pub fn builtins() -> Vec<Workspace> { /// Preferences › Devices › Enable Touch Workspace (#394): on, a touch screen switches to the Touch /// workspace; off, or when no touch screen is reported, the workspace before that switch is-/// restored if Touch was entered automatically.+/// restored if Touch was entered automatically. Each switch keeps the layout it leaves and brings+/// back the one it returns to as it was left, as Window › Workspace does. pub fn sync_touch_workspace(app: &mut VectorcraftApp, touch_screen: bool) { let auto = app.session.prefs.touch_workspace && touch_screen; if auto {@@ -147,15 +151,13 @@ pub fn sync_touch_workspace(app: &mut VectorcraftApp, touch_screen: bool) { if app.ui.touch_workspace_restore.is_none() { app.ui.touch_workspace_restore = Some(app.ui.workspace.clone()); }- if let Some(w) = find(&app.ui, TOUCH) {- apply(&mut app.ui, &w);- }+ // Touch is built in, so the switch finds it.+ let _ = switch(&mut app.ui, TOUCH); } } else if app.ui.touch_workspace_restore.is_some() && app.ui.workspace == TOUCH { let back = app.ui.touch_workspace_restore.take().filter(|n| find(&app.ui, n).is_some()).unwrap_or_else(|| ESSENTIALS.to_string());- if let Some(w) = find(&app.ui, &back) {- apply(&mut app.ui, &w);- }+ // `back` names a workspace that exists (or Essentials), so the switch finds it.+ let _ = switch(&mut app.ui, &back); } else if !auto { app.ui.touch_workspace_restore = None; }@@ -188,6 +190,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { docking_collapsed: Default::default(), toolbar_pos: ui.toolbar_pos, dock_width: ui.dock_width,+ toolbars: ui.custom_toolbars.clone(), } } @@ -212,6 +215,12 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.toolbar_pos = w.toolbar_pos; ui.dock_width = w.dock_width; crate::state::sanitize_toolbar_pos(&mut ui.toolbar_pos);+ ui.custom_toolbars = w.toolbars.clone();+ crate::custom_toolbars::sanitize(&mut ui.custom_toolbars);+ // The drawer edits a toolbar of the workspace left: it closes.+ if ui.drawer_toolbar.take().is_some() {+ ui.tools_drawer = false;+ } ui.flyout = None; ui.workspace = w.name.clone(); }@@ -241,10 +250,26 @@ pub fn switch(ui: &mut UiState, name: &str) -> Result<String, String> { pub fn reset(ui: &mut UiState) -> String { let w = find_saved(ui, &ui.workspace.clone()).unwrap_or_default(); ui.workspace_changes.retain(|x| !x.name.eq_ignore_ascii_case(&w.name));+ // The toolbars made with New Toolbar stay in the workspace they were made in.+ let toolbars = std::mem::take(&mut ui.custom_toolbars); apply(ui, &w);+ ui.custom_toolbars = toolbars; w.name } +/// Keep the toolbars made with New Toolbar in the current workspace's changes, and nothing else of+/// its layout that its changes do not already hold.+fn keep_toolbars(ui: &mut UiState) {+ let Some(saved) = find_saved(ui, &ui.workspace.clone()) else { return };+ if let Some(w) = ui.workspace_changes.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&saved.name)) {+ w.toolbars = ui.custom_toolbars.clone();+ } else if saved.toolbars != ui.custom_toolbars {+ ui.workspace_changes.push(Workspace { toolbars: ui.custom_toolbars.clone(), ..saved });+ let over = ui.workspace_changes.len().saturating_sub(64);+ ui.workspace_changes.drain(..over);+ }+}+ /// A workspace by name as saved (user workspaces shadow nothing: names are unique across both lists). pub fn find_saved(ui: &UiState, name: &str) -> Option<Workspace> { ui.custom_workspaces@@ -274,7 +299,11 @@ pub fn save_as(ui: &mut UiState, name: &str) -> Result<(), String> { return Err(format!("`{name}` is a built-in workspace; choose another name")); } // The changes go into the workspace saved, which starts with none; the one left keeps the- // changes it had (none of these).+ // changes it had (none of these), and the toolbars made with New Toolbar, which the workspace+ // saved starts with a copy of.+ if !ui.workspace.eq_ignore_ascii_case(name) {+ keep_toolbars(ui);+ } // A user workspace of that name in another case is replaced, as `find_saved` would find it. let w = capture(ui, name); match ui.custom_workspaces.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {@@ -349,6 +349,20 @@ of `fillStroke`, `coloring`, `drawMode`, `screenMode`) shows or hides the contro saved with the preferences (`ui.toolbar_show`). The toolbar names its groups only with Preferences › User Interface › Show Tool Group Labels, off as it ships (#1736). +New Toolbar: Window › Toolbars › New Toolbar… (or New Toolbar… in the drawer's ☰ menu) asks for a name, and+`window.toolbar.new {name}` makes the toolbar at once: a blank toolbar that floats on its own, moved by its title+bar; its tools scroll when the window is too short for them. The title bar's × hides it, and its double arrow+(`window.toolbarColumns {toolbar, double?}`) shows its tools in one column or two. Window › Toolbars lists each+toolbar by its name, and its item (`window.toolbar.custom {name, show?}`) shows or hides it. Window › Tools hides+the Tools panel alone, and Tab hides these toolbars with the other panels. A toolbar's ••• button+(`window.editToolbar {toolbar}`) opens the All Tools drawer beside it, and the drawer's drags and+`toolbar.addTool`, `toolbar.removeTool` and `toolbar.reset` with `toolbar` edit it; the drawer has no Show section+there. `window.floatTools {tool, toolbar}` floats one of its groups, and a group torn off it shows and hides with+it. `window.toolbar.list` returns the toolbars (`ui.custom_toolbars` in `ui.inspect`: name, slots, shown flag,+two-column flag, top-left corner, the groups torn off it). They are kept with the workspace they were made in:+switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new+workspace with a copy of them.+ 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@@ -519,8 +519,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen. - **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged. - **Done (#2849):** View › Rulers › Show Video Rulers (`view.videoRulers`): the rulers show and count the active artboard in video pixels from its top-left corner, a pixel as many points wide as the artboard's Video Ruler Pixel Aspect Ratio and one point tall ([public source](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)); Hide Video Rulers returns them to the General unit and the ruler origin. Shift-snapping a new guide to ruler ticks still uses the General unit's ticks. Estimate unchanged.-- **Missing (noted for reporters, 2026-10-14):** custom toolbars (Window › Toolbars › New Toolbar, #1759);- panel widths kept with a workspace (#1753). Inside the estimate below.+- **Partial:** custom toolbars (#1759): Window › Toolbars › New Toolbar makes them, kept with the workspace they+ were made in; Manage Toolbars (rename, copy, delete) is missing. Inside the estimate below.+- **Done (#1753):** a saved workspace keeps the width of the dock column. - **Done (#4135):** floating panels open in OS windows of their own on the desktop, so they can sit on another monitor; on the web and on Wayland they float inside the window. - **Done:** View › New View… and Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)): a document keeps up to 25 named views (zoom, centre and rotation), saved with the file; every one is listed by name at the bottom of the View menu (`view.goto1`–`view.goto25`), and choosing one changes the current window to it. Edit Views… lists them to rename or delete, applied together on OK as one undo step (`view.saved.edit {edits}`); a file's views are checked when it opens. Estimate unchanged. - **Missing:** multiple windows and Window › Arrange (Cascade, Tile, Float in Window, Consolidate All Windows).@@ -560,6 +560,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent. - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.+ - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. Each workspace keeps the toolbars made in it (<https://helpx.adobe.com/illustrator/using/tools.html>; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). Manage Toolbars is not done. - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian and Georgian ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).@@ -276,7 +276,8 @@ empty), without opening the dialog. | Panels dragged onto a dock group's tabs join it; onto its upper or lower half they make a new group above or below it (a top/bottom split, resizable); onto the icon column they become icons, the tabbed group's panels too; a dock tab's context menu closes it (Window menu reopens it) | done | `window.panel.dock {anchor, zone}`, `window.panel.dock {icon: true}`, `window.panel.close` | | A double-click on a dock group's tab collapses the group to its tabs (a group below it moves up into the room) and a second one expands it; a click on another of its tabs expands it showing that panel ([helpx: Workspace basics](https://helpx.adobe.com/illustrator/using/workspace-basics.html), panel groups) | done | `window.panel.collapse {panel, collapsed?}`; in dock groups and floating windows; a group beside another (a left/right split) doesn't collapse | | Panels outside the app window (another monitor) | done | floating groups in OS windows of their own on the desktop (#4135) |-| 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; tool flyouts are as wide as their longest translated name and shortcut need (#1616) |+| 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; Manage Toolbars missing; tool flyouts are as wide as their longest translated name and shortcut need (#1616) |+| Window › Toolbars › New Toolbar… (also in the All Tools drawer's menu): a name and OK make a blank floating toolbar, listed under Window › Toolbars (its item shows or hides it); its title bar moves it, its × hides it and its double arrow shows its tools in one column or two; its ••• button opens the All Tools drawer beside it to fill it; kept with the workspace it was made in | done | #1759 in part; `window.toolbar.new`, `window.toolbar.custom`, `window.toolbar.list`, the `toolbar` param of `window.toolbarColumns`, `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset` | | Quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button (Control bar, Properties) opens the editable controls | done | Per the public documentation, choosing a preset traces the image with it straight away, and the panel is opened with the Image Trace button in the Control panel or in Properties' Image Trace section (<https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html>). The controls show Threshold, Paths, Corners and Noise with Advanced open; `panels/image_trace.rs` headless tests (`a_quick_trace_leaves_the_panel_as_it_was`, `sketch_quick_trace_reveals_all_adjustments_and_keeps_the_source` for the button's route). No algorithm parity claim. | | Image Trace panel: holding the eye button next to View down shows the selected Image Trace objects' source image on the canvas | done | Per the public documentation, the control next to View is pressed and held to compare the trace result with the source image (<https://web.archive.org/web/20250831090623/https://helpx.adobe.com/illustrator/using/image-trace.html>). On release the objects show their View again; the document, its View and the undo history are unchanged. `RenderOptions::trace_source`; `panels/image_trace.rs` headless test `holding_the_eye_button_shows_the_source_image_until_released`, render test `trace_source_draws_the_source_image_of_the_listed_objects`. The Control bar and Properties have no such button | | Live trace result bounds and selection highlights exclude the retained bitmap; source-image views retain it | done | All TraceView modes and rotated result bounds regression-covered; ordinary hidden group behavior retained |