cadmus_core/view/
notification.rs

1//! Notification view component for displaying temporary or persistent messages.
2//!
3//! # Examples
4//!
5//! ## Auto-dismissing notification
6//!
7//! ```
8//! use cadmus_core::view::notification::Notification;
9//! use cadmus_core::view::{Event, NotificationEvent};
10//!
11//! let (tx, rx) = std::sync::mpsc::channel();
12//! // Send via event for standard notifications
13//! tx.send(Event::Notification(NotificationEvent::Show("File saved successfully.".to_string()))).ok();
14//! ```
15//!
16//! ## Pinned notification with progress bar
17//!
18//! ```
19//! use cadmus_core::view::{Event, NotificationEvent, ViewId, ID_FEEDER};
20//! let (tx, rx) = std::sync::mpsc::channel();
21//! // Create a pinned notification with a custom ID
22//! let download_id = ViewId::MessageNotif(ID_FEEDER.next());
23//! tx.send(Event::Notification(NotificationEvent::ShowPinned(download_id, "Download: 0%".to_string()))).ok();
24//!
25//! // Update the notification text as progress changes
26//! tx.send(Event::Notification(NotificationEvent::UpdateText(
27//!     download_id,
28//!     "Download: 50%".to_string()
29//! ))).ok();
30//!
31//! // Update the progress bar (0-100)
32//! tx.send(Event::Notification(NotificationEvent::UpdateProgress(download_id, 50))).ok();
33//!
34//! // Dismiss when done
35//! tx.send(Event::Close(download_id)).ok();
36//! ```
37
38use super::{Bus, Event, Hub, Id, RenderData, RenderQueue, View, ViewId, ID_FEEDER};
39use super::{BORDER_RADIUS_MEDIUM, SMALL_BAR_HEIGHT, THICKNESS_LARGE};
40use crate::color::{BLACK, TEXT_NORMAL, WHITE};
41use crate::context::Context;
42use crate::device::CURRENT_DEVICE;
43use crate::font::{font_from_style, Fonts, NORMAL_STYLE};
44use crate::framebuffer::{Framebuffer, UpdateMode};
45use crate::geom::{BorderSpec, CornerSpec, Rectangle};
46use crate::gesture::GestureEvent;
47use crate::input::DeviceEvent;
48use crate::unit::scale_by_dpi;
49use std::thread;
50use std::time::Duration;
51
52const NOTIFICATION_CLOSE_DELAY: Duration = Duration::from_secs(4);
53
54/// Events related to notifications.
55#[derive(Debug, Clone)]
56pub enum NotificationEvent {
57    /// Show a standard auto-dismissing notification.
58    Show(String),
59    /// Show a pinned notification that persists until dismissed.
60    ShowPinned(ViewId, String),
61    /// Update the text of a pinned notification.
62    UpdateText(ViewId, String),
63    /// Update the progress of a pinned notification (0-100).
64    UpdateProgress(ViewId, u8),
65}
66
67/// A notification view that displays temporary or persistent messages.
68///
69/// Notifications can either auto-dismiss after 4 seconds (standard notifications)
70/// or persist until manually dismissed (pinned notifications). Pinned notifications
71/// can also display an optional progress bar for long-running operations.
72///
73/// Notifications are positioned in a 3x2 grid at the top of the screen, alternating
74/// between left and right sides to avoid overlapping.
75pub struct Notification {
76    id: Id,
77    rect: Rectangle,
78    children: Vec<Box<dyn View>>,
79    text: String,
80    max_width: i32,
81    index: u8,
82    view_id: ViewId,
83    progress: Option<u8>,
84}
85
86impl Notification {
87    /// Creates a new notification.
88    ///
89    /// # Arguments
90    ///
91    /// * `view_id` - Optional ViewId for the notification. If None, generates a new one.
92    /// * `text` - The message to display
93    /// * `pinned` - If `false`, notification auto-dismisses after 4 seconds. If `true`, persists until dismissed.
94    /// * `hub` - Event hub for sending close events
95    /// * `rq` - Render queue for scheduling display updates
96    /// * `context` - Application context containing fonts, display dimensions, and notification index
97    ///
98    /// # Returns
99    ///
100    /// A new `Notification` instance with `progress` initialized to `None`.
101    pub fn new(
102        view_id: Option<ViewId>,
103        text: String,
104        pinned: bool,
105        hub: &Hub,
106        rq: &mut RenderQueue,
107        context: &mut Context,
108    ) -> Notification {
109        let id = ID_FEEDER.next();
110        let view_id = view_id.unwrap_or(ViewId::MessageNotif(id));
111        let index = context.notification_index;
112
113        if !pinned {
114            let hub2 = hub.clone();
115            thread::spawn(move || {
116                thread::sleep(NOTIFICATION_CLOSE_DELAY);
117                hub2.send(Event::Close(view_id)).ok();
118            });
119        }
120
121        let dpi = CURRENT_DEVICE.dpi;
122        let (width, _) = context.display.dims;
123        let small_height = scale_by_dpi(SMALL_BAR_HEIGHT, dpi) as i32;
124
125        let font = font_from_style(&mut context.fonts, &NORMAL_STYLE, dpi);
126        let x_height = font.x_heights.0 as i32;
127        let padding = font.em() as i32;
128
129        let max_message_width = width as i32 - 5 * padding;
130        let plan = font.plan(&text, Some(max_message_width), None);
131
132        let dialog_width = plan.width + 3 * padding;
133        let dialog_height = 7 * x_height;
134
135        let side = (index / 3) % 2;
136        let dx = if side == 0 {
137            width as i32 - dialog_width - padding
138        } else {
139            padding
140        };
141        let dy = small_height + padding + (index % 3) as i32 * (dialog_height + padding);
142
143        let rect = rect![dx, dy, dx + dialog_width, dy + dialog_height];
144
145        rq.add(RenderData::new(id, rect, UpdateMode::Gui));
146        context.notification_index = index.wrapping_add(1);
147
148        Notification {
149            id,
150            rect,
151            children: Vec::new(),
152            text,
153            max_width: max_message_width,
154            index,
155            view_id,
156            progress: None,
157        }
158    }
159
160    /// Updates the text content of the notification and schedules a re-render.
161    ///
162    /// # Arguments
163    ///
164    /// * `text` - The new message text to display
165    /// * `rq` - Render queue for scheduling the display update
166    ///
167    /// # Note
168    ///
169    /// This method does not recalculate the notification's position or size.
170    /// The text will be re-wrapped within the existing notification bounds.
171    pub fn update_text(&mut self, text: String, rq: &mut RenderQueue) {
172        self.text = text;
173        rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
174    }
175
176    /// Updates the progress percentage of the notification and schedules a re-render.
177    ///
178    /// # Arguments
179    ///
180    /// * `progress` - Progress percentage (0-100). Values outside this range will be clamped during rendering.
181    /// * `rq` - Render queue for scheduling the display update
182    ///
183    /// # Note
184    ///
185    /// The progress bar is displayed as a thin horizontal line below the text.
186    /// Setting progress to `None` via direct field access will hide the progress bar.
187    pub fn update_progress(&mut self, progress: u8, rq: &mut RenderQueue) {
188        self.progress = Some(progress);
189        rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
190    }
191}
192
193impl View for Notification {
194    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _hub, _bus, _rq, _context), fields(event = ?evt), ret(level=tracing::Level::TRACE)))]
195    fn handle_event(
196        &mut self,
197        evt: &Event,
198        _hub: &Hub,
199        _bus: &mut Bus,
200        _rq: &mut RenderQueue,
201        _context: &mut Context,
202    ) -> bool {
203        match *evt {
204            Event::Gesture(GestureEvent::Tap(center)) if self.rect.includes(center) => true,
205            Event::Gesture(GestureEvent::Swipe { start, .. }) if self.rect.includes(start) => true,
206            Event::Device(DeviceEvent::Finger { position, .. }) if self.rect.includes(position) => {
207                true
208            }
209            _ => false,
210        }
211    }
212
213    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, fb, fonts, _rect), fields(rect = ?_rect)))]
214    fn render(&self, fb: &mut dyn Framebuffer, _rect: Rectangle, fonts: &mut Fonts) {
215        let dpi = CURRENT_DEVICE.dpi;
216
217        let border_radius = scale_by_dpi(BORDER_RADIUS_MEDIUM, dpi) as i32;
218        let border_thickness = scale_by_dpi(THICKNESS_LARGE, dpi) as u16;
219
220        fb.draw_rounded_rectangle_with_border(
221            &self.rect,
222            &CornerSpec::Uniform(border_radius),
223            &BorderSpec {
224                thickness: border_thickness,
225                color: BLACK,
226            },
227            &WHITE,
228        );
229
230        let font = font_from_style(fonts, &NORMAL_STYLE, dpi);
231        let plan = font.plan(&self.text, Some(self.max_width), None);
232        let x_height = font.x_heights.0 as i32;
233
234        let dx = (self.rect.width() as i32 - plan.width) as i32 / 2;
235        let dy = (self.rect.height() as i32 - x_height) / 2;
236        let pt = pt!(self.rect.min.x + dx, self.rect.max.y - dy);
237
238        font.render(fb, TEXT_NORMAL[1], &plan, pt);
239
240        if let Some(progress) = self.progress {
241            let progress_clamped = progress.min(100);
242            let padding = font.em() as i32;
243            let progress_bar_height = scale_by_dpi(2.0, dpi) as i32;
244            let progress_bar_width = self.rect.width() as i32 - 2 * padding;
245            let progress_bar_y = self.rect.max.y - padding - progress_bar_height;
246
247            let progress_bg_rect = rect![
248                self.rect.min.x + padding,
249                progress_bar_y,
250                self.rect.min.x + padding + progress_bar_width,
251                progress_bar_y + progress_bar_height
252            ];
253            fb.draw_rectangle(&progress_bg_rect, TEXT_NORMAL[0]);
254
255            let filled_width = (progress_bar_width * progress_clamped as i32) / 100;
256            if filled_width > 0 {
257                let progress_fill_rect = rect![
258                    self.rect.min.x + padding,
259                    progress_bar_y,
260                    self.rect.min.x + padding + filled_width,
261                    progress_bar_y + progress_bar_height
262                ];
263                fb.draw_rectangle(&progress_fill_rect, BLACK);
264            }
265        }
266    }
267
268    fn resize(
269        &mut self,
270        _rect: Rectangle,
271        _hub: &Hub,
272        _rq: &mut RenderQueue,
273        context: &mut Context,
274    ) {
275        let dpi = CURRENT_DEVICE.dpi;
276        let (width, height) = context.display.dims;
277        let small_height = scale_by_dpi(SMALL_BAR_HEIGHT, dpi) as i32;
278        let side = (self.index / 3) % 2;
279        let padding = if side == 0 {
280            height as i32 - self.rect.max.x
281        } else {
282            self.rect.min.x
283        };
284        let dialog_width = self.rect.width() as i32;
285        let dialog_height = self.rect.height() as i32;
286        let dx = if side == 0 {
287            width as i32 - dialog_width - padding
288        } else {
289            padding
290        };
291        let dy = small_height + padding + (self.index % 3) as i32 * (dialog_height + padding);
292        let rect = rect![dx, dy, dx + dialog_width, dy + dialog_height];
293        self.rect = rect;
294    }
295
296    fn rect(&self) -> &Rectangle {
297        &self.rect
298    }
299
300    fn rect_mut(&mut self) -> &mut Rectangle {
301        &mut self.rect
302    }
303
304    fn children(&self) -> &Vec<Box<dyn View>> {
305        &self.children
306    }
307
308    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
309        &mut self.children
310    }
311
312    fn id(&self) -> Id {
313        self.id
314    }
315
316    fn view_id(&self) -> Option<ViewId> {
317        Some(self.view_id)
318    }
319}