tests.rs raw

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<u8> {
    static FONT: &[u8] = include_bytes!("../../../../guiduck-samples/fonts/DejaVuSans.ttf");
    static BLOB: std::sync::OnceLock<guiduck_scene::paint::Blob<u8>> = 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::<usize>()
}

/// 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::<Menu>(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::<Menu>(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::<Menu>(*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::<Menu>(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<WidgetId> = 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<bool> {
        rows.iter()
            .map(|id| {
                tree.widget::<MenuItem>(*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<WidgetId> = 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::<MenuItem>(rows[0]).unwrap().is_highlighted());
    assert!(tree.widget::<MenuItem>(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<WidgetId> = 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());
}