Skip to main content

agg_gui/widgets/
hyperlink.rs

1//! `Hyperlink` — a clickable label rendered in link style (blue, underlined).
2//!
3//! Unlike a full URL-opening widget, `Hyperlink` fires a plain `on_click`
4//! callback so callers can open URLs via whatever platform mechanism is
5//! available (`web_sys::window().open()` on WASM, `open::open()` on native).
6
7use std::sync::Arc;
8
9use crate::draw_ctx::DrawCtx;
10use crate::event::{Event, EventResult, MouseButton};
11use crate::geometry::{Rect, Size};
12use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
13use crate::text::{measure_text_metrics, Font};
14use crate::widget::Widget;
15
16// Colors are resolved from ctx.visuals() at paint time.
17
18/// A text label that looks like a hyperlink (blue, underlined) and fires a
19/// callback when clicked.
20pub struct Hyperlink {
21    bounds: Rect,
22    children: Vec<Box<dyn Widget>>,
23    base: WidgetBase,
24
25    text: String,
26    font: Arc<Font>,
27    font_size: f64,
28
29    hovered: bool,
30    on_click: Option<Box<dyn FnMut()>>,
31
32    cache: crate::widget::BackbufferCache,
33    last_sig: Option<(bool, u64, u64)>, // (hovered, w_bits, h_bits)
34}
35
36impl Hyperlink {
37    pub fn new(text: impl Into<String>, font: Arc<Font>) -> Self {
38        Self {
39            bounds: Rect::default(),
40            children: Vec::new(),
41            base: WidgetBase::new(),
42            text: text.into(),
43            font,
44            font_size: 14.0,
45            hovered: false,
46            on_click: None,
47            cache: crate::widget::BackbufferCache::default(),
48            last_sig: None,
49        }
50    }
51
52    pub fn with_font_size(mut self, size: f64) -> Self {
53        self.font_size = size;
54        self
55    }
56    pub fn on_click(mut self, cb: impl FnMut() + 'static) -> Self {
57        self.on_click = Some(Box::new(cb));
58        self
59    }
60
61    pub fn with_margin(mut self, m: Insets) -> Self {
62        self.base.margin = m;
63        self
64    }
65    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
66        self.base.h_anchor = h;
67        self
68    }
69    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
70        self.base.v_anchor = v;
71        self
72    }
73    pub fn with_min_size(mut self, s: Size) -> Self {
74        self.base.min_size = s;
75        self
76    }
77    pub fn with_max_size(mut self, s: Size) -> Self {
78        self.base.max_size = s;
79        self
80    }
81}
82
83impl Widget for Hyperlink {
84    fn type_name(&self) -> &'static str {
85        "Hyperlink"
86    }
87    fn bounds(&self) -> Rect {
88        self.bounds
89    }
90    fn set_bounds(&mut self, b: Rect) {
91        self.bounds = b;
92    }
93    fn children(&self) -> &[Box<dyn Widget>] {
94        &self.children
95    }
96    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
97        &mut self.children
98    }
99
100    fn margin(&self) -> Insets {
101        self.base.margin
102    }
103    fn widget_base(&self) -> Option<&WidgetBase> {
104        Some(&self.base)
105    }
106    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
107        Some(&mut self.base)
108    }
109    fn h_anchor(&self) -> HAnchor {
110        self.base.h_anchor
111    }
112    fn v_anchor(&self) -> VAnchor {
113        self.base.v_anchor
114    }
115    fn min_size(&self) -> Size {
116        self.base.min_size
117    }
118    fn max_size(&self) -> Size {
119        self.base.max_size
120    }
121
122    fn is_focusable(&self) -> bool {
123        true
124    }
125
126    fn backbuffer_cache_mut(&mut self) -> Option<&mut crate::widget::BackbufferCache> {
127        Some(&mut self.cache)
128    }
129
130    fn backbuffer_mode(&self) -> crate::widget::BackbufferMode {
131        if crate::font_settings::lcd_enabled() {
132            crate::widget::BackbufferMode::LcdCoverage
133        } else {
134            crate::widget::BackbufferMode::Rgba
135        }
136    }
137
138    fn layout(&mut self, _available: Size) -> Size {
139        let sig = (
140            self.hovered,
141            self.bounds.width.to_bits(),
142            self.bounds.height.to_bits(),
143        );
144        if self.last_sig != Some(sig) {
145            self.last_sig = Some(sig);
146            self.cache.invalidate();
147        }
148        let h = self.font_size * 1.5;
149        let w = measure_text_metrics(&self.font, &self.text, self.font_size).width;
150        Size::new(w, h)
151    }
152
153    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
154        let v = ctx.visuals();
155        let color = if self.hovered {
156            v.text_link_hovered
157        } else {
158            v.text_link
159        };
160        ctx.set_font(Arc::clone(&self.font));
161        ctx.set_font_size(self.font_size);
162        ctx.set_fill_color(color);
163
164        let h = self.bounds.height;
165        let m = ctx.measure_text(&self.text).unwrap_or_default();
166        let ty = h * 0.5 - (m.ascent - m.descent) * 0.5;
167        ctx.fill_text(&self.text, 0.0, ty);
168
169        // Underline — drawn at the text baseline.
170        let uw = m.width;
171        let uy = ty - m.descent - 1.0; // 1 px below baseline
172        ctx.set_stroke_color(color);
173        ctx.set_line_width(1.0);
174        ctx.begin_path();
175        ctx.move_to(0.0, uy);
176        ctx.line_to(uw, uy);
177        ctx.stroke();
178    }
179
180    fn on_event(&mut self, event: &Event) -> EventResult {
181        match event {
182            Event::MouseMove { pos } => {
183                let was = self.hovered;
184                self.hovered = self.hit_test(*pos);
185                if was != self.hovered {
186                    crate::animation::request_draw();
187                    return EventResult::Consumed;
188                }
189                EventResult::Ignored
190            }
191            Event::MouseDown {
192                button: MouseButton::Left,
193                ..
194            } => EventResult::Consumed,
195            Event::MouseUp {
196                button: MouseButton::Left,
197                pos,
198                ..
199            } => {
200                if self.hit_test(*pos) {
201                    if let Some(cb) = self.on_click.as_mut() {
202                        cb();
203                    }
204                    // Click handler typically mutates app state.
205                    crate::animation::request_draw();
206                }
207                EventResult::Consumed
208            }
209            _ => EventResult::Ignored,
210        }
211    }
212}