tests.rs raw

//! Links inside a paragraph: a point becomes a span, and everything the
//! widget says about that point — the cursor, the click, the accessibility
//! node — is a reading of the same answer.

use guiduck_scene::geom::{Point, Size};

use super::*;
use crate::clipboard::{LocalClipboard, NoClipboard};
use crate::event::Modifiers;
use crate::style::{InteractionState, default_theme};

/// The sample font, so hit testing lands on real glyph advances rather than
/// on whatever the machine happens to have installed.
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()
}

/// A paragraph laid out at `width`, dressed by the default theme exactly as
/// the style pass would dress it in a tree.
fn paragraph(content: RichText, width: f64) -> (Text, TextContext) {
    let mut text_cx = TextContext::hermetic([sample_font()]);
    let mut text = Text::new(content);
    text.apply_style(&default_theme().resolve("text", &[], InteractionState::default()));
    let _ = text.take_dirt();
    text.finalize_layout(&mut text_cx, Size::new(width, 0.0), Size::ZERO);
    (text, text_cx)
}

/// The middle of the cluster a byte belongs to — a point the pointer could
/// really be at, wrap and all, since it comes from the same layout the widget
/// hit-tests against.
fn point_in(text: &Text, byte: usize) -> Point {
    text.byte_bounds(byte)
        .expect("the byte is laid out")
        .center()
}

const PROSE: &str = "See the home page for more, or the FAQ.";
const HOME: std::ops::Range<usize> = 8..17;
const FAQ: std::ops::Range<usize> = 34..37;

fn prose() -> RichText {
    RichText::new(PROSE)
        .span(HOME, TextSpan::new().link("home"))
        .span(FAQ, TextSpan::new().link("faq"))
}

#[test]
fn a_point_inside_a_link_span_hits_it() {
    let (text, _cx) = paragraph(prose(), 400.0);
    assert_eq!(text.link_at(point_in(&text, HOME.start)), Some("home"));
    assert_eq!(text.link_at(point_in(&text, HOME.end - 1)), Some("home"));
    assert_eq!(text.link_at(point_in(&text, FAQ.start)), Some("faq"));
}

#[test]
fn a_point_in_plain_text_hits_nothing() {
    let (text, _cx) = paragraph(prose(), 400.0);
    // "See" before the link, "for" after it: prose on both sides.
    assert_eq!(text.link_at(point_in(&text, 0)), None);
    assert_eq!(text.link_at(point_in(&text, 20)), None);
    // Past the end of the last line is not the last word, which is what
    // `from_point_exact` buys over the caret-placing `from_point`.
    assert_eq!(text.link_at(Point::new(2000.0, 4.0)), None);
}

/// The whole reason a link is a span rather than a widget: the paragraph
/// wraps *between words*, through the middle of a link, and the link is still
/// one link — hittable on both of the lines it landed on.
#[test]
fn a_link_that_wraps_across_two_lines_hits_on_both() {
    // Narrow enough that "home page" cannot fit on one line, so the link's own
    // two words are split by the wrap.
    let (text, _cx) = paragraph(prose(), 62.0);
    assert!(text.line_count() > 1, "the paragraph wrapped");

    let start = point_in(&text, HOME.start);
    let end = point_in(&text, HOME.end - 1);
    assert_ne!(
        start.y, end.y,
        "the link's two ends landed on different lines",
    );
    assert_eq!(text.link_at(start), Some("home"), "the first line's half");
    assert_eq!(text.link_at(end), Some("home"), "the second line's half");
}

#[test]
fn a_link_nests_with_the_style_forms() {
    // `(link "x" (bold …))` — the span carries both, and the target survives
    // whatever else is on it.
    let span = TextSpan::new().link("home").bold();
    assert_eq!(span.target(), Some("home"));
    let (text, _cx) = paragraph(RichText::new(PROSE).span(HOME, span), 400.0);
    assert_eq!(text.link_at(point_in(&text, HOME.start)), Some("home"));
}

