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
30use std::rc::Rc;
31
32use teksilo_canvas::{Rect, Size, SizeProposal};
33use teksilo_core::accessibility::AccessNodeBuilder;
34use teksilo_core::build_context::BuildContext;
35use teksilo_core::event::{EventResponse, Key, WidgetEvent};
36use teksilo_core::signal::{Prop, Signal};
37use teksilo_core::styles::{LinkStyleConfig, SharedLinkStyle};
38use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
39use teksilo_core::widget_builder::HandlerSet;
40use teksilo_core::widget_id::WidgetId;
41
42use crate::button::InteractionState;
43use teksilo_i18n::LocalizedString;
44
45type CommandFactory = Box<dyn Fn(&mut EventContext)>;
46
47/// A clickable text link that renders as underlined inline text.
48pub struct Link {
49    text: LocalizedString,
50    url: Option<String>,
51    action: Option<CommandFactory>,
52    tooltip_text: Option<LocalizedString>,
53    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
54    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
55    interaction: Option<Signal<InteractionState>>,
56    /// Visited state — orthogonal to `InteractionState`. The app owns
57    /// the URL-visit tracking; this signal toggles `TextRole::LinkVisited`
58    /// when no transient interaction (hover / press) is active.
59    /// Default is a permanently-`false` signal so links that don't
60    /// represent URLs render as unvisited.
61    visited: Option<Prop<bool>>,
62    /// Enabled state, static or reactive; forwarded to the arena at
63    /// build time.
64    enabled: Prop<bool>,
65    /// Per-call override for the link chrome.
66    style_override: Option<SharedLinkStyle>,
67    root_child_id: Option<WidgetId>,
68}
69
70impl Link {
71    /// Create a link with the given display text.
72    pub fn new(text: impl Into<LocalizedString>) -> Self {
73        let ls: LocalizedString = text.into();
74        Self {
75            text: ls,
76            url: None,
77            action: None,
78            tooltip_text: None,
79            rich_tooltip_source: None,
80            composite_tooltip_content: None,
81            interaction: None,
82            visited: None,
83            enabled: Prop::Static(true),
84            style_override: None,
85            root_child_id: None,
86        }
87    }
88
89    /// Mark the link's target as visited. Drives `TextRole::LinkVisited`
90    /// when no transient interaction (hover / press) is active. Visited
91    /// is overridden by hover/press, following the web convention. The
92    /// app owns the signal (typically backed by URL-history state).
93    pub fn visited(mut self, visited: impl Into<Prop<bool>>) -> Self {
94        self.visited = Some(visited.into());
95        self
96    }
97
98    /// Per-call style override for the link chrome.
99    pub fn style(mut self, style: impl teksilo_core::styles::LinkStyle) -> Self {
100        self.style_override = Some(Rc::new(style));
101        self
102    }
103
104    /// Closure invoked on activation.
105    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
106        self.action = Some(Box::new(f));
107        self
108    }
109
110    /// Set a URL for the link (informational — not automatically opened).
111    pub fn url(mut self, url: impl Into<String>) -> Self {
112        self.url = Some(url.into());
113        self
114    }
115
116    /// Attach a plain single-line tooltip shown after a hover delay.
117    /// Mutually exclusive with `rich_tooltip` / `composite_tooltip` — last call wins.
118    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
119        self.tooltip_text = Some(text.into());
120        self.rich_tooltip_source = None;
121        self.composite_tooltip_content = None;
122        self
123    }
124
125    /// Attach a rich tooltip resolved from the app-wide tooltip
126    /// registry. See [`Button::rich_tooltip`](crate::button::Button::rich_tooltip).
127    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
128        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
129        self.tooltip_text = None;
130        self.composite_tooltip_content = None;
131        self
132    }
133
134    /// Attach a rich tooltip driven by inline `TooltipContent`.
135    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
136        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
137        self.tooltip_text = None;
138        self.composite_tooltip_content = None;
139        self
140    }
141
142    /// Attach a composite tooltip — third tier, hosting an arbitrary
143    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
144    pub fn composite_tooltip(
145        mut self,
146        content: impl teksilo_core::widget::Widget + 'static,
147    ) -> Self {
148        self.composite_tooltip_content = Some(Box::new(content));
149        self.tooltip_text = None;
150        self.rich_tooltip_source = None;
151        self
152    }
153
154    /// Return the URL previously set via [`url`](Self::url), if any.
155    pub fn get_url(&self) -> Option<&str> {
156        self.url.as_deref()
157    }
158
159    /// Set the enabled state, statically or reactively. Forwarded to the
160    /// arena at build time — a bound `Signal<bool>` updates live as it
161    /// changes.
162    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
163        self.enabled = enabled.into();
164        self
165    }
166}
167
168impl std::fmt::Debug for Link {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        f.debug_struct("Link").field("text", &self.text).finish()
171    }
172}
173
174impl Widget for Link {
175    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
176        let self_id = ctx.self_id();
177        // Forward the enabled state into the arena; see IconButton.
178        ctx.enabled_when(self_id, self.enabled.clone());
179        let effective_enabled = ctx.effective_enabled_signal(self_id);
180
181        let interaction = ctx.signal(InteractionState::Idle);
182        self.interaction = Some(interaction.clone());
183
184        // Derive the four state bools `LinkStyle` expects from the
185        // single `InteractionState` signal. `is_disabled` derives
186        // from the arena (reactive) instead of a build-time snapshot.
187        let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
188        let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
189        // `:focus-visible`: reveal the focus ring during keyboard navigation
190        // only, not on a mouse click. Gate raw focus on the input-modality
191        // signal (true after a key event, false after pointer-down).
192        let is_focused = interaction
193            .map(|s| matches!(s, InteractionState::Focused))
194            .and(&ctx.focus_visible());
195        let is_visited = self
196            .visited
197            .as_ref()
198            .map(|p| p.as_signal())
199            .unwrap_or_else(|| Signal::new(false));
200        let is_disabled = effective_enabled.map(|on| !*on);
201
202        let style: SharedLinkStyle = self
203            .style_override
204            .clone()
205            .or_else(|| ctx.theme().style_slots.link.clone())
206            .unwrap_or_else(|| Rc::new(crate::styles::RecipeLinkStyle::default()));
207        let root_id = style.make_body(
208            &LinkStyleConfig {
209                text: self.text.clone().into(),
210                is_hovered,
211                is_pressed,
212                is_focused,
213                is_visited,
214                is_disabled,
215            },
216            ctx,
217        );
218
219        if let Some(content) = self.composite_tooltip_content.take() {
220            let delay = ctx.theme().motion.tooltip_delay_heavy;
221            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
222        } else if let Some(source) = self.rich_tooltip_source.take() {
223            let delay = ctx.theme().motion.tooltip_delay;
224            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
225        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
226            let delay = ctx.theme().motion.tooltip_delay;
227            crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
228        }
229
230        self.root_child_id = Some(root_id);
231
232        // --- V2 attached handlers ---
233        let action = self.action.take();
234        let action_rc: std::rc::Rc<Option<CommandFactory>> = std::rc::Rc::new(action);
235        let action_for_tap = action_rc.clone();
236        let action_for_key = action_rc.clone();
237        let action_for_access = action_rc.clone();
238        let int_tap = interaction.clone();
239        let int_hover = interaction.clone();
240        let int_key = interaction.clone();
241        let int_focus = interaction.clone();
242
243        let handler_set = HandlerSet::new()
244            .on_tap({
245                move |_pos, ctx: &mut EventContext| {
246                    if let Some(ref action) = *action_for_tap {
247                        action(ctx);
248                    }
249                    int_tap.set(InteractionState::Hovered);
250                }
251            })
252            .on_hover({
253                move |entered: bool, _ctx: &mut EventContext| {
254                    if entered {
255                        int_hover.set(InteractionState::Hovered);
256                    } else {
257                        int_hover.set(InteractionState::Idle);
258                    }
259                }
260            })
261            .on_key({
262                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
263                    match event {
264                        WidgetEvent::KeyDown {
265                            key: Key::Space | Key::Enter,
266                            ..
267                        } => {
268                            int_key.set(InteractionState::Pressed);
269                            EventResponse::Handled
270                        }
271                        WidgetEvent::KeyUp {
272                            key: Key::Space | Key::Enter,
273                            ..
274                        } => {
275                            // Lone-KeyUp guard: only activate if we saw the
276                            // matching KeyDown (state is Pressed). A KeyUp with
277                            // no preceding KeyDown — e.g. a shortcut consumed the
278                            // KeyDown and focus returned here — must NOT activate.
279                            if int_key.get() != InteractionState::Pressed {
280                                return EventResponse::Ignored;
281                            }
282                            if let Some(ref action) = *action_for_key {
283                                action(ctx);
284                            }
285                            int_key.set(InteractionState::Focused);
286                            EventResponse::Handled
287                        }
288                        _ => EventResponse::Ignored,
289                    }
290                }
291            })
292            .on_focus({
293                move |gained: bool, _ctx: &mut EventContext| {
294                    if gained {
295                        if int_focus.get() == InteractionState::Idle {
296                            int_focus.set(InteractionState::Focused);
297                        }
298                    } else {
299                        int_focus.set(InteractionState::Idle);
300                    }
301                }
302            })
303            .on_access_action({
304                move |action: teksilo_core::accesskit::Action,
305                      ctx: &mut EventContext|
306                      -> EventResponse {
307                    if action == teksilo_core::accesskit::Action::Click {
308                        if let Some(ref act) = *action_for_access {
309                            act(ctx);
310                        }
311                        EventResponse::Handled
312                    } else {
313                        EventResponse::Ignored
314                    }
315                }
316            })
317            // Focus walker skips disabled subtrees; cursor stays
318            // Pointer here and the framework can choose to override
319            // for disabled subtrees in a future change.
320            .focusable(true)
321            .cursor(CursorIcon::Pointer);
322
323        ctx.apply_self_handlers(handler_set);
324
325        vec![root_id]
326    }
327
328    fn layout_response(
329        &self,
330        proposal: SizeProposal,
331        ctx: &LayoutContext,
332    ) -> teksilo_core::widget::LayoutResponse {
333        if let Some(root) = self.root_child_id
334            && let Some(size) = ctx.child_size(root, proposal)
335        {
336            return (size).into();
337        }
338        proposal.resolve(0.0, 0.0).into()
339    }
340
341    fn place_children(
342        &self,
343        bounds: Rect,
344        _proposal: SizeProposal,
345        children: &mut [WidgetPlacement],
346        _ctx: &LayoutContext,
347    ) {
348        for child in children.iter_mut() {
349            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
350            child.size = Size::new(bounds.width, bounds.height);
351        }
352    }
353
354    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
355        builder.set_role(teksilo_core::accesskit::Role::Link);
356        builder.set_name(self.text.resolve_now());
357        if let Some(ref url) = self.url {
358            builder.set_url(url.clone());
359        }
360        // Framework a11y walker sets `set_disabled` from arena state.
361        // Actions are always advertised — when disabled the framework
362        // gates them at dispatch via `arena.is_enabled`.
363        builder.add_action(teksilo_core::accesskit::Action::Click);
364        builder.add_action(teksilo_core::accesskit::Action::Focus);
365    }
366
367    fn children(&self) -> Vec<WidgetId> {
368        self.root_child_id.into_iter().collect()
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use std::cell::Cell;
376    use teksilo_core::event::Modifiers;
377    use teksilo_core::widget_tree::WidgetTree;
378    use teksilo_i18n::lit;
379
380    #[test]
381    fn keyup_without_keydown_does_not_fire() {
382        // Lone-KeyUp guard: when a shortcut consumes the KeyDown and
383        // focus returns to the link, the trailing KeyUp must NOT activate.
384        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
385        let fired = Rc::new(Cell::new(0_u32));
386        let fired_for_link = fired.clone();
387        let link = tree.add(Link::new(lit!("T")).on_activate_fn(move |_ctx| {
388            fired_for_link.set(fired_for_link.get() + 1);
389        }));
390        tree.layout(SizeProposal::exact(200.0, 80.0));
391        tree.focus(link);
392
393        tree.dispatch_event(WidgetEvent::KeyUp {
394            key: Key::Enter,
395            modifiers: Modifiers::NONE,
396        });
397        assert_eq!(
398            fired.get(),
399            0,
400            "a lone KeyUp (no matching KeyDown) must not activate the link",
401        );
402
403        tree.dispatch_event(WidgetEvent::KeyDown {
404            key: Key::Enter,
405            modifiers: Modifiers::NONE,
406            text: None,
407        });
408        tree.dispatch_event(WidgetEvent::KeyUp {
409            key: Key::Enter,
410            modifiers: Modifiers::NONE,
411        });
412        assert_eq!(
413            fired.get(),
414            1,
415            "a matched KeyDown + KeyUp pair must activate exactly once",
416        );
417    }
418}