Files
vectorcraft/crates/ui-egui/src/custom_toolbars.rs
T
dotCooCoo 315ff52ffd Workspace: make keeping Tools beside the canvas an option, off by default
Docked Tools stays at the window's left edge again, outside any panels
docked on the left. Window › Toolbars › Keep Tools Beside the Canvas
(`window.toolsBesideCanvas {on?}`) docks it between those panels and the
canvas instead.

- The setting is its own field in the UI state and in workspaces, and a
  missing field reads as off, so saved state and workspaces keep Tools at
  the window's edge. Built-in workspaces have it off.
- Off, app.rs draws docked Tools before the docking layout, the canvas
  reserves no width for it, and only the full-height window-edge strip
  docks a floating Tools panel. On, the canvas panel draws it, the canvas
  keeps room for it, and both the window edge and the canvas edge dock it
  beside the canvas.
- Turning the setting off forgets the canvas-edge strip, so a strip laid
  out while it was on does not dock Tools. A floating Tools panel stays
  floating when the setting changes.
- `window.panel.dock {panel: "tools"}` docks Tools where the setting says.
- The menu label has a row in every catalog.

Docs: docs/mcp.md, docs/control-protocol.md, the ROADMAP progress log and
docs/target-app-parity.md. The headline-numbers row and the paragraphs
under the status lines are gone, and scores are unchanged.
2026-11-08 23:51:40 +07:00

