tests.rs raw

use guiduck_scene::geom::{Point, Size};
use taffy::prelude::{length, percent};

use super::*;
use crate::clipboard::{LocalClipboard, Selection};
use crate::event::{Modifiers, PointerButton, PointerInput};
use crate::widget::{Container, WidgetTree};

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()
}

/// Root with two text inputs stacked in a column.
fn fixture() -> (WidgetTree, crate::widget::WidgetId, crate::widget::WidgetId) {
    let mut tree =
        WidgetTree::with_text_context(crate::text::TextContext::hermetic([sample_font()]));
    let root = tree.insert(
        Container::new(),
        taffy::Style {
            size: taffy::Size {
                width: percent(1.0_f32),
                height: percent(1.0_f32),
            },
            flex_direction: taffy::FlexDirection::Column,
            padding: taffy::Rect::length(10.0_f32),
            gap: taffy::Size {
                width: length(10.0_f32),
                height: length(10.0_f32),
            },
            align_items: Some(taffy::AlignItems::FLEX_START),
            ..Default::default()
        },
        None,
    );
    let first = tree.insert(
        TextInput::new(14.0).family("DejaVu Sans"),
        taffy::Style {
            size: taffy::Size {
                width: length(200.0_f32),
                height: taffy::prelude::auto(),
            },
            ..Default::default()
        },
        Some(root),
    );
    let second = tree.insert(
        TextInput::new(14.0).family("DejaVu Sans"),
        taffy::Style {
            size: taffy::Size {
                width: length(200.0_f32),
                height: taffy::prelude::auto(),
            },
            ..Default::default()
        },
        Some(root),
    );
    tree.render_frame(Size::new(400.0, 300.0));
    (tree, first, second)
}

fn button_press(
    tree: &mut WidgetTree,
    clipboard: &mut dyn Clipboard,
    pos: Point,
    button: PointerButton,
) {
    tree.dispatch_pointer(
        PointerInput::Down {
            time: std::time::Instant::now(),
            pos,
            button,
        },
        clipboard,
    );
    tree.dispatch_pointer(PointerInput::Up { pos, button }, clipboard);
}

fn click(tree: &mut WidgetTree, clipboard: &mut LocalClipboard, pos: Point) {
    button_press(tree, clipboard, pos, PointerButton::Left);
}

/// A left click with an explicit press time, for multi-click tests.
fn timed_click(
    tree: &mut WidgetTree,
    clipboard: &mut LocalClipboard,
    pos: Point,
    time: std::time::Instant,
) {
    tree.dispatch_pointer(
        PointerInput::Down {
            pos,
            button: PointerButton::Left,
            time,
        },
        clipboard,
    );
    tree.dispatch_pointer(
        PointerInput::Up {
            pos,
            button: PointerButton::Left,
        },
        clipboard,
    );
}

fn type_str(tree: &mut WidgetTree, clipboard: &mut LocalClipboard, text: &str) {
    for c in text.chars() {
        press(
            tree,
            clipboard,
            Key::Character(c.to_string()),
            Modifiers::default(),
        );
    }
}

fn press(tree: &mut WidgetTree, clipboard: &mut LocalClipboard, key: Key, modifiers: Modifiers) {
    tree.dispatch_key(
        &KeyInput {
            key,
            modifiers,
            pressed: true,
        },
        clipboard,
    );
}

const CTRL: Modifiers = Modifiers {
    ctrl: true,
    shift: false,
    alt: false,
    logo: false,
};
const SHIFT: Modifiers = Modifiers {
    ctrl: false,
    shift: true,
    alt: false,
    logo: false,
};

fn content(tree: &WidgetTree, id: crate::widget::WidgetId) -> String {
    tree.widget::<TextInput>(id).unwrap().content()
}

#[test]
fn click_focuses_and_typing_inserts() {
    let (mut tree, first, second) = fixture();
    let mut clipboard = LocalClipboard::default();
    // The focus invariant grants the first field focus on the initial
    // frame, before any interaction.
    assert_eq!(tree.focus(), Some(first));

    // Click-to-focus moves it.
    click(&mut tree, &mut clipboard, Point::new(50.0, 60.0));
    assert_eq!(tree.focus(), Some(second));

    type_str(&mut tree, &mut clipboard, "hello");
    assert_eq!(content(&tree, second), "hello");
    assert!(tree.needs_frame(), "edits schedule a frame");
}

