zest_widget/widget/
switch.rs1use 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
23const TRACK_W: u32 = 44;
25const TRACK_H: u32 = 24;
27const KNOB_INSET: i32 = 2;
29
30pub 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 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 #[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 #[must_use]
71 pub fn id(mut self, id: WidgetId) -> Self {
72 self.id = Some(id);
73 self
74 }
75
76 #[must_use]
78 pub fn width(mut self, width: impl Into<Length>) -> Self {
79 self.width = width.into();
80 self
81 }
82
83 #[must_use]
85 pub fn height(mut self, height: impl Into<Length>) -> Self {
86 self.height = height.into();
87 self
88 }
89
90 pub fn is_enabled(&self) -> bool {
93 self.on_toggle.is_some()
94 }
95
96 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 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}