fn pointer(
    text: &mut Text,
    cx: &mut TextContext,
    clip: &mut LocalClipboard,
    kind: EventKind,
    at: Point,
    button: Option<PointerButton>,
    click_count: u8,
) -> bool {
    text.on_pointer(
        kind,
        &PointerEvent {
            window: at,
            local: at,
            button,
            click_count,
        },
        cx,
        clip,
    )
}

/// A left-button press, drag, and release from `from` to `to`.
fn drag(text: &mut Text, cx: &mut TextContext, clip: &mut LocalClipboard, from: Point, to: Point) {
    let left = Some(PointerButton::Left);
    pointer(text, cx, clip, EventKind::PointerDown, from, left, 1);
    // A real move carries no button.
    pointer(text, cx, clip, EventKind::PointerMove, to, None, 1);
    pointer(text, cx, clip, EventKind::PointerUp, to, left, 1);
}

fn ctrl(text: &mut Text, cx: &mut TextContext, clip: &mut LocalClipboard, c: &str) -> bool {
    text.on_key(
        &KeyInput {
            key: Key::Character(c.to_owned()),
            modifiers: Modifiers {
                ctrl: true,
                shift: false,
                alt: false,
                logo: false,
            },
            pressed: true,
        },
        cx,
        clip,
    )
}

#[test]
fn text_is_click_focusable_but_not_a_tab_stop() {
    // A rendered note can be selected, so it takes focus when clicked; but a
    // label has no business in the Tab order between the controls around it.
    let (text, _cx) = paragraph(prose(), 400.0);
    assert!(Widget::focusable(&text));
    assert!(!Widget::tab_stop(&text));
}

#[test]
fn select_all_then_copy() {
    let (mut text, mut cx) = paragraph(prose(), 400.0);
    let mut clip = LocalClipboard::default();

    assert!(
        ctrl(&mut text, &mut cx, &mut clip, "a"),
        "Ctrl+A is consumed"
    );
    // Select-all publishes to the primary selection, and Ctrl+C to the
    // regular clipboard — the whole paragraph in both.
    assert_eq!(clip.get_text(Selection::Primary).as_deref(), Some(PROSE));
    assert!(
        ctrl(&mut text, &mut cx, &mut clip, "c"),
        "Ctrl+C is consumed"
    );
    assert_eq!(clip.get_text(Selection::Clipboard).as_deref(), Some(PROSE));
}

#[test]
fn dragging_selects_from_the_start() {
    let (mut text, mut cx) = paragraph(prose(), 400.0);
    let mut clip = LocalClipboard::default();

    // Left of the first glyph, across to the middle of the text.
    let to = point_in(&text, 20);
    drag(&mut text, &mut cx, &mut clip, Point::new(0.0, to.y), to);

    let primary = clip.get_text(Selection::Primary);
    assert!(
        primary
            .as_deref()
            .is_some_and(|s| s.starts_with("See") && s.len() > 3),
        "a left-to-middle drag selects a range from the start, got {primary:?}"
    );
}

#[test]
fn double_click_selects_a_word() {
    let (mut text, mut cx) = paragraph(prose(), 400.0);
    let mut clip = LocalClipboard::default();

    // "for" sits at bytes 18..21, between the two links.
    let at = point_in(&text, 19);
    pointer(
        &mut text,
        &mut cx,
        &mut clip,
        EventKind::PointerDown,
        at,
        Some(PointerButton::Left),
        2,
    );
    // A multi-click selection is complete at the press and publishes at once.
    assert_eq!(
        clip.get_text(Selection::Primary).as_deref().map(str::trim),
        Some("for")
    );
}

