use super::*; use crate::clipboard::NoClipboard; use crate::event::{Key, KeyInput, Modifiers, PointerButton, PointerInput}; use crate::text::TextContext; use guiduck_scene::geom::Size; const VIEWPORT: Size = Size::new(400.0, 300.0); fn sample_font() -> guiduck_scene::paint::Blob { static FONT: &[u8] = include_bytes!("../../../../guiduck-samples/fonts/DejaVuSans.ttf"); static BLOB: std::sync::OnceLock> = std::sync::OnceLock::new(); BLOB.get_or_init(|| guiduck_scene::paint::Blob::new(std::sync::Arc::new(FONT))) .clone() } fn tree() -> WidgetTree { WidgetTree::with_text_context(TextContext::hermetic([sample_font()])) } /// Every widget under `id`, by count — the measure of what actually exists. fn descendants(tree: &WidgetTree, id: WidgetId) -> usize { tree.children(id) .iter() .map(|child| 1 + descendants(tree, *child)) .sum() } fn total_widgets(tree: &WidgetTree) -> usize { let base = tree.root().map_or(0, |root| 1 + descendants(tree, root)); base + tree .overlay_roots() .iter() .map(|root| 1 + descendants(tree, *root)) .sum::() } /// A File menu with three plain rows. fn file_menu() -> Menu { Menu::new("File").content(ContentBuilder::new(|tree, parent| { for label in ["New", "Open", "Quit"] { tree.insert( MenuItem::new(label).family("DejaVu Sans"), taffy::Style::default(), Some(parent), ); } })) } fn bar_with(menu: Menu) -> (WidgetTree, WidgetId, WidgetId) { let mut tree = tree(); let bar = tree.insert(MenuBar::new(), taffy::Style::default(), None); let title = tree.insert( menu.family("DejaVu Sans"), taffy::Style::default(), Some(bar), ); tree.render_frame(VIEWPORT); (tree, bar, title) } fn click(tree: &mut WidgetTree, id: WidgetId) { let rect = tree.layout(id); let origin = tree.absolute_origin(id); let pos = Point::new( origin.x + rect.width() / 2.0, origin.y + rect.height() / 2.0, ); tree.dispatch_pointer( PointerInput::Down { time: std::time::Instant::now(), pos, button: PointerButton::Left, }, &mut NoClipboard, ); tree.dispatch_pointer( PointerInput::Up { pos, button: PointerButton::Left, }, &mut NoClipboard, ); tree.render_frame(VIEWPORT); } fn press(tree: &mut WidgetTree, key: Key) { tree.dispatch_key( &KeyInput { key, modifiers: Modifiers::default(), pressed: true, }, &mut NoClipboard, ); tree.render_frame(VIEWPORT); } #[test] fn a_closed_menu_has_no_content_at_all() { // The whole point of deferring: the rows do not exist, so they lay out // nothing, shape no text, and subscribe to nothing. let (tree, _bar, _title) = bar_with(file_menu()); assert_eq!(total_widgets(&tree), 2, "just the bar and its title"); assert!(!tree.menus_open()); } #[test] fn opening_builds_the_content_and_closing_takes_it_away() { let (mut tree, _bar, title) = bar_with(file_menu()); click(&mut tree, title); assert!(tree.menus_open()); // Bar, title, overlay root, panel, three rows. assert_eq!(total_widgets(&tree), 7); assert!(tree.widget::(title).expect("a menu").is_open()); // Escape closes the topmost layer — the overlay's own dismissal. press(&mut tree, Key::Escape); assert!(!tree.menus_open()); assert_eq!(total_widgets(&tree), 2, "the content is gone, not hidden"); assert!(!tree.widget::(title).expect("a menu").is_open()); } #[test] fn clicking_an_open_title_closes_it() { let (mut tree, _bar, title) = bar_with(file_menu()); click(&mut tree, title); assert!(tree.menus_open()); click(&mut tree, title); assert!(!tree.menus_open(), "a title toggles its own menu"); } #[test] fn only_the_opened_path_is_built() { // A submenu's rows are not built when its parent opens — only when the // submenu itself does. A deep tree costs the path you walked, not the // tree, which is what the pre-mounted design could never give. let deep = Menu::new("File").content(ContentBuilder::new(|tree, parent| { tree.insert( MenuItem::new("New").family("DejaVu Sans"), taffy::Style::default(), Some(parent), ); tree.insert( Menu::new("Recent") .submenu(true) .family("DejaVu Sans") .content(ContentBuilder::new(|tree, parent| { for label in ["a.txt", "b.txt", "c.txt", "d.txt"] { tree.insert( MenuItem::new(label).family("DejaVu Sans"), taffy::Style::default(), Some(parent), ); } })), taffy::Style::default(), Some(parent), ); })); let (mut tree, _bar, title) = bar_with(deep); click(&mut tree, title); // Bar, title, overlay, panel, "New", "Recent" — and none of Recent's four. assert_eq!( total_widgets(&tree), 6, "the submenu's rows do not exist yet" ); let recent = *tree .overlay_roots() .iter() .flat_map(|root| tree.children(*root).to_vec()) .flat_map(|panel| tree.children(panel).to_vec()) .find(|id| tree.widget::(*id).is_some()) .as_ref() .expect("the Recent row"); click(&mut tree, recent); // Now the second layer exists: overlay, panel, four rows. assert_eq!(total_widgets(&tree), 12); assert!( tree.widget::(title).expect("a menu").is_open(), "opening a submenu keeps its parent up" ); } #[test] fn arrows_rove_a_highlight_without_moving_focus() { let (mut tree, _bar, title) = bar_with(file_menu()); click(&mut tree, title); let panel = tree.children(tree.overlay_roots()[0])[0]; let rows: Vec = tree.children(panel).to_vec(); let focused = tree.focus(); assert_eq!(focused, Some(panel), "the panel holds focus, not the rows"); let highlighted = |tree: &WidgetTree, rows: &[WidgetId]| -> Vec { rows.iter() .map(|id| { tree.widget::(*id) .expect("a row") .is_highlighted() }) .collect() }; assert_eq!(highlighted(&tree, &rows), [false, false, false]); press(&mut tree, Key::Down); assert_eq!(highlighted(&tree, &rows), [true, false, false]); press(&mut tree, Key::Down); assert_eq!(highlighted(&tree, &rows), [false, true, false]); press(&mut tree, Key::Up); assert_eq!(highlighted(&tree, &rows), [true, false, false]); // Up from the first row wraps to the last: a menu is a ring. press(&mut tree, Key::Up); assert_eq!(highlighted(&tree, &rows), [false, false, true]); press(&mut tree, Key::Home); assert_eq!(highlighted(&tree, &rows), [true, false, false]); press(&mut tree, Key::End); assert_eq!(highlighted(&tree, &rows), [false, false, true]); // Roving never moved focus, which is what makes it cheap and what lets // the overlay restore the right widget when the menu closes. assert_eq!(tree.focus(), focused); } #[test] fn hovering_a_row_makes_it_the_current_one() { // The pointer and the arrows move the *same* highlight, so they cannot // disagree about which row is current. let (mut tree, _bar, title) = bar_with(file_menu()); click(&mut tree, title); let panel = tree.children(tree.overlay_roots()[0])[0]; let rows: Vec = tree.children(panel).to_vec(); press(&mut tree, Key::Down); let rect = tree.layout(rows[2]); let origin = tree.absolute_origin(rows[2]); tree.dispatch_pointer( PointerInput::Moved(Point::new( origin.x + rect.width() / 2.0, origin.y + rect.height() / 2.0, )), &mut NoClipboard, ); tree.render_frame(VIEWPORT); assert!(!tree.widget::(rows[0]).unwrap().is_highlighted()); assert!(tree.widget::(rows[2]).unwrap().is_highlighted()); } #[test] fn choosing_a_row_closes_the_whole_stack() { let (mut tree, _bar, title) = bar_with(file_menu()); click(&mut tree, title); let panel = tree.children(tree.overlay_roots()[0])[0]; let rows: Vec = tree.children(panel).to_vec(); click(&mut tree, rows[1]); assert!(!tree.menus_open(), "the command is done; the menu goes"); assert_eq!(total_widgets(&tree), 2); } #[test] fn enter_chooses_the_highlighted_row() { let (mut tree, _bar, title) = bar_with(file_menu()); click(&mut tree, title); press(&mut tree, Key::Down); press(&mut tree, Key::Enter); // Enter and a click are one path: both end with the stack closed. assert!(!tree.menus_open()); }