From 4a8e69490c187f6c96e8b286a2f0adb31c2e1265 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:14 +0000 Subject: [PATCH] refactor(ui-egui): move edit_toolbar.rs tests to edit_toolbar/tests.rs --- crates/ui-egui/src/edit_toolbar.rs | 377 +---------------------- crates/ui-egui/src/edit_toolbar/tests.rs | 374 ++++++++++++++++++++++ 2 files changed, 375 insertions(+), 376 deletions(-) create mode 100644 crates/ui-egui/src/edit_toolbar/tests.rs diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs index caec027b6..f61d19a55 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -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> { - crate::toolbar::slots(app).into_iter().map(|(_, s)| s).collect() - } - - /// The slot holding `tool`. - fn slot_of(app: &VectorcraftApp, tool: &str) -> Option { - slots(app).iter().position(|s| s.contains(&tool)) - } - - /// One headless frame of the toolbar, its keys handled first as in the app; returns the texts - /// painted: (text, a point inside it, colour). - fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec<(String, Pos2, Color32)> { - fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2, Color32)>) { - match s { - egui::Shape::Text(t) => { - let color = t.galley.job.sections.first().map_or(t.fallback_color, |s| s.format.color); - out.push((t.galley.text().to_string(), t.pos + vec2(4.0, t.galley.size().y / 2.0), color)); - } - egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), - _ => {} - } - } - let input = RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), ..Default::default() }; - let mut out = ctx.run_ui(input, |ui| { - crate::shortcuts::handle(app, ui.ctx()); - crate::toolbar::show(app, ui); - }); - out.textures_delta.clear(); - let mut shown = vec![]; - out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); - shown - } - - fn ctx() -> egui::Context { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - crate::theme::apply(&ctx, Default::default()); - ctx - } - - fn button(pos: Pos2, pressed: bool) -> Event { - Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } - } - - fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) { - frame(app, ctx, time, vec![Event::PointerMoved(at), button(at, true)]); - frame(app, ctx, time + 0.05, vec![button(at, false)]); - frame(app, ctx, time + 0.1, vec![]); - } - - /// A press at `from` dragged to `to` in steps, one frame each, then released there. - fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: Pos2) { - frame(app, ctx, time, vec![Event::PointerMoved(from), button(from, true)]); - for k in 1..=6 { - frame(app, ctx, time + 0.05 * f64::from(k), vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]); - } - frame(app, ctx, time + 0.4, vec![button(to, false)]); - frame(app, ctx, time + 0.45, vec![]); - } - - fn drawer_rect(ctx: &egui::Context) -> Option { - ctx.memory(|m| m.area_rect(drawer_id())) - } - - fn drawer_shown(ctx: &egui::Context) -> bool { - ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Foreground, drawer_id()))) - } - - /// A point inside the drawer's 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::(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::(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::(old).unwrap().toolbar_edits, ToolbarEdits::default()); - // A hand-edited file: unknown tools and names go, each tool once, emptied slots go. - let cat = |name: Option<&str>, slots: &[&[&str]]| ToolbarCategory { - name: name.map(str::to_string), - slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(), - }; - let ui = crate::state::UiState { - toolbar_edits: ToolbarEdits { - basic: Some(vec![cat(Some("Nope"), &[&["pen", "nope"], &["pen"], &["nope"]]), cat(Some("Draw"), &[&[]])]), - advanced: None, - }, - tools_drawer: true, - ..Default::default() - } - .sanitized(); - assert_eq!(ui.toolbar_edits.basic, Some(vec![cat(None, &[&["pen"]])])); - assert!(!ui.tools_drawer, "the drawer starts closed"); - } - - /// #1018: the ••• button opens the drawer beside the toolbar, by the button, not a centred - /// dialog; Escape, a click outside and the button close it, a click in the toolbar doesn't. - #[test] - fn edit_toolbar_opens_a_drawer_beside_the_toolbar() { - let mut app = app(); - let ctx = ctx(); - frame(&mut app, &ctx, 0.0, vec![]); - let edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).last().copied().unwrap(); - click(&mut app, &ctx, 1.0, edit.center()); - frame(&mut app, &ctx, 1.2, vec![]); - assert!(app.ui.tools_drawer && app.ui.dialog.is_none()); - assert_eq!(crate::menus::checked(&app, "window.editToolbar", &json!({})), Some(true)); - let drawer = drawer_rect(&ctx).expect("the drawer is shown"); - let bar = ctx.data(|d| d.get_temp::(toolbar_id())).unwrap(); - assert!((drawer.left() - bar.right() - GAP).abs() < 1.0, "beside the toolbar: {drawer:?} {bar:?}"); - assert!((drawer.top() - edit.top()).abs() < 1.0 || drawer.bottom() >= SCREEN.y - GAP - 1.0, "by the button: {drawer:?} {edit:?}"); - assert!(drawer.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; diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs new file mode 100644 index 000000000..5a8f1ce94 --- /dev/null +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -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> { + crate::toolbar::slots(app).into_iter().map(|(_, s)| s).collect() +} + +/// The slot holding `tool`. +fn slot_of(app: &VectorcraftApp, tool: &str) -> Option { + slots(app).iter().position(|s| s.contains(&tool)) +} + +/// One headless frame of the toolbar, its keys handled first as in the app; returns the texts +/// painted: (text, a point inside it, colour). +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec<(String, Pos2, Color32)> { + fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2, Color32)>) { + match s { + egui::Shape::Text(t) => { + let color = t.galley.job.sections.first().map_or(t.fallback_color, |s| s.format.color); + out.push((t.galley.text().to_string(), t.pos + vec2(4.0, t.galley.size().y / 2.0), color)); + } + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let input = RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + crate::toolbar::show(app, ui); + }); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + shown +} + +fn ctx() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + ctx +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) { + frame(app, ctx, time, vec![Event::PointerMoved(at), button(at, true)]); + frame(app, ctx, time + 0.05, vec![button(at, false)]); + frame(app, ctx, time + 0.1, vec![]); +} + +/// A press at `from` dragged to `to` in steps, one frame each, then released there. +fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: Pos2) { + frame(app, ctx, time, vec![Event::PointerMoved(from), button(from, true)]); + for k in 1..=6 { + frame(app, ctx, time + 0.05 * f64::from(k), vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]); + } + frame(app, ctx, time + 0.4, vec![button(to, false)]); + frame(app, ctx, time + 0.45, vec![]); +} + +fn drawer_rect(ctx: &egui::Context) -> Option { + ctx.memory(|m| m.area_rect(drawer_id())) +} + +fn drawer_shown(ctx: &egui::Context) -> bool { + ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Foreground, drawer_id()))) +} + +/// A point inside the drawer's 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::(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::(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::(old).unwrap().toolbar_edits, ToolbarEdits::default()); + // A hand-edited file: unknown tools and names go, each tool once, emptied slots go. + let cat = |name: Option<&str>, slots: &[&[&str]]| ToolbarCategory { + name: name.map(str::to_string), + slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(), + }; + let ui = crate::state::UiState { + toolbar_edits: ToolbarEdits { + basic: Some(vec![cat(Some("Nope"), &[&["pen", "nope"], &["pen"], &["nope"]]), cat(Some("Draw"), &[&[]])]), + advanced: None, + }, + tools_drawer: true, + ..Default::default() + } + .sanitized(); + assert_eq!(ui.toolbar_edits.basic, Some(vec![cat(None, &[&["pen"]])])); + assert!(!ui.tools_drawer, "the drawer starts closed"); +} + +/// #1018: the ••• button opens the drawer beside the toolbar, by the button, not a centred +/// dialog; Escape, a click outside and the button close it, a click in the toolbar doesn't. +#[test] +fn edit_toolbar_opens_a_drawer_beside_the_toolbar() { + let mut app = app(); + let ctx = ctx(); + frame(&mut app, &ctx, 0.0, vec![]); + let edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).last().copied().unwrap(); + click(&mut app, &ctx, 1.0, edit.center()); + frame(&mut app, &ctx, 1.2, vec![]); + assert!(app.ui.tools_drawer && app.ui.dialog.is_none()); + assert_eq!(crate::menus::checked(&app, "window.editToolbar", &json!({})), Some(true)); + let drawer = drawer_rect(&ctx).expect("the drawer is shown"); + let bar = ctx.data(|d| d.get_temp::(toolbar_id())).unwrap(); + assert!((drawer.left() - bar.right() - GAP).abs() < 1.0, "beside the toolbar: {drawer:?} {bar:?}"); + assert!((drawer.top() - edit.top()).abs() < 1.0 || drawer.bottom() >= SCREEN.y - GAP - 1.0, "by the button: {drawer:?} {edit:?}"); + assert!(drawer.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)); +}