Skip to main content

zest_widget/widget/
switch.rs

1//! On/off toggle switch: a pill-shaped track with a sliding knob.
2//!
3//! Immediate-mode: the host owns the `bool` and rebuilds the widget each
4//! frame, passing the current state to [`Switch::new`]. A tap emits
5//! `on_toggle(!on)` so the host can flip its stored value.
6//!
7//! Click semantics mirror [`Button`](crate::Button): the press registers
8//! on `TouchPhase::Down` and the toggle message fires on `TouchPhase::Up`
9//! if the press is still active. The runtime rehydrates the pressed flag
10//! each frame via [`mark_pressed`](Widget::mark_pressed).
11//!
12//! Colors come from the theme's accent [`Component`](zest_theme::Component):
13//! the "on" track uses `accent.base`/`accent.pressed`, the "off" track uses
14//! `background.divider`, and the knob uses `accent.on_base`.
15
16use super::Widget;
17use alloc::boxed::Box;
18use core::marker::PhantomData;
19use embedded_graphics::{pixelcolor::PixelColor, prelude::*, primitives::Rectangle};
20use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
21use zest_theme::Theme;
22
23/// Default track width in pixels.
24const TRACK_W: u32 = 44;
25/// Default track height in pixels.
26const TRACK_H: u32 = 24;
27/// Inset of the knob from the track edge in pixels.
28const KNOB_INSET: i32 = 2;
29
30/// On/off toggle switch. Host owns the `bool`; a tap emits the negated
31/// value through [`on_toggle`](Switch::on_toggle).
32pub struct Switch<'a, C: PixelColor, M: Clone> {
33    rect: Rectangle,
34    on: bool,
35    id: Option<WidgetId>,
36    on_toggle: Option<Box<dyn Fn(bool) -> M + 'a>>,
37    focused: bool,
38    pressed: bool,
39    width: Length,
40    height: Length,
41    _color: PhantomData<C>,
42}
43
44impl<'a, C: PixelColor, M: Clone> Switch<'a, C, M> {
45    /// New switch reflecting `on`. Position and size are assigned by the
46    /// parent container via `arrange`.
47    pub fn new(on: bool) -> Self {
48        Self {
49            rect: Rectangle::zero(),
50            on,
51            id: None,
52            on_toggle: None,
53            focused: false,
54            pressed: false,
55            width: Length::Fixed(TRACK_W),
56            height: Length::Fixed(TRACK_H),
57            _color: PhantomData,
58        }
59    }
60
61    /// Callback invoked with the toggled value (`!on`) on each
62    /// tap. Without it the switch is disabled and ignores touches.
63    #[must_use]
64    pub fn on_toggle<F: Fn(bool) -> M + 'a>(mut self, f: F) -> Self {
65        self.on_toggle = Some(Box::new(f));
66        self
67    }
68
69    /// Set a stable id so this switch can participate in focus traversal.
70    #[must_use]
71    pub fn id(mut self, id: WidgetId) -> Self {
72        self.id = Some(id);
73        self
74    }
75
76    /// Width sizing intent.
77    #[must_use]
78    pub fn width(mut self, width: impl Into<Length>) -> Self {
79        self.width = width.into();
80        self
81    }
82
83    /// Height sizing intent.
84    #[must_use]
85    pub fn height(mut self, height: impl Into<Length>) -> Self {
86        self.height = height.into();
87        self
88    }
89
90    /// True iff a toggle callback is bound. Disabled switches render
91    /// dimmed and ignore touches.
92    pub fn is_enabled(&self) -> bool {
93        self.on_toggle.is_some()
94    }
95
96    /// The pill track rect, vertically centered within `rect`.
97    fn track_rect(&self) -> Rectangle {
98        let h = self.rect.size.height.min(TRACK_H);
99        let y = self.rect.top_left.y + (self.rect.size.height.saturating_sub(h) / 2) as i32;
100        Rectangle::new(
101            Point::new(self.rect.top_left.x, y),
102            Size::new(self.rect.size.width, h),
103        )
104    }
105
106    fn hit_test(&self, point: Point) -> bool {
107        let tl = self.rect.top_left;
108        let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
109        point.x >= tl.x && point.x < br.x && point.y >= tl.y && point.y < br.y
110    }
111}
112
113impl<'a, C: PixelColor, M: Clone> Widget<C, M> for Switch<'a, C, M> {
114    fn measure(&mut self, constraints: Constraints) -> Size {
115        let w = self.width.resolve(TRACK_W, constraints.max.width);
116        let h = self.height.resolve(TRACK_H, constraints.max.height);
117        constraints.clamp(Size::new(w, h))
118    }
119
120    fn preferred_size(&self) -> (Length, Length) {
121        (self.width, self.height)
122    }
123
124    fn arrange(&mut self, rect: Rectangle) {
125        self.rect = rect;
126    }
127
128    fn rect(&self) -> Rectangle {
129        self.rect
130    }
131
132    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
133        if !self.is_enabled() || !self.hit_test(point) {
134            if matches!(phase, TouchPhase::Up | TouchPhase::Moved) {
135                self.pressed = false;
136            }
137            return None;
138        }
139        match phase {
140            TouchPhase::Down => {
141                self.pressed = true;
142                None
143            }
144            TouchPhase::Up => {
145                if self.pressed {
146                    self.pressed = false;
147                    self.on_toggle.as_ref().map(|cb| cb(!self.on))
148                } else {
149                    None
150                }
151            }
152            TouchPhase::Moved => None,
153        }
154    }
155
156    fn mark_pressed(&mut self, point: Point) {
157        if self.is_enabled() && self.hit_test(point) {
158            self.pressed = true;
159        }
160    }
161
162    fn widget_id(&self) -> Option<WidgetId> {
163        self.id
164    }
165
166    fn is_focusable(&self) -> bool {
167        self.id.is_some() && self.is_enabled()
168    }
169
170    fn handle_action(&mut self, action: UiAction) -> Option<M> {
171        if !self.is_enabled() {
172            return None;
173        }
174
175        match action {
176            UiAction::Activate => self.on_toggle.as_ref().map(|cb| cb(!self.on)),
177            _ => None,
178        }
179    }
180
181    fn sync_focus(&mut self, focused: Option<WidgetId>) {
182        self.focused = self.id.is_some() && self.id == focused;
183    }
184
185    fn focus_at(&self, point: Point) -> Option<WidgetId> {
186        if self.is_focusable() && self.hit_test(point) {
187            self.id
188        } else {
189            None
190        }
191    }
192
193    fn draw<'t>(
194        &self,
195        renderer: &mut dyn Renderer<C>,
196        theme: &Theme<'t, C>,
197    ) -> Result<(), RenderError> {
198        let track = self.track_rect();
199        let accent = &theme.accent;
200
201        let track_color = if !self.is_enabled() {
202            theme.background.divider
203        } else if self.on {
204            if self.pressed {
205                accent.pressed
206            } else {
207                accent.base
208            }
209        } else {
210            theme.background.divider
211        };
212        renderer.fill_rect(track, track_color)?;
213        let border = if self.focused {
214            accent.base
215        } else {
216            accent.border
217        };
218        renderer.stroke_rect(track, border)?;
219
220        // Knob: a circle inset on whichever end the state selects.
221        let radius = (track.size.height as i32 / 2 - KNOB_INSET).max(1) as u32;
222        let cy = track.top_left.y + track.size.height as i32 / 2;
223        let cx = if self.on {
224            track.top_left.x + track.size.width as i32 - KNOB_INSET - radius as i32
225        } else {
226            track.top_left.x + KNOB_INSET + radius as i32
227        };
228        let knob_color = if self.is_enabled() {
229            accent.on_base
230        } else {
231            theme.background.base
232        };
233        renderer.fill_circle(Point::new(cx, cy), radius, knob_color)?;
234
235        Ok(())
236    }
237}