#[test]
fn focus_invariant_refocuses_after_removal() {
    let (mut tree, first, second) = fixture();
    assert_eq!(tree.focus(), Some(first));

    tree.remove(first);
    tree.render_frame(Size::new(400.0, 300.0));
    assert_eq!(
        tree.focus(),
        Some(second),
        "removing the focused widget refocuses the next frame"
    );
}

#[test]
fn background_click_keeps_focus() {
    let (mut tree, first, _second) = fixture();
    let mut clipboard = LocalClipboard::default();
    assert_eq!(tree.focus(), Some(first));

    // The root container is not focusable; clicking it keeps the focus.
    click(&mut tree, &mut clipboard, Point::new(300.0, 250.0));
    assert_eq!(tree.focus(), Some(first));
}

#[test]
fn select_all_then_typing_replaces() {
    let (mut tree, first, _) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "hello");

    press(&mut tree, &mut clipboard, Key::Character("a".into()), CTRL);
    type_str(&mut tree, &mut clipboard, "bye");
    assert_eq!(content(&tree, first), "bye");
}

#[test]
fn editing_keys() {
    let (mut tree, first, _) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "abcd");

    press(
        &mut tree,
        &mut clipboard,
        Key::Backspace,
        Modifiers::default(),
    );
    assert_eq!(content(&tree, first), "abc");

    press(&mut tree, &mut clipboard, Key::Home, Modifiers::default());
    press(&mut tree, &mut clipboard, Key::Delete, Modifiers::default());
    assert_eq!(content(&tree, first), "bc");

    press(&mut tree, &mut clipboard, Key::Right, Modifiers::default());
    type_str(&mut tree, &mut clipboard, "x");
    assert_eq!(content(&tree, first), "bxc");

    // Shift+End selects to end; typing replaces the selection.
    press(&mut tree, &mut clipboard, Key::Home, Modifiers::default());
    press(&mut tree, &mut clipboard, Key::End, SHIFT);
    type_str(&mut tree, &mut clipboard, "z");
    assert_eq!(content(&tree, first), "z");
}

#[test]
fn clipboard_round_trip() {
    let (mut tree, first, second) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "copy me");

    press(&mut tree, &mut clipboard, Key::Character("a".into()), CTRL);
    press(&mut tree, &mut clipboard, Key::Character("c".into()), CTRL);
    assert_eq!(
        clipboard.get_text(Selection::Clipboard).as_deref(),
        Some("copy me")
    );

    // Paste into the second input.
    press(&mut tree, &mut clipboard, Key::Tab, Modifiers::default());
    assert_eq!(tree.focus(), Some(second));
    press(&mut tree, &mut clipboard, Key::Character("v".into()), CTRL);
    assert_eq!(content(&tree, second), "copy me");
    assert_eq!(content(&tree, first), "copy me");

    // Cut clears the source.
    press(&mut tree, &mut clipboard, Key::Character("a".into()), CTRL);
    press(&mut tree, &mut clipboard, Key::Character("x".into()), CTRL);
    assert_eq!(content(&tree, second), "");
    assert_eq!(
        clipboard.get_text(Selection::Clipboard).as_deref(),
        Some("copy me")
    );
}

#[test]
fn newlines_never_enter_a_single_line_field() {
    let (mut tree, first, second) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));

    // Typing a newline inserts nothing and does not consume the key (so a
    // default-button activation could still see it).
    type_str(&mut tree, &mut clipboard, "ab\ncd");
    assert_eq!(content(&tree, first), "abcd", "a typed newline is dropped");

    // A multi-line clipboard paste lands as one line — the defect: the paste
    // path used to insert the newlines the typed path rejects.
    clipboard.set_text(Selection::Clipboard, "one\ntwo\r\nthree");
    press(&mut tree, &mut clipboard, Key::Character("a".into()), CTRL);
    press(&mut tree, &mut clipboard, Key::Character("v".into()), CTRL);
    assert_eq!(
        content(&tree, first),
        "onetwothree",
        "paste collapses lines"
    );

    // A multi-line primary-selection paste, likewise.
    clipboard.set_text(Selection::Primary, "left\nright");
    button_press(
        &mut tree,
        &mut clipboard,
        Point::new(50.0, 60.0),
        PointerButton::Middle,
    );
    assert_eq!(content(&tree, second), "leftright", "primary paste too");

    // And an IME commit carrying a newline.
    press(&mut tree, &mut clipboard, Key::Character("a".into()), CTRL);
    tree.dispatch_ime(&ImeInput::Commit("here\nthere".into()));
    assert_eq!(content(&tree, second), "herethere", "IME commit too");
}

