Skip to main content

jag_ui/elements/
button.rs

1//! Clickable button element.
2
3use jag_draw::{Brush, ColorLinPremul, Rect, RoundedRadii, RoundedRect};
4use jag_surface::Canvas;
5
6use crate::event::{
7    ElementState, EventHandler, EventResult, KeyCode, KeyboardEvent, MouseButton, MouseClickEvent,
8    MouseMoveEvent, ScrollEvent,
9};
10use crate::focus::FocusId;
11use crate::theme::Theme;
12
13use super::Element;
14
15/// Horizontal alignment for button labels.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ButtonLabelAlign {
18    Start,
19    Center,
20    End,
21}
22
23/// A clickable button with label, optional icon, and focus support.
24pub struct Button {
25    pub rect: Rect,
26    pub label: String,
27    pub label_size: f32,
28    pub label_align: ButtonLabelAlign,
29    pub bg: ColorLinPremul,
30    pub fg: ColorLinPremul,
31    pub radius: f32,
32    pub focused: bool,
33    pub focus_visible: bool,
34    /// Padding: [top, right, bottom, left].
35    pub padding: [f32; 4],
36    /// Optional icon asset path (SVG or raster).
37    pub icon_path: Option<String>,
38    /// Logical icon size in pixels (square).
39    pub icon_size: f32,
40    /// Horizontal spacing between icon and label.
41    pub icon_spacing: f32,
42    /// When `true`, suppress label rendering (icon-only button).
43    pub icon_only: bool,
44    /// Focus identifier for this button.
45    pub focus_id: FocusId,
46}
47
48impl Button {
49    /// Create a button with sensible defaults.
50    pub fn new(label: impl Into<String>) -> Self {
51        Self {
52            rect: Rect {
53                x: 0.0,
54                y: 0.0,
55                w: 120.0,
56                h: 36.0,
57            },
58            label: label.into(),
59            label_size: 14.0,
60            label_align: ButtonLabelAlign::Center,
61            bg: ColorLinPremul::from_srgba_u8([59, 130, 246, 255]),
62            fg: ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
63            radius: 6.0,
64            focused: false,
65            focus_visible: false,
66            padding: [8.0, 16.0, 8.0, 16.0],
67            icon_path: None,
68            icon_size: 16.0,
69            icon_spacing: 6.0,
70            icon_only: false,
71            focus_id: FocusId(0),
72        }
73    }
74
75    /// Create a button that derives its colors from a [`Theme`].
76    pub fn with_theme(label: impl Into<String>, theme: &Theme) -> Self {
77        let mut btn = Self::new(label);
78        btn.bg = theme.colors.button_bg;
79        btn.fg = theme.colors.button_fg;
80        btn.radius = theme.border_radius;
81        btn.label_size = theme.font_size;
82        btn
83    }
84
85    /// Hit-test: is `(x, y)` inside the button rect?
86    pub fn hit_test(&self, x: f32, y: f32) -> bool {
87        x >= self.rect.x
88            && x <= self.rect.x + self.rect.w
89            && y >= self.rect.y
90            && y <= self.rect.y + self.rect.h
91    }
92}
93
94// ---------------------------------------------------------------------------
95// Element trait
96// ---------------------------------------------------------------------------
97
98impl Element for Button {
99    fn rect(&self) -> Rect {
100        self.rect
101    }
102
103    fn set_rect(&mut self, rect: Rect) {
104        self.rect = rect;
105    }
106
107    fn render(&self, canvas: &mut Canvas, z: i32) {
108        // Rounded background
109        let rrect = RoundedRect {
110            rect: self.rect,
111            radii: RoundedRadii {
112                tl: self.radius,
113                tr: self.radius,
114                br: self.radius,
115                bl: self.radius,
116            },
117        };
118        canvas.rounded_rect(rrect, Brush::Solid(self.bg), z);
119
120        // Padding unpacking
121        let pad_top = self.padding[0];
122        let pad_right = self.padding[1];
123        let pad_bottom = self.padding[2];
124        let pad_left = self.padding[3];
125
126        let trimmed_label = if self.icon_only {
127            ""
128        } else {
129            self.label.trim()
130        };
131
132        let label_len = trimmed_label.chars().count() as f32;
133        let approx_text_width = if label_len == 0.0 {
134            0.0
135        } else {
136            canvas.measure_text_width(trimmed_label, self.label_size) + 2.0
137        };
138
139        let content_w = (self.rect.w - pad_left - pad_right).max(0.0);
140        let content_h = (self.rect.h - pad_top - pad_bottom).max(0.0);
141        let base_x = self.rect.x + pad_left;
142
143        let has_icon = self.icon_path.is_some() && self.icon_size > 0.0;
144        let icon_w = if has_icon { self.icon_size } else { 0.0 };
145        let icon_spacing = if has_icon {
146            self.icon_spacing.max(0.0)
147        } else {
148            0.0
149        };
150        let combined_width = icon_w + icon_spacing + approx_text_width;
151
152        let origin_x = match self.label_align {
153            ButtonLabelAlign::Center => base_x + (content_w - combined_width).max(0.0) * 0.5,
154            ButtonLabelAlign::End => base_x + (content_w - combined_width).max(0.0),
155            ButtonLabelAlign::Start => base_x,
156        };
157
158        let text_x = if has_icon {
159            origin_x + icon_w + icon_spacing
160        } else {
161            origin_x
162        };
163
164        let content_center_y = self.rect.y + pad_top + content_h * 0.5;
165        let text_y = content_center_y + self.label_size * 0.35;
166
167        // Draw label (skip icon rendering since jag-ui is standalone and does
168        // not have asset resolution; icon support is provided as data fields
169        // for higher-level integrations to use).
170        if !self.icon_only {
171            canvas.draw_text_run_weighted(
172                [text_x, text_y],
173                trimmed_label.to_string(),
174                self.label_size,
175                400.0,
176                self.fg,
177                z + 2,
178            );
179        }
180
181        // Focus ring
182        if self.focused && self.focus_visible {
183            let focus_color = ColorLinPremul::from_srgba_u8([59, 130, 246, 180]);
184            let offset = 3.0;
185            let focus_rect = Rect {
186                x: self.rect.x - offset,
187                y: self.rect.y - offset,
188                w: self.rect.w + offset * 2.0,
189                h: self.rect.h + offset * 2.0,
190            };
191            let focus_rrect = RoundedRect {
192                rect: focus_rect,
193                radii: RoundedRadii {
194                    tl: self.radius + offset,
195                    tr: self.radius + offset,
196                    br: self.radius + offset,
197                    bl: self.radius + offset,
198                },
199            };
200            jag_surface::shapes::draw_snapped_rounded_rectangle(
201                canvas,
202                focus_rrect,
203                None,
204                Some(2.0),
205                Some(Brush::Solid(focus_color)),
206                z + 3,
207            );
208        }
209    }
210
211    fn focus_id(&self) -> Option<FocusId> {
212        Some(self.focus_id)
213    }
214}
215
216// ---------------------------------------------------------------------------
217// EventHandler trait
218// ---------------------------------------------------------------------------
219
220impl EventHandler for Button {
221    fn handle_mouse_click(&mut self, event: &MouseClickEvent) -> EventResult {
222        if event.button != MouseButton::Left || event.state != ElementState::Pressed {
223            return EventResult::Ignored;
224        }
225        if self.contains_point(event.x, event.y) {
226            EventResult::Handled
227        } else {
228            EventResult::Ignored
229        }
230    }
231
232    fn handle_keyboard(&mut self, event: &KeyboardEvent) -> EventResult {
233        if event.state != ElementState::Pressed || !self.focused {
234            return EventResult::Ignored;
235        }
236        match event.key {
237            KeyCode::Space | KeyCode::Enter => EventResult::Handled,
238            _ => EventResult::Ignored,
239        }
240    }
241
242    fn handle_mouse_move(&mut self, _event: &MouseMoveEvent) -> EventResult {
243        EventResult::Ignored
244    }
245
246    fn handle_scroll(&mut self, _event: &ScrollEvent) -> EventResult {
247        EventResult::Ignored
248    }
249
250    fn is_focused(&self) -> bool {
251        self.focused
252    }
253
254    fn set_focused(&mut self, focused: bool) {
255        self.focused = focused;
256    }
257
258    fn contains_point(&self, x: f32, y: f32) -> bool {
259        self.hit_test(x, y)
260    }
261}
262
263// ---------------------------------------------------------------------------
264// Tests
265// ---------------------------------------------------------------------------
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn button_new_defaults() {
273        let btn = Button::new("Click me");
274        assert_eq!(btn.label, "Click me");
275        assert!(!btn.focused);
276        assert!(!btn.focus_visible);
277        assert!(!btn.icon_only);
278        assert!(btn.icon_path.is_none());
279    }
280
281    #[test]
282    fn button_hit_test() {
283        let mut btn = Button::new("Test");
284        btn.set_rect(Rect {
285            x: 10.0,
286            y: 10.0,
287            w: 100.0,
288            h: 40.0,
289        });
290        assert!(btn.contains_point(50.0, 30.0));
291        assert!(!btn.contains_point(0.0, 0.0));
292        assert!(btn.contains_point(10.0, 10.0)); // edge
293        assert!(btn.contains_point(110.0, 50.0)); // opposite edge
294        assert!(!btn.contains_point(111.0, 30.0)); // just outside
295    }
296
297    #[test]
298    fn button_focus() {
299        let mut btn = Button::new("Focus");
300        assert!(!btn.is_focused());
301        btn.set_focused(true);
302        assert!(btn.is_focused());
303        btn.set_focused(false);
304        assert!(!btn.is_focused());
305    }
306
307    #[test]
308    fn button_with_theme() {
309        let theme = Theme::dark();
310        let btn = Button::with_theme("Themed", &theme);
311        assert_eq!(btn.bg, theme.colors.button_bg);
312        assert_eq!(btn.fg, theme.colors.button_fg);
313        assert_eq!(btn.radius, theme.border_radius);
314        assert_eq!(btn.label_size, theme.font_size);
315    }
316
317    #[test]
318    fn button_element_trait() {
319        let mut btn = Button::new("Elem");
320        let r = Rect {
321            x: 5.0,
322            y: 5.0,
323            w: 80.0,
324            h: 30.0,
325        };
326        btn.set_rect(r);
327        assert_eq!(btn.rect().x, 5.0);
328        assert_eq!(btn.rect().w, 80.0);
329        assert!(btn.focus_id().is_some());
330    }
331
332    #[test]
333    fn button_keyboard_requires_focus() {
334        let mut btn = Button::new("KB");
335        btn.focused = false;
336        let evt = KeyboardEvent {
337            key: KeyCode::Enter,
338            state: ElementState::Pressed,
339            modifiers: Default::default(),
340            text: None,
341        };
342        assert_eq!(btn.handle_keyboard(&evt), EventResult::Ignored);
343
344        btn.focused = true;
345        assert_eq!(btn.handle_keyboard(&evt), EventResult::Handled);
346    }
347}