Skip to main content

reovim_tui_mod_notification/
lib.rs

1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
2#![cfg_attr(coverage_nightly, allow(unused_features))]
3//! Notification toast chrome module.
4//!
5//! Displays stacked toast notifications in the top-right corner.
6//! Each toast auto-dismisses after a configurable timeout (default 4s).
7//! Progress notifications remain until dismissed by the server.
8//! Native `ClientModule` implementation (no `TuiExtension` bridge).
9
10use std::{
11    sync::Arc,
12    time::{Duration, Instant},
13};
14
15use reovim_client_driver::{
16    ChromePosition, ClientModule, ClientModuleError, ModuleContext, PlatformCapabilities,
17    ProbeResult, Rect, RenderSurface, Style, Version, types::Color,
18};
19
20// Re-export the Clock trait from arch for constructor usage.
21pub use reovim_client_driver::reovim_arch::clock::{Clock, SystemClock};
22
23/// Default auto-dismiss timeout for non-progress toasts.
24const DEFAULT_TIMEOUT: Duration = Duration::from_secs(4);
25
26/// Maximum number of visible toasts.
27const MAX_VISIBLE: usize = 5;
28
29/// Toast width (fixed, right-aligned).
30const TOAST_WIDTH: u16 = 40;
31
32const KIND: &str = "notification";
33
34/// Notification level (client-side mirror).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36enum Level {
37    Info,
38    Success,
39    Warning,
40    Error,
41}
42
43/// Client-side toast entry with display lifecycle.
44#[derive(Debug)]
45struct Toast {
46    /// Server-assigned unique ID.
47    id: u64,
48    /// Severity level.
49    level: Level,
50    /// Short title.
51    title: String,
52    /// Optional body text.
53    body: String,
54    /// Optional progress (percent, detail).
55    progress: Option<(u8, String)>,
56    /// When this toast was first displayed.
57    displayed_at: Instant,
58    /// Whether this is a progress toast (no auto-dismiss).
59    is_progress: bool,
60    /// Optional producer name for grouped display (#691).
61    source: Option<String>,
62}
63
64/// Notification toast chrome module.
65pub struct NotificationModule {
66    /// Active toasts being displayed.
67    toasts: Vec<Toast>,
68    /// Auto-dismiss timeout.
69    timeout: Duration,
70    /// Clock source for deterministic testing.
71    clock: Arc<dyn Clock>,
72}
73
74impl NotificationModule {
75    /// Create a new notification module with default 4s timeout.
76    #[must_use]
77    pub fn new() -> Self {
78        Self {
79            toasts: Vec::new(),
80            timeout: DEFAULT_TIMEOUT,
81            clock: Arc::new(SystemClock),
82        }
83    }
84
85    /// Create with custom timeout and clock (for testing).
86    #[must_use]
87    pub fn with_clock(clock: Arc<dyn Clock>, timeout: Duration) -> Self {
88        Self {
89            toasts: Vec::new(),
90            timeout,
91            clock,
92        }
93    }
94
95    /// Parse level string from JSON.
96    fn parse_level(s: &str) -> Level {
97        match s {
98            "success" => Level::Success,
99            "warning" => Level::Warning,
100            "error" => Level::Error,
101            _ => Level::Info,
102        }
103    }
104
105    /// Get border color for a notification level.
106    const fn level_color(level: Level) -> Color {
107        match level {
108            Level::Info => Color::Cyan,
109            Level::Success => Color::Green,
110            Level::Warning => Color::Yellow,
111            Level::Error => Color::Red,
112        }
113    }
114
115    /// Get Nerd Font icon for a notification level.
116    const fn level_icon(level: Level) -> &'static str {
117        match level {
118            Level::Info => "\u{f02fd}",    // 󰋽 nf-md-information
119            Level::Success => "\u{f012c}", // 󰄬 nf-md-check
120            Level::Warning => "\u{f002a}", // 󰀪 nf-md-alert
121            Level::Error => "\u{f0159}",   // 󰅙 nf-md-close_circle
122        }
123    }
124}
125
126impl Default for NotificationModule {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132impl ClientModule for NotificationModule {
133    fn id(&self) -> &'static str {
134        KIND
135    }
136
137    fn kind(&self) -> &'static str {
138        KIND
139    }
140
141    fn name(&self) -> &'static str {
142        "Notification"
143    }
144
145    fn version(&self) -> Version {
146        Version::new(0, 1, 0)
147    }
148
149    fn init(&mut self, _ctx: &ModuleContext) -> ProbeResult {
150        ProbeResult::Success
151    }
152
153    fn exit(&mut self) -> Result<(), ClientModuleError> {
154        Ok(())
155    }
156
157    fn has_chrome(&self) -> bool {
158        true
159    }
160
161    fn chrome_position(&self) -> ChromePosition {
162        ChromePosition::Overlay
163    }
164
165    fn chrome_priority(&self) -> u16 {
166        40
167    }
168
169    fn on_notification(&mut self, data: &str) {
170        let Ok(json) = serde_json::from_str::<serde_json::Value>(data) else {
171            return;
172        };
173
174        let Some(entries) = json.get("entries").and_then(serde_json::Value::as_array) else {
175            return;
176        };
177
178        // Collect server-side IDs for reconciliation
179        let mut server_ids: Vec<u64> = Vec::new();
180
181        for entry in entries {
182            let Some(id) = entry.get("id").and_then(serde_json::Value::as_u64) else {
183                continue;
184            };
185            server_ids.push(id);
186
187            // Update progress on existing toast if present
188            if let Some(toast) = self.toasts.iter_mut().find(|t| t.id == id) {
189                if let Some(progress) = entry.get("progress") {
190                    let percent = u8::try_from(
191                        progress
192                            .get("percent")
193                            .and_then(serde_json::Value::as_u64)
194                            .unwrap_or(0),
195                    )
196                    .unwrap_or(100);
197                    let detail = progress
198                        .get("detail")
199                        .and_then(serde_json::Value::as_str)
200                        .unwrap_or("")
201                        .to_string();
202                    toast.progress = Some((percent, detail));
203                }
204                continue;
205            }
206
207            // New toast - parse and add
208            let level_str = entry
209                .get("level")
210                .and_then(serde_json::Value::as_str)
211                .unwrap_or("info");
212            let title = entry
213                .get("title")
214                .and_then(serde_json::Value::as_str)
215                .unwrap_or("")
216                .to_string();
217            let body = entry
218                .get("body")
219                .and_then(serde_json::Value::as_str)
220                .unwrap_or("")
221                .to_string();
222
223            let progress = entry.get("progress").map(|p| {
224                let pct = u8::try_from(
225                    p.get("percent")
226                        .and_then(serde_json::Value::as_u64)
227                        .unwrap_or(0),
228                )
229                .unwrap_or(100);
230                let detail = p
231                    .get("detail")
232                    .and_then(serde_json::Value::as_str)
233                    .unwrap_or("")
234                    .to_string();
235                (pct, detail)
236            });
237
238            let is_progress = progress.is_some();
239
240            let source = entry
241                .get("source")
242                .and_then(serde_json::Value::as_str)
243                .map(String::from);
244
245            self.toasts.push(Toast {
246                id,
247                level: Self::parse_level(level_str),
248                title,
249                body,
250                progress,
251                displayed_at: self.clock.now(),
252                is_progress,
253                source,
254            });
255        }
256
257        // Remove toasts whose IDs are no longer in server state
258        self.toasts.retain(|t| server_ids.contains(&t.id));
259    }
260
261    fn tick(&mut self) -> bool {
262        let now = self.clock.now();
263        let before = self.toasts.len();
264
265        // Auto-dismiss non-progress toasts after timeout
266        self.toasts.retain(|toast| {
267            if toast.is_progress {
268                return true;
269            }
270            now.duration_since(toast.displayed_at) < self.timeout
271        });
272
273        self.toasts.len() != before
274    }
275
276    #[allow(clippy::cast_possible_truncation)]
277    fn chrome_render(
278        &self,
279        surface: &mut dyn RenderSurface,
280        bounds: Rect,
281        _caps: &dyn PlatformCapabilities,
282    ) {
283        if self.toasts.is_empty() {
284            return;
285        }
286
287        let width = bounds.width;
288        let toast_w = TOAST_WIDTH.min(width.saturating_sub(2));
289        let toast_x = width.saturating_sub(toast_w + 1);
290        let mut y: u16 = 1;
291
292        // Collect visible toasts (newest first), capped at MAX_VISIBLE
293        let visible: Vec<&Toast> = self.toasts.iter().rev().take(MAX_VISIBLE).collect();
294
295        // Partition into groups: grouped by source, or standalone (None source)
296        let mut groups: Vec<(Option<&str>, Vec<&Toast>)> = Vec::new();
297        for toast in &visible {
298            if let Some(ref source) = toast.source {
299                if let Some(group) = groups.iter_mut().find(|(s, _)| *s == Some(source.as_str())) {
300                    group.1.push(toast);
301                } else {
302                    groups.push((Some(source.as_str()), vec![toast]));
303                }
304            } else {
305                // Standalone toast (no source) — each gets its own "group"
306                groups.push((None, vec![toast]));
307            }
308        }
309
310        for (source, toasts) in &groups {
311            if source.is_some() {
312                y = render_grouped_box(self, surface, toast_x, y, toast_w, *source, toasts);
313            } else {
314                for toast in toasts {
315                    y = render_standalone_toast(self, surface, toast_x, y, toast_w, toast);
316                }
317            }
318        }
319    }
320}
321
322/// Render a grouped notification box for toasts sharing the same source (#691).
323///
324/// Layout:
325/// ```text
326/// ┌─ rust-analyzer ──────────────────┐
327/// │  ✓ Language server ready         │
328/// │  42% ████████░░░░ Indexing       │
329/// └──────────────────────────────────┘
330/// ```
331#[cfg_attr(coverage_nightly, coverage(off))]
332#[allow(clippy::cast_possible_truncation)]
333fn render_grouped_box(
334    module: &NotificationModule,
335    surface: &mut dyn RenderSurface,
336    x: u16,
337    y: u16,
338    width: u16,
339    source: Option<&str>,
340    toasts: &[&Toast],
341) -> u16 {
342    // Calculate total content height: one line per toast entry
343    let content_lines: u16 = toasts
344        .iter()
345        .map(|t| {
346            let has_body = !t.body.is_empty();
347            let has_progress = t.progress.is_some();
348            1 + u16::from(has_body) + u16::from(has_progress)
349        })
350        .sum();
351    let box_height = content_lines + 2; // +2 for top/bottom borders
352
353    // Use highest-priority level color for the group border
354    let group_level = toasts
355        .iter()
356        .map(|t| t.level)
357        .max_by_key(|l| match l {
358            Level::Error => 3,
359            Level::Warning => 2,
360            Level::Success => 1,
361            Level::Info => 0,
362        })
363        .unwrap_or(Level::Info);
364    let border_color = NotificationModule::level_color(group_level);
365    let border_style = Style::new().fg(border_color);
366
367    // Draw border with source name in top border
368    reovim_client_driver::chrome_utils::render_box_border(
369        surface,
370        x,
371        y,
372        width,
373        box_height,
374        &border_style,
375    );
376
377    // Write source name into top border
378    if let Some(name) = source {
379        let label = format!(" {name} ");
380        let max_label = (width.saturating_sub(4)) as usize;
381        let display = reovim_client_driver::ui::truncate_end(&label, max_label);
382        let label_style = Style::new().fg(border_color).bold();
383        surface.write_styled(x + 2, y, &display, label_style);
384    }
385
386    let content_x = x + 2;
387    let content_width = width.saturating_sub(4);
388
389    // Clear interior
390    for row in 1..box_height.saturating_sub(1) {
391        surface.fill(
392            Rect {
393                x: content_x,
394                y: y + row,
395                width: content_width,
396                height: 1,
397            },
398            ' ',
399            Style::new(),
400        );
401    }
402
403    // Render each toast entry inside the box
404    let mut current_row = y + 1;
405    for toast in toasts {
406        render_toast_content(module, surface, content_x, current_row, content_width, toast);
407        current_row += 1;
408        if !toast.body.is_empty() {
409            current_row += 1;
410        }
411        if toast.progress.is_some() {
412            current_row += 1;
413        }
414    }
415
416    y + box_height + 1 // 1-row gap
417}
418
419/// Render a standalone toast (no source grouping — backward compat).
420#[allow(clippy::cast_possible_truncation)]
421fn render_standalone_toast(
422    module: &NotificationModule,
423    surface: &mut dyn RenderSurface,
424    x: u16,
425    y: u16,
426    width: u16,
427    toast: &Toast,
428) -> u16 {
429    let has_body = !toast.body.is_empty();
430    let has_progress = toast.progress.is_some();
431    let content_lines = 1 + u16::from(has_body) + u16::from(has_progress);
432    let toast_height = content_lines + 2;
433
434    let border_color = NotificationModule::level_color(toast.level);
435    let border_style = Style::new().fg(border_color);
436
437    reovim_client_driver::chrome_utils::render_box_border(
438        surface,
439        x,
440        y,
441        width,
442        toast_height,
443        &border_style,
444    );
445
446    let content_x = x + 2;
447    let content_width = width.saturating_sub(4);
448
449    for row in 1..toast_height.saturating_sub(1) {
450        surface.fill(
451            Rect {
452                x: content_x,
453                y: y + row,
454                width: content_width,
455                height: 1,
456            },
457            ' ',
458            Style::new(),
459        );
460    }
461
462    render_toast_content(module, surface, content_x, y + 1, content_width, toast);
463
464    y + toast_height + 1
465}
466
467/// Render the content of a single toast entry (icon + title + optional body + progress).
468#[allow(clippy::cast_possible_truncation)]
469fn render_toast_content(
470    _module: &NotificationModule,
471    surface: &mut dyn RenderSurface,
472    x: u16,
473    y: u16,
474    width: u16,
475    toast: &Toast,
476) {
477    let border_color = NotificationModule::level_color(toast.level);
478    let icon = NotificationModule::level_icon(toast.level);
479    let icon_style = Style::new().fg(border_color);
480    let icon_written = surface.write_styled(x, y, icon, icon_style);
481    surface.write_styled(x + icon_written, y, " ", Style::new());
482
483    let title_style = Style::new().fg(Color::White);
484    let max_title_len = width.saturating_sub(2) as usize;
485    let title_display = reovim_client_driver::ui::truncate_end(&toast.title, max_title_len);
486    surface.write_styled(x + 2, y, &title_display, title_style);
487
488    let mut current_row = y + 1;
489
490    if !toast.body.is_empty() {
491        let body_style = Style::new().fg(Color::DarkGrey);
492        let body_display = reovim_client_driver::ui::truncate_end(&toast.body, width as usize);
493        surface.write_styled(x, current_row, &body_display, body_style);
494        current_row += 1;
495    }
496
497    if let Some((percent, ref detail)) = toast.progress {
498        render_progress_bar(surface, x, current_row, width, percent, detail);
499    }
500}
501
502/// Render a horizontal progress bar.
503///
504/// Layout: `NNN% [bar...] detail`
505/// If detail is non-empty, the bar shrinks to make room.
506#[allow(clippy::cast_possible_truncation)]
507#[cfg_attr(coverage_nightly, coverage(off))]
508fn render_progress_bar(
509    surface: &mut dyn RenderSurface,
510    x: u16,
511    y: u16,
512    width: u16,
513    percent: u8,
514    detail: &str,
515) {
516    // "100% " = 5 chars for the percentage label
517    let label_width: u16 = 5;
518    let available = width.saturating_sub(label_width);
519    if available == 0 {
520        return;
521    }
522
523    // Reserve space for " detail" if detail is non-empty
524    let detail_cols = if detail.is_empty() {
525        0u16
526    } else {
527        let needed = 1 + detail.len() as u16;
528        needed.min(available / 2)
529    };
530    let bar_width = u32::from(available.saturating_sub(detail_cols));
531    debug_assert!(bar_width > 0);
532
533    let filled = (u32::from(percent.min(100)) * bar_width / 100) as u16;
534
535    // Percentage label
536    let pct_str = format!("{percent:>3}%");
537    let pct_style = Style::new().fg(Color::White);
538    surface.write_styled(x, y, &pct_str, pct_style);
539    surface.write_styled(x + 4, y, " ", Style::new());
540
541    // Bar
542    let bar_x = x + label_width;
543    let filled_style = Style::new().fg(Color::Green);
544    let empty_style = Style::new().fg(Color::DarkGrey);
545
546    for col in 0..bar_width as u16 {
547        if col < filled {
548            surface.write_styled(bar_x + col, y, "\u{2588}", filled_style.clone());
549        } else {
550            surface.write_styled(bar_x + col, y, "\u{2591}", empty_style.clone());
551        }
552    }
553
554    // Detail text after bar
555    if !detail.is_empty() && detail_cols > 1 {
556        let detail_x = bar_x + bar_width as u16 + 1;
557        let max_detail = (detail_cols - 1) as usize;
558        let detail_display = reovim_client_driver::ui::truncate_end(detail, max_detail);
559        let detail_style = Style::new().fg(Color::DarkGrey);
560        surface.write_styled(detail_x, y, &detail_display, detail_style);
561    }
562}
563
564#[cfg(test)]
565#[path = "lib_tests.rs"]
566mod tests;