1624 lines
87 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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`).
//! Window › Toolbars › Manage Toolbars (also in the drawer's menu) renames, copies and deletes them.
//!
//! 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. Manage toolbars: Manage Toolbars from Window › Toolbars or the drawer's flyout
//! menu; a new name typed for the selected toolbar renames it, + copies the selected toolbar or,
//! with none selected, makes a new one, and Delete deletes 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;
/// The most characters a toolbar's name has.
const MAX_NAME: usize = 200;
/// 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 name.chars().count() > MAX_NAME {
return Err(format!("a toolbar name has at most {MAX_NAME} characters"));
}
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![],
});
show_alone_after_tab(ui, name);
Ok(name.to_string())
}
/// Toolbar `name`, shown after Tab hid the toolbars, shows alone, as Window › Tools shows the
/// Tools panel: the other toolbars Tab hid are turned off, and the next Tab hides it.
fn show_alone_after_tab(ui: &mut UiState, name: &str) {
if ui.tab_hid.take().is_some() {
for b in ui.custom_toolbars.iter_mut().filter(|b| b.name != name) {
b.shown = false;
}
}
}
/// Where the toolbar named `name` is in the list, whatever its case.
fn index(ui: &UiState, name: &str) -> Result<usize, String> {
let k = key(name);
ui.custom_toolbars.iter().position(|b| key(&b.name) == k).ok_or_else(|| format!("no toolbar named `{name}`"))
}
/// The All Tools drawer beside the toolbar named `old`, and a group's flyout open on it (`ctx`),
/// close when it is deleted (`new` is `None`) or renamed; a new name that changes only the case
/// leaves them open on it.
fn close_popups(ui: &mut UiState, ctx: Option<&egui::Context>, old: &str, new: Option<&str>) {
let same = new.is_some_and(|n| key(n) == key(old));
if drawer_target(ui).map(key) == Some(key(old)) {
if same {
ui.drawer_toolbar = new.map(String::from);
} else {
ui.tools_drawer = false;
ui.drawer_toolbar = None;
}
}
let Some(ctx) = ctx else { return };
let id = crate::toolbar::flyout_toolbar_id();
if ui.flyout.is_some() && ctx.data(|d| d.get_temp::<Option<String>>(id)).flatten().is_some_and(|t| key(&t) == key(old)) {
if same {
ctx.data_mut(|d| d.insert_temp(id, new.map(String::from)));
} else {
ui.flyout = None;
}
}
}
/// Give the toolbar named `name` the name `to`; returns the new name.
pub(crate) fn rename(ui: &mut UiState, ctx: Option<&egui::Context>, name: &str, to: &str) -> Result<String, String> {
let to = to.trim();
if to.is_empty() {
return Err("toolbar name can't be empty".into());
}
if to.chars().count() > MAX_NAME {
return Err(format!("a toolbar name has at most {MAX_NAME} characters"));
}
let k = index(ui, name)?;
if ui.custom_toolbars.iter().enumerate().any(|(i, b)| i != k && key(&b.name) == key(to)) {
return Err(format!("there is already a toolbar named `{to}`"));
}
let old = ui.custom_toolbars.get(k).map(|b| b.name.clone()).unwrap_or_default();
close_popups(ui, ctx, &old, Some(to));
if let Some(b) = ui.custom_toolbars.get_mut(k) {
b.name = to.to_string();
}
Ok(to.to_string())
}
/// A copy of the toolbar named `name`, with its tools, shown a little below it (further down
/// while an earlier copy is there) and called
/// "`name` Copy" (then "`name` Copy 2"…); without `name`, a blank toolbar called "Toolbar N".
/// Returns the new toolbar's name.
pub(crate) fn duplicate(ui: &mut UiState, name: Option<&str>) -> Result<String, String> {
let Some(name) = name else {
let name = (1..).map(|i| format!("Toolbar {i}")).find(|n| find(ui, n).is_none()).unwrap_or_default();
return create(ui, &name);
};
let k = index(ui, name)?;
if ui.custom_toolbars.len() >= MAX {
return Err(format!("a workspace keeps at most {MAX} toolbars"));
}
let Some(mut copy) = ui.custom_toolbars.get(k).cloned() else { return Err(format!("no toolbar named `{name}`")) };
// A long name is shortened so that "Copy" and its number fit in MAX_NAME characters.
let stem: String = copy.name.chars().take(MAX_NAME - " Copy 64".len()).collect();
let base = format!("{} Copy", stem.trim_end());
copy.name = std::iter::once(base.clone()).chain((2..).map(|i| format!("{base} {i}"))).find(|n| find(ui, n).is_none()).unwrap_or_default();
copy.shown = true;
let from = copy.pos;
let at = |n: f32| [from[0] + 16.0 * n, from[1] + 24.0 * n];
let n = (1..=MAX + 1).map(|n| n as f32).find(|&n| ui.custom_toolbars.iter().all(|b| b.pos != at(n))).unwrap_or(1.0);
copy.pos = at(n);
for f in &mut copy.floating {
f.pos = [f.pos[0] + 16.0 * n, f.pos[1] + 24.0 * n];
}
let name = copy.name.clone();
ui.custom_toolbars.push(copy);
show_alone_after_tab(ui, &name);
Ok(name)
}
/// Delete the toolbar named `name`, with the groups torn off it.
pub(crate) fn delete(ui: &mut UiState, ctx: Option<&egui::Context>, name: &str) -> Result<(), String> {
let k = index(ui, name)?;
close_popups(ui, ctx, name, None);
ui.custom_toolbars.remove(k);
Ok(())
}
/// Keep named toolbars, each name once whatever its case and at most [`MAX_NAME`] characters, of
/// known tools, each once in its toolbar, at a position that is a number, with one strip per group
/// torn off them, and each group's tool chosen last only when the group holds it (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());
let slots = &b.slots;
b.slot_tool.retain(|first, id| slots.iter().any(|s| s.first() == Some(first) && s.contains(id)));
crate::state::FloatingFlyout::sanitize(&mut b.floating);
keep_strips(b);
if !b.pos.iter().all(|v| v.is_finite()) {
b.pos = NEW_AT;
}
!b.name.is_empty() && b.name.chars().count() <= MAX_NAME && names.insert(key(&b.name))
});
bars.truncate(MAX);
}
/// Keep the strips torn off `bar` whose group it still holds, each with the group's tools as they
/// are now; a strip whose group is gone, now starts with another tool or holds one tool alone goes
/// back.
pub(crate) fn keep_strips(bar: &mut CustomToolbar) {
let slots = &bar.slots;
bar.floating.retain_mut(|f| match f.tools.first().and_then(|k| slots.iter().find(|s| s.len() > 1 && s.first() == Some(k))) {
Some(slot) => {
f.tools.clone_from(slot);
true
}
None => false,
});
}
/// 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());
if shown {
show_alone_after_tab(&mut app.ui, &name);
}
// The drawer beside a toolbar that is hidden closes with it, and so does an open flyout.
if !shown {
app.ui.flyout = None;
if drawer_target(&app.ui) == Some(name.as_str()) {
app.ui.tools_drawer = false;
app.ui.drawer_toolbar = None;
}
}
Ok(json!(shown))
})(),
"window.toolbar.manage" => {
app.ui.dialog = Some(Dialog::new(MANAGE, json!({ "selected": "", "name": "" })));
Ok(Value::Null)
}
"window.toolbar.rename" => (|| {
let name = p.get("name").and_then(Value::as_str).ok_or("name (a toolbar's name) is required")?;
let to = p.get("to").and_then(Value::as_str).ok_or("to (the new name) is required")?;
rename(&mut app.ui, app.ui_ctx.as_ref(), name, to).map(|name| json!({ "toolbar": name }))
})(),
"window.toolbar.duplicate" => (|| {
let name = match p.get("name").filter(|v| !v.is_null()) {
Some(v) => Some(v.as_str().ok_or("name must be a toolbar's name")?),
None => None,
};
duplicate(&mut app.ui, name).map(|name| json!({ "toolbar": name }))
})(),
"window.toolbar.delete" => (|| {
let name = p.get("name").and_then(Value::as_str).ok_or("name (a toolbar's name) is required")?;
delete(&mut app.ui, app.ui_ctx.as_ref(), name).map(|_| Value::Null)
})(),
"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);
// As wide as the field and as tall as the buttons, as in `dialogs::show`: a right-to-left
// layout would take all the height left in the window and center the buttons in it.
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
ui.allocate_ui_with_layout(row, 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);
}
}
/// Manage Toolbars' dialog.
pub(crate) const MANAGE: &str = "manageToolbars";
/// The name typed in Manage Toolbars' text box becomes the selected toolbar's (a no-op while it is
/// the same). Returns true when the selected toolbar was deleted or renamed while the dialog was
/// open (a command, another workspace): none is selected then, and the name typed for it is dropped.
fn rename_selected(app: &mut VectorcraftApp) -> Result<bool, String> {
let Some(d) = app.ui.dialog.as_ref() else { return Ok(false) };
let (sel, name) = (d.str("selected"), d.str("name"));
if !sel.is_empty() && find(&app.ui, &sel).is_none() {
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert("selected".into(), json!(""));
d.fields.insert("name".into(), json!(""));
}
return Ok(true);
}
if sel.is_empty() || sel == name.trim() {
return Ok(false);
}
let new = app.run("window.toolbar.rename", json!({ "name": sel, "to": name }))?;
let new = new.get("toolbar").and_then(Value::as_str).unwrap_or_default().to_string();
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert("selected".into(), json!(new));
d.fields.insert("name".into(), json!(new));
}
Ok(false)
}
/// OK in Manage Toolbars: the typed name is kept, and the dialog closes.
pub(crate) fn confirm_manage(app: &mut VectorcraftApp) -> Result<Value, String> {
rename_selected(app)?;
app.ui.dialog = None;
Ok(Value::Null)
}
/// The Manage Toolbars dialog: the toolbars made with New Toolbar; the selected one's name in the
/// text box, where a new name renames it; + copies the selected toolbar (none selected: makes a
/// blank one), and the trash can deletes it.
pub(crate) fn show_manage(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 commit) = (false, false);
// A toolbar clicked in the list (none: the click left none selected), + (true) or the trash
// can (false): each applied after the name typed for the selected toolbar.
let mut pick: Option<String> = None;
let mut action: Option<bool> = None;
crate::dialogs::modal::show(ctx, "Manage Toolbars", egui::Id::unique("dialog-manage-toolbars"), -40.0, 22, |ui| {
ui.set_width(320.0);
crate::dialogs::modal::heading(ui, tl!("Manage Toolbars"));
ui.add_space(12.0);
let sel = d.str("selected");
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_min_height(140.0);
ui.set_width(ui.available_width());
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
for b in &app.ui.custom_toolbars {
let on = !sel.is_empty() && key(&sel) == key(&b.name);
if ui.selectable_label(on, egui::RichText::new(&b.name).color(t.text)).clicked() {
// A click on the selected toolbar leaves none selected.
pick = Some(if on { String::new() } else { b.name.clone() });
}
}
});
});
ui.add_space(8.0);
ui.horizontal(|ui| {
let mut name = d.str("name");
let r = ui.add_enabled(!sel.is_empty(), egui::TextEdit::singleline(&mut name).desired_width(220.0));
if r.changed() {
d.fields.insert("name".into(), json!(name));
}
commit = r.lost_focus();
let tip = if sel.is_empty() { tl!("New Toolbar") } else { tl!("Duplicate Toolbar") };
if widgets::icon_button(ui, "plus", tip, false, 24.0).clicked() {
action = Some(true);
}
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Toolbar"), false, !sel.is_empty(), 24.0).clicked() {
action = Some(false);
}
});
ui.add_space(16.0);
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
}
});
});
app.ui.dialog = Some(d);
if ok {
if let Err(e) = confirm_manage(app) {
app.status(e);
}
return;
}
manage_step(app, commit, pick, action);
}
/// What the Manage Toolbars dialog does after a frame: the name typed for the selected toolbar is
/// applied when the text box loses focus, another toolbar is clicked (`pick`) or + (`Some(true)`)
/// or the trash can (`Some(false)`) is clicked. When that rename fails, the click is not applied,
/// so the name typed can be corrected; when the selected toolbar has gone meanwhile, + and the trash
/// can are not applied either.
fn manage_step(app: &mut VectorcraftApp, commit: bool, pick: Option<String>, mut action: Option<bool>) {
if commit || pick.is_some() || action.is_some() {
match rename_selected(app) {
Err(e) => {
app.status(e);
return;
}
// The toolbar + or the trash can was meant for is gone.
Ok(true) => action = None,
Ok(false) => {}
}
}
let sel = app.ui.dialog.as_ref().map(|d| d.str("selected")).unwrap_or_default();
let done = match action {
Some(true) => Some(app.run("window.toolbar.duplicate", if sel.is_empty() { json!({}) } else { json!({ "name": sel }) })),
Some(false) => Some(app.run("window.toolbar.delete", json!({ "name": sel }))),
None => None,
};
// The new toolbar is selected, ready to be renamed; after Delete, none is.
let select = match done {
Some(Ok(r)) => Some(r.get("toolbar").and_then(Value::as_str).unwrap_or_default().to_string()),
Some(Err(e)) => {
app.status(e);
None
}
None => pick,
};
if let Some(name) = select
&& let Some(d) = app.ui.dialog.as_mut()
{
d.fields.insert("selected".into(), json!(name));
d.fields.insert("name".into(), json!(name));
}
}
// ---------- 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, Double Column and Keep Tools Beside the Canvas, the toolbars made
/// with New Toolbar (each checked while it shows), New Toolbar… and Manage Toolbars….
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"),
c("Keep Tools Beside the Canvas", "window.toolsBesideCanvas"),
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.push(c("Manage Toolbars…", "window.toolbar.manage"));
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) {
// Tab hides them, also when Window › Tools shows the Tools panel again.
if app.ui.tab_hid.is_some() || 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));
}
// Not focusable (`Sense::drag()` is): only the pointer drags the grip, so Tab does not stop on it.
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();
let last: Vec<_> = toolbars_menu(&app).iter().rev().take(2).map(|(l, id, _)| (*l, *id)).collect();
assert_eq!(last, [("Manage Toolbars…", "window.toolbar.manage"), ("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; an open flyout closes.
app.ui.flyout = Some(0);
app.run("window.toolbar.custom", json!({"name": "Mine", "show": false})).unwrap();
assert_eq!(app.ui.flyout, None, "the flyout closes with the toolbar");
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");
// With the dock, the Tools panel and the Control bar all off, Tab still hides it first, and
// a second Tab shows it alone.
(app.ui.dock, app.ui.toolbar, app.ui.control_bar) = (false, false, false);
app.run("window.dock", json!({})).unwrap();
frames(&mut app);
assert!(!drawn(&ctx, area_id("Mine"), egui::Order::Middle), "Tab hides it with the other bars off");
assert!(!app.ui.dock && !app.ui.toolbar && !app.ui.control_bar);
app.run("window.dock", json!({})).unwrap();
frames(&mut app);
assert!(drawn(&ctx, area_id("Mine"), egui::Order::Middle), "a second Tab shows it again");
assert!(!app.ui.dock && !app.ui.toolbar && !app.ui.control_bar, "and only it");
// Hidden with its item while Tab hides it, it stays hidden, and Tab turns no other bar on.
app.run("window.dock", json!({})).unwrap();
app.run("window.toolbar.custom", json!({"name": "Mine", "show": false})).unwrap();
app.run("window.dock", json!({})).unwrap();
frames(&mut app);
assert!(!drawn(&ctx, area_id("Mine"), egui::Order::Middle));
assert!(!app.ui.dock && !app.ui.toolbar && !app.ui.control_bar, "the layout Tab hid comes back");
// Shown with its item after Tab hid the toolbars, it shows alone, as Window › Tools shows
// the Tools panel; the other toolbar Tab hid is unchecked, and the next Tab hides it.
app.run("window.toolbar.new", json!({"name": "Other"})).unwrap();
app.run("window.dock", json!({})).unwrap();
assert!(app.ui.tab_hid.is_some());
app.run("window.toolbar.custom", json!({"name": "Mine", "show": true})).unwrap();
frames(&mut app);
assert!(drawn(&ctx, area_id("Mine"), egui::Order::Middle), "shown with its item after Tab");
assert!(!drawn(&ctx, area_id("Other"), egui::Order::Middle));
assert!(!find(&app.ui, "Other").unwrap().shown, "the menu shows what is on screen");
app.run("window.dock", json!({})).unwrap();
frames(&mut app);
assert!(!drawn(&ctx, area_id("Mine"), egui::Order::Middle), "the next Tab hides it");
// A flyout of the Tools panel closes when Window › Tools hides the panel; one of a toolbar
// made with New Toolbar stays open.
app.run("window.dock", json!({})).unwrap();
app.ui.toolbar = true;
frames(&mut app);
let open = |app: &mut VectorcraftApp, toolbar: Option<String>| {
app.ui.flyout = Some(0);
// A group not torn off as a strip of the same toolbar.
let group = if toolbar.is_some() { ["rectangle", "ellipse"] } else { ["pen", "curvature"] };
ctx.data_mut(|d| {
d.insert_temp(egui::Id::unique("flyout-tools"), group.map(String::from).to_vec());
d.insert_temp(crate::toolbar::flyout_toolbar_id(), toolbar);
});
};
open(&mut app, None);
app.run("window.toolbar", json!({})).unwrap();
assert!(!app.ui.toolbar);
frames(&mut app);
assert_eq!(app.ui.flyout, None, "the hidden Tools panel's flyout closes");
assert!(!drawn(&ctx, egui::Id::unique("tool-flyout"), egui::Order::Foreground));
open(&mut app, Some("Mine".into()));
frames(&mut app);
assert_eq!(app.ui.flyout, Some(0), "a toolbar's own flyout stays open");
app.ui.flyout = None;
// Window › Tools after Tab shows the Tools panel alone; a toolbar made with New Toolbar
// then shows alone too.
app.run("window.dock", json!({})).unwrap();
app.run("window.toolbar", json!({})).unwrap();
frames(&mut app);
assert!(app.ui.toolbar && !drawn(&ctx, area_id("Mine"), egui::Order::Middle), "Window › Tools shows only the Tools panel");
app.run("window.toolbar.new", json!({"name": "Third"})).unwrap();
frames(&mut app);
assert!(drawn(&ctx, area_id("Third"), egui::Order::Middle), "a new toolbar shows after Tab");
assert!(!drawn(&ctx, area_id("Mine"), egui::Order::Middle) && !find(&app.ui, "Mine").unwrap().shown);
}
/// 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 tool taken off a toolbar made with New Toolbar leaves the group torn off it too, and the
/// strip of a group the toolbar no longer holds goes, as Reset blanks the toolbar.
#[test]
fn groups_torn_off_follow_the_toolbars_tools() {
let mut app = app();
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();
for tool in ["curvature", "rectangle"] {
app.run("toolbar.addTool", json!({"tool": tool, "toolbar": "Mine", "slot": 0, "place": "group"})).unwrap();
}
app.run("window.floatTools", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();
let strips = |app: &VectorcraftApp| find(&app.ui, "Mine").unwrap().floating.iter().map(|f| f.tools.join(",")).collect::<Vec<_>>();
assert_eq!(strips(&app), ["pen,curvature,rectangle"]);
app.run("toolbar.removeTool", json!({"tool": "curvature", "toolbar": "Mine"})).unwrap();
assert_eq!(strips(&app), ["pen,rectangle"]);
app.run("toolbar.reset", json!({"toolbar": "Mine"})).unwrap();
assert!(strips(&app).is_empty() && !crate::toolbar::is_floating(&app, Some("Mine"), "pen"));
}
/// A toolbar's name has at most [`MAX_NAME`] characters.
#[test]
fn a_toolbar_name_past_the_limit_is_refused() {
let mut app = app();
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let long = "x".repeat(MAX_NAME + 1);
assert!(app.run("window.toolbar.new", json!({ "name": long })).is_err());
assert!(app.ui.custom_toolbars.is_empty());
app.run("window.toolbar.new", json!({ "name": &long[1..] })).unwrap();
assert_eq!(app.ui.custom_toolbars.len(), 1);
}
/// 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));
}
fn listed(app: &VectorcraftApp) -> Vec<&'static str> {
toolbars_menu(app).into_iter().filter(|(_, id, _)| *id == "window.toolbar.custom").map(|(l, ..)| l).collect()
}
/// Manage Toolbars renames, copies and deletes the toolbars made with New Toolbar: each change
/// shows under Window › Toolbars and stays in the workspace after switching away and back.
#[test]
fn manage_toolbars_renames_duplicates_and_deletes() {
let mut app = app();
let menu = toolbars_menu(&app);
assert_eq!(menu.last().map(|(l, id, _)| (*l, *id)), Some(("Manage Toolbars…", "window.toolbar.manage")));
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();
app.run("window.toolbar.new", json!({"name": "Other"})).unwrap();
let back = |app: &mut VectorcraftApp| {
app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
app.run("window.workspace", json!({"name": "Drawing"})).unwrap();
};
// Rename.
assert_eq!(app.run("window.toolbar.rename", json!({"name": "mine", "to": " Pens "})).unwrap(), json!({"toolbar": "Pens"}));
assert_eq!(listed(&app), ["Pens", "Other"]);
back(&mut app);
assert_eq!(listed(&app), ["Pens", "Other"]);
assert_eq!(find(&app.ui, "Pens").unwrap().slots, [["pen"]]);
assert!(app.run("window.toolbar.rename", json!({"name": "Pens", "to": "other"})).unwrap_err().contains("already"));
assert!(app.run("window.toolbar.rename", json!({"name": "Pens", "to": " "})).unwrap_err().contains("empty"));
assert!(app.run("window.toolbar.rename", json!({"name": "Nope", "to": "X"})).unwrap_err().contains("Nope"));
// Only the case changes.
app.run("window.toolbar.rename", json!({"name": "Pens", "to": "PENS"})).unwrap();
app.run("window.toolbar.rename", json!({"name": "pens", "to": "Pens"})).unwrap();
// Duplicate: a copy with its tools, shown; without a name, a blank toolbar.
app.run("window.toolbar.custom", json!({"name": "Pens", "show": false})).unwrap();
assert_eq!(app.run("window.toolbar.duplicate", json!({"name": "pens"})).unwrap(), json!({"toolbar": "Pens Copy"}));
assert_eq!(app.run("window.toolbar.duplicate", json!({"name": "Pens"})).unwrap(), json!({"toolbar": "Pens Copy 2"}));
assert_eq!(app.run("window.toolbar.duplicate", json!({})).unwrap(), json!({"toolbar": "Toolbar 1"}));
let copy = find(&app.ui, "Pens Copy").unwrap();
assert!(copy.shown && copy.slots == [["pen"]]);
assert_ne!(copy.pos, find(&app.ui, "Pens").unwrap().pos, "not on top of the toolbar it copies");
assert_ne!(copy.pos, find(&app.ui, "Pens Copy 2").unwrap().pos, "nor on top of an earlier copy");
assert!(find(&app.ui, "Toolbar 1").unwrap().slots.is_empty());
assert_eq!(listed(&app), ["Pens", "Other", "Pens Copy", "Pens Copy 2", "Toolbar 1"]);
// The copy is a toolbar of its own.
app.run("toolbar.addTool", json!({"tool": "rectangle", "toolbar": "Pens Copy"})).unwrap();
assert_eq!(find(&app.ui, "Pens").unwrap().slots, [["pen"]]);
back(&mut app);
assert_eq!(listed(&app), ["Pens", "Other", "Pens Copy", "Pens Copy 2", "Toolbar 1"]);
assert!(app.run("window.toolbar.duplicate", json!({"name": "Nope"})).is_err());
// Delete.
app.run("window.toolbar.delete", json!({"name": "pens copy 2"})).unwrap();
app.run("window.toolbar.delete", json!({"name": "Toolbar 1"})).unwrap();
assert_eq!(listed(&app), ["Pens", "Other", "Pens Copy"]);
back(&mut app);
assert_eq!(listed(&app), ["Pens", "Other", "Pens Copy"]);
assert!(app.run("window.toolbar.delete", json!({"name": "Toolbar 1"})).unwrap_err().contains("Toolbar 1"));
// Kept in the saved workspace and the preferences.
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();
crate::workspaces::switch(&mut ui, "Essentials").unwrap();
crate::workspaces::switch(&mut ui, "Drawing").unwrap();
assert_eq!(ui.custom_toolbars.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Pens", "Other", "Pens Copy"]);
}
/// Renaming or deleting the toolbar the All Tools drawer edits closes the drawer; a new name
/// that changes only the case leaves it open.
#[test]
fn renaming_or_deleting_the_toolbar_being_edited_closes_the_drawer() {
let mut app = app();
app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
app.run("window.toolbar.new", json!({"name": "Other"})).unwrap();
app.run("window.editToolbar", json!({"toolbar": "Mine", "open": true})).unwrap();
app.run("window.toolbar.rename", json!({"name": "Other", "to": "Second"})).unwrap();
assert_eq!(drawer_target(&app.ui), Some("Mine"), "another toolbar's rename leaves it open");
app.run("window.toolbar.rename", json!({"name": "Mine", "to": "MINE"})).unwrap();
assert_eq!(drawer_target(&app.ui), Some("MINE"), "a change of case leaves it open");
app.run("window.toolbar.rename", json!({"name": "MINE", "to": "Mine"})).unwrap();
app.run("window.toolbar.rename", json!({"name": "Mine", "to": "Pens"})).unwrap();
assert_eq!(drawer_target(&app.ui), None);
app.run("window.editToolbar", json!({"toolbar": "Pens", "open": true})).unwrap();
app.run("window.toolbar.delete", json!({"name": "Pens"})).unwrap();
assert!(!app.ui.tools_drawer && app.ui.drawer_toolbar.is_none());
}
/// The Manage Toolbars dialog: the name typed for the selected toolbar renames it at OK, and a
/// name another toolbar has keeps the dialog open.
#[test]
fn the_manage_toolbars_dialog_renames_the_selected_toolbar() {
let mut app = app();
app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
assert_eq!(app.run("window.toolbar.manage", json!({})).unwrap(), Value::Null);
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!(d.kind, MANAGE);
d.fields.insert("selected".into(), json!("Mine"));
d.fields.insert("name".into(), json!("Pens"));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert!(find(&app.ui, "Pens").is_some() && find(&app.ui, "Mine").is_none());
// A name taken by another toolbar keeps the dialog open.
app.run("window.toolbar.new", json!({"name": "Other"})).unwrap();
app.run("window.toolbar.manage", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("selected".into(), json!("Pens"));
d.fields.insert("name".into(), json!("other"));
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains("already"));
assert!(app.ui.dialog.is_some());
// Drawn with no toolbar selected, the dialog stays open.
let ctx = egui::Context::default();
app.ui.dialog.as_mut().unwrap().fields.insert("selected".into(), json!(""));
for k in 0..3 {
window_frame(&mut app, &ctx, f64::from(k) * 0.05, 800.0);
}
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == MANAGE));
}
/// An open flyout of a group on a toolbar closes when that toolbar is renamed or deleted; the
/// flyout of another toolbar or of the Tools panel stays open.
#[test]
fn renaming_or_deleting_a_toolbar_closes_only_its_own_flyout() {
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("window.toolbar.new", json!({"name": "Other"})).unwrap();
let mut time = 0.0;
let mut frames = |app: &mut VectorcraftApp| {
for _ in 0..2 {
time += 0.05;
window_frame(app, &ctx, time, 800.0);
}
};
frames(&mut app);
let open = |app: &mut VectorcraftApp, toolbar: Option<&str>| {
app.ui.flyout = Some(0);
ctx.data_mut(|d| {
d.insert_temp(egui::Id::unique("flyout-tools"), vec!["rectangle".to_string(), "ellipse".to_string()]);
d.insert_temp(crate::toolbar::flyout_toolbar_id(), toolbar.map(String::from));
});
};
for (toolbar, change) in [
(None, json!({"name": "Other", "to": "Second"})),
(Some("Mine"), json!({"name": "Second", "to": "Third"})),
(Some("Mine"), json!({"name": "Mine", "to": "mine"})),
] {
open(&mut app, toolbar);
app.run("window.toolbar.rename", change).unwrap();
frames(&mut app);
assert_eq!(app.ui.flyout, Some(0), "{toolbar:?}'s flyout stays open");
}
open(&mut app, Some("mine"));
app.run("window.toolbar.rename", json!({"name": "mine", "to": "Pens"})).unwrap();
frames(&mut app);
assert_eq!(app.ui.flyout, None, "the renamed toolbar's flyout closes");
open(&mut app, Some("Pens"));
app.run("window.toolbar.delete", json!({"name": "Third"})).unwrap();
frames(&mut app);
assert_eq!(app.ui.flyout, Some(0), "another toolbar's delete leaves it open");
app.run("window.toolbar.delete", json!({"name": "Pens"})).unwrap();
frames(&mut app);
assert_eq!(app.ui.flyout, None, "the deleted toolbar's flyout closes");
// Another toolbar that takes the old name before the next frame doesn't keep it open.
app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
app.run("window.toolbar.new", json!({"name": "Other"})).unwrap();
frames(&mut app);
open(&mut app, Some("Mine"));
app.run("window.toolbar.rename", json!({"name": "Mine", "to": "X"})).unwrap();
app.run("window.toolbar.rename", json!({"name": "Other", "to": "Mine"})).unwrap();
assert_eq!(app.ui.flyout, None, "renamed, then its name taken");
open(&mut app, Some("Mine"));
app.run("window.toolbar.delete", json!({"name": "Mine"})).unwrap();
app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
assert_eq!(app.ui.flyout, None, "deleted, then its name taken");
}
/// A toolbar deleted while Manage Toolbars has it selected leaves none selected, and a click
/// on + or the trash can meant for it does nothing, so the dialog can go on.
#[test]
fn manage_toolbars_lets_go_of_a_toolbar_deleted_elsewhere() {
let mut app = app();
app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
app.run("window.toolbar.new", json!({"name": "Other"})).unwrap();
for action in [Some(false), Some(true), None] {
app.run("window.toolbar.manage", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("selected".into(), json!("Mine"));
d.fields.insert("name".into(), json!("Typed"));
app.run("window.toolbar.delete", json!({"name": "Mine"})).unwrap();
manage_step(&mut app, action.is_none(), None, action);
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("selected"), d.str("name")), (String::new(), String::new()), "{action:?}");
assert_eq!(listed(&app), ["Other"], "{action:?}");
app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
app.run("window.toolbar.delete", json!({"name": "Other"})).unwrap();
app.run("window.toolbar.new", json!({"name": "Other"})).unwrap();
}
// OK closes the dialog.
app.ui.dialog.as_mut().unwrap().fields.insert("selected".into(), json!("Gone"));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
}
/// A name that can't be given to the selected toolbar stops a click on +, the trash can or
/// another toolbar, so the name typed stays to be corrected.
#[test]
fn a_refused_name_stops_the_next_click_in_manage_toolbars() {
let mut app = app();
app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
app.run("window.toolbar.new", json!({"name": "Other"})).unwrap();
app.run("window.toolbar.manage", json!({})).unwrap();
let typed = |app: &mut VectorcraftApp| {
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("selected".into(), json!("Mine"));
d.fields.insert("name".into(), json!("other"));
};
for (pick, action) in [(None, Some(false)), (None, Some(true)), (Some("Other".to_string()), None)] {
typed(&mut app);
manage_step(&mut app, false, pick, action);
assert_eq!(listed(&app), ["Mine", "Other"]);
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("selected"), d.str("name")), ("Mine".to_string(), "other".to_string()));
}
// A name that is free renames the toolbar first, then the trash can deletes it.
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Pens"));
manage_step(&mut app, false, None, Some(false));
assert_eq!(listed(&app), ["Other"]);
}
/// A new name and a copy's name keep to the length New Toolbar allows, so the toolbar is kept
/// when the preferences load again.
#[test]
fn renamed_and_copied_toolbars_keep_names_short_enough_to_load() {
let mut app = app();
let long = "x".repeat(MAX_NAME);
app.run("window.toolbar.new", json!({"name": long})).unwrap();
assert!(app.run("window.toolbar.rename", json!({"name": long, "to": "y".repeat(MAX_NAME + 1)})).unwrap_err().contains("at most"));
let copy = app.run("window.toolbar.duplicate", json!({"name": long})).unwrap();
let copy = copy["toolbar"].as_str().unwrap().to_string();
assert!(copy.ends_with(" Copy") && copy.chars().count() <= MAX_NAME, "{copy}");
let second = app.run("window.toolbar.duplicate", json!({"name": long})).unwrap();
assert!(second["toolbar"].as_str().unwrap().ends_with(" Copy 2"));
let mut bars = app.ui.custom_toolbars.clone();
sanitize(&mut bars);
assert_eq!(bars.len(), 3);
}
/// A copy made while Tab hides the toolbars shows alone, as a new toolbar does.
#[test]
fn a_copy_made_while_tab_hides_the_toolbars_shows_alone() {
let mut app = app();
app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
app.run("window.toolbar.new", json!({"name": "Other"})).unwrap();
app.ui.tab_hid = Some(Default::default());
app.run("window.toolbar.duplicate", json!({"name": "Mine"})).unwrap();
assert!(app.ui.tab_hid.is_none());
let shown: Vec<_> = app.ui.custom_toolbars.iter().filter(|b| b.shown).map(|b| b.name.as_str()).collect();
assert_eq!(shown, ["Mine Copy"]);
}
/// A hand-edited file: unnamed toolbars and repeated names go, unknown tools go, each tool once,
/// and a group shows a tool chosen last only when the group holds it.
#[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 long = "x".repeat(MAX_NAME + 1);
let mut bars = vec![
bar("A", &[&["pen", "nope", "curvature"], &["pen"], &["nope"]]),
bar("a", &[]),
bar(" ", &[]),
bar(&long, &[]),
bar("B", &[]),
bar("C", &[&["pen"]]),
];
// The tool a group shows: kept only for a group the toolbar holds, and a tool in it.
let chosen = |pairs: &[(&str, &str)]| pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
bars[0].slot_tool = chosen(&[("pen", "curvature"), ("curvature", "pen"), ("nope", "pen")]);
bars[4].slot_tool = chosen(&[("pen", "curvature")]);
bars[5].slot_tool = chosen(&[("pen", "curvature")]);
sanitize(&mut bars);
assert_eq!(bars.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["A", "B", "C"], "a name past {MAX_NAME} characters goes too");
assert_eq!(bars[0].slot_tool, chosen(&[("pen", "curvature")]));
assert!(bars[1].slot_tool.is_empty() && bars[2].slot_tool.is_empty(), "{:?} {:?}", bars[1].slot_tool, bars[2].slot_tool);
assert_eq!(bars[0].slots, [["pen", "curvature"]]);
assert_eq!(bars[0].pos, NEW_AT);
// One strip per group, of known tools, for a group the toolbar holds.
assert_eq!(bars[0].floating.iter().map(|f| f.tools.join(",")).collect::<Vec<_>>(), ["pen,curvature"]);
assert!(bars[1].floating.is_empty(), "B holds no group");
}
}