#[test]
fn a_drag_onto_a_link_selects_rather_than_follows() {
    let (mut text, mut cx) = paragraph(prose(), 400.0);
    let mut clip = LocalClipboard::default();

    // Drag from plain prose into the FAQ link, then the click the tree would
    // synthesize: the selection is non-empty, so the click is spent on it.
    let at = point_in(&text, FAQ.start);
    drag(&mut text, &mut cx, &mut clip, Point::new(0.0, at.y), at);
    let _ = text.take_emitted();

    let fired = pointer(
        &mut text,
        &mut cx,
        &mut clip,
        EventKind::CountedClick,
        at,
        Some(PointerButton::Left),
        1,
    );
    assert!(!fired, "a drag spent the click; the link did not fire");
    assert!(text.take_emitted().is_empty(), "and no link was reported");
}

#[test]
fn a_plain_click_on_a_link_still_follows_it() {
    let (mut text, mut cx) = paragraph(prose(), 400.0);
    let mut clip = LocalClipboard::default();

    // Press and release in place (no drag) on the link: the selection stays
    // collapsed, so the click follows the link.
    let at = point_in(&text, FAQ.start);
    let left = Some(PointerButton::Left);
    pointer(
        &mut text,
        &mut cx,
        &mut clip,
        EventKind::PointerDown,
        at,
        left,
        1,
    );
    pointer(
        &mut text,
        &mut cx,
        &mut clip,
        EventKind::PointerUp,
        at,
        left,
        1,
    );
    let fired = pointer(
        &mut text,
        &mut cx,
        &mut clip,
        EventKind::CountedClick,
        at,
        left,
        1,
    );
    assert!(fired, "an undragged click on a link is the link's");
    assert_eq!(text.take_emitted(), vec![EventData::Link("faq".to_owned())]);
}

#[test]
fn losing_focus_clears_the_selection() {
    let (mut text, mut cx) = paragraph(prose(), 400.0);
    let mut clip = LocalClipboard::default();
    ctrl(&mut text, &mut cx, &mut clip, "a");

    text.on_focus_changed(false);

    // With the selection gone, Ctrl+C copies nothing.
    let mut fresh = LocalClipboard::default();
    ctrl(&mut text, &mut cx, &mut fresh, "c");
    assert_eq!(
        fresh.get_text(Selection::Clipboard),
        None,
        "blur cleared the selection"
    );
}

#[test]
fn the_cursor_follows_the_span() {
    let (text, _cx) = paragraph(prose(), 400.0);
    assert_eq!(
        text.cursor(point_in(&text, HOME.start)),
        CursorShape::Pointer,
        "the hand over a link",
    );
    assert_eq!(
        text.cursor(point_in(&text, 0)),
        CursorShape::Default,
        "and nothing special over prose",
    );
}

#[test]
fn clicking_a_link_reports_its_target() {
    let (mut text, mut text_cx) = paragraph(prose(), 400.0);
    let click = |text: &mut Text, cx: &mut TextContext, at: Point| {
        let event = PointerEvent {
            window: at,
            local: at,
            button: Some(PointerButton::Left),
            click_count: 1,
        };
        let consumed = text.on_pointer(EventKind::CountedClick, &event, cx, &mut NoClipboard);
        (consumed, text.take_emitted())
    };

    let at = point_in(&text, FAQ.start);
    let (consumed, emitted) = click(&mut text, &mut text_cx, at);
    assert!(consumed, "a click on a link is the link's");
    assert_eq!(emitted, vec![EventData::Link("faq".to_owned())]);

    let at = point_in(&text, 0);
    let (consumed, emitted) = click(&mut text, &mut text_cx, at);
    assert!(!consumed, "a click on prose is nobody's");
    assert!(emitted.is_empty());
}

