Skip to main content

jag_ui/elements/
link.rs

1//! Hyperlink element with underline and click handling.
2
3use jag_draw::{ColorLinPremul, FontStyle, Hyperlink, Rect};
4use jag_surface::Canvas;
5
6use crate::event::{
7    ElementState, EventHandler, EventResult, KeyCode, KeyboardEvent, MouseButton, MouseClickEvent,
8    MouseMoveEvent, ScrollEvent,
9};
10use crate::focus::FocusId;
11
12use super::Element;
13
14/// A clickable hyperlink rendered as underlined text.
15pub struct Link {
16    /// Display text.
17    pub text: String,
18    /// Baseline-left position in logical coordinates.
19    pub pos: [f32; 2],
20    /// Font size in logical pixels.
21    pub size: f32,
22    /// Text color.
23    pub color: ColorLinPremul,
24    /// Target URL.
25    pub url: String,
26    /// Font weight (e.g. 400.0 normal, 700.0 bold).
27    pub weight: f32,
28    /// Pre-measured text width for accurate hit-testing.
29    pub measured_width: Option<f32>,
30    /// Whether to show underline decoration.
31    pub underline: bool,
32    /// Custom underline color (defaults to text color if `None`).
33    pub underline_color: Option<ColorLinPremul>,
34    /// Optional font family override.
35    pub font_family: Option<String>,
36    /// Font style.
37    pub font_style: FontStyle,
38    /// Whether this link is focused.
39    pub focused: bool,
40    /// Whether this link is hovered.
41    pub hovered: bool,
42    /// Focus identifier.
43    pub focus_id: FocusId,
44}
45
46impl Link {
47    /// Create a new hyperlink with default styling (blue with underline).
48    pub fn new(text: impl Into<String>, url: impl Into<String>, pos: [f32; 2], size: f32) -> Self {
49        Self {
50            text: text.into(),
51            pos,
52            size,
53            color: ColorLinPremul::from_srgba_u8([0x00, 0x7a, 0xff, 0xff]),
54            url: url.into(),
55            weight: 400.0,
56            measured_width: None,
57            underline: true,
58            underline_color: None,
59            font_family: None,
60            font_style: FontStyle::Normal,
61            focused: false,
62            hovered: false,
63            focus_id: FocusId(0),
64        }
65    }
66
67    /// Builder: set the text color.
68    pub fn with_color(mut self, color: ColorLinPremul) -> Self {
69        self.color = color;
70        self
71    }
72
73    /// Builder: set the font weight.
74    pub fn with_weight(mut self, weight: f32) -> Self {
75        self.weight = weight;
76        self
77    }
78
79    /// Builder: set an explicit measured width.
80    pub fn with_measured_width(mut self, measured_width: f32) -> Self {
81        self.measured_width = Some(measured_width.max(0.0));
82        self
83    }
84
85    /// Builder: enable or disable underline.
86    pub fn with_underline(mut self, underline: bool) -> Self {
87        self.underline = underline;
88        self
89    }
90
91    /// Get the URL of this link.
92    pub fn get_url(&self) -> &str {
93        &self.url
94    }
95
96    /// Set the URL.
97    pub fn set_url(&mut self, url: String) {
98        self.url = url;
99    }
100
101    /// Approximate bounding box for hit-testing.
102    fn get_bounds(&self) -> (f32, f32, f32, f32) {
103        let char_width = self.size * 0.5;
104        let text_width = self.text.len() as f32 * char_width;
105        let text_height = self.size * 1.2;
106        (
107            self.pos[0],
108            self.pos[1] - self.size * 0.8,
109            text_width,
110            text_height,
111        )
112    }
113
114    /// Hit-test the link text area.
115    pub fn hit_test(&self, x: f32, y: f32) -> bool {
116        let (bx, by, bw, bh) = self.get_bounds();
117        x >= bx && x <= bx + bw && y >= by && y <= by + bh
118    }
119}
120
121impl Default for Link {
122    fn default() -> Self {
123        Self::new("Link", "https://example.com", [0.0, 0.0], 16.0)
124    }
125}
126
127// ---------------------------------------------------------------------------
128// Element trait
129// ---------------------------------------------------------------------------
130
131impl Element for Link {
132    fn rect(&self) -> Rect {
133        let (bx, by, bw, bh) = self.get_bounds();
134        Rect {
135            x: bx,
136            y: by,
137            w: bw,
138            h: bh,
139        }
140    }
141
142    fn set_rect(&mut self, rect: Rect) {
143        self.pos = [rect.x, rect.y + rect.h * 0.8];
144    }
145
146    fn render(&self, canvas: &mut Canvas, z: i32) {
147        let hyperlink = Hyperlink {
148            text: self.text.clone(),
149            pos: self.pos,
150            size: self.size,
151            color: self.color,
152            url: self.url.clone(),
153            weight: self.weight,
154            measured_width: self.measured_width,
155            underline: self.underline,
156            underline_color: self.underline_color,
157            family: self.font_family.clone(),
158            style: self.font_style,
159        };
160        canvas.draw_hyperlink(hyperlink, z);
161    }
162
163    fn focus_id(&self) -> Option<FocusId> {
164        Some(self.focus_id)
165    }
166}
167
168// ---------------------------------------------------------------------------
169// EventHandler trait
170// ---------------------------------------------------------------------------
171
172impl EventHandler for Link {
173    fn handle_mouse_click(&mut self, event: &MouseClickEvent) -> EventResult {
174        if event.button != MouseButton::Left || event.state != ElementState::Pressed {
175            return EventResult::Ignored;
176        }
177        if self.hit_test(event.x, event.y) {
178            EventResult::Handled
179        } else {
180            EventResult::Ignored
181        }
182    }
183
184    fn handle_keyboard(&mut self, event: &KeyboardEvent) -> EventResult {
185        if event.state != ElementState::Pressed || !self.focused {
186            return EventResult::Ignored;
187        }
188        match event.key {
189            KeyCode::Space | KeyCode::Enter => EventResult::Handled,
190            _ => EventResult::Ignored,
191        }
192    }
193
194    fn handle_mouse_move(&mut self, event: &MouseMoveEvent) -> EventResult {
195        let was_hovered = self.hovered;
196        self.hovered = self.hit_test(event.x, event.y);
197        if was_hovered != self.hovered {
198            EventResult::Handled
199        } else {
200            EventResult::Ignored
201        }
202    }
203
204    fn handle_scroll(&mut self, _event: &ScrollEvent) -> EventResult {
205        EventResult::Ignored
206    }
207
208    fn is_focused(&self) -> bool {
209        self.focused
210    }
211
212    fn set_focused(&mut self, focused: bool) {
213        self.focused = focused;
214    }
215
216    fn contains_point(&self, x: f32, y: f32) -> bool {
217        self.hit_test(x, y)
218    }
219}
220
221// ---------------------------------------------------------------------------
222// Tests
223// ---------------------------------------------------------------------------
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn link_new_defaults() {
231        let link = Link::new("Click", "https://example.com", [10.0, 20.0], 14.0);
232        assert_eq!(link.text, "Click");
233        assert_eq!(link.url, "https://example.com");
234        assert!(link.underline);
235        assert!(!link.focused);
236        assert!(!link.hovered);
237    }
238
239    #[test]
240    fn link_hit_test() {
241        let link = Link::new("Hello", "https://example.com", [10.0, 20.0], 14.0);
242        // bounds: x=10, y=20-14*0.8=8.8, w=5*7=35, h=14*1.2=16.8
243        assert!(link.hit_test(15.0, 15.0));
244        assert!(!link.hit_test(0.0, 0.0));
245    }
246
247    #[test]
248    fn link_builder_methods() {
249        let link = Link::new("Test", "http://test.com", [0.0, 0.0], 16.0)
250            .with_weight(700.0)
251            .with_underline(false)
252            .with_measured_width(100.0);
253        assert_eq!(link.weight, 700.0);
254        assert!(!link.underline);
255        assert_eq!(link.measured_width, Some(100.0));
256    }
257
258    #[test]
259    fn link_get_set_url() {
260        let mut link = Link::default();
261        assert_eq!(link.get_url(), "https://example.com");
262        link.set_url("https://other.com".to_string());
263        assert_eq!(link.get_url(), "https://other.com");
264    }
265
266    #[test]
267    fn link_hover_state() {
268        let mut link = Link::new("Hover", "https://example.com", [10.0, 20.0], 14.0);
269        assert!(!link.hovered);
270        let evt = MouseMoveEvent { x: 15.0, y: 15.0 };
271        let result = link.handle_mouse_move(&evt);
272        assert_eq!(result, EventResult::Handled);
273        assert!(link.hovered);
274    }
275}