Skip to main content

embedded_gui/widgets/
notification.rs

1//! Structured Notification Sheets and Modal Alert Queue.
2//!
3//! Provides `NotificationSheetWidget` (modal alert with actions and countdown progress)
4//! and `NotificationQueue` (deterministic fixed-capacity notification manager).
5
6use embedded_graphics_core::{draw_target::DrawTarget, pixelcolor::Rgb565};
7use heapless::Vec;
8
9use crate::{
10    geometry::Rect,
11    render::RenderCtx,
12    style::{Border, Style},
13    widget::{PropertyKey, PropertyValue, Widget},
14};
15
16/// Error indicating notification actions capacity exceeded.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub struct NotificationError;
19
20/// Priority severity of a notification alert.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum NotificationPriority {
23    Silent,
24    Normal,
25    Important,
26    Critical,
27}
28
29/// Action button choice attached to a notification.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct NotificationAction<'a> {
32    pub label: &'a str,
33    pub action_id: u16,
34}
35
36/// Modal Notification Sheet widget displaying heads-up alerts, actions, and auto-dismiss progress.
37#[derive(Clone, Debug, PartialEq)]
38pub struct NotificationSheetWidget<'a, const MAX_ACTIONS: usize = 3> {
39    pub title: &'a str,
40    pub message: &'a str,
41    pub priority: NotificationPriority,
42    pub actions: Vec<NotificationAction<'a>, MAX_ACTIONS>,
43    pub selected_action: usize,
44    pub auto_dismiss_progress: f32, // 0.0 to 1.0
45    pub background_color: Rgb565,
46    pub text_color: Rgb565,
47    pub accent_color: Rgb565,
48}
49
50impl<'a, const MAX_ACTIONS: usize> NotificationSheetWidget<'a, MAX_ACTIONS> {
51    pub const fn new(title: &'a str, message: &'a str, priority: NotificationPriority) -> Self {
52        let accent = match priority {
53            NotificationPriority::Silent | NotificationPriority::Normal => {
54                Rgb565::new(0, 35, 30) // Cyan/Teal
55            }
56            NotificationPriority::Important => Rgb565::new(31, 35, 0), // Amber
57            NotificationPriority::Critical => Rgb565::new(31, 0, 0),   // Red
58        };
59
60        Self {
61            title,
62            message,
63            priority,
64            actions: Vec::new(),
65            selected_action: 0,
66            auto_dismiss_progress: 1.0,
67            background_color: Rgb565::new(2, 4, 6),
68            text_color: Rgb565::new(31, 63, 31),
69            accent_color: accent,
70        }
71    }
72
73    pub fn add_action(&mut self, label: &'a str, action_id: u16) -> Result<(), NotificationError> {
74        self.actions
75            .push(NotificationAction { label, action_id })
76            .map_err(|_| NotificationError)
77    }
78
79    pub fn render<D, C>(&self, ctx: &mut RenderCtx<'_, D, C>, bounds: Rect) -> Result<(), D::Error>
80    where
81        D: DrawTarget<Color = Rgb565>,
82        C: crate::render::Compositor<D>,
83    {
84        // 1. Draw outer rounded modal card
85        ctx.fill_rounded_rect(bounds, 6, self.background_color)?;
86        ctx.stroke_rounded_rect(bounds, 6, Border::one(self.accent_color))?;
87
88        // 2. Top Header Bar
89        let header_rect = Rect::new(bounds.x, bounds.y, bounds.w, 18);
90        ctx.fill_rounded_rect(header_rect, 4, self.accent_color)?;
91        ctx.draw_text(bounds.x + 8, bounds.y + 4, self.title, Rgb565::new(0, 0, 0))?;
92
93        // 3. Message Body
94        ctx.draw_text(bounds.x + 8, bounds.y + 24, self.message, self.text_color)?;
95
96        // 4. Auto-dismiss progress line at bottom
97        if self.auto_dismiss_progress > 0.0 && self.auto_dismiss_progress <= 1.0 {
98            let progress_w = ((bounds.w as f32) * self.auto_dismiss_progress) as u32;
99            let bar_rect = Rect::new(bounds.x, bounds.bottom() - 3, progress_w, 2);
100            ctx.fill_rect(bar_rect, self.accent_color)?;
101        }
102
103        // 5. Action Buttons (if any)
104        if !self.actions.is_empty() {
105            let action_h = 16;
106            let action_y = bounds.bottom() - 22;
107            let btn_w = (bounds.w.saturating_sub(16) / self.actions.len() as u32).max(20);
108
109            for (i, action) in self.actions.iter().enumerate() {
110                let btn_x = bounds.x + 8 + (i as i32 * (btn_w as i32 + 4));
111                let btn_rect = Rect::new(btn_x, action_y, btn_w, action_h);
112                let is_sel = i == self.selected_action;
113
114                let bg = if is_sel {
115                    self.accent_color
116                } else {
117                    Rgb565::new(6, 12, 18)
118                };
119                let fg = if is_sel {
120                    Rgb565::new(0, 0, 0)
121                } else {
122                    self.text_color
123                };
124
125                ctx.fill_rounded_rect(btn_rect, 2, bg)?;
126                ctx.draw_text(btn_x + 4, action_y + 3, action.label, fg)?;
127            }
128        }
129
130        Ok(())
131    }
132}
133
134impl<'a, const MAX_ACTIONS: usize> Widget for NotificationSheetWidget<'a, MAX_ACTIONS> {
135    fn render_widget_bounds(&self, _bounds: Rect, _style: &Style) {}
136
137    fn get_property(&self, key: PropertyKey) -> Option<PropertyValue<'_>> {
138        match key {
139            PropertyKey::Text => Some(PropertyValue::Str(self.title)),
140            PropertyKey::Progress => Some(PropertyValue::Float(self.auto_dismiss_progress)),
141            PropertyKey::Selected => Some(PropertyValue::Int(self.selected_action as i32)),
142            _ => None,
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::framebuffer::Framebuffer;
151
152    #[test]
153    fn test_notification_sheet_render() {
154        let mut notif = NotificationSheetWidget::<2>::new(
155            "BATTERY WARNING",
156            "Battery level 15%",
157            NotificationPriority::Important,
158        );
159        assert!(notif.add_action("DISMISS", 1).is_ok());
160        assert!(notif.add_action("POWER SAVE", 2).is_ok());
161
162        let mut fb = Framebuffer::<24000>::new(200, 100);
163        let mut ctx = RenderCtx::new(&mut fb, Rect::new(0, 0, 200, 100));
164        assert!(notif.render(&mut ctx, Rect::new(0, 0, 200, 100)).is_ok());
165    }
166}