Skip to main content

tui_lipan/widgets/
hyperlink.rs

1//! Hyperlink widget.
2
3use std::sync::Arc;
4
5use crate::callback::{Callback, KeyHandler};
6use crate::core::element::Element;
7use crate::core::event::{KeyCode, KeyEvent, KeyMods, MouseEvent};
8use crate::style::{Align, Length, Padding, Style, StyleSlot};
9use crate::widgets::Button;
10
11/// Event emitted when a [`Hyperlink`] is activated.
12#[derive(Clone, Debug)]
13pub struct HyperlinkEvent {
14    /// Link label text.
15    pub label: Arc<str>,
16    /// Optional destination URL.
17    pub href: Option<Arc<str>>,
18}
19
20/// Clickable text-style link widget.
21///
22/// This is an interactive wrapper over [`Button`] with link-focused defaults
23/// (underlined text, no chrome, keyboard activation).
24#[derive(Clone)]
25pub struct Hyperlink {
26    label: Arc<str>,
27    href: Option<Arc<str>>,
28    style: Style,
29    hover_style: StyleSlot,
30    focus_style: StyleSlot,
31    disabled_style: Style,
32    visited_style: Option<Style>,
33    width: Length,
34    height: Length,
35    align: Align,
36    padding: Padding,
37    focusable: bool,
38    tab_stop: bool,
39    on_focus: Option<Callback<()>>,
40    on_blur: Option<Callback<()>>,
41    disabled: bool,
42    visited: bool,
43    on_activate: Option<Callback<HyperlinkEvent>>,
44    on_key: Option<KeyHandler>,
45}
46
47impl Hyperlink {
48    /// Create a new hyperlink with the given visible label.
49    pub fn new(label: impl Into<Arc<str>>) -> Self {
50        Self {
51            label: label.into(),
52            href: None,
53            style: Style::new().underline(),
54            hover_style: StyleSlot::Extend(Style::new().underline()),
55            focus_style: StyleSlot::Extend(Style::new().underline().bold()),
56            disabled_style: Style::default(),
57            visited_style: None,
58            width: Length::Auto,
59            height: Length::Auto,
60            align: Align::Start,
61            padding: Padding::default(),
62            focusable: false,
63            tab_stop: true,
64            on_focus: None,
65            on_blur: None,
66            disabled: false,
67            visited: false,
68            on_activate: None,
69            on_key: None,
70        }
71    }
72
73    /// Set destination URL associated with this hyperlink.
74    pub fn href(mut self, href: impl Into<Arc<str>>) -> Self {
75        self.href = Some(href.into());
76        self
77    }
78
79    /// Set base style.
80    pub fn style(mut self, style: Style) -> Self {
81        self.style = style;
82        self
83    }
84
85    /// Set hover style.
86    pub fn hover_style(mut self, style: Style) -> Self {
87        self.hover_style = StyleSlot::Replace(style);
88        self
89    }
90
91    /// Extend the themed hover style.
92    pub fn extend_hover_style(mut self, style: Style) -> Self {
93        self.hover_style = StyleSlot::Extend(style);
94        self
95    }
96
97    /// Inherit the themed hover style.
98    pub fn inherit_hover_style(mut self) -> Self {
99        self.hover_style = StyleSlot::Inherit;
100        self
101    }
102
103    /// Set hover style slot directly for composite forwarding.
104    pub fn hover_style_slot(mut self, slot: StyleSlot) -> Self {
105        self.hover_style = slot;
106        self
107    }
108
109    /// Set focus style.
110    pub fn focus_style(mut self, style: Style) -> Self {
111        self.focus_style = StyleSlot::Replace(style);
112        self
113    }
114
115    /// Extend the themed focus style.
116    pub fn extend_focus_style(mut self, style: Style) -> Self {
117        self.focus_style = StyleSlot::Extend(style);
118        self
119    }
120
121    /// Inherit the themed focus style.
122    pub fn inherit_focus_style(mut self) -> Self {
123        self.focus_style = StyleSlot::Inherit;
124        self
125    }
126
127    /// Set focus style slot directly for composite forwarding.
128    pub fn focus_style_slot(mut self, slot: StyleSlot) -> Self {
129        self.focus_style = slot;
130        self
131    }
132
133    /// Set disabled style.
134    pub fn disabled_style(mut self, style: Style) -> Self {
135        self.disabled_style = style;
136        self
137    }
138
139    /// Set style overlay applied when `visited(true)`.
140    pub fn visited_style(mut self, style: Style) -> Self {
141        self.visited_style = Some(style);
142        self
143    }
144
145    /// Set width.
146    pub fn width(mut self, width: Length) -> Self {
147        self.width = width;
148        self
149    }
150
151    /// Set height.
152    pub fn height(mut self, height: Length) -> Self {
153        self.height = height;
154        self
155    }
156
157    /// Set label alignment.
158    pub fn align(mut self, align: Align) -> Self {
159        self.align = align;
160        self
161    }
162
163    /// Set inner padding.
164    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
165        self.padding = padding.into();
166        self
167    }
168
169    /// Control focus traversal participation.
170    pub fn focusable(mut self, focusable: bool) -> Self {
171        self.focusable = focusable;
172        self
173    }
174
175    /// Control whether the hyperlink participates in tab traversal.
176    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
177        self.tab_stop = tab_stop;
178        self
179    }
180
181    /// Set the callback fired when the hyperlink gains focus.
182    pub fn on_focus(mut self, cb: Callback<()>) -> Self {
183        self.on_focus = Some(cb);
184        self
185    }
186
187    /// Set the callback fired when the hyperlink loses focus.
188    pub fn on_blur(mut self, cb: Callback<()>) -> Self {
189        self.on_blur = Some(cb);
190        self
191    }
192
193    /// Set disabled state.
194    pub fn disabled(mut self, disabled: bool) -> Self {
195        self.disabled = disabled;
196        self
197    }
198
199    /// Mark the hyperlink as visited.
200    pub fn visited(mut self, visited: bool) -> Self {
201        self.visited = visited;
202        self
203    }
204
205    /// Set activation callback (mouse click, `Enter`, or `Space`).
206    ///
207    /// For the common "just open the URL" case, see [`crate::callbacks::open_hyperlink`].
208    pub fn on_activate(mut self, cb: Callback<HyperlinkEvent>) -> Self {
209        self.on_activate = Some(cb);
210        self
211    }
212
213    /// Set keyboard handler.
214    ///
215    /// The custom handler runs when activation keys are not handled.
216    pub fn on_key(mut self, cb: KeyHandler) -> Self {
217        self.on_key = Some(cb);
218        self
219    }
220}
221
222impl From<Hyperlink> for Element {
223    fn from(value: Hyperlink) -> Self {
224        let Hyperlink {
225            label,
226            href,
227            style,
228            hover_style,
229            focus_style,
230            disabled_style,
231            visited_style,
232            width,
233            height,
234            align,
235            padding,
236            focusable,
237            tab_stop,
238            on_focus,
239            on_blur,
240            disabled,
241            visited,
242            on_activate,
243            on_key,
244        } = value;
245
246        let style = apply_visited_style(style, visited, visited_style);
247
248        let mut button = Button::filled(label.clone())
249            .style(style)
250            .hover_style_slot(hover_style)
251            .focus_style_slot(focus_style)
252            .disabled_style(disabled_style)
253            .width(width)
254            .height(height)
255            .align(align)
256            .padding(padding)
257            .focusable(focusable && !disabled)
258            .tab_stop(tab_stop)
259            .disabled(disabled);
260
261        if let Some(cb) = on_focus {
262            button = button.on_focus(cb);
263        }
264        if let Some(cb) = on_blur {
265            button = button.on_blur(cb);
266        }
267
268        if let Some(cb) = on_activate.clone() {
269            let event_label = label.clone();
270            let event_href = href.clone();
271            button = button.on_click(Callback::new(move |_: MouseEvent| {
272                cb.emit(HyperlinkEvent {
273                    label: event_label.clone(),
274                    href: event_href.clone(),
275                });
276            }));
277        }
278
279        match (on_activate, on_key) {
280            (Some(cb), custom_key) => {
281                let event_label = label;
282                let event_href = href;
283                button = button.on_key(KeyHandler::new(move |key: KeyEvent| {
284                    if custom_key.as_ref().is_some_and(|h| h.handle(key)) {
285                        return true;
286                    }
287                    if is_activation_key(key) {
288                        cb.emit(HyperlinkEvent {
289                            label: event_label.clone(),
290                            href: event_href.clone(),
291                        });
292                        return true;
293                    }
294                    false
295                }));
296            }
297            (None, Some(custom_key)) => {
298                button = button.on_key(custom_key);
299            }
300            (None, None) => {}
301        }
302
303        button.into()
304    }
305}
306
307fn is_activation_key(key: KeyEvent) -> bool {
308    if has_non_shift_modifiers(key.mods) {
309        return false;
310    }
311
312    matches!(key.code, KeyCode::Enter | KeyCode::Char(' '))
313}
314
315fn has_non_shift_modifiers(mods: KeyMods) -> bool {
316    mods.ctrl || mods.alt || mods.super_key
317}
318
319fn apply_visited_style(style: Style, visited: bool, visited_style: Option<Style>) -> Style {
320    if visited {
321        style.patch(visited_style.unwrap_or_default())
322    } else {
323        style
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::{apply_visited_style, is_activation_key};
330    use crate::core::event::{KeyCode, KeyEvent, KeyMods};
331    use crate::style::Style;
332
333    #[test]
334    fn enter_and_space_are_activation_keys() {
335        let enter = KeyEvent {
336            code: KeyCode::Enter,
337            mods: KeyMods::default(),
338        };
339        let space = KeyEvent {
340            code: KeyCode::Char(' '),
341            mods: KeyMods::default(),
342        };
343
344        assert!(is_activation_key(enter));
345        assert!(is_activation_key(space));
346    }
347
348    #[test]
349    fn ctrl_modified_key_is_not_activation() {
350        let key = KeyEvent {
351            code: KeyCode::Enter,
352            mods: KeyMods {
353                ctrl: true,
354                ..KeyMods::default()
355            },
356        };
357
358        assert!(!is_activation_key(key));
359    }
360
361    #[test]
362    fn visited_style_overlays_base_style() {
363        let base = Style::new().fg(crate::style::Color::Blue).underline();
364        let visited = Style::new().fg(crate::style::Color::Magenta);
365
366        let resolved = apply_visited_style(base, true, Some(visited));
367
368        assert_eq!(resolved.fg, Some(crate::style::Color::Magenta.into()));
369        assert_eq!(resolved.underline, Some(true));
370    }
371}