1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
2#![cfg_attr(coverage_nightly, allow(unused_features))]
3use 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
20pub use reovim_client_driver::reovim_arch::clock::{Clock, SystemClock};
22
23const DEFAULT_TIMEOUT: Duration = Duration::from_secs(4);
25
26const MAX_VISIBLE: usize = 5;
28
29const TOAST_WIDTH: u16 = 40;
31
32const KIND: &str = "notification";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36enum Level {
37 Info,
38 Success,
39 Warning,
40 Error,
41}
42
43#[derive(Debug)]
45struct Toast {
46 id: u64,
48 level: Level,
50 title: String,
52 body: String,
54 progress: Option<(u8, String)>,
56 displayed_at: Instant,
58 is_progress: bool,
60 source: Option<String>,
62}
63
64pub struct NotificationModule {
66 toasts: Vec<Toast>,
68 timeout: Duration,
70 clock: Arc<dyn Clock>,
72}
73
74impl NotificationModule {
75 #[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 #[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 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 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 const fn level_icon(level: Level) -> &'static str {
117 match level {
118 Level::Info => "\u{f02fd}", Level::Success => "\u{f012c}", Level::Warning => "\u{f002a}", Level::Error => "\u{f0159}", }
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 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 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 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 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 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 let visible: Vec<&Toast> = self.toasts.iter().rev().take(MAX_VISIBLE).collect();
294
295 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 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#[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 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; 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 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 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 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 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 }
418
419#[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#[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#[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 let label_width: u16 = 5;
518 let available = width.saturating_sub(label_width);
519 if available == 0 {
520 return;
521 }
522
523 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 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 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 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;