#[test]
fn multiline_field_accepts_line_breaks() {
    let mut tree =
        WidgetTree::with_text_context(crate::text::TextContext::hermetic([sample_font()]));
    let root = tree.insert(Container::new(), taffy::Style::default(), None);
    let field = tree.insert(
        TextInput::new(14.0).family("DejaVu Sans").multiline(true),
        taffy::Style {
            size: taffy::Size {
                width: length(200.0_f32),
                height: taffy::prelude::auto(),
            },
            ..Default::default()
        },
        Some(root),
    );
    tree.render_frame(Size::new(400.0, 300.0));
    let mut clipboard = LocalClipboard::default();
    assert_eq!(tree.focus(), Some(field));

    // Enter inserts a newline instead of being declined.
    type_str(&mut tree, &mut clipboard, "a");
    press(&mut tree, &mut clipboard, Key::Enter, Modifiers::default());
    type_str(&mut tree, &mut clipboard, "b");
    assert_eq!(content(&tree, field), "a\nb");

    // Pasted newlines survive in a multi-line field.
    clipboard.set_text(Selection::Clipboard, "c\nd");
    press(&mut tree, &mut clipboard, Key::End, Modifiers::default());
    press(&mut tree, &mut clipboard, Key::Character("v".into()), CTRL);
    assert_eq!(content(&tree, field), "a\nbc\nd");

    // It names itself a multi-line editor to assistive technology.
    assert_eq!(
        tree.widget::<TextInput>(field).unwrap().role(),
        accesskit::Role::MultilineTextInput
    );
}

#[test]
fn single_line_field_declines_enter() {
    let (mut tree, first, _) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "text");

    // Enter is not consumed by a single-line field, so a default button
    // could still act on it, and it inserts nothing.
    let consumed = tree.dispatch_key(
        &KeyInput {
            key: Key::Enter,
            modifiers: Modifiers::default(),
            pressed: true,
        },
        &mut clipboard,
    );
    assert!(!consumed, "a single-line field declines Enter");
    assert_eq!(content(&tree, first), "text", "and inserts no newline");
    assert_eq!(
        tree.widget::<TextInput>(first).unwrap().role(),
        accesskit::Role::TextInput
    );
}

#[test]
fn tab_cycles_focus() {
    let (mut tree, first, second) = fixture();
    let mut clipboard = LocalClipboard::default();
    // The focus invariant starts the cycle at the first field.
    assert_eq!(tree.focus(), Some(first));
    press(&mut tree, &mut clipboard, Key::Tab, Modifiers::default());
    assert_eq!(tree.focus(), Some(second));
    press(&mut tree, &mut clipboard, Key::Tab, Modifiers::default());
    assert_eq!(tree.focus(), Some(first), "cycles");
    press(&mut tree, &mut clipboard, Key::Tab, SHIFT);
    assert_eq!(tree.focus(), Some(second), "shift-tab goes backwards");
}

#[test]
fn ime_preedit_and_commit() {
    let (mut tree, first, _) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "ab");

    tree.dispatch_ime(&ImeInput::Preedit {
        text: "しか".into(),
        cursor: Some((6, 6)),
    });
    // Preedit is displayed but not part of the committed content.
    assert_eq!(
        tree.widget::<TextInput>(first).unwrap().display_content(),
        "abしか"
    );
    assert_eq!(content(&tree, first), "ab");

    tree.dispatch_ime(&ImeInput::Commit("鹿".into()));
    assert_eq!(content(&tree, first), "ab鹿");

    // A cancelled composition leaves committed text alone.
    tree.dispatch_ime(&ImeInput::Preedit {
        text: "x".into(),
        cursor: Some((1, 1)),
    });
    tree.dispatch_ime(&ImeInput::Preedit {
        text: String::new(),
        cursor: None,
    });
    assert_eq!(content(&tree, first), "ab鹿");
}

