Skip to main content

jag_ui/elements/
toggle_switch.rs

1//! Toggle switch element with sliding thumb.
2
3use jag_draw::{Brush, Color, 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;
11
12use super::Element;
13
14/// A toggle switch with a sliding thumb, on/off state, and optional label.
15pub struct ToggleSwitch {
16    /// Bounding rect of the toggle track.
17    pub rect: Rect,
18    /// Whether the toggle is ON (`true`) or OFF (`false`).
19    pub on: bool,
20    /// Whether the toggle is currently focused.
21    pub focused: bool,
22    /// Optional label displayed after the toggle.
23    pub label: Option<String>,
24    /// Label font size.
25    pub label_size: f32,
26    /// Label text color.
27    pub label_color: ColorLinPremul,
28    /// Track color when ON.
29    pub on_color: ColorLinPremul,
30    /// Track color when OFF.
31    pub off_color: ColorLinPremul,
32    /// Thumb (sliding circle) color.
33    pub thumb_color: ColorLinPremul,
34    /// Border color for the track.
35    pub border_color: ColorLinPremul,
36    /// Border width.
37    pub border_width: f32,
38    /// Validation error message.
39    pub validation_error: Option<String>,
40    /// Focus identifier.
41    pub focus_id: FocusId,
42}
43
44impl ToggleSwitch {
45    /// Default track width.
46    pub const DEFAULT_WIDTH: f32 = 44.0;
47    /// Default track height.
48    pub const DEFAULT_HEIGHT: f32 = 24.0;
49    /// Padding inside the track for the thumb.
50    const THUMB_PADDING: f32 = 2.0;
51
52    /// Create a toggle switch with sensible defaults (OFF).
53    pub fn new() -> Self {
54        Self {
55            rect: Rect {
56                x: 0.0,
57                y: 0.0,
58                w: Self::DEFAULT_WIDTH,
59                h: Self::DEFAULT_HEIGHT,
60            },
61            on: false,
62            focused: false,
63            label: None,
64            label_size: 14.0,
65            label_color: ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
66            on_color: ColorLinPremul::from_srgba_u8([59, 130, 246, 255]),
67            off_color: ColorLinPremul::from_srgba_u8([120, 120, 120, 255]),
68            thumb_color: ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
69            border_color: ColorLinPremul::from_srgba_u8([80, 80, 80, 255]),
70            border_width: 0.0,
71            validation_error: None,
72            focus_id: FocusId(0),
73        }
74    }
75
76    /// Toggle the switch state.
77    pub fn toggle(&mut self) {
78        self.on = !self.on;
79    }
80
81    /// Hit-test the toggle track.
82    pub fn hit_test_track(&self, x: f32, y: f32) -> bool {
83        x >= self.rect.x
84            && x <= self.rect.x + self.rect.w
85            && y >= self.rect.y
86            && y <= self.rect.y + self.rect.h
87    }
88
89    /// Hit-test the label area.
90    pub fn hit_test_label(&self, x: f32, y: f32) -> bool {
91        if let Some(label) = &self.label {
92            let label_x = self.rect.x + self.rect.w + 8.0;
93            let char_width = self.label_size * 0.5;
94            let label_width = label.len() as f32 * char_width;
95            let clickable_height = self.rect.h.max(self.label_size * 1.2);
96
97            x >= label_x
98                && x <= label_x + label_width
99                && y >= self.rect.y
100                && y <= self.rect.y + clickable_height
101        } else {
102            false
103        }
104    }
105}
106
107impl Default for ToggleSwitch {
108    fn default() -> Self {
109        Self::new()
110    }
111}
112
113// ---------------------------------------------------------------------------
114// Element trait
115// ---------------------------------------------------------------------------
116
117impl Element for ToggleSwitch {
118    fn rect(&self) -> Rect {
119        self.rect
120    }
121
122    fn set_rect(&mut self, rect: Rect) {
123        self.rect = rect;
124    }
125
126    fn render(&self, canvas: &mut Canvas, z: i32) {
127        let track_height = self.rect.h;
128        let corner_radius = track_height * 0.5; // pill shape
129
130        let track_color = if self.on {
131            self.on_color
132        } else {
133            self.off_color
134        };
135
136        let has_error = self.validation_error.is_some();
137        let border_color = if has_error {
138            Color::rgba(220, 38, 38, 255)
139        } else {
140            self.border_color
141        };
142        let border_width = if has_error {
143            self.border_width.max(2.0)
144        } else {
145            self.border_width
146        };
147
148        let track_rrect = RoundedRect {
149            rect: self.rect,
150            radii: RoundedRadii {
151                tl: corner_radius,
152                tr: corner_radius,
153                br: corner_radius,
154                bl: corner_radius,
155            },
156        };
157
158        if border_width > 0.0 {
159            jag_surface::shapes::draw_snapped_rounded_rectangle(
160                canvas,
161                track_rrect,
162                Some(Brush::Solid(track_color)),
163                Some(border_width),
164                Some(Brush::Solid(border_color)),
165                z,
166            );
167        } else {
168            canvas.rounded_rect(track_rrect, Brush::Solid(track_color), z);
169        }
170
171        // Focus outline
172        if self.focused {
173            let focus_rr = RoundedRect {
174                rect: self.rect,
175                radii: RoundedRadii {
176                    tl: corner_radius,
177                    tr: corner_radius,
178                    br: corner_radius,
179                    bl: corner_radius,
180                },
181            };
182            jag_surface::shapes::draw_snapped_rounded_rectangle(
183                canvas,
184                focus_rr,
185                None,
186                Some(2.0),
187                Some(Brush::Solid(Color::rgba(63, 130, 246, 255))),
188                z + 2,
189            );
190        }
191
192        // Thumb
193        let thumb_diameter = track_height - Self::THUMB_PADDING * 2.0;
194        let thumb_radius = thumb_diameter * 0.5;
195
196        let thumb_x = if self.on {
197            self.rect.x + self.rect.w - Self::THUMB_PADDING - thumb_diameter
198        } else {
199            self.rect.x + Self::THUMB_PADDING
200        };
201        let thumb_y = self.rect.y + Self::THUMB_PADDING;
202
203        let thumb_rect = Rect {
204            x: thumb_x,
205            y: thumb_y,
206            w: thumb_diameter,
207            h: thumb_diameter,
208        };
209        let thumb_rrect = RoundedRect {
210            rect: thumb_rect,
211            radii: RoundedRadii {
212                tl: thumb_radius,
213                tr: thumb_radius,
214                br: thumb_radius,
215                bl: thumb_radius,
216            },
217        };
218
219        // Shadow
220        let shadow_rect = Rect {
221            x: thumb_rect.x,
222            y: thumb_rect.y + 1.0,
223            w: thumb_rect.w,
224            h: thumb_rect.h,
225        };
226        let shadow_rrect = RoundedRect {
227            rect: shadow_rect,
228            radii: thumb_rrect.radii,
229        };
230        canvas.rounded_rect(shadow_rrect, Brush::Solid(Color::rgba(0, 0, 0, 40)), z + 2);
231
232        // Thumb circle
233        canvas.rounded_rect(thumb_rrect, Brush::Solid(self.thumb_color), z + 3);
234
235        // Label
236        if let Some(text) = &self.label {
237            let tx = self.rect.x + self.rect.w + 8.0;
238            let ty = self.rect.y + self.rect.h * 0.5 + self.label_size * 0.32;
239            canvas.draw_text_run_weighted(
240                [tx, ty],
241                text.clone(),
242                self.label_size,
243                400.0,
244                self.label_color,
245                z + 3,
246            );
247        }
248
249        // Validation error
250        if let Some(ref error_msg) = self.validation_error {
251            let error_size = (self.label_size * 0.85).max(12.0);
252            let baseline_offset = error_size * 0.8;
253            let top_gap = 3.0;
254            let control_height = self.rect.h.max(self.label_size * 1.2);
255            let error_y = self.rect.y + control_height + top_gap + baseline_offset;
256            let error_color = ColorLinPremul::from_srgba_u8([220, 38, 38, 255]);
257
258            canvas.draw_text_run_weighted(
259                [self.rect.x, error_y],
260                error_msg.clone(),
261                error_size,
262                400.0,
263                error_color,
264                z + 4,
265            );
266        }
267    }
268
269    fn focus_id(&self) -> Option<FocusId> {
270        Some(self.focus_id)
271    }
272}
273
274// ---------------------------------------------------------------------------
275// EventHandler trait
276// ---------------------------------------------------------------------------
277
278impl EventHandler for ToggleSwitch {
279    fn handle_mouse_click(&mut self, event: &MouseClickEvent) -> EventResult {
280        if event.button != MouseButton::Left || event.state != ElementState::Pressed {
281            return EventResult::Ignored;
282        }
283        if self.hit_test_track(event.x, event.y) || self.hit_test_label(event.x, event.y) {
284            self.toggle();
285            EventResult::Handled
286        } else {
287            EventResult::Ignored
288        }
289    }
290
291    fn handle_keyboard(&mut self, event: &KeyboardEvent) -> EventResult {
292        if event.state != ElementState::Pressed || !self.focused {
293            return EventResult::Ignored;
294        }
295        match event.key {
296            KeyCode::Space | KeyCode::Enter => {
297                self.toggle();
298                EventResult::Handled
299            }
300            _ => EventResult::Ignored,
301        }
302    }
303
304    fn handle_mouse_move(&mut self, _event: &MouseMoveEvent) -> EventResult {
305        EventResult::Ignored
306    }
307
308    fn handle_scroll(&mut self, _event: &ScrollEvent) -> EventResult {
309        EventResult::Ignored
310    }
311
312    fn is_focused(&self) -> bool {
313        self.focused
314    }
315
316    fn set_focused(&mut self, focused: bool) {
317        self.focused = focused;
318    }
319
320    fn contains_point(&self, x: f32, y: f32) -> bool {
321        self.hit_test_track(x, y) || self.hit_test_label(x, y)
322    }
323}
324
325// ---------------------------------------------------------------------------
326// Tests
327// ---------------------------------------------------------------------------
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn toggle_new_defaults() {
335        let ts = ToggleSwitch::new();
336        assert!(!ts.on);
337        assert!(!ts.focused);
338        assert!(ts.label.is_none());
339    }
340
341    #[test]
342    fn toggle_toggle() {
343        let mut ts = ToggleSwitch::new();
344        assert!(!ts.on);
345        ts.toggle();
346        assert!(ts.on);
347        ts.toggle();
348        assert!(!ts.on);
349    }
350
351    #[test]
352    fn toggle_hit_test_track() {
353        let mut ts = ToggleSwitch::new();
354        ts.rect = Rect {
355            x: 10.0,
356            y: 10.0,
357            w: 44.0,
358            h: 24.0,
359        };
360        assert!(ts.hit_test_track(30.0, 20.0));
361        assert!(!ts.hit_test_track(0.0, 0.0));
362    }
363
364    #[test]
365    fn toggle_keyboard() {
366        let mut ts = ToggleSwitch::new();
367        ts.focused = true;
368        let evt = KeyboardEvent {
369            key: KeyCode::Space,
370            state: ElementState::Pressed,
371            modifiers: Default::default(),
372            text: None,
373        };
374        assert!(!ts.on);
375        assert_eq!(ts.handle_keyboard(&evt), EventResult::Handled);
376        assert!(ts.on);
377    }
378
379    #[test]
380    fn toggle_focus() {
381        let mut ts = ToggleSwitch::new();
382        assert!(!ts.is_focused());
383        ts.set_focused(true);
384        assert!(ts.is_focused());
385    }
386}