Skip to main content

core_s3/
ui.rs

1//! Lightweight embedded-graphics widgets for CoreS3 examples and applications.
2
3use 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}