#[test]
fn drag_selects_and_replaces() {
    let (mut tree, first, _) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "wide text");

    // Press near the start, drag right, release: a selection exists.
    tree.dispatch_pointer(
        PointerInput::Down {
            time: std::time::Instant::now(),
            pos: Point::new(20.0, 25.0),
            button: PointerButton::Left,
        },
        &mut clipboard,
    );
    tree.dispatch_pointer(PointerInput::Moved(Point::new(180.0, 25.0)), &mut clipboard);
    tree.dispatch_pointer(
        PointerInput::Up {
            pos: Point::new(180.0, 25.0),
            button: PointerButton::Left,
        },
        &mut clipboard,
    );
    // Finishing the drag stored the selection as the primary selection,
    // without touching the regular clipboard.
    assert_eq!(
        clipboard.get_text(Selection::Primary).as_deref(),
        Some("wide text")
    );
    assert_eq!(clipboard.get_text(Selection::Clipboard), None);

    type_str(&mut tree, &mut clipboard, "replaced");
    assert_eq!(content(&tree, first), "replaced");
}

#[test]
fn keyboard_selection_updates_primary() {
    let (mut tree, _first, _) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "primary");

    press(&mut tree, &mut clipboard, Key::Character("a".into()), CTRL);
    assert_eq!(
        clipboard.get_text(Selection::Primary).as_deref(),
        Some("primary"),
        "select-all populates the primary selection"
    );

    // Collapsing the selection leaves the primary alone.
    press(&mut tree, &mut clipboard, Key::Home, Modifiers::default());
    press(&mut tree, &mut clipboard, Key::Right, SHIFT);
    assert_eq!(
        clipboard.get_text(Selection::Primary).as_deref(),
        Some("p"),
        "shift-selection replaces the primary selection"
    );
}

#[test]
fn middle_click_pastes_primary() {
    let (mut tree, first, second) = fixture();
    let mut clipboard = LocalClipboard::default();
    clipboard.set_text(Selection::Primary, "pasted");

    button_press(
        &mut tree,
        &mut clipboard,
        Point::new(50.0, 25.0),
        PointerButton::Middle,
    );
    assert_eq!(content(&tree, first), "pasted");
    assert_eq!(
        tree.focus(),
        Some(first),
        "middle-click focuses like any click"
    );

    // Select the pasted word by dragging, then middle-click the second
    // field: the drag's selection is what pastes.
    tree.dispatch_pointer(
        PointerInput::Down {
            time: std::time::Instant::now(),
            pos: Point::new(20.0, 25.0),
            button: PointerButton::Left,
        },
        &mut clipboard,
    );
    tree.dispatch_pointer(PointerInput::Moved(Point::new(180.0, 25.0)), &mut clipboard);
    tree.dispatch_pointer(
        PointerInput::Up {
            pos: Point::new(180.0, 25.0),
            button: PointerButton::Left,
        },
        &mut clipboard,
    );
    button_press(
        &mut tree,
        &mut clipboard,
        Point::new(50.0, 60.0),
        PointerButton::Middle,
    );
    assert_eq!(content(&tree, second), "pasted");
}

#[test]
fn double_click_selects_word() {
    let (mut tree, first, _) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "hello");

    let base = std::time::Instant::now();
    let pos = Point::new(30.0, 25.0);
    timed_click(&mut tree, &mut clipboard, pos, base);
    timed_click(
        &mut tree,
        &mut clipboard,
        pos,
        base + std::time::Duration::from_millis(100),
    );
    assert_eq!(
        clipboard.get_text(Selection::Primary).as_deref(),
        Some("hello"),
        "double-click selects the word and syncs the primary selection"
    );
    type_str(&mut tree, &mut clipboard, "bye");
    assert_eq!(content(&tree, first), "bye", "typing replaces the word");
}

#[test]
fn triple_click_selects_line() {
    let (mut tree, first, _) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "hello world");

    let base = std::time::Instant::now();
    let pos = Point::new(30.0, 25.0);
    for i in 0..3 {
        timed_click(
            &mut tree,
            &mut clipboard,
            pos,
            base + std::time::Duration::from_millis(100 * i),
        );
    }
    assert_eq!(
        clipboard.get_text(Selection::Primary).as_deref(),
        Some("hello world"),
        "triple-click selects the line"
    );
    type_str(&mut tree, &mut clipboard, "z");
    assert_eq!(content(&tree, first), "z");
}

#[test]
fn slow_second_click_just_moves_the_caret() {
    let (mut tree, first, _) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "hello");

    let base = std::time::Instant::now();
    let pos = Point::new(30.0, 25.0);
    timed_click(&mut tree, &mut clipboard, pos, base);
    timed_click(
        &mut tree,
        &mut clipboard,
        pos,
        base + std::time::Duration::from_secs(1),
    );
    assert_eq!(
        clipboard.get_text(Selection::Primary),
        None,
        "no selection was made"
    );
    type_str(&mut tree, &mut clipboard, "X");
    assert_eq!(content(&tree, first).len(), 6, "insert, not replace");
}

