Skip to main content

embedded_gui/widgets/
status_bar.rs

1//! Dynamic Wearable System Status Bar Widget
2//!
3//! Provides a standardized, configurable status bar displaying:
4//! - Centered Clock time (12-hour or 24-hour mode)
5//! - Battery percentage & battery gauge icon with optional charging lightning bolt glyph
6//! - Bluetooth connectivity status indicator
7//! - Do-Not-Disturb / Quiet Time indicator
8//! - Unobstructed area integration for smooth slide-out and layout adaptation
9
10use core::fmt::Debug;
11use embedded_graphics_core::{
12    draw_target::DrawTarget,
13    pixelcolor::{Rgb565, WebColors},
14};
15use heapless::String;
16
17use crate::{
18    geometry::Rect,
19    render::{Compositor, RenderCtx},
20    round::UnobstructedArea,
21    style::Border,
22};
23
24/// Errors produced during status bar operations.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum StatusBarError {
27    /// Render target failure.
28    RenderError,
29}
30
31/// Status bar display modes.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
33pub enum StatusBarMode {
34    /// Standard clock with battery and icons.
35    #[default]
36    ClockAndIcons,
37    /// Clock only centered across the entire bar width.
38    ClockOnly,
39    /// Icons only (battery, BT, DND) without clock.
40    IconsOnly,
41}
42
43/// Battery charging state.
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
45pub enum BatteryState {
46    /// Discharging with percentage [0..100].
47    #[default]
48    Discharging,
49    /// Actively charging with percentage [0..100].
50    Charging,
51    /// Fully charged (100%).
52    Full,
53}
54
55/// Dynamic Wearable System Status Bar.
56#[derive(Clone, Debug)]
57pub struct StatusBarWidget {
58    /// Current display mode.
59    pub mode: StatusBarMode,
60    /// Time text string (e.g. "10:42" or "10:42 AM").
61    pub time_text: String<12>,
62    /// Battery level percentage [0..100].
63    pub battery_percent: u8,
64    /// Battery charging state.
65    pub battery_state: BatteryState,
66    /// Bluetooth link connected flag.
67    pub bluetooth_connected: bool,
68    /// Do-Not-Disturb / Quiet Time active flag.
69    pub dnd_active: bool,
70    /// Background color of the status bar.
71    pub background_color: Rgb565,
72    /// Foreground text and icon color.
73    pub foreground_color: Rgb565,
74    /// Accent color for charging / connected indicators.
75    pub accent_color: Rgb565,
76    /// Optional bottom separator line color.
77    pub separator_color: Option<Rgb565>,
78    /// Standard bar height in pixels (typically 18..24).
79    pub height: u16,
80    /// Visibility toggle.
81    pub is_visible: bool,
82}
83
84impl Default for StatusBarWidget {
85    fn default() -> Self {
86        let mut time_text = String::new();
87        let _ = time_text.push_str("12:00");
88
89        Self {
90            mode: StatusBarMode::ClockAndIcons,
91            time_text,
92            battery_percent: 85,
93            battery_state: BatteryState::Discharging,
94            bluetooth_connected: true,
95            dnd_active: false,
96            background_color: Rgb565::new(2, 4, 8),
97            foreground_color: Rgb565::CSS_WHITE,
98            accent_color: Rgb565::CSS_CYAN,
99            separator_color: Some(Rgb565::new(6, 12, 18)),
100            height: 20,
101            is_visible: true,
102        }
103    }
104}
105
106impl StatusBarWidget {
107    /// Creates a new status bar with the specified initial time string.
108    pub fn new(time_text: &str) -> Self {
109        let mut widget = Self::default();
110        widget.set_time(time_text);
111        widget
112    }
113
114    /// Sets the time text.
115    pub fn set_time(&mut self, time_str: &str) {
116        self.time_text.clear();
117        let _ = self.time_text.push_str(time_str);
118    }
119
120    /// Updates battery metrics.
121    pub fn set_battery(&mut self, percent: u8, state: BatteryState) {
122        self.battery_percent = percent.min(100);
123        self.battery_state = state;
124    }
125
126    /// Applies the status bar bounds to an `UnobstructedArea`.
127    pub fn apply_to_unobstructed_area(&self, area: &mut UnobstructedArea) {
128        if self.is_visible && self.height > 0 {
129            area.set_insets(self.height, 0, 0, 0);
130        }
131    }
132
133    /// Renders the status bar inside the provided bounds.
134    pub fn render<D, C>(
135        &self,
136        ctx: &mut RenderCtx<'_, D, C>,
137        bounds: Rect,
138    ) -> Result<(), StatusBarError>
139    where
140        D: DrawTarget<Color = Rgb565>,
141        C: Compositor<D>,
142    {
143        if !self.is_visible || bounds.is_empty() {
144            return Ok(());
145        }
146
147        // 1. Background fill
148        ctx.fill_rect(bounds, self.background_color)
149            .map_err(|_| StatusBarError::RenderError)?;
150
151        // 2. Optional bottom separator line
152        if let Some(sep_color) = self.separator_color {
153            ctx.fill_rect(
154                Rect::new(bounds.x, bounds.bottom() - 1, bounds.w, 1),
155                sep_color,
156            )
157            .map_err(|_| StatusBarError::RenderError)?;
158        }
159
160        let center_y = bounds.y + (bounds.h as i32 / 2);
161
162        // 3. Render Clock Time (Center)
163        if self.mode == StatusBarMode::ClockAndIcons || self.mode == StatusBarMode::ClockOnly {
164            let char_width = 4;
165            let text_w = self.time_text.len() as i32 * char_width;
166            let time_x = bounds.x + (bounds.w as i32 - text_w) / 2;
167            let time_y = center_y - 3;
168            ctx.draw_text(time_x, time_y, &self.time_text, self.foreground_color)
169                .map_err(|_| StatusBarError::RenderError)?;
170        }
171
172        // 4. Render Left-Side Icons (Bluetooth & DND)
173        if self.mode == StatusBarMode::ClockAndIcons || self.mode == StatusBarMode::IconsOnly {
174            let mut left_cursor = bounds.x + 6;
175
176            if self.bluetooth_connected {
177                // Bluetooth glyph icon (5x7 diamond / antenna)
178                let bt_color = self.accent_color;
179                let bx = left_cursor;
180                let by = center_y - 4;
181
182                let _ = ctx.stroke_rect(Rect::new(bx, by, 6, 8), Border::one(bt_color));
183                let _ = ctx.draw_text(bx + 1, by + 1, "B", bt_color);
184                left_cursor += 10;
185            }
186
187            if self.dnd_active {
188                // Moon crescent / dot glyph for DND
189                let mx = left_cursor;
190                let my = center_y - 3;
191                let _ = ctx.fill_circle(mx + 3, my + 3, 3, Rgb565::CSS_GOLD);
192                let _ = ctx.fill_circle(mx + 4, my + 2, 2, self.background_color);
193            }
194
195            // 5. Render Right-Side Icons (Battery gauge & percent)
196            let right_cursor = bounds.right() - 6;
197
198            // Battery shell: 16x8 rectangle with 2x4 terminal nipple
199            let batt_w = 16u32;
200            let batt_h = 8u32;
201            let batt_x = right_cursor - (batt_w as i32);
202            let batt_y = center_y - 4;
203
204            let shell_rect = Rect::new(batt_x, batt_y, batt_w, batt_h);
205            ctx.stroke_rect(shell_rect, Border::one(self.foreground_color))
206                .map_err(|_| StatusBarError::RenderError)?;
207
208            // Terminal nipple on right edge
209            ctx.fill_rect(
210                Rect::new(batt_x + batt_w as i32, batt_y + 2, 2, 4),
211                self.foreground_color,
212            )
213            .map_err(|_| StatusBarError::RenderError)?;
214
215            // Fill battery level inside
216            let max_fill = batt_w.saturating_sub(4);
217            let fill_w = ((self.battery_percent as u32 * max_fill) / 100).max(1);
218            let fill_color = if self.battery_percent <= 15 {
219                Rgb565::CSS_RED
220            } else if self.battery_state == BatteryState::Charging {
221                Rgb565::CSS_GREEN
222            } else {
223                self.foreground_color
224            };
225
226            ctx.fill_rect(
227                Rect::new(batt_x + 2, batt_y + 2, fill_w, batt_h - 4),
228                fill_color,
229            )
230            .map_err(|_| StatusBarError::RenderError)?;
231
232            // Charging lightning bolt indicator
233            if self.battery_state == BatteryState::Charging {
234                ctx.draw_text(batt_x - 8, batt_y + 1, "~", Rgb565::CSS_YELLOW)
235                    .map_err(|_| StatusBarError::RenderError)?;
236            }
237        }
238
239        Ok(())
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::framebuffer::Framebuffer;
247
248    #[test]
249    fn test_status_bar_render_and_insets() {
250        let screen = Rect::new(0, 0, 240, 240);
251        let mut fb = Framebuffer::<{ 240 * 240 }>::new(240, 240);
252        let mut ctx = RenderCtx::new(&mut fb, screen);
253
254        let mut status_bar = StatusBarWidget::new("09:41");
255        status_bar.set_battery(72, BatteryState::Charging);
256        status_bar.dnd_active = true;
257
258        let bar_bounds = Rect::new(0, 0, 240, 20);
259        let res = status_bar.render(&mut ctx, bar_bounds);
260        assert!(res.is_ok());
261
262        let mut area = UnobstructedArea::new(screen);
263        status_bar.apply_to_unobstructed_area(&mut area);
264        assert_eq!(area.visible_rect(), Rect::new(0, 20, 240, 220));
265    }
266}