Files
vectorcraft/crates/ui-egui/src/panel_docking/tests.rs
T
BFlat 4bde73708e Merge pull request #6215 from ruchirguitar/SidePanelCollapseArrow
Add collapse-to-icons arrows to floating panels and rail flyouts
2026-11-09 14:09:43 +08:00

1154 lines
57 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.
//! The dock area as the user sees it: whole-window frames driven by pointer and key events.
use craft_ui::docking::Hit;
use egui::{Event, PointerButton, Pos2, Rect, pos2, vec2};
use vectorcraft_engine::Session;
use super::*;
const SCREEN: Rect = Rect::from_min_max(Pos2::ZERO, pos2(1600.0, 1000.0));
struct Harness {
app: VectorcraftApp,
ctx: egui::Context,
time: f64,
}
impl Harness {
fn new() -> Self {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let mut h = Self { app, ctx: egui::Context::default(), time: 0.0 };
for _ in 0..3 {
h.frame(vec![]);
}
h
}
fn frame(&mut self, events: Vec<Event>) {
self.time += 0.05;
let input = egui::RawInput { time: Some(self.time), events, screen_rect: Some(SCREEN), ..Default::default() };
self.run(input);
}
/// A frame whose input first goes through [`VectorcraftApp::release_lost_buttons`], with the
/// system reporting a mouse button down or not.
fn frame_with_system_buttons(&mut self, events: Vec<Event>, system_held: bool) {
self.time += 0.05;
let mut input = egui::RawInput { time: Some(self.time), events, screen_rect: Some(SCREEN), ..Default::default() };
self.app.release_lost_buttons(&self.ctx, &mut input, system_held);
self.run(input);
}
fn run(&mut self, input: egui::RawInput) {
let app = &mut self.app;
let mut out = self.ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
}
fn hit(&self, hit: &Hit<String>) -> Option<Rect> {
hit_rects(&self.ctx).into_iter().find(|(h, _)| h == hit).map(|(_, r)| r)
}
fn tab(&self, panel: &str) -> Option<Rect> {
self.hit(&Hit::Tab(panel.into()))
}
fn click(&mut self, at: Pos2) {
self.frame(vec![Event::PointerMoved(at), button(at, true)]);
self.frame(vec![button(at, false)]);
self.frame(vec![]);
}
fn press(&mut self, key: egui::Key, modifiers: egui::Modifiers) {
self.frame(vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]);
self.frame(vec![Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }]);
}
/// Press at `from` and move to `to` in steps, holding the button.
fn press_and_move(&mut self, from: Pos2, to: Pos2) {
self.frame(vec![Event::PointerMoved(from), button(from, true)]);
for k in 1..=6 {
let at = from + (to - from) * (k as f32 / 6.0);
self.frame(vec![Event::PointerMoved(at)]);
}
}
fn drag(&mut self, from: Pos2, to: Pos2) {
self.press_and_move(from, to);
self.frame(vec![button(to, false)]);
self.frame(vec![]);
self.frame(vec![]);
}
fn layout(&self) -> Layout<String> {
layout_of(&self.app.ui)
}
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
fn main_panels(layout: &Layout<String>) -> Vec<String> {
dock_columns(layout).first().map(|c| c.groups.iter().flat_map(|g| g.panels.clone()).collect()).unwrap_or_default()
}
#[test]
fn the_default_layout_is_the_canvas_beside_the_main_column_and_the_icon_column() {
let mut h = Harness::new();
let layout = h.layout();
assert!(h.app.ui.docking.is_none(), "an untouched workspace has no saved layout");
let columns = dock_columns(&layout);
assert_eq!(main_panels(&layout), ["properties", "layers", "libraries"]);
let icons = columns.get(1).expect("an icon column");
assert!(icons.iconic && !columns[0].iconic);
assert_eq!(icons.groups.len(), ICON_PANEL_GROUPS.len());
let properties = h.tab("properties").expect("the main column's tabs are drawn");
let swatches = h.hit(&Hit::Icon("swatches".into())).expect("the icon column is drawn");
assert!(swatches.right() <= properties.left(), "the icon column is inside the main column");
// The canvas takes the rest and gets the pointer: the Rectangle tool draws on it.
let canvas = h.app.canvas_rect.expect("the canvas is drawn");
assert!(canvas.right() <= swatches.left() + 1.0 && canvas.width() > 800.0);
h.app.select_tool("rectangle");
let count = |h: &Harness| h.app.session.active().unwrap().doc.node_count();
let before = count(&h);
h.drag(canvas.center(), canvas.center() + vec2(60.0, 40.0));
assert_eq!(count(&h), before + 1);
}
#[test]
fn an_icon_opens_its_flyout_and_open_panel_follows() {
let mut h = Harness::new();
let icon = h.hit(&Hit::Icon("swatches".into())).unwrap();
h.click(icon.center());
assert_eq!(h.app.ui.open_panel.as_deref(), Some("swatches"));
assert!(h.tab("swatches").is_some(), "the flyout shows its group's tabs");
// Window › Brushes switches the flyout on the next frame.
h.app.run("window.panel", json!({"panel": "brushes"})).unwrap();
h.frame(vec![]);
h.frame(vec![]);
assert_eq!(craft_ui::docking::flyout::<String>(&h.ctx, area_id()).as_deref(), Some("brushes"));
assert_eq!(h.app.ui.open_panel.as_deref(), Some("brushes"));
h.app.run("window.panel", json!({"panel": "brushes"})).unwrap();
h.frame(vec![]);
assert_eq!(craft_ui::docking::flyout::<String>(&h.ctx, area_id()), None);
}
#[test]
fn a_tab_dragged_out_floats_and_comes_back_where_it_was_after_its_window_closes() {
let mut h = Harness::new();
let tab = h.tab("layers").unwrap();
let canvas = h.app.canvas_rect.unwrap();
h.drag(tab.center(), canvas.center());
let layout = h.layout();
let window = floating_window(&layout, "layers").expect("Layers floats");
assert_eq!(window_panels(window), ["layers"]);
let rect = window.rect;
assert_eq!(main_panels(&layout), ["properties", "libraries"]);
let close = h.hit(&Hit::Close("layers".into())).expect("the floating window has a close button");
h.click(close.center());
assert!(!h.layout().contains(&"layers".to_string()), "× hides its panels");
assert!(h.app.ui.docking_hidden.contains_key("layers"));
h.app.run("window.panel", json!({"panel": "layers"})).unwrap();
h.frame(vec![]);
let layout = h.layout();
let window = floating_window(&layout, "layers").expect("Window › Layers brings it back where it was");
assert_eq!(window.rect, rect);
assert!(h.tab("layers").is_some());
}
#[test]
fn thumbnail_work_is_needed_only_while_layers_or_links_can_be_seen() {
let mut h = Harness::new();
h.app.run("window.panel", json!({"panel": "layers"})).unwrap();
assert!(thumbnail_panel_showing(&h.app.ui, &h.ctx));
h.app.ui.dock = false;
assert!(!thumbnail_panel_showing(&h.app.ui, &h.ctx), "Tab hides every panel");
h.app.ui.dock = true;
h.app.ui.screen_mode = 3;
assert!(!thumbnail_panel_showing(&h.app.ui, &h.ctx), "presentation mode hides panels");
h.app.ui.screen_mode = 0;
h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap();
assert!(thumbnail_panel_showing(&h.app.ui, &h.ctx), "a floating Layers panel still needs thumbnails");
h.app.run("window.panel", json!({"panel": "layers", "toggle": true})).unwrap();
assert!(!thumbnail_panel_showing(&h.app.ui, &h.ctx));
h.app.run("window.panel", json!({"panel": "links"})).unwrap();
h.frame(vec![]);
assert!(thumbnail_panel_showing(&h.app.ui, &h.ctx), "the Links flyout also uses row thumbnails");
}
#[test]
fn a_collapsed_dock_uses_the_flyouts_active_tab_for_thumbnail_work() {
let mut h = Harness::new();
h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap();
// A customized layout keeps the group's active tab independent of the icon that opened it.
h.app.ui.docking = Some(layout_of(&h.app.ui));
h.app.run("window.panel", json!({"panel": "properties"})).unwrap();
h.frame(vec![]);
assert!(!thumbnail_panel_showing(&h.app.ui, &h.ctx), "Properties does not use row thumbnails");
// A tab press emits Activate without revealing a different flyout anchor.
gesture(&mut h.app, Action::Activate { panel: "layers".into() }).unwrap();
h.frame(vec![]);
assert_eq!(h.app.ui.open_panel.as_deref(), Some("properties"), "the flyout is still named for its opening icon");
assert!(thumbnail_panel_showing(&h.app.ui, &h.ctx), "the active Layers tab keeps thumbnail jobs");
gesture(&mut h.app, Action::Activate { panel: "properties".into() }).unwrap();
h.frame(vec![]);
assert!(!thumbnail_panel_showing(&h.app.ui, &h.ctx), "returning to Properties drops hidden Layers jobs");
h.app.run("window.panel", json!({"panel": "layers"})).unwrap();
h.frame(vec![]);
assert!(thumbnail_panel_showing(&h.app.ui, &h.ctx));
gesture(&mut h.app, Action::Activate { panel: "properties".into() }).unwrap();
h.frame(vec![]);
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"));
assert!(!thumbnail_panel_showing(&h.app.ui, &h.ctx), "a Properties tab under the Layers icon does not need thumbnails");
}
/// A Character field shortcut with the Character panel in front of its floating window brings
/// that window forward and leaves the panel showing.
#[test]
fn a_character_field_shortcut_brings_the_floating_character_panel_forward() {
let mut h = Harness::new();
let id = h.app.run("text.create", json!({"x": 20, "y": 50, "text": "AVAX"})).unwrap()["id"].clone();
h.app.run("select.set", json!({"ids": [id]})).unwrap();
h.app.run("window.panel.float", json!({"panel": "character"})).unwrap();
h.frame(vec![]);
assert!(showing(&h.app.ui, "character"));
assert_eq!(h.app.dock_rt.raise, None);
for command in ["type.highlightKerning", "type.highlightTracking", "type.highlightFontName"] {
h.app.run(command, json!({})).unwrap();
assert_eq!(h.app.dock_rt.raise.as_deref(), Some("character"), "{command}");
h.frame(vec![]);
assert!(showing(&h.app.ui, "character"), "{command} leaves the panel showing");
}
}
#[test]
fn the_dock_collapses_to_icons_and_back() {
let mut h = Harness::new();
let collapse = h.hit(&Hit::Collapse("properties".into())).expect("the main column's double arrow");
h.click(collapse.center());
assert!(dock_collapsed(&h.app.ui) && h.app.ui.dock_collapsed);
assert!(h.app.ui.docking.is_none(), "collapsing keeps the workspace's default layout");
assert!(h.tab("properties").is_none() && h.hit(&Hit::Icon("properties".into())).is_some());
assert!(h.app.run("window.collapseDock", json!({"collapsed": false})).is_ok());
h.frame(vec![]);
assert!(!dock_collapsed(&h.app.ui));
assert!(h.tab("properties").is_some());
}
#[test]
fn tab_hides_the_panels_and_the_canvas_keeps_drawing() {
let mut h = Harness::new();
let narrow = h.app.canvas_rect.unwrap();
h.app.run("window.dock", json!({})).unwrap();
h.frame(vec![]);
h.frame(vec![]);
assert!(h.tab("properties").is_none() && h.hit(&Hit::Icon("swatches".into())).is_none());
let wide = h.app.canvas_rect.expect("the canvas still draws");
assert!(wide.width() > narrow.width(), "the canvas takes the panels' room");
h.app.run("window.dock", json!({})).unwrap();
h.frame(vec![]);
h.frame(vec![]);
assert!(h.tab("properties").is_some());
}
#[test]
fn switching_workspace_during_a_tab_drag_keeps_the_new_workspace_layout() {
let mut h = Harness::new();
let tab = h.tab("layers").unwrap();
let canvas = h.app.canvas_rect.unwrap();
h.press_and_move(tab.center(), canvas.center());
h.app.run("window.workspace", json!({"name": "Layout"})).unwrap();
h.frame(vec![Event::PointerMoved(canvas.center())]);
h.frame(vec![button(canvas.center(), false)]);
h.frame(vec![]);
assert!(h.app.ui.docking.is_none(), "the drag ended with the switch");
assert!(h.layout().floating.is_empty());
assert_eq!(main_panels(&h.layout()), ["properties", "layers", "libraries"]);
}
#[test]
fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() {
let mut h = Harness::new();
let zone = docked_tools(&h);
let title = zone.min + vec2(20.0, 12.0);
h.drag(title, pos2(600.0, 400.0));
let [x, y] = h.app.ui.toolbar_pos.expect("the Tools panel floats");
assert!((300.0..700.0).contains(&x) && (250.0..450.0).contains(&y), "{x} {y}");
let title = pos2(x + 20.0, y + 7.0);
h.drag(title, zone.center());
assert_eq!(h.app.ui.toolbar_pos, None, "dropped on the left edge, it docks");
}
/// The Tools panel also docks on the right, left of the panels: dropped on the window's right edge
/// it stands between the canvas and the panels, its flyouts open toward the canvas, the side is
/// kept with the preferences, and it drags back to the left edge.
#[test]
fn the_tools_panel_docks_on_the_right_left_of_the_panels() {
let mut h = Harness::new();
let zone = docked_tools(&h);
h.drag(zone.min + vec2(20.0, 12.0), pos2(600.0, 400.0));
let [x, y] = h.app.ui.toolbar_pos.expect("the Tools panel floats");
h.drag(pos2(x + 20.0, y + 7.0), pos2(SCREEN.right() - 10.0, 500.0));
assert_eq!((h.app.ui.toolbar_pos, h.app.ui.toolbar_right), (None, true), "dropped on the right edge, it docks on the right");
settle(&mut h);
let bar = docked_tools(&h);
let canvas = h.app.canvas_rect.expect("the canvas");
let layers = h.tab("layers").expect("the Layers panel, docked right");
assert!(canvas.right() <= bar.left() + 1.0 && bar.right() <= layers.left() + 1.0, "canvas {canvas:?}, Tools {bar:?}, Layers {layers:?}");
assert!(SCREEN.right() - bar.right() > 100.0, "the panels keep the window's right edge: {bar:?}");
// Its flyouts and drawer open toward the canvas, not over the panels.
let room = crate::toolbar::room(&h.app, &h.ctx);
assert!(room.right() <= bar.right() + 0.5, "{room:?}");
let left = crate::toolbar::beside(bar, 160.0, 8.0, room);
assert!(left + 160.0 <= bar.left() - 7.0, "left of the toolbar: {left}");
let prefs: crate::state::UiState = serde_json::from_slice(&serde_json::to_vec(&h.app.ui).unwrap()).unwrap();
assert!(prefs.toolbar_right, "kept with the preferences");
// Dragged back to the window's left edge, it docks there again.
h.drag(bar.min + vec2(20.0, 12.0), pos2(600.0, 400.0));
let [x, y] = h.app.ui.toolbar_pos.expect("floats again");
h.drag(pos2(x + 20.0, y + 7.0), pos2(12.0, 500.0));
assert_eq!((h.app.ui.toolbar_pos, h.app.ui.toolbar_right), (None, false));
settle(&mut h);
assert!(docked_tools(&h).left() < 10.0, "at the window's left edge again");
// Agents: window.panel.dock takes the side (left/right, or Start/End as panels do).
let r = h.app.run("window.panel.dock", json!({"panel": "tools", "side": "End"})).unwrap();
assert_eq!((r["side"].as_str(), h.app.ui.toolbar_right), (Some("right"), true));
h.app.run("window.panel.float", json!({"panel": "tools"})).unwrap();
assert_eq!(h.app.run("window.panel.dock", json!({"panel": "tools"})).unwrap()["side"], "right", "back on the side it docked at last");
assert!(h.app.run("window.panel.dock", json!({"panel": "tools", "side": "top"})).is_err());
}
/// The docked Tools panel as last drawn.
fn docked_tools(h: &Harness) -> Rect {
h.ctx.data(|d| d.get_temp::<Rect>(crate::toolbar::docked_rect_id())).expect("the docked Tools panel")
}
fn settle(h: &mut Harness) {
for _ in 0..3 {
h.frame(vec![]);
}
}
fn keep_tools_beside_canvas(h: &mut Harness, on: bool) {
assert_eq!(h.app.run("window.toolsBesideCanvas", json!({"on": on})).unwrap(), json!(on));
settle(h);
}
/// Where docked Tools sits with a left-side dock: `Some(true)` between the dock and the canvas,
/// `Some(false)` at the window's edge outside the dock.
fn tools_place(h: &Harness) -> Option<bool> {
let tools = docked_tools(h);
let layers = h.tab("layers")?;
let canvas = h.app.canvas_rect?;
if layers.right() <= tools.left() && tools.right() <= canvas.left() && canvas.left() - tools.right() < 40.0 {
Some(true)
} else if tools.left() < 10.0 && tools.right() <= layers.left() {
Some(false)
} else {
None
}
}
/// Off by default: docked Tools stays at the window's edge, outside a left-side dock, and the
/// setting moves it between the dock and the canvas and back.
#[test]
fn docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas() {
let mut h = Harness::new();
assert!(!h.app.ui.tools_beside_canvas);
assert_eq!(crate::menus::checked(&h.app, "window.toolsBesideCanvas", &json!({})), Some(false));
h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap();
settle(&mut h);
assert_eq!(tools_place(&h), Some(false), "Tools, then the left dock, then the canvas");
assert_eq!(crate::toolbar::docked_width(&h.app.ui), 0.0, "the canvas reserves no room for Tools");
keep_tools_beside_canvas(&mut h, true);
assert_eq!(crate::menus::checked(&h.app, "window.toolsBesideCanvas", &json!({})), Some(true));
assert_eq!(tools_place(&h), Some(true), "the left dock, then Tools, then the canvas");
keep_tools_beside_canvas(&mut h, false);
assert_eq!(tools_place(&h), Some(false), "back at the window's edge");
}
#[test]
fn keep_tools_beside_the_canvas_toggles_and_rejects_a_value_that_is_not_true_or_false() {
let mut h = Harness::new();
assert_eq!(h.app.run("window.toolsBesideCanvas", json!({})).unwrap(), json!(true));
assert_eq!(h.app.run("window.toolsBesideCanvas", json!({})).unwrap(), json!(false));
assert!(h.app.run("window.toolsBesideCanvas", json!({"on": "yes"})).is_err());
assert!(!h.app.ui.tools_beside_canvas, "a bad value changes nothing");
}
/// Off, only the window's edge docks a floating Tools panel; on, the canvas's edge does too, and
/// both dock it beside the canvas.
#[test]
fn tools_redock_at_the_targets_of_the_chosen_placement() {
for beside in [false, true] {
let mut h = Harness::new();
keep_tools_beside_canvas(&mut h, beside);
h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap();
for at_window_edge in [false, true] {
h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap();
settle(&mut h);
let canvas = h.app.canvas_rect.unwrap();
let canvas_edge = h.ctx.data(|d| d.get_temp::<Rect>(crate::tools_float::tools_zone_id()));
assert_eq!(canvas_edge.is_some(), beside, "a canvas-edge target only while kept beside the canvas");
let canvas_edge = canvas_edge.map_or(pos2(canvas.left() + 12.0, canvas.center().y), |r| r.center());
let target = if at_window_edge { pos2(12.0, canvas.center().y) } else { canvas_edge };
h.drag(pos2(620.0, 307.0), target);
if !beside && !at_window_edge {
assert!(h.app.ui.toolbar_pos.is_some(), "the canvas's edge does not dock Tools while the setting is off");
continue;
}
assert_eq!(h.app.ui.toolbar_pos, None, "beside: {beside}, at the window edge: {at_window_edge}");
settle(&mut h);
assert_eq!(tools_place(&h), Some(beside), "beside: {beside}, at the window edge: {at_window_edge}");
}
}
}
/// Turning the setting off while Tools floats forgets the canvas-edge target, and turning it on
/// leaves a floating Tools panel floating.
#[test]
fn toggling_while_tools_floats_keeps_it_floating_and_drops_the_canvas_target() {
let mut h = Harness::new();
h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap();
keep_tools_beside_canvas(&mut h, true);
h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap();
settle(&mut h);
let zone = h.ctx.data(|d| d.get_temp::<Rect>(crate::tools_float::tools_zone_id())).expect("the canvas-edge target");
keep_tools_beside_canvas(&mut h, false);
assert!(h.app.ui.toolbar_pos.is_some(), "turning it off leaves Tools floating");
assert!(h.ctx.data(|d| d.get_temp::<Rect>(crate::tools_float::tools_zone_id())).is_none(), "the canvas-edge target is gone");
h.drag(pos2(620.0, 307.0), zone.center());
assert!(h.app.ui.toolbar_pos.is_some(), "the old canvas-edge strip docks nothing");
let [x, y] = h.app.ui.toolbar_pos.unwrap();
keep_tools_beside_canvas(&mut h, true);
assert_eq!(h.app.ui.toolbar_pos, Some([x, y]), "turning it on leaves Tools floating where it was");
keep_tools_beside_canvas(&mut h, false);
h.drag(pos2(x + 20.0, y + 7.0), pos2(12.0, zone.center().y));
assert_eq!(h.app.ui.toolbar_pos, None);
settle(&mut h);
assert_eq!(tools_place(&h), Some(false));
}
/// The setting is kept with a workspace; state and workspaces saved without it keep Tools at the
/// window's edge.
#[test]
fn keep_tools_beside_the_canvas_is_saved_and_off_in_state_saved_without_it() {
let mut saved = serde_json::to_value(crate::state::UiState { tools_beside_canvas: true, ..Default::default() }).unwrap();
assert_eq!(saved["tools_beside_canvas"], json!(true));
let reloaded: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap();
assert!(reloaded.tools_beside_canvas);
saved.as_object_mut().unwrap().remove("tools_beside_canvas");
let old: crate::state::UiState = serde_json::from_value(saved).unwrap();
assert!(!old.tools_beside_canvas && old.toolbar_pos.is_none(), "older state docks Tools at the window's edge");
let mut h = Harness::new();
h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap();
keep_tools_beside_canvas(&mut h, true);
let mut workspace = serde_json::to_value(crate::workspaces::capture(&h.app.ui, "Mine")).unwrap();
assert_eq!(workspace["toolsBesideCanvas"], json!(true));
workspace.as_object_mut().unwrap().remove("toolsBesideCanvas");
let old: crate::workspaces::Workspace = serde_json::from_value(workspace).unwrap();
assert!(!old.tools_beside_canvas, "a workspace saved without it docks Tools at the window's edge");
h.app.run("window.workspace.new", json!({"name": "Mine"})).unwrap();
h.app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
settle(&mut h);
assert!(!h.app.ui.tools_beside_canvas, "built-in workspaces keep Tools at the window's edge");
h.app.run("window.workspace", json!({"name": "Mine"})).unwrap();
settle(&mut h);
assert!(h.app.ui.tools_beside_canvas);
assert_eq!(tools_place(&h), Some(true), "the saved workspace brings Tools back beside the canvas");
}
#[test]
fn tools_follow_left_panels_when_resized_hidden_and_restored() {
for (beside, double) in [(false, false), (false, true), (true, false), (true, true)] {
let mut h = Harness::new();
keep_tools_beside_canvas(&mut h, beside);
h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap();
gesture(&mut h.app, Action::ResizeColumn { panel: "layers".into(), width: 430.0 }).unwrap();
h.app.ui.toolbar_double = double;
let width = if double { 80.0 } else { 48.0 };
for hidden in [false, true, false] {
if hidden == h.app.ui.dock {
h.app.run("window.panels", json!({})).unwrap();
}
settle(&mut h);
let tools = docked_tools(&h);
assert!((tools.width() - (width + 6.0)).abs() < 1.0, "{} columns: {tools:?}", if double { 2 } else { 1 });
if hidden {
assert!(h.tab("layers").is_none());
assert!(tools.left() < 10.0);
} else {
assert_eq!(tools_place(&h), Some(beside), "beside: {beside}, double: {double}");
assert!(h.tab("layers").unwrap().width() > 0.0);
}
let canvas = h.app.canvas_rect.unwrap();
assert!(canvas.left() - tools.left() >= width, "Tools has its own room: {tools:?}, {canvas:?}");
assert!(canvas.width() > 200.0);
}
}
}
/// A layout with panels above or below the canvas: the full-height strip along the window's left
/// edge docks Tools in both placements; off, Tools runs the window's height beside those panels,
/// and on, it stays in the canvas's row.
#[test]
fn tools_redock_at_window_edge_above_and_below_canvas() {
for (beside, above) in [(false, true), (false, false), (true, true), (true, false)] {
let mut h = Harness::new();
keep_tools_beside_canvas(&mut h, beside);
let tabs = |panel: &str| Box::new(Node::Tabs { panels: vec![panel.to_string()], active: 0 });
h.app.ui.docking = Some(Layout {
root: Some(Node::Split {
axis: craft_ui::layout::SplitAxis::Vertical,
size: craft_ui::layout::SplitSize::FixedFirst(180.0),
first: tabs("layers"),
second: Box::new(Node::Split {
axis: craft_ui::layout::SplitAxis::Vertical,
size: craft_ui::layout::SplitSize::FixedSecond(180.0),
first: tabs(CANVAS),
second: tabs("properties"),
}),
}),
floating: vec![],
});
assert!(valid(&h.layout()));
h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap();
for _ in 0..3 {
h.frame(vec![]);
}
let panel = h.tab(if above { "layers" } else { "properties" }).unwrap();
let canvas = h.app.canvas_rect.unwrap();
assert!(panel.center().y < canvas.top() || panel.center().y > canvas.bottom());
h.drag(pos2(620.0, 307.0), pos2(12.0, panel.center().y));
assert_eq!(h.app.ui.toolbar_pos, None, "window-edge drop, beside: {beside}, above the canvas: {above}");
settle(&mut h);
let tools = docked_tools(&h);
let panel = h.tab(if above { "layers" } else { "properties" }).unwrap();
assert!(tools.left() < 10.0 && tools.right() <= panel.left() + 60.0, "{tools:?}");
match (beside, above) {
(false, true) => assert!(tools.top() <= panel.top(), "Tools runs past the panel above: {tools:?}, {panel:?}"),
(false, false) => assert!(tools.bottom() >= panel.bottom(), "Tools runs past the panel below: {tools:?}, {panel:?}"),
(true, true) => assert!(tools.top() >= panel.bottom(), "Tools stays below the panel above: {tools:?}, {panel:?}"),
(true, false) => assert!(tools.bottom() <= panel.top(), "Tools stays above the panel below: {tools:?}, {panel:?}"),
}
}
}
#[test]
#[ignore = "manual visual check; set LEFT_TOOLS_SCREENSHOTS to an existing harness directory"]
fn capture_tools_beside_left_panels() {
let dir = std::path::PathBuf::from(std::env::var_os("LEFT_TOOLS_SCREENSHOTS").unwrap());
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("window.toolsBesideCanvas", json!({"on": true})).unwrap();
app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap();
let mut h = egui_kittest::Harness::builder()
.with_size(vec2(1200.0, 800.0))
.with_accessibility_check(false)
.wgpu()
.build_ui_state(|ui, app: &mut VectorcraftApp| app.ui(ui), app);
h.run_steps(6);
h.render().unwrap().save(dir.join("tools-inside-left-dock.png")).unwrap();
h.state_mut().ui.toolbar_double = true;
h.run_steps(3);
h.render().unwrap().save(dir.join("tools-inside-left-dock-double.png")).unwrap();
}
#[test]
fn a_library_panel_opens_renames_and_closes() {
let mut h = Harness::new();
h.app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap();
let pastels = "library:swatches:pastels".to_string();
h.frame(vec![]);
assert!(floating_window(&h.layout(), &pastels).is_some(), "a library panel floats");
assert!(h.tab(&pastels).is_some());
h.app.run("ui.libraryPanel", json!({"panel": pastels, "library": "web-safe-216"})).unwrap();
let web = "library:swatches:web-safe-216".to_string();
h.frame(vec![]);
assert!(h.layout().contains(&web) && !h.layout().contains(&pastels));
assert!(h.tab(&web).is_some());
h.app.run("window.panel.close", json!({"panel": web})).unwrap();
h.frame(vec![]);
assert!(!h.layout().contains(&web));
}
/// The floating window holding `panel`, by its panels.
fn window_of(layout: &Layout<String>, panel: &str) -> Vec<String> {
floating_window(layout, panel).map(window_panels).unwrap_or_default()
}
#[test]
fn float_onto_above_and_below_stack_with_a_floating_group() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "swatches"})).unwrap();
h.app.run("window.panel.float", json!({"panel": "stroke", "onto": "Swatches"})).unwrap();
let layout = h.layout();
assert_eq!(group_of(&layout, &"stroke".into()), vec!["swatches".to_string(), "stroke".into()], "a tab of its group");
h.app.run("window.panel.float", json!({"panel": "color", "below": "swatches"})).unwrap();
h.app.run("window.panel.float", json!({"panel": "gradient", "above": "swatches"})).unwrap();
let layout = h.layout();
let window = window_of(&layout, "swatches");
for panel in ["color", "gradient", "stroke"] {
assert!(window.contains(&panel.to_string()), "{panel} in the window: {window:?}");
}
assert_eq!(group_of(&layout, &"color".into()), vec!["color".to_string()], "a group of its own below");
assert_eq!(group_of(&layout, &"gradient".into()), vec!["gradient".to_string()]);
h.frame(vec![]);
let (top, middle, bottom) = (h.tab("gradient").unwrap(), h.tab("swatches").unwrap(), h.tab("color").unwrap());
assert!(top.top() < middle.top() && middle.top() < bottom.top(), "gradient above, color below");
// `onto` names a floating group; a docked one is refused.
assert!(h.app.run("window.panel.float", json!({"panel": "stroke", "onto": "layers"})).is_err());
}
#[test]
fn float_collapsed_minimizes_its_window() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "swatches", "collapsed": true})).unwrap();
assert!(floating_window(&h.layout(), "swatches").unwrap().minimized);
h.app.run("window.panel.float", json!({"panel": "swatches", "collapsed": false})).unwrap();
assert!(!floating_window(&h.layout(), "swatches").unwrap().minimized);
let r = h.app.run("window.panel.collapse", json!({"panel": "Layers"})).unwrap();
assert_eq!((r["panel"].clone(), r["collapsed"].clone()), (json!("layers"), json!(true)), "a docked group minimizes too");
}
#[test]
fn dock_column_n_docks_a_column_at_that_place() {
let mut h = Harness::new();
let before = end_columns(&h.layout()).len();
h.app.run("window.panel.float", json!({"panel": "swatches"})).unwrap();
h.app.run("window.panel.float", json!({"panel": "color", "below": "swatches"})).unwrap();
h.app.run("window.panel.dock", json!({"panel": "swatches", "column": 1})).unwrap();
let layout = h.layout();
let columns = end_columns(&layout);
assert_eq!(columns.len(), before + 1);
let panels: Vec<&String> = columns[2].groups.iter().flat_map(|g| &g.panels).collect();
assert_eq!(panels, ["swatches", "color"], "the whole floating window, next to the icon column");
assert!(layout.floating.is_empty());
// `true` docks at the innermost place; without `group`, the panel leaves its group.
h.app.run("window.panel.dock", json!({"panel": "layers", "column": true})).unwrap();
let layout = h.layout();
let columns = end_columns(&layout);
assert_eq!(columns.len(), before + 2);
assert_eq!(columns.last().unwrap().groups[0].panels, vec!["layers".to_string()]);
assert_eq!(main_panels(&layout), ["properties", "libraries"]);
}
#[test]
fn dock_icon_puts_a_panel_in_the_icon_column() {
let mut h = Harness::new();
h.app.run("window.panel.dock", json!({"panel": "layers", "icon": true})).unwrap();
let layout = h.layout();
assert!(in_iconic_column(&layout, "layers"));
assert!(!main_panels(&layout).contains(&"layers".to_string()));
assert_eq!(group_of(&layout, &"layers".into()), vec!["layers".to_string()], "a row of its own");
}
fn column_panels(layout: &Layout<String>) -> Vec<Vec<String>> {
end_columns(layout).iter().map(|c| c.groups.iter().flat_map(|g| g.panels.clone()).collect()).collect()
}
#[test]
fn a_docked_column_moves_to_a_later_place_and_back() {
let mut h = Harness::new();
h.app.run("window.panel.dock", json!({"panel": "swatches", "column": 1})).unwrap();
h.app.run("window.panel.dock", json!({"panel": "color", "column": 2})).unwrap();
let columns = column_panels(&h.layout());
assert_eq!(columns[2..], [vec!["swatches".to_string()], vec!["color".to_string()]]);
h.app.run("window.panel.dock", json!({"panel": "swatches", "column": 2})).unwrap();
let columns = column_panels(&h.layout());
assert_eq!(columns[2..], [vec!["color".to_string()], vec!["swatches".to_string()]], "swatches moved inwards");
h.app.run("window.panel.dock", json!({"panel": "swatches", "column": 1})).unwrap();
let columns = column_panels(&h.layout());
assert_eq!(columns[2..], [vec!["swatches".to_string()], vec!["color".to_string()]], "and back");
let before = h.layout();
h.app.run("window.panel.dock", json!({"panel": "swatches", "column": 1})).unwrap();
assert_eq!(h.layout(), before, "already there");
}
/// A layout converted from an old dock split side by side, with the dock collapsed: two
/// collapsed tree columns before the icon column.
#[test]
fn column_places_and_icon_count_from_the_icon_column() {
let mut h = Harness::new();
let mut layout = h.layout();
if let Some(Node::Split { second, .. }) = layout.root.as_mut()
&& let Node::Dock { columns, .. } = second.as_mut()
{
let libraries = columns[0].groups[0].panels.pop().unwrap();
assert_eq!(libraries, "libraries");
columns.insert(1, Column { groups: vec![Group::new(vec![libraries], 0)], width: 200.0, iconic: true, iconic_width: Some(ICON_WIDTH) });
columns[0].iconic = true;
}
let width: f32 = end_columns(&layout).iter().map(|c| c.iconic_width.unwrap_or(ICON_WIDTH)).sum();
if let Some(Node::Split { size, .. }) = layout.root.as_mut() {
*size = craft_ui::layout::SplitSize::FixedSecond(width);
}
assert!(valid(&layout));
h.app.ui.docking = Some(layout);
h.app.run("window.panel.dock", json!({"panel": "layers", "icon": true})).unwrap();
let layout = h.layout();
let columns = column_panels(&layout);
assert!(columns[2].contains(&"layers".to_string()), "{columns:?}");
h.app.run("window.panel.dock", json!({"panel": "swatches", "column": 1})).unwrap();
let columns = column_panels(&h.layout());
assert_eq!(columns[..2], [vec!["properties".to_string()], vec!["libraries".to_string()]]);
assert_eq!(columns[3], ["swatches"], "next to the icon column: {columns:?}");
// No icon column: `icon` makes one.
let mut layout = h.layout();
if let Some(Node::Split { second, .. }) = layout.root.as_mut()
&& let Node::Dock { columns, .. } = second.as_mut()
{
columns.remove(2);
}
h.app.ui.docking = Some(layout);
assert_eq!(icon_column(&end_columns(&h.layout())), None);
h.app.run("window.panel.dock", json!({"panel": "properties", "icon": true})).unwrap();
let layout = h.layout();
assert!(in_iconic_column(&layout, "properties"));
assert!(icon_column(&end_columns(&layout)).is_none_or(|k| column_panels(&layout)[k].contains(&"properties".to_string())));
}
#[test]
fn invalid_docking_parameters_change_nothing() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "swatches"})).unwrap();
let before = h.layout();
for (id, params) in [
("window.panel.float", json!({"panel": "layers", "onto": "nope"})),
("window.panel.float", json!({"panel": "layers", "onto": "swatches", "below": "swatches"})),
("window.panel.float", json!({"panel": "layers", "onto": "swatches", "x": 10, "y": 10})),
("window.panel.float", json!({"panel": "swatches", "onto": "swatches"})),
("window.panel.float", json!({"panel": "layers", "icon": true})),
("window.panel.float", json!({"panel": "layers", "collapsed": "yes"})),
("window.panel.dock", json!({"panel": "layers", "icon": true, "anchor": "swatches"})),
("window.panel.dock", json!({"panel": "layers", "column": 0})),
("window.panel.dock", json!({"panel": "layers", "column": "left"})),
("window.panel.dock", json!({"panel": "layers", "icon": "yes"})),
] {
assert!(h.app.run(id, params.clone()).is_err(), "{id} {params}");
assert_eq!(h.layout(), before, "{id} {params} changed the layout");
}
}
#[test]
fn ui_docking_reports_the_layout_and_hit_rects() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "swatches"})).unwrap();
h.frame(vec![]);
let tab = h.tab("layers").unwrap();
h.click(tab.center());
let report = inspect(&h.app, &h.ctx);
assert_eq!(report["layout"], serde_json::to_value(h.layout()).unwrap());
assert_eq!(report["focused"], json!("layers"));
let hits = report["hitRects"].as_array().unwrap();
for hit in [json!({"Tab": "layers"}), json!({"Title": "swatches"})] {
let found = hits.iter().find(|h| h["hit"] == hit).unwrap_or_else(|| panic!("{hit} in {hits:?}"));
assert_eq!(found["rect"].as_array().unwrap().len(), 4);
}
}
/// Press on the main column's divider and drag it 60 pt toward the canvas, holding the button.
fn press_on_the_main_column_divider(h: &mut Harness) -> f32 {
let edge =
hit_rects(&h.ctx).into_iter().filter(|(hit, _)| matches!(hit, Hit::Divider(_))).map(|(_, r)| r.center().x).fold(f32::NEG_INFINITY, f32::max);
h.press_and_move(pos2(edge, 600.0), pos2(edge - 60.0, 600.0));
dock_columns(&h.layout())[0].width
}
#[test]
fn a_column_resize_whose_release_was_lost_ends_when_the_system_reports_no_button_down() {
let mut h = Harness::new();
let width = press_on_the_main_column_divider(&mut h);
for x in [900.0, 1000.0, 850.0, 1100.0] {
h.frame_with_system_buttons(vec![Event::PointerMoved(pos2(x, 600.0))], false);
assert_eq!(dock_columns(&h.layout())[0].width, width, "the column followed the pointer to {x}");
}
assert!(!h.ctx.input(|i| i.pointer.any_down()));
}
#[test]
fn a_column_resize_continues_while_the_system_reports_the_button_down() {
let mut h = Harness::new();
let width = press_on_the_main_column_divider(&mut h);
h.frame_with_system_buttons(vec![Event::PointerMoved(pos2(900.0, 600.0))], true);
assert_ne!(dock_columns(&h.layout())[0].width, width);
assert!(h.ctx.input(|i| i.pointer.primary_down()));
}
#[test]
fn a_lost_release_after_the_pointer_moved_away_is_not_a_click() {
let mut h = Harness::new();
h.frame(vec![Event::PointerMoved(pos2(800.0, 600.0)), button(pos2(800.0, 600.0), true)]);
h.frame_with_system_buttons(vec![Event::PointerMoved(pos2(1000.0, 600.0))], false);
assert!(!h.ctx.input(|i| i.pointer.any_down()));
assert!(!h.ctx.input(|i| i.pointer.any_click()), "the release at the press position made a click");
}
#[test]
fn control_channel_drags_keep_their_button_between_steps() {
let mut h = Harness::new();
h.frame(vec![Event::PointerMoved(pos2(800.0, 600.0)), button(pos2(800.0, 600.0), true)]);
h.app.synthetic.push(button(pos2(820.0, 600.0), false));
let mut input = egui::RawInput { events: vec![Event::PointerMoved(pos2(810.0, 600.0))], ..Default::default() };
h.app.release_lost_buttons(&h.ctx, &mut input, false);
assert_eq!(input.events, [Event::PointerMoved(pos2(810.0, 600.0))]);
}
#[test]
fn window_menu_panels_without_a_place_open_in_their_icon_group_or_float() {
let mut h = Harness::new();
// A saved layout that lost Swatches without remembering where it was.
let mut layout = h.layout();
layout.apply(Action::Close { panel: "swatches".into() }).unwrap();
h.app.ui.docking = Some(layout);
h.app.ui.docking_hidden.clear();
h.app.run("window.panel", json!({"panel": "Swatches"})).unwrap();
let layout = h.layout();
assert!(in_iconic_column(&layout, "swatches"), "back in the icon column");
let group = dock_columns(&layout).iter().flat_map(|c| &c.groups).find(|g| g.panels.contains(&"swatches".to_string())).unwrap().panels.clone();
assert!(group.contains(&"brushes".to_string()), "with its icon group: {group:?}");
assert_eq!(h.app.ui.open_panel.as_deref(), Some("swatches"), "its flyout opens");
// A panel of no icon group floats.
h.app.run("window.panel", json!({"panel": "openType"})).unwrap();
assert!(floating_window(&h.layout(), "openType").is_some());
h.frame(vec![]);
assert!(h.tab("openType").is_some());
}
#[test]
fn f7_hides_and_restores_the_expanded_layers_tab() {
let mut h = Harness::new();
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(showing(&h.app.ui, "layers"), "the first press activates the tab");
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(!showing(&h.app.ui, "layers"), "a second press hides the active tab");
assert!(h.app.ui.docking_hidden.contains_key("layers"));
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(showing(&h.app.ui, "layers"), "the next press restores it");
assert_eq!(main_panels(&h.layout()), ["properties", "layers", "libraries"]);
h.app.run("window.panel.collapse", json!({"panel": "layers", "collapsed": true})).unwrap();
let layout = h.layout();
let group = dock_columns(&layout).iter().flat_map(|c| &c.groups).find(|g| g.panels.contains(&"layers".to_string())).unwrap();
assert!(group.minimized);
h.press(egui::Key::F7, egui::Modifiers::NONE);
let layout = h.layout();
let group = dock_columns(&layout).iter().flat_map(|c| &c.groups).find(|g| g.panels.contains(&"layers".to_string())).unwrap();
assert!(!group.minimized, "the first press expands a collapsed group");
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(!showing(&h.app.ui, "layers"), "the following press hides the expanded group");
}
#[test]
fn f7_activates_a_background_tab_before_hiding_it() {
let mut h = Harness::new();
h.app.run("window.panel.move", json!({"panel": "layers", "anchor": "properties"})).unwrap();
h.app.run("window.panel.activate", json!({"panel": "properties"})).unwrap();
let before = group_of(&h.layout(), &"layers".into());
assert!(!showing(&h.app.ui, "layers"));
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(showing(&h.app.ui, "layers"));
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string())));
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(showing(&h.app.ui, "layers"));
assert_eq!(group_of(&h.layout(), &"layers".into()), before);
}
#[test]
fn f7_hides_and_restores_the_whole_expanded_group() {
let mut h = Harness::new();
h.press(egui::Key::F7, egui::Modifiers::NONE);
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string())));
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]);
assert!(showing(&h.app.ui, "layers"));
h.press(egui::Key::F7, egui::Modifiers::NONE);
h.app.run("window.panel", json!({"panel": "properties", "toggle": true})).unwrap();
assert_eq!(group_of(&h.layout(), &"properties".into()), ["properties", "layers", "libraries"]);
assert!(showing(&h.app.ui, "properties"));
}
#[test]
fn f7_restores_only_tabs_hidden_with_the_group() {
let mut h = Harness::new();
h.app.run("window.panel.close", json!({"panel": "libraries"})).unwrap();
assert!(h.app.ui.docking_hidden.contains_key("libraries"));
h.press(egui::Key::F7, egui::Modifiers::NONE);
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(["properties", "layers"].iter().all(|panel| h.app.ui.docking_hidden.contains_key(*panel)));
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers"]);
assert!(!h.layout().contains(&"libraries".to_string()));
assert!(showing(&h.app.ui, "layers"));
}
#[test]
fn independently_reopened_tab_is_not_restored_by_the_group_key() {
let mut h = Harness::new();
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(showing(&h.app.ui, "layers"));
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(["properties", "layers", "libraries"].iter().all(|panel| h.app.ui.docking_hidden.contains_key(*panel)));
assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string())));
h.app.run("window.panel.activate", json!({"panel": "libraries"})).unwrap();
assert!(h.layout().contains(&"libraries".to_string()));
assert!(!h.app.ui.docking_hidden.contains_key("libraries"));
assert!(["properties", "layers"].iter().all(|panel| h.app.ui.docking_hidden.contains_key(*panel)));
h.app.run("window.panel.close", json!({"panel": "libraries"})).unwrap();
assert!(h.app.ui.docking_hidden.contains_key("libraries"));
assert!(!h.layout().contains(&"libraries".to_string()));
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers"]);
assert!(!h.layout().contains(&"libraries".to_string()));
assert!(showing(&h.app.ui, "layers"));
}
#[test]
fn f7_hides_and_restores_a_floating_tab_group() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap();
h.app.run("window.panel.activate", json!({"panel": "layers"})).unwrap();
h.frame(vec![]);
let before = h.layout();
let window = floating_window(&before, "layers").unwrap();
let rect = window.rect;
assert_eq!(group_of(&before, &"layers".into()), ["properties", "layers", "libraries"]);
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(floating_window(&h.layout(), "layers").is_none());
assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string())));
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert_eq!(floating_window(&h.layout(), "layers").unwrap().rect, rect);
assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]);
assert!(showing(&h.app.ui, "layers"));
}
#[test]
fn showing_a_hidden_floating_group_returns_its_window_panels() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "swatches"})).unwrap();
h.app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap();
h.app.run("window.panel.activate", json!({"panel": "layers"})).unwrap();
h.frame(vec![]);
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(floating_window(&h.layout(), "layers").is_none());
let reply = h.app.run("window.panel", json!({"panel": "layers"})).unwrap();
assert_eq!(reply, json!({"floating": ["properties", "layers", "libraries"]}), "only the restored window's panels");
}
#[test]
fn f7_toggles_a_multi_tab_icon_flyout() {
let mut h = Harness::new();
h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap();
h.frame(vec![]);
assert!(in_iconic_column(&h.layout(), "layers"));
assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]);
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"));
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert_eq!(h.app.ui.open_panel, None);
assert!(in_iconic_column(&h.layout(), "layers"));
assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]);
}
#[test]
fn panel_function_keys_toggle_icon_flyouts_and_floating_groups() {
let mut h = Harness::new();
h.press(egui::Key::F5, egui::Modifiers::NONE);
assert_eq!(h.app.ui.open_panel.as_deref(), Some("brushes"));
h.press(egui::Key::F5, egui::Modifiers::NONE);
assert_eq!(h.app.ui.open_panel, None);
h.press(egui::Key::F5, egui::Modifiers::NONE);
assert_eq!(h.app.ui.open_panel.as_deref(), Some("brushes"));
h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap();
h.frame(vec![]);
let rect = floating_window(&h.layout(), "layers").unwrap().rect;
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(floating_window(&h.layout(), "layers").is_none());
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert_eq!(floating_window(&h.layout(), "layers").unwrap().rect, rect);
h.app.run("window.panel.dock", json!({"panel": "layers"})).unwrap();
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(!showing(&h.app.ui, "layers"), "the expanded dock also toggles");
}
#[test]
fn other_function_keys_and_keyboard_guards_keep_their_routes() {
let mut h = Harness::new();
h.app.run("window.panel", json!({"panel": "layers"})).unwrap();
let keymap = crate::shortcut_editor::current();
for (chord, command) in [("F8", "ui.symbolOptions"), ("F12", "file.revert")] {
let bound = crate::keymap::vc_chord(chord).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c)).unwrap();
assert_eq!(bound.id, command);
}
h.app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
h.press(egui::Key::F8, egui::Modifiers::NONE);
assert_eq!(h.app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("symbolOptions"));
crate::dialogs::confirm(&mut h.app).unwrap();
assert_eq!(h.app.session.doc().unwrap().doc.symbols.len(), 1);
crate::menus::invoke(&mut h.app, "view.rotate", json!({}));
assert!(h.app.ui.dialog.is_some());
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(showing(&h.app.ui, "layers"), "F7 does nothing while a dialog is open");
h.app.ui.dialog = None;
let field = egui::Id::unique("panel-shortcut-text-focus");
h.ctx.memory_mut(|m| m.request_focus(field));
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(showing(&h.app.ui, "layers"), "F7 does nothing while a text field has focus");
h.ctx.memory_mut(|m| m.surrender_focus(field));
h.press(egui::Key::F7, egui::Modifiers::NONE);
assert!(!showing(&h.app.ui, "layers"), "F7 toggles when the field releases focus");
let dir = vectorcraft_testkit::temp_dir("panel-shortcut-revert");
let path = dir.join(format!("panel-shortcut-revert-{}.vectorcraft", std::process::id()));
let services = crate::Services {
read: Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p, data| std::fs::write(p, data).map_err(|e| e.to_string()))),
..Default::default()
};
let mut saved = VectorcraftApp::new(Session::new(), services);
saved.run("file.new", json!({"width": 100, "height": 100})).unwrap();
saved.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
saved.run("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
saved.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap();
assert_eq!(saved.session.doc().unwrap().doc.node_count(), 3);
let ctx = egui::Context::default();
let key = Event::Key { key: egui::Key::F12, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
ctx.run_ui(egui::RawInput { events: vec![key], ..Default::default() }, |ui| crate::shortcuts::handle(&mut saved, ui.ctx()))
.textures_delta
.clear();
assert_eq!(saved.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::confirm::KIND));
crate::dialogs::confirm(&mut saved).unwrap();
assert_eq!(saved.session.doc().unwrap().doc.node_count(), 2, "F12 reloads the saved document");
std::fs::remove_dir_all(dir).unwrap();
}
fn collapse_to_icons_button(h: &Harness, panel: &str) -> Option<Rect> {
let panel = panel.to_string();
h.ctx.read_response(area_id().with(("header", &panel)).with(("collapse-to-icons", &panel))).map(|r| r.rect)
}
#[test]
fn floating_collapse_arrow_moves_the_whole_group_into_the_icon_rail() {
let mut h = Harness::new();
assert!(collapse_to_icons_button(&h, "properties").is_none(), "docked headers have no extra button");
h.app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap();
h.app.run("window.panel.activate", json!({"panel": "layers"})).unwrap();
for _ in 0..3 {
h.frame(vec![]);
}
let before = h.layout();
let members = group_of(&before, &"layers".into());
let columns = end_columns(&before).len();
let arrow = collapse_to_icons_button(&h, "layers").expect("floating header offers collapse to icons");
assert!(arrow.width() <= 24.0 && arrow.height() <= 24.0, "a compact header control: {arrow:?}");
h.click(arrow.center());
let layout = h.layout();
assert!(floating_window(&layout, "layers").is_none(), "the old floating window is removed");
assert_eq!(group_of(&layout, &"layers".into()), members, "all tabs move together");
assert!(members.iter().all(|p| in_iconic_column(&layout, p)));
assert_eq!(end_columns(&layout).len(), columns, "reuse the existing icon rail");
assert!(h.app.ui.open_panel.is_none(), "the group stays collapsed until its icon is clicked");
let icon = h.hit(&Hit::Icon("layers".into())).expect("Layers now has a rail icon");
h.click(icon.center());
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"));
assert!(h.tab("layers").is_some(), "the collapsed group can reopen as a flyout");
assert!(collapse_to_icons_button(&h, "layers").is_some(), "the reopened flyout also offers collapse to icons");
}
#[test]
fn floating_collapse_arrow_leaves_other_floating_groups_alone() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap();
h.app.run("window.panel.float", json!({"panel": "color", "below": "layers"})).unwrap();
for _ in 0..3 {
h.frame(vec![]);
}
let arrow = collapse_to_icons_button(&h, "layers").expect("floating group has its own arrow");
h.click(arrow.center());
let layout = h.layout();
assert!(in_iconic_column(&layout, "layers"));
assert!(floating_window(&layout, "color").is_some(), "another group in the same window stays floating");
assert!(!in_iconic_column(&layout, "color"));
assert_eq!(main_panels(&layout), ["properties", "libraries"]);
}
#[test]
fn floating_collapse_arrow_creates_a_rail_when_none_exists_and_keeps_the_menu() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap();
let mut layout = h.layout();
if let Some(Node::Split { second, .. }) = layout.root.as_mut()
&& let Node::Dock { columns, .. } = second.as_mut()
{
columns.retain(|column| !column.iconic);
}
assert!(icon_column(&end_columns(&layout)).is_none());
h.app.ui.docking = Some(layout);
for _ in 0..3 {
h.frame(vec![]);
}
let panel = "layers".to_string();
let menu =
h.ctx.read_response(area_id().with(("header", &panel)).with(("panel-menu", &panel))).expect("the existing panel menu remains available").rect;
let arrow = collapse_to_icons_button(&h, "layers").expect("floating group offers collapse");
assert!(!menu.intersects(arrow), "menu and arrow have separate hit targets");
h.click(menu.center());
assert!(egui::Popup::is_any_open(&h.ctx), "the existing menu still opens");
h.press(egui::Key::Escape, egui::Modifiers::NONE);
h.click(arrow.center());
let layout = h.layout();
assert!(in_iconic_column(&layout, "layers"));
assert!(floating_window(&layout, "layers").is_none());
assert!(h.app.ui.open_panel.is_none(), "collapse does not immediately open a flyout");
assert_eq!(main_panels(&layout), ["properties", "libraries"]);
}
#[test]
fn rail_flyout_collapse_arrow_folds_the_panel_back_without_moving_it() {
let mut h = Harness::new();
let icon = h.hit(&Hit::Icon("swatches".into())).unwrap();
h.click(icon.center());
let before = h.layout();
let arrow = collapse_to_icons_button(&h, "swatches").expect("opened rail panels offer collapse to icons");
h.click(arrow.center());
assert!(h.app.ui.open_panel.is_none());
assert!(craft_ui::docking::flyout::<String>(&h.ctx, area_id()).is_none());
assert_eq!(h.layout(), before, "folding a flyout leaves the dock layout intact");
let icon = h.hit(&Hit::Icon("swatches".into())).expect("the rail icon stays available");
h.click(icon.center());
assert_eq!(h.app.ui.open_panel.as_deref(), Some("swatches"));
assert!(h.tab("swatches").is_some());
}
#[test]
fn rail_flyout_collapse_arrow_closes_the_opening_icon_after_switching_tabs() {
let mut h = Harness::new();
h.app.ui.docking = Some(h.layout());
let icon = h.hit(&Hit::Icon("swatches".into())).unwrap();
h.click(icon.center());
let brushes = h.tab("brushes").unwrap();
h.click(brushes.center());
assert_eq!(h.app.ui.open_panel.as_deref(), Some("swatches"));
let before = h.layout();
let arrow = collapse_to_icons_button(&h, "brushes").expect("the active tab has a collapse arrow");
h.click(arrow.center());
assert!(h.app.ui.open_panel.is_none());
assert!(craft_ui::docking::flyout::<String>(&h.ctx, area_id()).is_none());
assert_eq!(h.layout(), before, "all tabs and their active state stay in the rail");
}