1use embedded_graphics::{
4 mono_font::{MonoTextStyle, ascii::FONT_6X10},
5 pixelcolor::Rgb565,
6 prelude::*,
7 primitives::{PrimitiveStyle, Rectangle},
8 text::{Alignment, Text},
9};
10
11use crate::{
12 power::BatteryStatus,
13 touch::{TouchEvent, TouchPhase},
14};
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub struct Theme {
18 pub foreground: Rgb565,
19 pub background: Rgb565,
20 pub accent: Rgb565,
21 pub disabled: Rgb565,
22 pub warning: Rgb565,
23}
24
25impl Theme {
26 pub const DARK: Self = Self {
27 foreground: Rgb565::WHITE,
28 background: Rgb565::BLACK,
29 accent: Rgb565::CYAN,
30 disabled: Rgb565::new(8, 16, 8),
31 warning: Rgb565::YELLOW,
32 };
33}
34
35pub trait Widget {
36 fn bounds(&self) -> Rectangle;
37 fn hit_test(&self, point: Point) -> bool {
38 self.bounds().contains(point)
39 }
40}
41
42pub trait InteractiveWidget: Widget {
43 fn handle_touch(&mut self, event: TouchEvent) -> bool;
44}
45
46pub struct Label<'a> {
47 pub text: &'a str,
48 pub top_left: Point,
49 pub color: Rgb565,
50}
51
52impl Label<'_> {
53 pub fn draw<T>(&self, target: &mut T) -> Result<(), T::Error>
54 where
55 T: DrawTarget<Color = Rgb565>,
56 {
57 Text::new(
58 self.text,
59 self.top_left,
60 MonoTextStyle::new(&FONT_6X10, self.color),
61 )
62 .draw(target)
63 .map(|_| ())
64 }
65}
66
67impl Widget for Label<'_> {
68 fn bounds(&self) -> Rectangle {
69 Rectangle::new(self.top_left, Size::new((self.text.len() as u32) * 6, 10))
70 }
71}
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub struct Button<'a> {
75 pub bounds: Rectangle,
76 pub label: &'a str,
77 pub pressed: bool,
78}
79
80impl Button<'_> {
81 pub fn draw<T>(&self, target: &mut T, theme: Theme) -> Result<(), T::Error>
82 where
83 T: DrawTarget<Color = Rgb565>,
84 {
85 let fill = if self.pressed {
86 theme.accent
87 } else {
88 theme.background
89 };
90 self.bounds
91 .into_styled(PrimitiveStyle::with_fill(fill))
92 .draw(target)?;
93 self.bounds
94 .into_styled(PrimitiveStyle::with_stroke(theme.foreground, 1))
95 .draw(target)?;
96 Text::with_alignment(
97 self.label,
98 Point::new(self.bounds.center().x, self.bounds.center().y + 4),
99 MonoTextStyle::new(&FONT_6X10, theme.foreground),
100 Alignment::Center,
101 )
102 .draw(target)
103 .map(|_| ())
104 }
105}
106impl Widget for Button<'_> {
107 fn bounds(&self) -> Rectangle {
108 self.bounds
109 }
110}
111impl InteractiveWidget for Button<'_> {
112 fn handle_touch(&mut self, event: TouchEvent) -> bool {
113 if !self.hit_test(event.point) {
114 return false;
115 }
116 self.pressed = !matches!(event.phase, TouchPhase::Up);
117 true
118 }
119}
120
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub struct Toggle<'a> {
123 pub bounds: Rectangle,
124 pub label: &'a str,
125 pub on: bool,
126}
127
128impl Toggle<'_> {
129 pub fn draw<T>(&self, target: &mut T, theme: Theme) -> Result<(), T::Error>
130 where
131 T: DrawTarget<Color = Rgb565>,
132 {
133 Button {
134 bounds: self.bounds,
135 label: self.label,
136 pressed: self.on,
137 }
138 .draw(target, theme)
139 }
140}
141impl Widget for Toggle<'_> {
142 fn bounds(&self) -> Rectangle {
143 self.bounds
144 }
145}
146impl InteractiveWidget for Toggle<'_> {
147 fn handle_touch(&mut self, event: TouchEvent) -> bool {
148 if self.hit_test(event.point) && matches!(event.phase, TouchPhase::Up) {
149 self.on = !self.on;
150 true
151 } else {
152 false
153 }
154 }
155}
156
157#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub struct Slider {
159 pub bounds: Rectangle,
160 pub value: u8,
161}
162
163impl Slider {
164 pub fn draw<T>(&self, target: &mut T, theme: Theme) -> Result<(), T::Error>
165 where
166 T: DrawTarget<Color = Rgb565>,
167 {
168 ProgressBar {
169 bounds: self.bounds,
170 value: self.value,
171 }
172 .draw(target, theme)
173 }
174
175 fn value_from_x(&self, x: i32) -> u8 {
176 let rel = (x - self.bounds.top_left.x).clamp(0, self.bounds.size.width as i32);
177 ((rel as u32 * 100) / self.bounds.size.width.max(1)) as u8
178 }
179}
180impl Widget for Slider {
181 fn bounds(&self) -> Rectangle {
182 self.bounds
183 }
184}
185impl InteractiveWidget for Slider {
186 fn handle_touch(&mut self, event: TouchEvent) -> bool {
187 if self.hit_test(event.point) {
188 self.value = self.value_from_x(event.point.x);
189 true
190 } else {
191 false
192 }
193 }
194}
195
196#[derive(Clone, Copy, Debug, Eq, PartialEq)]
197pub struct ProgressBar {
198 pub bounds: Rectangle,
199 pub value: u8,
200}
201
202impl ProgressBar {
203 pub fn draw<T>(&self, target: &mut T, theme: Theme) -> Result<(), T::Error>
204 where
205 T: DrawTarget<Color = Rgb565>,
206 {
207 self.bounds
208 .into_styled(PrimitiveStyle::with_stroke(theme.foreground, 1))
209 .draw(target)?;
210 let inner = Rectangle::new(
211 self.bounds.top_left + Point::new(2, 2),
212 Size::new(
213 self.bounds.size.width.saturating_sub(4),
214 self.bounds.size.height.saturating_sub(4),
215 ),
216 );
217 let fill_width = inner
218 .size
219 .width
220 .saturating_mul(u32::from(self.value.min(100)))
221 / 100;
222 Rectangle::new(inner.top_left, Size::new(fill_width, inner.size.height))
223 .into_styled(PrimitiveStyle::with_fill(theme.accent))
224 .draw(target)
225 }
226}
227impl Widget for ProgressBar {
228 fn bounds(&self) -> Rectangle {
229 self.bounds
230 }
231}
232
233#[derive(Clone, Copy, Debug, Eq, PartialEq)]
234pub struct BatteryIndicator {
235 pub bounds: Rectangle,
236 pub status: BatteryStatus,
237}
238
239impl BatteryIndicator {
240 pub fn draw<T>(&self, target: &mut T, theme: Theme) -> Result<(), T::Error>
241 where
242 T: DrawTarget<Color = Rgb565>,
243 {
244 let color = if self.status.low_battery {
245 theme.warning
246 } else {
247 theme.accent
248 };
249 ProgressBar {
250 bounds: self.bounds,
251 value: self.status.percentage,
252 }
253 .draw(
254 target,
255 Theme {
256 accent: color,
257 ..theme
258 },
259 )
260 }
261}
262impl Widget for BatteryIndicator {
263 fn bounds(&self) -> Rectangle {
264 self.bounds
265 }
266}
267
268pub struct StatusBar<'a> {
269 pub bounds: Rectangle,
270 pub text: &'a str,
271}
272
273impl StatusBar<'_> {
274 pub fn draw<T>(&self, target: &mut T, theme: Theme) -> Result<(), T::Error>
275 where
276 T: DrawTarget<Color = Rgb565>,
277 {
278 self.bounds
279 .into_styled(PrimitiveStyle::with_fill(theme.accent))
280 .draw(target)?;
281 Text::new(
282 self.text,
283 self.bounds.top_left + Point::new(4, 12),
284 MonoTextStyle::new(&FONT_6X10, theme.background),
285 )
286 .draw(target)
287 .map(|_| ())
288 }
289}
290impl Widget for StatusBar<'_> {
291 fn bounds(&self) -> Rectangle {
292 self.bounds
293 }
294}
295
296pub struct Menu<'a, const N: usize> {
297 pub bounds: Rectangle,
298 pub items: [&'a str; N],
299 pub selected: usize,
300 pub row_height: u32,
301}
302
303impl<const N: usize> Menu<'_, N> {
304 pub fn draw<T>(&self, target: &mut T, theme: Theme) -> Result<(), T::Error>
305 where
306 T: DrawTarget<Color = Rgb565>,
307 {
308 for (index, item) in self.items.iter().enumerate() {
309 let y = self.bounds.top_left.y + (index as i32 * self.row_height as i32);
310 let row = Rectangle::new(
311 Point::new(self.bounds.top_left.x, y),
312 Size::new(self.bounds.size.width, self.row_height),
313 );
314 if index == self.selected {
315 row.into_styled(PrimitiveStyle::with_fill(theme.accent))
316 .draw(target)?;
317 }
318 Text::new(
319 item,
320 row.top_left + Point::new(4, 12),
321 MonoTextStyle::new(&FONT_6X10, theme.foreground),
322 )
323 .draw(target)?;
324 }
325 Ok(())
326 }
327}
328impl<const N: usize> Widget for Menu<'_, N> {
329 fn bounds(&self) -> Rectangle {
330 self.bounds
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn slider_maps_touch_to_value() {
340 let mut slider = Slider {
341 bounds: Rectangle::new(Point::new(10, 0), Size::new(100, 20)),
342 value: 0,
343 };
344 assert!(slider.handle_touch(TouchEvent {
345 id: 0,
346 point: Point::new(60, 10),
347 phase: TouchPhase::Move
348 }));
349 assert_eq!(slider.value, 50);
350 }
351
352 #[test]
353 fn toggle_flips_on_touch_up() {
354 let mut toggle = Toggle {
355 bounds: Rectangle::new(Point::zero(), Size::new(50, 20)),
356 label: "x",
357 on: false,
358 };
359 assert!(toggle.handle_touch(TouchEvent {
360 id: 0,
361 point: Point::new(2, 2),
362 phase: TouchPhase::Up
363 }));
364 assert!(toggle.on);
365 }
366}