Skip to main content

embedded_gui/widgets/
wearable.rs

1//! Wearable and compact UI widgets and interaction controls.
2//!
3//! Includes `ContentIndicator`, `CrumbsIndicator`, `SelectionWidget`, and `ActionBar`.
4
5use embedded_graphics_core::{draw_target::DrawTarget, pixelcolor::Rgb565};
6
7use crate::{
8    geometry::Rect,
9    render::RenderCtx,
10    style::Style,
11    widget::{PropertyKey, PropertyValue, Widget},
12};
13
14/// Direction for content overflow indicator arrows.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum ContentIndicatorDirection {
17    Up,
18    Down,
19    Left,
20    Right,
21}
22
23/// Content indicator arrow widget showing off-screen scrollable content.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct ContentIndicatorWidget {
26    pub direction: ContentIndicatorDirection,
27    pub visible: bool,
28    pub color: Rgb565,
29    pub pulse_progress: f32,
30}
31
32impl ContentIndicatorWidget {
33    pub const fn new(direction: ContentIndicatorDirection) -> Self {
34        Self {
35            direction,
36            visible: true,
37            color: Rgb565::new(31, 63, 31),
38            pulse_progress: 1.0,
39        }
40    }
41
42    pub fn render<D, C>(&self, ctx: &mut RenderCtx<'_, D, C>, bounds: Rect) -> Result<(), D::Error>
43    where
44        D: DrawTarget<Color = Rgb565>,
45        C: crate::render::Compositor<D>,
46    {
47        if !self.visible || bounds.w == 0 || bounds.h == 0 {
48            return Ok(());
49        }
50
51        let cx = bounds.x + (bounds.w as i32 / 2);
52        let cy = bounds.y + (bounds.h as i32 / 2);
53        let s = (bounds.w.min(bounds.h) as i32 / 3).max(2);
54
55        // Draw chevron arrow
56        match self.direction {
57            ContentIndicatorDirection::Up => {
58                ctx.draw_line(cx - s, cy + s / 2, cx, cy - s / 2, self.color)?;
59                ctx.draw_line(cx, cy - s / 2, cx + s, cy + s / 2, self.color)?;
60            }
61            ContentIndicatorDirection::Down => {
62                ctx.draw_line(cx - s, cy - s / 2, cx, cy + s / 2, self.color)?;
63                ctx.draw_line(cx, cy + s / 2, cx + s, cy - s / 2, self.color)?;
64            }
65            ContentIndicatorDirection::Left => {
66                ctx.draw_line(cx + s / 2, cy - s, cx - s / 2, cy, self.color)?;
67                ctx.draw_line(cx - s / 2, cy, cx + s / 2, cy + s, self.color)?;
68            }
69            ContentIndicatorDirection::Right => {
70                ctx.draw_line(cx - s / 2, cy - s, cx + s / 2, cy, self.color)?;
71                ctx.draw_line(cx + s / 2, cy, cx - s / 2, cy + s, self.color)?;
72            }
73        }
74        Ok(())
75    }
76}
77
78impl Widget for ContentIndicatorWidget {
79    fn render_widget_bounds(&self, _bounds: Rect, _style: &Style) {}
80
81    fn get_property(&self, key: PropertyKey) -> Option<PropertyValue<'_>> {
82        match key {
83            PropertyKey::State => Some(PropertyValue::Bool(self.visible)),
84            PropertyKey::Progress => Some(PropertyValue::Float(self.pulse_progress)),
85            _ => None,
86        }
87    }
88}
89
90/// Crumbs pagination dots widget showing horizontal screen/card deck positions.
91#[derive(Clone, Copy, Debug, PartialEq)]
92pub struct CrumbsIndicatorWidget {
93    pub count: u8,
94    pub active_index: u8,
95    pub dot_radius: u8,
96    pub dot_spacing: u8,
97    pub active_color: Rgb565,
98    pub inactive_color: Rgb565,
99}
100
101impl CrumbsIndicatorWidget {
102    pub const fn new(count: u8, active_index: u8) -> Self {
103        Self {
104            count,
105            active_index,
106            dot_radius: 2,
107            dot_spacing: 6,
108            active_color: Rgb565::new(31, 63, 31),
109            inactive_color: Rgb565::new(10, 20, 10),
110        }
111    }
112
113    pub fn render<D, C>(&self, ctx: &mut RenderCtx<'_, D, C>, bounds: Rect) -> Result<(), D::Error>
114    where
115        D: DrawTarget<Color = Rgb565>,
116        C: crate::render::Compositor<D>,
117    {
118        if self.count == 0 {
119            return Ok(());
120        }
121
122        let total_w =
123            (self.count as i32 - 1) * (self.dot_spacing as i32) + (self.dot_radius as i32 * 2);
124        let start_x = bounds.x + (bounds.w as i32 - total_w) / 2 + self.dot_radius as i32;
125        let cy = bounds.y + (bounds.h as i32 / 2);
126
127        for i in 0..self.count {
128            let cx = start_x + (i as i32 * self.dot_spacing as i32);
129            let is_active = i == self.active_index;
130            let color = if is_active {
131                self.active_color
132            } else {
133                self.inactive_color
134            };
135            let r = if is_active {
136                self.dot_radius as u32 + 1
137            } else {
138                self.dot_radius as u32
139            };
140            ctx.fill_circle(cx, cy, r, color)?;
141        }
142        Ok(())
143    }
144}
145
146impl Widget for CrumbsIndicatorWidget {
147    fn render_widget_bounds(&self, _bounds: Rect, _style: &Style) {}
148
149    fn get_property(&self, key: PropertyKey) -> Option<PropertyValue<'_>> {
150        match key {
151            PropertyKey::Selected => Some(PropertyValue::Int(self.active_index as i32)),
152            _ => None,
153        }
154    }
155}
156
157/// Segmented multi-cell selection control (digits, PIN, time/date).
158#[derive(Clone, Copy, Debug, PartialEq)]
159pub struct SelectionWidget<'a, const MAX_CELLS: usize = 6> {
160    pub cell_texts: [&'a str; MAX_CELLS],
161    pub cell_count: usize,
162    pub selected_cell: usize,
163    pub is_active: bool,
164    pub bump_offset_y: i8,
165    pub slide_offset_x: i8,
166    pub active_bg_color: Rgb565,
167    pub active_text_color: Rgb565,
168    pub inactive_bg_color: Rgb565,
169    pub inactive_text_color: Rgb565,
170}
171
172impl<'a, const MAX_CELLS: usize> SelectionWidget<'a, MAX_CELLS> {
173    pub const fn new(cell_texts: [&'a str; MAX_CELLS], cell_count: usize) -> Self {
174        Self {
175            cell_texts,
176            cell_count,
177            selected_cell: 0,
178            is_active: true,
179            bump_offset_y: 0,
180            slide_offset_x: 0,
181            active_bg_color: Rgb565::new(31, 63, 31),
182            active_text_color: Rgb565::new(0, 0, 0),
183            inactive_bg_color: Rgb565::new(3, 6, 3),
184            inactive_text_color: Rgb565::new(31, 63, 31),
185        }
186    }
187
188    pub fn render<D, C>(&self, ctx: &mut RenderCtx<'_, D, C>, bounds: Rect) -> Result<(), D::Error>
189    where
190        D: DrawTarget<Color = Rgb565>,
191        C: crate::render::Compositor<D>,
192    {
193        if self.cell_count == 0 {
194            return Ok(());
195        }
196
197        let cell_w = (bounds.w as i32 / self.cell_count as i32).max(1);
198        let cell_h = bounds.h as i32;
199
200        for i in 0..self.cell_count {
201            let cx = bounds.x + (i as i32 * cell_w);
202            let is_selected = i == self.selected_cell;
203
204            let cell_rect = Rect::new(cx, bounds.y, cell_w as u32, cell_h as u32);
205            let bg_color = if is_selected && self.is_active {
206                self.active_bg_color
207            } else {
208                self.inactive_bg_color
209            };
210            let text_color = if is_selected && self.is_active {
211                self.active_text_color
212            } else {
213                self.inactive_text_color
214            };
215
216            ctx.fill_rounded_rect(cell_rect, 2, bg_color)?;
217
218            let text = if i < self.cell_texts.len() {
219                self.cell_texts[i]
220            } else {
221                ""
222            };
223            let text_y = bounds.y
224                + (if is_selected {
225                    self.bump_offset_y as i32
226                } else {
227                    0
228                });
229            let text_x = cx + (cell_w / 2) - 4;
230
231            ctx.draw_text(text_x, text_y + (cell_h / 2) - 3, text, text_color)?;
232        }
233        Ok(())
234    }
235}
236
237impl<'a, const MAX_CELLS: usize> Widget for SelectionWidget<'a, MAX_CELLS> {
238    fn render_widget_bounds(&self, _bounds: Rect, _style: &Style) {}
239
240    fn get_property(&self, key: PropertyKey) -> Option<PropertyValue<'_>> {
241        match key {
242            PropertyKey::Selected => Some(PropertyValue::Int(self.selected_cell as i32)),
243            PropertyKey::State => Some(PropertyValue::Bool(self.is_active)),
244            _ => None,
245        }
246    }
247}
248
249/// 3-Slot contextual Action Bar widget mapping hardware buttons (Up, Select, Down).
250#[derive(Clone, Copy, Debug, PartialEq)]
251pub struct ActionBarWidget<'a> {
252    pub up_icon: Option<char>,
253    pub select_icon: Option<char>,
254    pub down_icon: Option<char>,
255    pub up_label: Option<&'a str>,
256    pub select_label: Option<&'a str>,
257    pub down_label: Option<&'a str>,
258    pub background_color: Rgb565,
259    pub icon_color: Rgb565,
260}
261
262impl<'a> Default for ActionBarWidget<'a> {
263    fn default() -> Self {
264        Self::new()
265    }
266}
267
268impl<'a> ActionBarWidget<'a> {
269    pub const fn new() -> Self {
270        Self {
271            up_icon: None,
272            select_icon: None,
273            down_icon: None,
274            up_label: None,
275            select_label: None,
276            down_label: None,
277            background_color: Rgb565::new(0, 0, 0),
278            icon_color: Rgb565::new(31, 63, 31),
279        }
280    }
281
282    pub fn render<D, C>(&self, ctx: &mut RenderCtx<'_, D, C>, bounds: Rect) -> Result<(), D::Error>
283    where
284        D: DrawTarget<Color = Rgb565>,
285        C: crate::render::Compositor<D>,
286    {
287        ctx.fill_rounded_rect(bounds, 2, self.background_color)?;
288
289        let slot_h = bounds.h as i32 / 3;
290        let icon_x = bounds.x + (bounds.w as i32 / 2) - 3;
291
292        // Slot 0: Up
293        if let Some(lbl) = self.up_label {
294            ctx.draw_text(icon_x, bounds.y + (slot_h / 2) - 3, lbl, self.icon_color)?;
295        }
296
297        // Slot 1: Select
298        if let Some(lbl) = self.select_label {
299            ctx.draw_text(
300                icon_x,
301                bounds.y + slot_h + (slot_h / 2) - 3,
302                lbl,
303                self.icon_color,
304            )?;
305        }
306
307        // Slot 2: Down
308        if let Some(lbl) = self.down_label {
309            ctx.draw_text(
310                icon_x,
311                bounds.y + (slot_h * 2) + (slot_h / 2) - 3,
312                lbl,
313                self.icon_color,
314            )?;
315        }
316
317        Ok(())
318    }
319}
320
321impl<'a> Widget for ActionBarWidget<'a> {
322    fn render_widget_bounds(&self, _bounds: Rect, _style: &Style) {}
323
324    fn get_property(&self, key: PropertyKey) -> Option<PropertyValue<'_>> {
325        match key {
326            PropertyKey::Text => self.select_label.map(PropertyValue::Str),
327            _ => None,
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::framebuffer::Framebuffer;
336
337    #[test]
338    fn test_content_indicator_render() {
339        let indicator = ContentIndicatorWidget::new(ContentIndicatorDirection::Down);
340        let mut fb = Framebuffer::<400>::new(20, 20);
341        let mut ctx = RenderCtx::new(&mut fb, Rect::new(0, 0, 20, 20));
342        assert!(indicator.render(&mut ctx, Rect::new(0, 0, 20, 20)).is_ok());
343    }
344
345    #[test]
346    fn test_crumbs_indicator_render() {
347        let crumbs = CrumbsIndicatorWidget::new(4, 1);
348        let mut fb = Framebuffer::<400>::new(20, 20);
349        let mut ctx = RenderCtx::new(&mut fb, Rect::new(0, 0, 20, 20));
350        assert!(crumbs.render(&mut ctx, Rect::new(0, 0, 20, 20)).is_ok());
351    }
352
353    #[test]
354    fn test_selection_widget_render() {
355        let sel = SelectionWidget::<3>::new(["12", "34", "56"], 3);
356        let mut fb = Framebuffer::<1200>::new(60, 20);
357        let mut ctx = RenderCtx::new(&mut fb, Rect::new(0, 0, 60, 20));
358        assert!(sel.render(&mut ctx, Rect::new(0, 0, 60, 20)).is_ok());
359    }
360}