#[test]
fn the_theme_owns_what_a_link_looks_like() {
    // Not the widget: the paragraph reads `link-color` the way it reads
    // `color`, and an app theme moves it with no code involved.
    let (text, _cx) = paragraph(prose(), 400.0);
    let default_color = text.paragraph.link_style().color.clone();
    assert!(
        !crate::widget::is_transparent(&default_color),
        "the default theme dresses a link",
    );
    assert!(text.paragraph.link_style().underline_width > 0.0);

    let app = crate::style::Theme::parse(
        r##"(theme App (rule text :link-color "#ff0000" :link-underline-width 0))"##,
    )
    .expect("valid theme")
    .over(default_theme());
    let mut text = Text::new(prose());
    text.apply_style(&app.resolve("text", &[], InteractionState::default()));
    assert_eq!(
        text.paragraph.link_style().color,
        guiduck_scene::paint::Color::from_rgba8(0xff, 0, 0, 0xff).into(),
    );
    assert_eq!(
        text.paragraph.link_style().underline_width,
        0.0,
        "zero is no underline, on the border-width precedent",
    );
    assert_ne!(
        default_color,
        text.paragraph.link_style().color,
        "the app theme won"
    );
}

#[test]
fn a_link_is_announced_and_actionable() {
    let (mut text, mut text_cx) = paragraph(prose(), 400.0);
    let mut node = accesskit::Node::new(accesskit::Role::Label);
    let mut update = accesskit::TreeUpdate {
        nodes: Vec::new(),
        tree: None,
        tree_id: accesskit::TreeId::ROOT,
        focus: accesskit::NodeId(0),
    };
    let mut counter = 1_u64;
    let mut next_id = || {
        let id = accesskit::NodeId(counter);
        counter += 1;
        id
    };
    text.accessibility_extended(
        &mut node,
        &mut update,
        &mut next_id,
        Point::ORIGIN,
        &mut text_cx,
    );

    assert_eq!(node.children().len(), 2, "a node per link");
    let (id, link) = &update.nodes[0];
    assert_eq!(link.role(), accesskit::Role::Link);
    assert_eq!(link.label(), Some("home page"));
    assert_eq!(link.url(), Some("home"));
    assert!(link.bounds().is_some(), "a link is somewhere on screen");
    assert!(
        link.supports_action(accesskit::Action::Click),
        "an unclickable link is not accessible",
    );

    // Acting on the generated node reports the target through the same wire
    // the pointer uses.
    text.accessibility_action(*id, accesskit::Action::Click);
    assert_eq!(
        text.take_emitted(),
        vec![EventData::Link("home".to_owned())]
    );

    // The ids survive a second pass, so an assistive technology tracking a
    // link does not watch it vanish and reappear every frame.
    let before: Vec<accesskit::NodeId> = text.paragraph.link_access_ids().to_vec();
    let mut node = accesskit::Node::new(accesskit::Role::Label);
    text.accessibility_extended(
        &mut node,
        &mut update,
        &mut next_id,
        Point::ORIGIN,
        &mut text_cx,
    );
    assert_eq!(text.paragraph.link_access_ids(), before);
}

/// A wrapped link is one node, whose bounds cover both of the lines it
/// crosses — which is what a single accessibility rectangle can honestly say
/// about it, and what every platform's API means by a wrapped link's extents.
#[test]
fn a_wrapped_link_is_one_node_covering_both_lines() {
    let (mut text, mut text_cx) = paragraph(prose(), 62.0);
    let mut node = accesskit::Node::new(accesskit::Role::Label);
    let mut update = accesskit::TreeUpdate {
        nodes: Vec::new(),
        tree: None,
        tree_id: accesskit::TreeId::ROOT,
        focus: accesskit::NodeId(0),
    };
    let mut counter = 1_u64;
    let mut next_id = || {
        let id = accesskit::NodeId(counter);
        counter += 1;
        id
    };
    text.accessibility_extended(
        &mut node,
        &mut update,
        &mut next_id,
        Point::ORIGIN,
        &mut text_cx,
    );
    let bounds = update.nodes[0].1.bounds().expect("bounds");
    let line_height = f64::from(
        text.paragraph
            .layout()
            .expect("laid out")
            .get(0)
            .expect("a first line")
            .metrics()
            .line_height,
    );
    assert!(
        bounds.y1 - bounds.y0 > line_height,
        "the link's box spans more than one line",
    );
}