#[test]
fn user_edits_raise_changed_events() {
    let (mut tree, first, _) = fixture();
    let mut clipboard = LocalClipboard::default();
    let root = tree.root().unwrap();

    let seen: std::rc::Rc<std::cell::RefCell<Vec<String>>> = Default::default();
    let seen2 = seen.clone();
    // Subscribed on the root: Changed bubbles like any other event.
    tree.on_event(root, crate::event::EventKind::Changed, move |_ctx, ev| {
        seen2
            .borrow_mut()
            .push(ev.changed().expect("changed payload").to_owned());
    });

    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "hi");
    assert_eq!(*seen.borrow(), vec!["h".to_owned(), "hi".to_owned()]);

    // Programmatic set_content is silent…
    tree.widget_mut::<TextInput>(first)
        .unwrap()
        .set_content("reset");
    assert_eq!(seen.borrow().len(), 2);

    // …and the next user edit compares against the programmatic value.
    press(&mut tree, &mut clipboard, Key::End, Modifiers::default());
    press(
        &mut tree,
        &mut clipboard,
        Key::Backspace,
        Modifiers::default(),
    );
    assert_eq!(seen.borrow().last().map(String::as_str), Some("rese"));

    // Copy does not edit; paste at the end does.
    press(&mut tree, &mut clipboard, Key::Character("a".into()), CTRL);
    press(&mut tree, &mut clipboard, Key::Character("c".into()), CTRL);
    assert_eq!(seen.borrow().len(), 3);
    press(&mut tree, &mut clipboard, Key::End, Modifiers::default());
    press(&mut tree, &mut clipboard, Key::Character("v".into()), CTRL);
    assert_eq!(seen.borrow().last().map(String::as_str), Some("reserese"));
    assert_eq!(seen.borrow().len(), 4);

    // An IME commit edits; a bare preedit does not.
    tree.dispatch_ime(&ImeInput::Preedit {
        text: "x".into(),
        cursor: Some((1, 1)),
    });
    assert_eq!(seen.borrow().len(), 4);
    tree.dispatch_ime(&ImeInput::Commit("鹿".into()));
    assert_eq!(seen.borrow().last().map(String::as_str), Some("reserese鹿"));
}

#[test]
fn right_click_does_not_move_caret() {
    let (mut tree, first, _) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "abc");

    // Caret sits at the end; a right-click near the start must not move it.
    button_press(
        &mut tree,
        &mut clipboard,
        Point::new(12.0, 25.0),
        PointerButton::Right,
    );
    type_str(&mut tree, &mut clipboard, "d");
    assert_eq!(content(&tree, first), "abcd");
}

#[test]
fn focus_reflects_in_accessibility_tree() {
    let (mut tree, first, _) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 25.0));
    type_str(&mut tree, &mut clipboard, "a11y");

    let update = tree.accessibility_tree();
    let focused = update
        .nodes
        .iter()
        .find(|(id, _)| *id == update.focus)
        .map(|(_, n)| n)
        .expect("focused node present");
    assert_eq!(focused.role(), accesskit::Role::TextInput);
    assert_eq!(focused.value(), Some("a11y"));
    let _ = first;
}

/// Whether the focused input currently paints its caret (the retained
/// fragment holds one more fill than the blink-hidden phase).
fn caret_painted(tree: &mut WidgetTree, id: crate::widget::WidgetId) -> bool {
    tree.render_frame(Size::new(400.0, 300.0));
    let mut fragment = guiduck_scene::Fragment::new();
    let size = tree.layout(id).size();
    tree.widget_mut::<TextInput>(id)
        .expect("input exists")
        .paint(&mut fragment, size);
    let fills = fragment
        .items
        .iter()
        .filter(|i| matches!(i, guiduck_scene::DisplayItem::Fill { .. }))
        .count();
    // Background fill + (optionally) the caret fill; no selection here.
    fills > 1
}

