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",
);
}