refactor(ui-egui): move edit_toolbar.rs tests to edit_toolbar/tests.rs

This commit is contained in:
aiongg
2026-10-26 18:18:17 +00:00
parent 04b9425f5e
commit 4a8e69490c
2 changed files with 375 additions and 376 deletions
+1 -376
View File
@@ -637,379 +637,4 @@ fn ghost(ctx: &egui::Context, t: &Tokens) {
}
#[cfg(test)]
mod tests {
use egui::{Color32, Event, PointerButton, RawInput};
use vectorcraft_engine::Session;
use super::*;
/// Tall enough for the whole Basic toolbar and the whole drawer.
const SCREEN: egui::Vec2 = vec2(1000.0, 4000.0);
/// A toolbar tool button.
const BUTTON: egui::Vec2 = vec2(36.0, 29.0);
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app
}
fn slots(app: &VectorcraftApp) -> Vec<Vec<&'static str>> {
crate::toolbar::slots(app).into_iter().map(|(_, s)| s).collect()
}
/// The slot holding `tool`.
fn slot_of(app: &VectorcraftApp, tool: &str) -> Option<usize> {
slots(app).iter().position(|s| s.contains(&tool))
}
/// One headless frame of the toolbar, its keys handled first as in the app; returns the texts
/// painted: (text, a point inside it, colour).
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<Event>) -> Vec<(String, Pos2, Color32)> {
fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2, Color32)>) {
match s {
egui::Shape::Text(t) => {
let color = t.galley.job.sections.first().map_or(t.fallback_color, |s| s.format.color);
out.push((t.galley.text().to_string(), t.pos + vec2(4.0, t.galley.size().y / 2.0), color));
}
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let input = RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
crate::shortcuts::handle(app, ui.ctx());
crate::toolbar::show(app, ui);
});
out.textures_delta.clear();
let mut shown = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
shown
}
fn ctx() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
ctx
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) {
frame(app, ctx, time, vec![Event::PointerMoved(at), button(at, true)]);
frame(app, ctx, time + 0.05, vec![button(at, false)]);
frame(app, ctx, time + 0.1, vec![]);
}
/// A press at `from` dragged to `to` in steps, one frame each, then released there.
fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: Pos2) {
frame(app, ctx, time, vec![Event::PointerMoved(from), button(from, true)]);
for k in 1..=6 {
frame(app, ctx, time + 0.05 * f64::from(k), vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]);
}
frame(app, ctx, time + 0.4, vec![button(to, false)]);
frame(app, ctx, time + 0.45, vec![]);
}
fn drawer_rect(ctx: &egui::Context) -> Option<Rect> {
ctx.memory(|m| m.area_rect(drawer_id()))
}
fn drawer_shown(ctx: &egui::Context) -> bool {
ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Foreground, drawer_id())))
}
/// A point inside the drawer's row of the tool named `name` (as the drawer shows it), and the
/// colour of its name.
fn cell_at(shown: &[(String, Pos2, Color32)], drawer: Rect, name: &str) -> (Pos2, Color32) {
shown
.iter()
.find(|(s, p, _)| s == name && drawer.contains(*p))
.map(|(_, p, c)| (*p, *c))
.unwrap_or_else(|| panic!("no {name} in the drawer {drawer:?}"))
}
/// The drawer frames each flyout group whole, under the heading most of its tools have: every
/// tool once, the Pencil's group (led by the Shaper) under Draw.
#[test]
fn the_drawer_frames_every_flyout_group_once() {
let listed: Vec<&str> = groups().iter().flat_map(|(_, gs)| gs.iter().flat_map(|g| g.iter().map(|t| t.id))).collect();
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
assert_eq!(listed.len(), all.len());
assert!(all.iter().all(|id| listed.contains(id)));
let under = |id: &str| groups().iter().find(|(_, gs)| gs.iter().any(|g| g.iter().any(|t| t.id == id))).map(|(c, _)| *c);
assert_eq!(under("pencil"), Some("Draw"));
assert_eq!(under("shaper"), Some("Draw"), "framed with the Pencil");
assert_eq!(under("rectangle"), Some("Shapes"));
assert_eq!(under("objectsOnPath"), Some("Modify"), "framed with the Blend tool");
assert_eq!(heading("objectsOnPath"), "Modify", "though not in the Basic toolbar");
}
/// The Show section's controls (#1736): each shows (true), hides (false) or toggles, saved with
/// the preferences; an unknown control is an error naming them.
#[test]
fn toolbar_show_turns_the_controls_under_the_tools_on_and_off() {
let mut app = app();
let all_on = json!({"fillStroke": true, "coloring": true, "drawMode": true, "screenMode": true});
assert_eq!(app.run("toolbar.show", json!({})).unwrap(), all_on);
assert_eq!(app.run("toolbar.show", json!({"control": "coloring"})).unwrap()["coloring"], json!(false));
assert_eq!(app.run("toolbar.show", json!({"control": "screenMode", "on": false})).unwrap()["screenMode"], json!(false));
assert!(!app.ui.toolbar_show.coloring && !app.ui.toolbar_show.screen_mode && app.ui.toolbar_show.fill_stroke);
let err = app.run("toolbar.show", json!({"control": "swatches"})).unwrap_err();
assert!(err.contains("swatches") && err.contains("fillStroke"), "{err}");
// Saved with the preferences; older preferences show them all.
let saved = serde_json::to_value(&app.ui).unwrap();
assert_eq!(serde_json::from_value::<crate::state::UiState>(saved).unwrap().toolbar_show, app.ui.toolbar_show);
let mut old = serde_json::to_value(&app.ui).unwrap();
old.as_object_mut().unwrap().remove("toolbar_show");
assert_eq!(serde_json::from_value::<crate::state::UiState>(old).unwrap().toolbar_show, crate::state::ToolbarShow::default());
}
/// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order.
#[test]
fn every_tool_is_listed_once_under_a_heading() {
let s = sections();
let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect();
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
assert_eq!(listed.len(), all.len());
assert!(all.iter().all(|id| listed.contains(id)));
let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect();
assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]);
assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}");
assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}");
}
#[test]
fn the_commands_add_move_remove_and_reset_tools() {
let mut app = app();
let shipped = slots(&app);
// At the end; the toolbar's slots come back.
let r = app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
assert_eq!(r, json!(slots(&app)));
assert_eq!(slots(&app).last().unwrap(), &["artboard"]);
// Before the first slot, moved out of its group.
app.run("toolbar.addTool", json!({"tool": "magicWand", "slot": 0})).unwrap();
assert_eq!(&slots(&app)[..3], [&["magicWand"][..], &["selection"], &["directSelection", "groupSelection"]]);
assert_eq!(slots(&app)[3], ["lasso"]);
// After a slot, and into a group (last), moved out of the Rectangle group.
app.run("toolbar.addTool", json!({"tool": "slice", "slot": 1, "place": "after"})).unwrap();
assert_eq!(slots(&app)[2], ["slice"]);
let ellipse = slot_of(&app, "ellipse").unwrap();
app.run("toolbar.addTool", json!({"tool": "star", "slot": ellipse, "place": "group"})).unwrap();
assert_eq!(slots(&app)[ellipse], ["ellipse", "star"]);
assert!(!slots(&app)[slot_of(&app, "rectangle").unwrap()].contains(&"star"));
// Onto its own group: nothing changes.
let before = slots(&app);
app.run("toolbar.addTool", json!({"tool": "ellipse", "slot": ellipse, "place": "group"})).unwrap();
assert_eq!(slots(&app), before);
// The category label still names the first slot of its category.
assert_eq!(crate::toolbar::slots(&app)[0].0, Some("Select"));
// Out: one tool, or its whole group; an emptied slot goes.
app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap();
assert_eq!(slots(&app)[ellipse], ["star"]);
app.run("toolbar.removeTool", json!({"tool": "addAnchor", "group": true})).unwrap();
assert!(slot_of(&app, "pen").is_none() && slot_of(&app, "addAnchor").is_none());
// Bad params change nothing.
let before = slots(&app);
for p in [
json!({}),
json!({"tool": "nope"}),
json!({"tool": "pen", "slot": 999}),
json!({"tool": "pen", "slot": -1}),
json!({"tool": "pen", "place": "inside"}),
] {
assert!(app.run("toolbar.addTool", p.clone()).is_err(), "{p}");
}
assert_eq!(app.run("toolbar.removeTool", json!({"tool": "pen"})).unwrap(), json!(before), "not in the toolbar: stays out");
assert!(app.run("toolbar.addTool", json!({"tool": "pen", "slot": "first"})).unwrap_err().contains("between 0 and"));
assert!(app.run("toolbar.removeTool", json!({"tool": "star", "group": "yes"})).is_err());
assert_eq!(slots(&app), before);
// The Advanced toolbar keeps its own edits.
app.run("window.toolbarAdvanced", json!({})).unwrap();
assert_eq!(slots(&app).len(), TOOL_GROUPS.len());
app.run("toolbar.removeTool", json!({"tool": "selection"})).unwrap();
assert!(app.ui.toolbar_edits.advanced.is_some() && slot_of(&app, "selection").is_none());
app.run("window.toolbarAdvanced", json!({})).unwrap();
assert_eq!(slots(&app)[0], ["magicWand"]);
// Reset puts the current one back as it ships.
assert_eq!(app.run("toolbar.reset", json!({})).unwrap(), json!(shipped));
assert!(app.ui.toolbar_edits.basic.is_none() && app.ui.toolbar_edits.advanced.is_some());
// An edit that gives back the shipped toolbar forgets the edits.
app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
app.run("toolbar.removeTool", json!({"tool": "artboard"})).unwrap();
assert!(app.ui.toolbar_edits.basic.is_none());
// Everything out, then a tool back: it starts its own category.
for tool in shipped.iter().flatten() {
app.run("toolbar.removeTool", json!({"tool": tool})).unwrap();
}
assert!(slots(&app).is_empty());
app.run("toolbar.addTool", json!({"tool": "pen"})).unwrap();
assert_eq!(crate::toolbar::slots(&app), [(Some("Draw"), vec!["pen"])]);
}
#[test]
fn the_edited_toolbar_is_saved_and_loads_sanitized() {
let mut app = app();
app.run("toolbar.addTool", json!({"tool": "artboard", "slot": 0})).unwrap();
let saved = serde_json::to_value(&app.ui).unwrap();
let back: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap();
assert_eq!(back.toolbar_edits, app.ui.toolbar_edits);
// Preferences saved before Edit Toolbar load the shipped toolbars.
let mut old = saved;
old.as_object_mut().unwrap().remove("toolbar_edits");
assert_eq!(serde_json::from_value::<crate::state::UiState>(old).unwrap().toolbar_edits, ToolbarEdits::default());
// A hand-edited file: unknown tools and names go, each tool once, emptied slots go.
let cat = |name: Option<&str>, slots: &[&[&str]]| ToolbarCategory {
name: name.map(str::to_string),
slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(),
};
let ui = crate::state::UiState {
toolbar_edits: ToolbarEdits {
basic: Some(vec![cat(Some("Nope"), &[&["pen", "nope"], &["pen"], &["nope"]]), cat(Some("Draw"), &[&[]])]),
advanced: None,
},
tools_drawer: true,
..Default::default()
}
.sanitized();
assert_eq!(ui.toolbar_edits.basic, Some(vec![cat(None, &[&["pen"]])]));
assert!(!ui.tools_drawer, "the drawer starts closed");
}
/// #1018: the ••• button opens the drawer beside the toolbar, by the button, not a centred
/// dialog; Escape, a click outside and the button close it, a click in the toolbar doesn't.
#[test]
fn edit_toolbar_opens_a_drawer_beside_the_toolbar() {
let mut app = app();
let ctx = ctx();
frame(&mut app, &ctx, 0.0, vec![]);
let edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).last().copied().unwrap();
click(&mut app, &ctx, 1.0, edit.center());
frame(&mut app, &ctx, 1.2, vec![]);
assert!(app.ui.tools_drawer && app.ui.dialog.is_none());
assert_eq!(crate::menus::checked(&app, "window.editToolbar", &json!({})), Some(true));
let drawer = drawer_rect(&ctx).expect("the drawer is shown");
let bar = ctx.data(|d| d.get_temp::<Rect>(toolbar_id())).unwrap();
assert!((drawer.left() - bar.right() - GAP).abs() < 1.0, "beside the toolbar: {drawer:?} {bar:?}");
assert!((drawer.top() - edit.top()).abs() < 1.0 || drawer.bottom() >= SCREEN.y - GAP - 1.0, "by the button: {drawer:?} {edit:?}");
assert!(drawer.top() >= bar.top() - 0.5, "never above the toolbar (the title bar's there): {drawer:?} {bar:?}");
assert!(drawer.y_range().contains(edit.center().y), "by the button: {drawer:?} {edit:?}");
assert!(drawer.width() < WIDTH + 24.0, "one row of tools wide: {drawer:?}");
// A click on a toolbar tool picks it and leaves the drawer open.
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
let ellipse = tools[slot_of(&app, "ellipse").unwrap()].center();
click(&mut app, &ctx, 2.0, ellipse);
assert!(app.ui.tools_drawer && app.session.tool_id() == "ellipse");
// Escape closes it.
let esc = |pressed| Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
frame(&mut app, &ctx, 3.0, vec![esc(true), esc(false)]);
assert!(!app.ui.tools_drawer);
frame(&mut app, &ctx, 3.1, vec![]);
assert!(!drawer_shown(&ctx));
// A click outside closes it, and so does the ••• button.
app.run("window.editToolbar", json!({"open": true})).unwrap();
frame(&mut app, &ctx, 4.0, vec![]);
click(&mut app, &ctx, 4.5, pos2(SCREEN.x - 20.0, 300.0));
assert!(!app.ui.tools_drawer);
click(&mut app, &ctx, 5.0, edit.center());
assert!(app.ui.tools_drawer);
click(&mut app, &ctx, 6.0, edit.center());
assert!(!app.ui.tools_drawer);
// Picking a tool in the drawer selects it and closes the drawer.
app.run("window.editToolbar", json!({})).unwrap();
frame(&mut app, &ctx, 7.0, vec![]);
let shown = frame(&mut app, &ctx, 7.1, vec![]);
let (at, _) = cell_at(&shown, drawer_rect(&ctx).unwrap(), "Artboard Tool");
click(&mut app, &ctx, 8.0, at);
assert!(!app.ui.tools_drawer && app.session.tool_id() == "artboard");
}
/// The drawer dims the tools the toolbar holds.
#[test]
fn tools_in_the_toolbar_are_dimmed_in_the_drawer() {
let mut app = app();
let ctx = ctx();
app.run("window.editToolbar", json!({"open": true})).unwrap();
frame(&mut app, &ctx, 0.0, vec![]);
let shown = frame(&mut app, &ctx, 0.1, vec![]);
let drawer = drawer_rect(&ctx).unwrap();
let dim = Tokens::get(&ctx).text_disabled;
assert_eq!(cell_at(&shown, drawer, "Ellipse Tool").1, dim);
assert_ne!(cell_at(&shown, drawer, "Artboard Tool").1, dim);
app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap();
let shown = frame(&mut app, &ctx, 0.2, vec![]);
assert_ne!(cell_at(&shown, drawer, "Ellipse Tool").1, dim);
assert_eq!(cell_at(&shown, drawer, "Artboard Tool").1, dim);
}
/// #1018: tools drag from the drawer onto the toolbar (between two tools, or onto one to join
/// its group), from the toolbar into the drawer, and within the toolbar, only while the drawer
/// is open.
#[test]
fn tools_drag_between_the_drawer_and_the_toolbar() {
let mut app = app();
let ctx = ctx();
app.run("window.editToolbar", json!({"open": true})).unwrap();
frame(&mut app, &ctx, 0.0, vec![]);
let shown = frame(&mut app, &ctx, 0.1, vec![]);
let drawer = drawer_rect(&ctx).unwrap();
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
// Onto the divider under the Selection tool: its own slot, second.
let (artboard, _) = cell_at(&shown, drawer, "Artboard Tool");
drag(&mut app, &ctx, 1.0, artboard, tools[0].center_bottom() - vec2(0.0, 3.0));
assert_eq!(&slots(&app)[..2], [&["selection"][..], &["artboard"]]);
assert!(app.ui.tools_drawer, "the drawer stays open");
// Onto the middle of the Ellipse tool: into its group.
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
let ellipse = slot_of(&app, "ellipse").unwrap();
// The drawer moves with the ••• button the new slot pushed down: read it again.
let shown = frame(&mut app, &ctx, 1.9, vec![]);
let drawer = drawer_rect(&ctx).unwrap();
let (slice, _) = cell_at(&shown, drawer, "Slice Tool");
drag(&mut app, &ctx, 2.0, slice, tools[ellipse].center());
assert_eq!(slots(&app)[ellipse], ["ellipse", "slice"]);
// A dimmed tool (in the toolbar already) doesn't drag.
let before = slots(&app);
let shown = frame(&mut app, &ctx, 2.9, vec![]);
let drawer = drawer_rect(&ctx).unwrap();
let (rectangle, _) = cell_at(&shown, drawer, "Rectangle Tool");
drag(&mut app, &ctx, 3.0, rectangle, tools[0].center_bottom() - vec2(0.0, 3.0));
assert_eq!(slots(&app), before);
// From the toolbar into the drawer: out of the toolbar.
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
drag(&mut app, &ctx, 4.0, tools[1].center(), drawer.center());
assert!(slot_of(&app, "artboard").is_none(), "{:?}", slots(&app));
// Within the toolbar: Selection onto the divider over the Lasso.
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
drag(&mut app, &ctx, 5.0, tools[0].center(), tools[2].center_top() + vec2(0.0, 3.0));
assert_eq!(&slots(&app)[..3], [&["directSelection", "groupSelection"][..], &["selection"], &["lasso", "magicWand"]]);
// The drawer closed, the toolbar is locked: the same drag moves nothing.
app.run("window.editToolbar", json!({"open": false})).unwrap();
frame(&mut app, &ctx, 6.0, vec![]);
let before = slots(&app);
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
drag(&mut app, &ctx, 7.0, tools[0].center(), tools[2].center_bottom() - vec2(0.0, 3.0));
assert_eq!(slots(&app), before);
}
#[test]
fn drop_targets_follow_the_buttons_edges() {
let r = |y: f32| Rect::from_min_size(pos2(6.0, y), BUTTON);
let one = [(0, r(0.0)), (1, r(30.0))];
assert_eq!(drop_target(&one, pos2(20.0, 2.0), 1), (Some(0), Place::Before));
assert_eq!(drop_target(&one, pos2(20.0, 14.0), 1), (Some(0), Place::Group));
assert_eq!(drop_target(&one, pos2(20.0, 27.0), 1), (Some(0), Place::After));
// Past the last button, or beside them: next to the nearest one.
assert_eq!(drop_target(&one, pos2(20.0, 200.0), 1), (Some(1), Place::After));
assert_eq!(drop_target(&one, pos2(44.5, 40.0), 1), (Some(1), Place::After));
// Two columns: the left and right edges.
let two = [(0, Rect::from_min_size(pos2(2.0, 0.0), BUTTON)), (1, Rect::from_min_size(pos2(38.0, 0.0), BUTTON))];
assert_eq!(drop_target(&two, pos2(40.0, 10.0), 2), (Some(1), Place::Before));
assert_eq!(drop_target(&two, pos2(36.0, 10.0), 2), (Some(0), Place::After));
assert_eq!(drop_target(&[], pos2(0.0, 0.0), 1), (None, Place::Before));
}
}
mod tests;
+374
View File
@@ -0,0 +1,374 @@
use egui::{Color32, Event, PointerButton, RawInput};
use vectorcraft_engine::Session;
use super::*;
/// Tall enough for the whole Basic toolbar and the whole drawer.
const SCREEN: egui::Vec2 = vec2(1000.0, 4000.0);
/// A toolbar tool button.
const BUTTON: egui::Vec2 = vec2(36.0, 29.0);
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app
}
fn slots(app: &VectorcraftApp) -> Vec<Vec<&'static str>> {
crate::toolbar::slots(app).into_iter().map(|(_, s)| s).collect()
}
/// The slot holding `tool`.
fn slot_of(app: &VectorcraftApp, tool: &str) -> Option<usize> {
slots(app).iter().position(|s| s.contains(&tool))
}
/// One headless frame of the toolbar, its keys handled first as in the app; returns the texts
/// painted: (text, a point inside it, colour).
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<Event>) -> Vec<(String, Pos2, Color32)> {
fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2, Color32)>) {
match s {
egui::Shape::Text(t) => {
let color = t.galley.job.sections.first().map_or(t.fallback_color, |s| s.format.color);
out.push((t.galley.text().to_string(), t.pos + vec2(4.0, t.galley.size().y / 2.0), color));
}
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let input = RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
crate::shortcuts::handle(app, ui.ctx());
crate::toolbar::show(app, ui);
});
out.textures_delta.clear();
let mut shown = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
shown
}
fn ctx() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
ctx
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) {
frame(app, ctx, time, vec![Event::PointerMoved(at), button(at, true)]);
frame(app, ctx, time + 0.05, vec![button(at, false)]);
frame(app, ctx, time + 0.1, vec![]);
}
/// A press at `from` dragged to `to` in steps, one frame each, then released there.
fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: Pos2) {
frame(app, ctx, time, vec![Event::PointerMoved(from), button(from, true)]);
for k in 1..=6 {
frame(app, ctx, time + 0.05 * f64::from(k), vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]);
}
frame(app, ctx, time + 0.4, vec![button(to, false)]);
frame(app, ctx, time + 0.45, vec![]);
}
fn drawer_rect(ctx: &egui::Context) -> Option<Rect> {
ctx.memory(|m| m.area_rect(drawer_id()))
}
fn drawer_shown(ctx: &egui::Context) -> bool {
ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Foreground, drawer_id())))
}
/// A point inside the drawer's row of the tool named `name` (as the drawer shows it), and the
/// colour of its name.
fn cell_at(shown: &[(String, Pos2, Color32)], drawer: Rect, name: &str) -> (Pos2, Color32) {
shown
.iter()
.find(|(s, p, _)| s == name && drawer.contains(*p))
.map(|(_, p, c)| (*p, *c))
.unwrap_or_else(|| panic!("no {name} in the drawer {drawer:?}"))
}
/// The drawer frames each flyout group whole, under the heading most of its tools have: every
/// tool once, the Pencil's group (led by the Shaper) under Draw.
#[test]
fn the_drawer_frames_every_flyout_group_once() {
let listed: Vec<&str> = groups().iter().flat_map(|(_, gs)| gs.iter().flat_map(|g| g.iter().map(|t| t.id))).collect();
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
assert_eq!(listed.len(), all.len());
assert!(all.iter().all(|id| listed.contains(id)));
let under = |id: &str| groups().iter().find(|(_, gs)| gs.iter().any(|g| g.iter().any(|t| t.id == id))).map(|(c, _)| *c);
assert_eq!(under("pencil"), Some("Draw"));
assert_eq!(under("shaper"), Some("Draw"), "framed with the Pencil");
assert_eq!(under("rectangle"), Some("Shapes"));
assert_eq!(under("objectsOnPath"), Some("Modify"), "framed with the Blend tool");
assert_eq!(heading("objectsOnPath"), "Modify", "though not in the Basic toolbar");
}
/// The Show section's controls (#1736): each shows (true), hides (false) or toggles, saved with
/// the preferences; an unknown control is an error naming them.
#[test]
fn toolbar_show_turns_the_controls_under_the_tools_on_and_off() {
let mut app = app();
let all_on = json!({"fillStroke": true, "coloring": true, "drawMode": true, "screenMode": true});
assert_eq!(app.run("toolbar.show", json!({})).unwrap(), all_on);
assert_eq!(app.run("toolbar.show", json!({"control": "coloring"})).unwrap()["coloring"], json!(false));
assert_eq!(app.run("toolbar.show", json!({"control": "screenMode", "on": false})).unwrap()["screenMode"], json!(false));
assert!(!app.ui.toolbar_show.coloring && !app.ui.toolbar_show.screen_mode && app.ui.toolbar_show.fill_stroke);
let err = app.run("toolbar.show", json!({"control": "swatches"})).unwrap_err();
assert!(err.contains("swatches") && err.contains("fillStroke"), "{err}");
// Saved with the preferences; older preferences show them all.
let saved = serde_json::to_value(&app.ui).unwrap();
assert_eq!(serde_json::from_value::<crate::state::UiState>(saved).unwrap().toolbar_show, app.ui.toolbar_show);
let mut old = serde_json::to_value(&app.ui).unwrap();
old.as_object_mut().unwrap().remove("toolbar_show");
assert_eq!(serde_json::from_value::<crate::state::UiState>(old).unwrap().toolbar_show, crate::state::ToolbarShow::default());
}
/// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order.
#[test]
fn every_tool_is_listed_once_under_a_heading() {
let s = sections();
let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect();
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
assert_eq!(listed.len(), all.len());
assert!(all.iter().all(|id| listed.contains(id)));
let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect();
assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]);
assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}");
assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}");
}
#[test]
fn the_commands_add_move_remove_and_reset_tools() {
let mut app = app();
let shipped = slots(&app);
// At the end; the toolbar's slots come back.
let r = app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
assert_eq!(r, json!(slots(&app)));
assert_eq!(slots(&app).last().unwrap(), &["artboard"]);
// Before the first slot, moved out of its group.
app.run("toolbar.addTool", json!({"tool": "magicWand", "slot": 0})).unwrap();
assert_eq!(&slots(&app)[..3], [&["magicWand"][..], &["selection"], &["directSelection", "groupSelection"]]);
assert_eq!(slots(&app)[3], ["lasso"]);
// After a slot, and into a group (last), moved out of the Rectangle group.
app.run("toolbar.addTool", json!({"tool": "slice", "slot": 1, "place": "after"})).unwrap();
assert_eq!(slots(&app)[2], ["slice"]);
let ellipse = slot_of(&app, "ellipse").unwrap();
app.run("toolbar.addTool", json!({"tool": "star", "slot": ellipse, "place": "group"})).unwrap();
assert_eq!(slots(&app)[ellipse], ["ellipse", "star"]);
assert!(!slots(&app)[slot_of(&app, "rectangle").unwrap()].contains(&"star"));
// Onto its own group: nothing changes.
let before = slots(&app);
app.run("toolbar.addTool", json!({"tool": "ellipse", "slot": ellipse, "place": "group"})).unwrap();
assert_eq!(slots(&app), before);
// The category label still names the first slot of its category.
assert_eq!(crate::toolbar::slots(&app)[0].0, Some("Select"));
// Out: one tool, or its whole group; an emptied slot goes.
app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap();
assert_eq!(slots(&app)[ellipse], ["star"]);
app.run("toolbar.removeTool", json!({"tool": "addAnchor", "group": true})).unwrap();
assert!(slot_of(&app, "pen").is_none() && slot_of(&app, "addAnchor").is_none());
// Bad params change nothing.
let before = slots(&app);
for p in [
json!({}),
json!({"tool": "nope"}),
json!({"tool": "pen", "slot": 999}),
json!({"tool": "pen", "slot": -1}),
json!({"tool": "pen", "place": "inside"}),
] {
assert!(app.run("toolbar.addTool", p.clone()).is_err(), "{p}");
}
assert_eq!(app.run("toolbar.removeTool", json!({"tool": "pen"})).unwrap(), json!(before), "not in the toolbar: stays out");
assert!(app.run("toolbar.addTool", json!({"tool": "pen", "slot": "first"})).unwrap_err().contains("between 0 and"));
assert!(app.run("toolbar.removeTool", json!({"tool": "star", "group": "yes"})).is_err());
assert_eq!(slots(&app), before);
// The Advanced toolbar keeps its own edits.
app.run("window.toolbarAdvanced", json!({})).unwrap();
assert_eq!(slots(&app).len(), TOOL_GROUPS.len());
app.run("toolbar.removeTool", json!({"tool": "selection"})).unwrap();
assert!(app.ui.toolbar_edits.advanced.is_some() && slot_of(&app, "selection").is_none());
app.run("window.toolbarAdvanced", json!({})).unwrap();
assert_eq!(slots(&app)[0], ["magicWand"]);
// Reset puts the current one back as it ships.
assert_eq!(app.run("toolbar.reset", json!({})).unwrap(), json!(shipped));
assert!(app.ui.toolbar_edits.basic.is_none() && app.ui.toolbar_edits.advanced.is_some());
// An edit that gives back the shipped toolbar forgets the edits.
app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
app.run("toolbar.removeTool", json!({"tool": "artboard"})).unwrap();
assert!(app.ui.toolbar_edits.basic.is_none());
// Everything out, then a tool back: it starts its own category.
for tool in shipped.iter().flatten() {
app.run("toolbar.removeTool", json!({"tool": tool})).unwrap();
}
assert!(slots(&app).is_empty());
app.run("toolbar.addTool", json!({"tool": "pen"})).unwrap();
assert_eq!(crate::toolbar::slots(&app), [(Some("Draw"), vec!["pen"])]);
}
#[test]
fn the_edited_toolbar_is_saved_and_loads_sanitized() {
let mut app = app();
app.run("toolbar.addTool", json!({"tool": "artboard", "slot": 0})).unwrap();
let saved = serde_json::to_value(&app.ui).unwrap();
let back: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap();
assert_eq!(back.toolbar_edits, app.ui.toolbar_edits);
// Preferences saved before Edit Toolbar load the shipped toolbars.
let mut old = saved;
old.as_object_mut().unwrap().remove("toolbar_edits");
assert_eq!(serde_json::from_value::<crate::state::UiState>(old).unwrap().toolbar_edits, ToolbarEdits::default());
// A hand-edited file: unknown tools and names go, each tool once, emptied slots go.
let cat = |name: Option<&str>, slots: &[&[&str]]| ToolbarCategory {
name: name.map(str::to_string),
slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(),
};
let ui = crate::state::UiState {
toolbar_edits: ToolbarEdits {
basic: Some(vec![cat(Some("Nope"), &[&["pen", "nope"], &["pen"], &["nope"]]), cat(Some("Draw"), &[&[]])]),
advanced: None,
},
tools_drawer: true,
..Default::default()
}
.sanitized();
assert_eq!(ui.toolbar_edits.basic, Some(vec![cat(None, &[&["pen"]])]));
assert!(!ui.tools_drawer, "the drawer starts closed");
}
/// #1018: the ••• button opens the drawer beside the toolbar, by the button, not a centred
/// dialog; Escape, a click outside and the button close it, a click in the toolbar doesn't.
#[test]
fn edit_toolbar_opens_a_drawer_beside_the_toolbar() {
let mut app = app();
let ctx = ctx();
frame(&mut app, &ctx, 0.0, vec![]);
let edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).last().copied().unwrap();
click(&mut app, &ctx, 1.0, edit.center());
frame(&mut app, &ctx, 1.2, vec![]);
assert!(app.ui.tools_drawer && app.ui.dialog.is_none());
assert_eq!(crate::menus::checked(&app, "window.editToolbar", &json!({})), Some(true));
let drawer = drawer_rect(&ctx).expect("the drawer is shown");
let bar = ctx.data(|d| d.get_temp::<Rect>(toolbar_id())).unwrap();
assert!((drawer.left() - bar.right() - GAP).abs() < 1.0, "beside the toolbar: {drawer:?} {bar:?}");
assert!((drawer.top() - edit.top()).abs() < 1.0 || drawer.bottom() >= SCREEN.y - GAP - 1.0, "by the button: {drawer:?} {edit:?}");
assert!(drawer.top() >= bar.top() - 0.5, "never above the toolbar (the title bar's there): {drawer:?} {bar:?}");
assert!(drawer.y_range().contains(edit.center().y), "by the button: {drawer:?} {edit:?}");
assert!(drawer.width() < WIDTH + 24.0, "one row of tools wide: {drawer:?}");
// A click on a toolbar tool picks it and leaves the drawer open.
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
let ellipse = tools[slot_of(&app, "ellipse").unwrap()].center();
click(&mut app, &ctx, 2.0, ellipse);
assert!(app.ui.tools_drawer && app.session.tool_id() == "ellipse");
// Escape closes it.
let esc = |pressed| Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
frame(&mut app, &ctx, 3.0, vec![esc(true), esc(false)]);
assert!(!app.ui.tools_drawer);
frame(&mut app, &ctx, 3.1, vec![]);
assert!(!drawer_shown(&ctx));
// A click outside closes it, and so does the ••• button.
app.run("window.editToolbar", json!({"open": true})).unwrap();
frame(&mut app, &ctx, 4.0, vec![]);
click(&mut app, &ctx, 4.5, pos2(SCREEN.x - 20.0, 300.0));
assert!(!app.ui.tools_drawer);
click(&mut app, &ctx, 5.0, edit.center());
assert!(app.ui.tools_drawer);
click(&mut app, &ctx, 6.0, edit.center());
assert!(!app.ui.tools_drawer);
// Picking a tool in the drawer selects it and closes the drawer.
app.run("window.editToolbar", json!({})).unwrap();
frame(&mut app, &ctx, 7.0, vec![]);
let shown = frame(&mut app, &ctx, 7.1, vec![]);
let (at, _) = cell_at(&shown, drawer_rect(&ctx).unwrap(), "Artboard Tool");
click(&mut app, &ctx, 8.0, at);
assert!(!app.ui.tools_drawer && app.session.tool_id() == "artboard");
}
/// The drawer dims the tools the toolbar holds.
#[test]
fn tools_in_the_toolbar_are_dimmed_in_the_drawer() {
let mut app = app();
let ctx = ctx();
app.run("window.editToolbar", json!({"open": true})).unwrap();
frame(&mut app, &ctx, 0.0, vec![]);
let shown = frame(&mut app, &ctx, 0.1, vec![]);
let drawer = drawer_rect(&ctx).unwrap();
let dim = Tokens::get(&ctx).text_disabled;
assert_eq!(cell_at(&shown, drawer, "Ellipse Tool").1, dim);
assert_ne!(cell_at(&shown, drawer, "Artboard Tool").1, dim);
app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap();
let shown = frame(&mut app, &ctx, 0.2, vec![]);
assert_ne!(cell_at(&shown, drawer, "Ellipse Tool").1, dim);
assert_eq!(cell_at(&shown, drawer, "Artboard Tool").1, dim);
}
/// #1018: tools drag from the drawer onto the toolbar (between two tools, or onto one to join
/// its group), from the toolbar into the drawer, and within the toolbar, only while the drawer
/// is open.
#[test]
fn tools_drag_between_the_drawer_and_the_toolbar() {
let mut app = app();
let ctx = ctx();
app.run("window.editToolbar", json!({"open": true})).unwrap();
frame(&mut app, &ctx, 0.0, vec![]);
let shown = frame(&mut app, &ctx, 0.1, vec![]);
let drawer = drawer_rect(&ctx).unwrap();
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
// Onto the divider under the Selection tool: its own slot, second.
let (artboard, _) = cell_at(&shown, drawer, "Artboard Tool");
drag(&mut app, &ctx, 1.0, artboard, tools[0].center_bottom() - vec2(0.0, 3.0));
assert_eq!(&slots(&app)[..2], [&["selection"][..], &["artboard"]]);
assert!(app.ui.tools_drawer, "the drawer stays open");
// Onto the middle of the Ellipse tool: into its group.
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
let ellipse = slot_of(&app, "ellipse").unwrap();
// The drawer moves with the ••• button the new slot pushed down: read it again.
let shown = frame(&mut app, &ctx, 1.9, vec![]);
let drawer = drawer_rect(&ctx).unwrap();
let (slice, _) = cell_at(&shown, drawer, "Slice Tool");
drag(&mut app, &ctx, 2.0, slice, tools[ellipse].center());
assert_eq!(slots(&app)[ellipse], ["ellipse", "slice"]);
// A dimmed tool (in the toolbar already) doesn't drag.
let before = slots(&app);
let shown = frame(&mut app, &ctx, 2.9, vec![]);
let drawer = drawer_rect(&ctx).unwrap();
let (rectangle, _) = cell_at(&shown, drawer, "Rectangle Tool");
drag(&mut app, &ctx, 3.0, rectangle, tools[0].center_bottom() - vec2(0.0, 3.0));
assert_eq!(slots(&app), before);
// From the toolbar into the drawer: out of the toolbar.
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
drag(&mut app, &ctx, 4.0, tools[1].center(), drawer.center());
assert!(slot_of(&app, "artboard").is_none(), "{:?}", slots(&app));
// Within the toolbar: Selection onto the divider over the Lasso.
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
drag(&mut app, &ctx, 5.0, tools[0].center(), tools[2].center_top() + vec2(0.0, 3.0));
assert_eq!(&slots(&app)[..3], [&["directSelection", "groupSelection"][..], &["selection"], &["lasso", "magicWand"]]);
// The drawer closed, the toolbar is locked: the same drag moves nothing.
app.run("window.editToolbar", json!({"open": false})).unwrap();
frame(&mut app, &ctx, 6.0, vec![]);
let before = slots(&app);
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
drag(&mut app, &ctx, 7.0, tools[0].center(), tools[2].center_bottom() - vec2(0.0, 3.0));
assert_eq!(slots(&app), before);
}
#[test]
fn drop_targets_follow_the_buttons_edges() {
let r = |y: f32| Rect::from_min_size(pos2(6.0, y), BUTTON);
let one = [(0, r(0.0)), (1, r(30.0))];
assert_eq!(drop_target(&one, pos2(20.0, 2.0), 1), (Some(0), Place::Before));
assert_eq!(drop_target(&one, pos2(20.0, 14.0), 1), (Some(0), Place::Group));
assert_eq!(drop_target(&one, pos2(20.0, 27.0), 1), (Some(0), Place::After));
// Past the last button, or beside them: next to the nearest one.
assert_eq!(drop_target(&one, pos2(20.0, 200.0), 1), (Some(1), Place::After));
assert_eq!(drop_target(&one, pos2(44.5, 40.0), 1), (Some(1), Place::After));
// Two columns: the left and right edges.
let two = [(0, Rect::from_min_size(pos2(2.0, 0.0), BUTTON)), (1, Rect::from_min_size(pos2(38.0, 0.0), BUTTON))];
assert_eq!(drop_target(&two, pos2(40.0, 10.0), 2), (Some(1), Place::Before));
assert_eq!(drop_target(&two, pos2(36.0, 10.0), 2), (Some(0), Place::After));
assert_eq!(drop_target(&[], pos2(0.0, 0.0), 1), (None, Place::Before));
}