#[test]
fn the_caret_blinks_deterministically() {
    let (mut tree, first, _second) = fixture();
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(60.0, 25.0));
    assert!(caret_painted(&mut tree, first), "fresh focus: caret solid");

    // The focused input scheduled a wake; unfocused trees schedule none.
    let now = std::time::Instant::now();
    let deadline = tree.next_wake(now).expect("a blink is scheduled");
    assert_eq!(deadline, now + BLINK_INTERVAL);

    // The deadline arrives: the caret toggles off, and the next toggle is
    // scheduled.
    tree.tick(deadline);
    assert!(tree.needs_frame(), "the toggle repaints");
    assert!(
        !caret_painted(&mut tree, first),
        "blink phase: caret hidden"
    );
    let second_deadline = tree
        .next_wake(deadline)
        .expect("the blink keeps rescheduling");
    assert_eq!(second_deadline, deadline + BLINK_INTERVAL);

    // Typing resets the phase to solid and re-anchors the schedule.
    type_str(&mut tree, &mut clipboard, "x");
    assert!(caret_painted(&mut tree, first), "edits show the caret");
    let anchored = tree
        .next_wake(second_deadline)
        .expect("typing re-anchored the blink");

    // Focus loss stops the blink. Plain `set_focus(None)` would not do it —
    // the focus invariant refocuses the input on the next frame — so take
    // the focusables away by disabling the tree: focus clears, the
    // already-anchored deadline fires once (the unfocused input declines
    // it), and nothing further is scheduled.
    let root = tree.root().expect("root");
    tree.set_enabled(root, false);
    tree.render_frame(Size::new(400.0, 300.0));
    assert_eq!(tree.focus(), None, "no enabled focusables remain");
    tree.tick(anchored);
    tree.render_frame(Size::new(400.0, 300.0));
    assert_eq!(
        tree.next_wake(anchored + BLINK_INTERVAL),
        None,
        "an unfocused input requests no wakes (zero-idle preserved)"
    );
}

/// A clipboard whose reads always answer `Pending`, like a platform that
/// must negotiate with the selection's owner. Records what was asked for.
#[derive(Default)]
struct PendingClipboard {
    requests: Vec<Selection>,
}

impl Clipboard for PendingClipboard {
    fn request_text(&mut self, selection: Selection) -> crate::clipboard::TextRequest {
        self.requests.push(selection);
        crate::clipboard::TextRequest::Pending
    }

    fn set_text(&mut self, _selection: Selection, _text: &str) {}
}

#[test]
fn a_pending_paste_inserts_on_delivery_not_on_the_gesture() {
    let (mut tree, first, _second) = fixture();
    let mut clipboard = PendingClipboard::default();
    assert_eq!(tree.focus(), Some(first));

    // The gesture itself inserts nothing: the platform hasn't answered yet.
    tree.dispatch_key(
        &KeyInput {
            key: Key::Character("v".into()),
            modifiers: CTRL,
            pressed: true,
        },
        &mut clipboard,
    );
    assert_eq!(clipboard.requests, vec![Selection::Clipboard]);
    assert_eq!(content(&tree, first), "");

    // The delivery is what pastes.
    tree.dispatch_paste("arrived");
    assert_eq!(content(&tree, first), "arrived");
    assert!(tree.needs_frame(), "a delivered paste schedules a frame");
}

#[test]
fn a_delivered_paste_follows_focus() {
    let (mut tree, first, second) = fixture();
    let mut pending = PendingClipboard::default();
    assert_eq!(tree.focus(), Some(first));

    // Middle-click asks for the primary selection; the answer is Pending.
    button_press(
        &mut tree,
        &mut pending,
        Point::new(50.0, 20.0),
        PointerButton::Middle,
    );
    assert!(pending.requests.contains(&Selection::Primary));

    // Focus moves before the platform answers. Delivery goes to the
    // focused field — pasted text is input, and input follows focus.
    let mut clipboard = LocalClipboard::default();
    click(&mut tree, &mut clipboard, Point::new(50.0, 60.0));
    assert_eq!(tree.focus(), Some(second));
    tree.dispatch_paste("late");
    assert_eq!(content(&tree, first), "");
    assert_eq!(content(&tree, second), "late");
}

#[test]
fn a_delivered_paste_is_sanitized_like_any_other() {
    let (mut tree, first, _second) = fixture();
    assert_eq!(tree.focus(), Some(first));

    // A single-line field collapses a delivered multi-line paste, exactly
    // as it would a synchronous one.
    tree.dispatch_paste("one\ntwo");
    assert_eq!(content(&tree, first), "onetwo");
}