//! 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 { 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() } /// 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 = 8..17; const FAQ: std::ops::Range = 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, 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 = 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", ); }