Skip to main content

teksilo_widgets/
link.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Link — a clickable text label rendered as underlined inline text.
5//!
6//! `Link` is Teksilo's hyperlink control: it responds to tap, Enter, and
7//! Space like a `Button`, but renders as styled underlined text rather than a
8//! bordered box. It supports an optional `url` field (informational — the app
9//! decides whether and how to open it), a reactive `visited` state that shifts
10//! the text colour, and all three tooltip tiers (plain / rich / composite).
11//!
12//! Keyboard behaviour follows the platform link convention: Space and Enter
13//! activate; a bare KeyUp with no preceding KeyDown is ignored (lone-KeyUp
14//! guard). The focus ring appears only after keyboard navigation
15//! (`focus_visible`), not after a mouse click.
16//!
17//! ## Accessibility
18//!
19//! `Role::Link` with the label as the AT name. When `url` is set it is
20//! forwarded to `set_url` so screen readers can announce the destination.
21//! Exposes `Action::Click` and `Action::Focus`.
22//!
23//! ```rust
24//! # use teksilo_widgets::Link;
25//! # use teksilo_i18n::lit;
26//! let _w = Link::new(lit!("Open documentation"))
27//!     .url("https://example.com/docs");
28//! ```
29//!
30//! ## Touch and pen
31//!
32//! The pressed state is the framework's (`docs/touch-and-pen.md` §7.1), so it
33//! survives a slide-off and comes back on re-entry, and a pan claimant winning
34//! the press clears it with no release. Following the link lands on the
35//! release, as it always did.
36//!
37//! A link is text-height, so it can fall under the 24 dp target floor. What
38//! carries it is WCAG 2.2 SC 2.5.8's *inline* exception — the target's size is
39//! constrained by the line height of the text it is set in — and **not** the
40//! miss-only slop pass, which this module used to claim as well. The pass
41//! re-attributes a near miss only where the exact hit's whole bubble path
42//! carries no eligible handler, so it is denied wherever the link sits inside a
43//! row that takes presses: a link in a list row, a link in an archived
44//! notification. Measured by the target-conformance gate (the
45//! `teksilo-target-conformance` crate): 112 × 17 dp beside a
46//! tappable row label and 32 × 17 dp as a notification's replay action both
47//! reach exactly their own 17 dp on the short axis, at all three densities.
48
49use std::cell::Cell;
50use std::rc::Rc;
51
52use teksilo_canvas::{Rect, Size, SizeProposal};
53use teksilo_core::accessibility::AccessNodeBuilder;
54use teksilo_core::build_context::BuildContext;
55use teksilo_core::event::{EventResponse, Key, WidgetEvent};
56use teksilo_core::signal::{Prop, Signal};
57use teksilo_core::styles::{LinkStyleConfig, SharedLinkStyle};
58use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
59use teksilo_core::widget_builder::HandlerSet;
60use teksilo_core::widget_id::WidgetId;
61
62use crate::button::InteractionState;
63use teksilo_i18n::LocalizedString;
64
65type CommandFactory = Box<dyn Fn(&mut EventContext)>;
66
67/// A clickable text link that renders as underlined inline text.
68pub struct Link {
69    text: LocalizedString,
70    url: Option<String>,
71    action: Option<CommandFactory>,
72    tooltip_text: Option<LocalizedString>,
73    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
74    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
75    interaction: Option<Signal<InteractionState>>,
76    /// Visited state — orthogonal to `InteractionState`. The app owns
77    /// the URL-visit tracking; this signal toggles `TextRole::LinkVisited`
78    /// when no transient interaction (hover / press) is active.
79    /// Default is a permanently-`false` signal so links that don't
80    /// represent URLs render as unvisited.
81    visited: Option<Prop<bool>>,
82    /// Enabled state, static or reactive; forwarded to the arena at
83    /// build time.
84    enabled: Prop<bool>,
85    /// Per-call override for the link chrome.
86    style_override: Option<SharedLinkStyle>,
87    root_child_id: Option<WidgetId>,
88}
89
90impl Link {
91    /// Create a link with the given display text.
92    pub fn new(text: impl Into<LocalizedString>) -> Self {
93        let ls: LocalizedString = text.into();
94        Self {
95            text: ls,
96            url: None,
97            action: None,
98            tooltip_text: None,
99            rich_tooltip_source: None,
100            composite_tooltip_content: None,
101            interaction: None,
102            visited: None,
103            enabled: Prop::Static(true),
104            style_override: None,
105            root_child_id: None,
106        }
107    }
108
109    /// Mark the link's target as visited. Drives `TextRole::LinkVisited`
110    /// when no transient interaction (hover / press) is active. Visited
111    /// is overridden by hover/press, following the web convention. The
112    /// app owns the signal (typically backed by URL-history state).
113    pub fn visited(mut self, visited: impl Into<Prop<bool>>) -> Self {
114        self.visited = Some(visited.into());
115        self
116    }
117
118    /// Per-call style override for the link chrome.
119    pub fn style(mut self, style: impl teksilo_core::styles::LinkStyle) -> Self {
120        self.style_override = Some(Rc::new(style));
121        self
122    }
123
124    /// Closure invoked on activation.
125    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
126        self.action = Some(Box::new(f));
127        self
128    }
129
130    /// Set a URL for the link (informational — not automatically opened).
131    pub fn url(mut self, url: impl Into<String>) -> Self {
132        self.url = Some(url.into());
133        self
134    }
135
136    /// Attach a plain single-line tooltip shown after a hover delay.
137    /// Mutually exclusive with `rich_tooltip` / `composite_tooltip` — last call wins.
138    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
139        self.tooltip_text = Some(text.into());
140        self.rich_tooltip_source = None;
141        self.composite_tooltip_content = None;
142        self
143    }
144
145    /// Attach a rich tooltip resolved from the app-wide tooltip
146    /// registry. See [`Button::rich_tooltip`](crate::button::Button::rich_tooltip).
147    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
148        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
149        self.tooltip_text = None;
150        self.composite_tooltip_content = None;
151        self
152    }
153
154    /// Attach a rich tooltip driven by inline `TooltipContent`.
155    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
156        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
157        self.tooltip_text = None;
158        self.composite_tooltip_content = None;
159        self
160    }
161
162    /// Attach a composite tooltip — third tier, hosting an arbitrary
163    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
164    pub fn composite_tooltip(
165        mut self,
166        content: impl teksilo_core::widget::Widget + 'static,
167    ) -> Self {
168        self.composite_tooltip_content = Some(Box::new(content));
169        self.tooltip_text = None;
170        self.rich_tooltip_source = None;
171        self
172    }
173
174    /// Return the URL previously set via [`url`](Self::url), if any.
175    pub fn get_url(&self) -> Option<&str> {
176        self.url.as_deref()
177    }
178
179    /// Set the enabled state, statically or reactively. Forwarded to the
180    /// arena at build time — a bound `Signal<bool>` updates live as it
181    /// changes.
182    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
183        self.enabled = enabled.into();
184        self
185    }
186}
187
188impl std::fmt::Debug for Link {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        f.debug_struct("Link").field("text", &self.text).finish()
191    }
192}
193
194impl Widget for Link {
195    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
196        let self_id = ctx.self_id();
197        // Forward the enabled state into the arena; see IconButton.
198        ctx.enabled_when(self_id, self.enabled.clone());
199        let effective_enabled = ctx.effective_enabled_signal(self_id);
200
201        let interaction = ctx.signal(InteractionState::Idle);
202        self.interaction = Some(interaction.clone());
203
204        // Derive the four state bools `LinkStyle` expects from the
205        // single `InteractionState` signal. `is_disabled` derives
206        // from the arena (reactive) instead of a build-time snapshot.
207        let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
208        let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
209        // `:focus-visible`: reveal the focus ring during keyboard navigation
210        // only, not on a mouse click. Gate raw focus on the input-modality
211        // signal (true after a key event, false after pointer-down).
212        let is_focused = interaction
213            .map(|s| matches!(s, InteractionState::Focused))
214            .and(&ctx.focus_visible());
215        let is_visited = self
216            .visited
217            .as_ref()
218            .map(|p| p.as_signal())
219            .unwrap_or_else(|| Signal::new(false));
220        let is_disabled = effective_enabled.map(|on| !*on);
221
222        let style: SharedLinkStyle = self
223            .style_override
224            .clone()
225            .or_else(|| ctx.theme().style_slots.link.clone())
226            .unwrap_or_else(|| {
227                Rc::new(crate::styles::RecipeLinkStyle::for_tokens(
228                    &ctx.theme().input,
229                ))
230            });
231        let root_id = style.make_body(
232            &LinkStyleConfig {
233                text: self.text.clone().into(),
234                is_hovered,
235                is_pressed,
236                is_focused,
237                is_visited,
238                is_disabled,
239            },
240            ctx,
241        );
242
243        if let Some(content) = self.composite_tooltip_content.take() {
244            let delay = ctx.theme().motion.tooltip_delay_heavy;
245            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
246        } else if let Some(source) = self.rich_tooltip_source.take() {
247            let delay = ctx.theme().motion.tooltip_delay;
248            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
249        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
250            let delay = ctx.theme().motion.tooltip_delay;
251            crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
252        }
253
254        self.root_child_id = Some(root_id);
255
256        // --- V2 attached handlers ---
257        let action = self.action.take();
258        let action_rc: std::rc::Rc<Option<CommandFactory>> = std::rc::Rc::new(action);
259        let action_for_tap = action_rc.clone();
260        let action_for_key = action_rc.clone();
261        let action_for_access = action_rc.clone();
262        let int_tap = interaction.clone();
263        let int_hover = interaction.clone();
264        let int_key = interaction.clone();
265        let int_focus = interaction.clone();
266
267        // The pointer press is the framework's, not this control's own: the
268        // router knows about a press that slid off its target, one that slid
269        // back on, and one a pan claimant took away with no release to reset
270        // from — none of which a `PointerDown` / `PointerUp` pair here can
271        // see. `docs/touch-and-pen.md` §7.1. `pointer_over` carries the hover
272        // truth across the press, so a press that ends without an activation
273        // rests on the right state.
274        let pointer_over = Rc::new(Cell::new(false));
275        crate::button::bind_press_interaction(ctx, interaction.clone(), pointer_over.clone());
276
277        let handler_set = HandlerSet::new()
278            .on_tap({
279                let hovering = pointer_over.clone();
280                move |_pos, ctx: &mut EventContext| {
281                    if let Some(ref action) = *action_for_tap {
282                        action(ctx);
283                    }
284                    int_tap.set(if ctx.pointer_kind().hovers() {
285                        hovering.set(true);
286                        InteractionState::Hovered
287                    } else {
288                        InteractionState::Idle
289                    });
290                }
291            })
292            .on_hover({
293                let hovering = pointer_over.clone();
294                move |entered: bool, _ctx: &mut EventContext| {
295                    hovering.set(entered);
296                    if entered {
297                        int_hover.set(InteractionState::Hovered);
298                    } else {
299                        int_hover.set(InteractionState::Idle);
300                    }
301                }
302            })
303            .on_key({
304                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
305                    match event {
306                        WidgetEvent::KeyDown {
307                            key: Key::Space | Key::Enter,
308                            ..
309                        } => {
310                            int_key.set(InteractionState::Pressed);
311                            EventResponse::Handled
312                        }
313                        WidgetEvent::KeyUp {
314                            key: Key::Space | Key::Enter,
315                            ..
316                        } => {
317                            // Lone-KeyUp guard: only activate if we saw the
318                            // matching KeyDown (state is Pressed). A KeyUp with
319                            // no preceding KeyDown — e.g. a shortcut consumed the
320                            // KeyDown and focus returned here — must NOT activate.
321                            if int_key.get() != InteractionState::Pressed {
322                                return EventResponse::Ignored;
323                            }
324                            if let Some(ref action) = *action_for_key {
325                                action(ctx);
326                            }
327                            int_key.set(InteractionState::Focused);
328                            EventResponse::Handled
329                        }
330                        _ => EventResponse::Ignored,
331                    }
332                }
333            })
334            .on_focus({
335                move |gained: bool, _ctx: &mut EventContext| {
336                    if gained {
337                        if int_focus.get() == InteractionState::Idle {
338                            int_focus.set(InteractionState::Focused);
339                        }
340                    } else {
341                        int_focus.set(InteractionState::Idle);
342                    }
343                }
344            })
345            .on_access_action({
346                move |action: teksilo_core::accesskit::Action,
347                      ctx: &mut EventContext|
348                      -> EventResponse {
349                    if action == teksilo_core::accesskit::Action::Click {
350                        if let Some(ref act) = *action_for_access {
351                            act(ctx);
352                        }
353                        EventResponse::Handled
354                    } else {
355                        EventResponse::Ignored
356                    }
357                }
358            })
359            // Focus walker skips disabled subtrees; cursor stays
360            // Pointer here and the framework can choose to override
361            // for disabled subtrees in a future change.
362            .focusable(true)
363            .cursor(CursorIcon::Pointer);
364
365        ctx.apply_self_handlers(handler_set);
366
367        vec![root_id]
368    }
369
370    fn layout_response(
371        &self,
372        proposal: SizeProposal,
373        ctx: &LayoutContext,
374    ) -> teksilo_core::widget::LayoutResponse {
375        if let Some(root) = self.root_child_id
376            && let Some(size) = ctx.child_size(root, proposal)
377        {
378            return (size).into();
379        }
380        proposal.resolve(0.0, 0.0).into()
381    }
382
383    fn place_children(
384        &self,
385        bounds: Rect,
386        _proposal: SizeProposal,
387        children: &mut [WidgetPlacement],
388        _ctx: &LayoutContext,
389    ) {
390        for child in children.iter_mut() {
391            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
392            child.size = Size::new(bounds.width, bounds.height);
393        }
394    }
395
396    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
397        builder.set_role(teksilo_core::accesskit::Role::Link);
398        builder.set_name(self.text.resolve_now());
399        if let Some(ref url) = self.url {
400            builder.set_url(url.clone());
401        }
402        // Framework a11y walker sets `set_disabled` from arena state.
403        // Actions are always advertised — when disabled the framework
404        // gates them at dispatch via `arena.is_enabled`.
405        builder.add_action(teksilo_core::accesskit::Action::Click);
406        builder.add_action(teksilo_core::accesskit::Action::Focus);
407    }
408
409    fn children(&self) -> Vec<WidgetId> {
410        self.root_child_id.into_iter().collect()
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use std::cell::Cell;
418    use teksilo_core::event::Modifiers;
419    use teksilo_core::widget_tree::WidgetTree;
420    use teksilo_i18n::lit;
421
422    #[test]
423    fn keyup_without_keydown_does_not_fire() {
424        // Lone-KeyUp guard: when a shortcut consumes the KeyDown and
425        // focus returns to the link, the trailing KeyUp must NOT activate.
426        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
427        let fired = Rc::new(Cell::new(0_u32));
428        let fired_for_link = fired.clone();
429        let link = tree.add(Link::new(lit!("T")).on_activate_fn(move |_ctx| {
430            fired_for_link.set(fired_for_link.get() + 1);
431        }));
432        tree.layout(SizeProposal::exact(200.0, 80.0));
433        tree.focus(link);
434
435        tree.dispatch_event(WidgetEvent::KeyUp {
436            key: Key::Enter,
437            modifiers: Modifiers::NONE,
438        });
439        assert_eq!(
440            fired.get(),
441            0,
442            "a lone KeyUp (no matching KeyDown) must not activate the link",
443        );
444
445        tree.dispatch_event(WidgetEvent::KeyDown {
446            key: Key::Enter,
447            modifiers: Modifiers::NONE,
448            text: None,
449        });
450        tree.dispatch_event(WidgetEvent::KeyUp {
451            key: Key::Enter,
452            modifiers: Modifiers::NONE,
453        });
454        assert_eq!(
455            fired.get(),
456            1,
457            "a matched KeyDown + KeyUp pair must activate exactly once",
458        );
459    }
460
461    // -----------------------------------------------------------------
462    // The framework press (docs/touch-and-pen.md §7.1)
463    // -----------------------------------------------------------------
464
465    struct PressProbe(std::rc::Rc<std::cell::RefCell<Option<(Signal<bool>, Signal<bool>)>>>);
466
467    impl teksilo_core::styles::LinkStyle for PressProbe {
468        fn make_body(
469            &self,
470            cfg: &teksilo_core::styles::LinkStyleConfig,
471            ctx: &mut BuildContext,
472        ) -> WidgetId {
473            *self.0.borrow_mut() = Some((cfg.is_pressed.clone(), cfg.is_hovered.clone()));
474            ctx.add(crate::primitives::FixedSize::new().width(60.0).height(18.0))
475        }
476    }
477
478    #[allow(clippy::type_complexity)]
479    fn probed_link_with_hover() -> (
480        WidgetTree,
481        WidgetId,
482        Signal<bool>,
483        Signal<bool>,
484        std::rc::Rc<Cell<u32>>,
485    ) {
486        let probe: std::rc::Rc<std::cell::RefCell<Option<(Signal<bool>, Signal<bool>)>>> =
487            std::rc::Rc::new(std::cell::RefCell::new(None));
488        let hits = std::rc::Rc::new(Cell::new(0_u32));
489        let counter = hits.clone();
490        let mut theme = teksilo_core::presets::intui::light();
491        theme.style_slots.link = Some(std::rc::Rc::new(PressProbe(probe.clone())));
492        let mut tree = WidgetTree::new().with_theme(theme);
493        let link = tree.add(
494            Link::new(lit!("Read more")).on_activate_fn(move |_| counter.set(counter.get() + 1)),
495        );
496        tree.layout(SizeProposal::exact(200.0, 60.0));
497        let (pressed, hovered) = probe.borrow().clone().expect("style ran");
498        (tree, link, pressed, hovered, hits)
499    }
500
501    fn probed_link() -> (WidgetTree, WidgetId, Signal<bool>, std::rc::Rc<Cell<u32>>) {
502        let (tree, link, pressed, _hovered, hits) = probed_link_with_hover();
503        (tree, link, pressed, hits)
504    }
505
506    /// Where the link comes to rest after it is followed — its own copy of the
507    /// button family's `on_tap` resting-state rule. A link's hover state is its
508    /// underline, so resting in the wrong one is not a subtle tint: a
509    /// finger-tapped link that rests hovered stays underlined with nothing
510    /// touching it.
511    #[test]
512    fn a_mouse_follow_rests_hovered_and_a_finger_follow_rests_idle() {
513        use crate::button::press_test_support::touch_tap;
514
515        let (mut tree, link, pressed, hovered, hits) = probed_link_with_hover();
516        let at = tree.bounds(link).center();
517        tree.pointer_move(at);
518        assert!(hovered.get(), "the pointer arrived over the link");
519        tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
520        tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
521        assert_eq!(hits.get(), 1, "the release followed the link");
522        assert!(!pressed.get());
523        assert!(
524            hovered.get(),
525            "a mouse that clicked the link is still on it, so it rests hovered",
526        );
527
528        let (mut tree, link, pressed, hovered, hits) = probed_link_with_hover();
529        let at = tree.bounds(link).center();
530        touch_tap(&mut tree, at);
531        assert_eq!(hits.get(), 1, "the contact followed on its release");
532        assert!(!pressed.get());
533        assert!(
534            !hovered.get(),
535            "a finger leaves nothing behind, so the link must rest idle",
536        );
537    }
538
539    /// A mouse press lights the link's `is_pressed` — the state its style has
540    /// always been handed and which, before the controls sweep, only a keyboard
541    /// `Space` or `Enter` could set. Following the link still lands on the
542    /// release.
543    #[test]
544    fn a_mouse_press_lights_the_pressed_state_and_the_release_follows() {
545        let (mut tree, link, pressed, hits) = probed_link();
546        let at = tree.bounds(link).center();
547        tree.pointer_move(at);
548        tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
549        assert!(pressed.get());
550        assert_eq!(hits.get(), 0);
551        tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
552        assert!(!pressed.get());
553        assert_eq!(hits.get(), 1);
554    }
555
556    /// A finger follows the link on the release, and a slide-off abandons it.
557    #[test]
558    fn a_touch_tap_follows_on_release_and_a_slide_off_abandons_it() {
559        use crate::button::press_test_support::{finger, touch};
560        use teksilo_core::pointer::PointerPhase;
561
562        let (mut tree, link, pressed, hits) = probed_link();
563        let bounds = tree.bounds(link);
564        let at = bounds.center();
565        let away = teksilo_canvas::Point::new(at.x, bounds.y + bounds.height + 80.0);
566
567        let id = finger();
568        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
569        assert!(pressed.get());
570        tree.dispatch_pointer(touch(id, PointerPhase::Move, away, 20));
571        assert!(!pressed.get());
572        tree.dispatch_pointer(touch(id, PointerPhase::Up, away, 40));
573        assert_eq!(hits.get(), 0);
574
575        let id = finger();
576        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 100));
577        tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 130));
578        assert_eq!(hits.get(), 1);
579    }
580}