Skip to main content

stynx_code_tui/widgets/
toast.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Modifier, Style},
5    text::{Line, Span},
6    widgets::{Clear, Paragraph, Widget},
7};
8
9use crate::state::{ToastKind, ToastState};
10use crate::theme;
11
12pub struct ToastStack<'a> {
13    pub state: &'a ToastState,
14}
15
16impl<'a> ToastStack<'a> {
17    pub fn new(state: &'a ToastState) -> Self {
18        Self { state }
19    }
20}
21
22impl<'a> Widget for ToastStack<'a> {
23    fn render(self, area: Rect, buf: &mut Buffer) {
24        if self.state.items.is_empty() {
25            return;
26        }
27
28        // Noice-style notify cards: rounded, level-colored, stacked top-right.
29        let max_box_w = 48u16.min(area.width.saturating_sub(2));
30        if max_box_w < 14 {
31            return;
32        }
33        let bg = theme::BACKGROUND_ELEMENT();
34        let right_edge = area.x + area.width.saturating_sub(1);
35        let mut y = area.y + 1;
36
37        for toast in &self.state.items {
38            let (icon, color) = match toast.kind {
39                ToastKind::Info => ("\u{24D8}", theme::ACCENT()),    // ⓘ
40                ToastKind::Success => ("\u{2713}", theme::SUCCESS()), // ✓
41                ToastKind::Warning => ("\u{25B3}", theme::WARNING()), // △
42                ToastKind::Error => ("\u{2717}", theme::ERROR()),     // ✗
43            };
44
45            // A "title\nbody" message renders the first line bold as a heading.
46            let (title, body) = match toast.message.split_once('\n') {
47                Some((t, b)) => (t.trim(), b.trim()),
48                None => (toast.message.as_str(), ""),
49            };
50
51            // Inside the borders we reserve: "│ " + 2-col icon gutter + text + " │".
52            let text_w = (max_box_w as usize).saturating_sub(6).max(1);
53            let mut wrapped: Vec<(String, bool)> = Vec::new(); // (text, is_title)
54            for line in wrap_plain(title, text_w) {
55                wrapped.push((line, true));
56            }
57            if !body.is_empty() {
58                for line in wrap_plain(body, text_w) {
59                    wrapped.push((line, false));
60                }
61            }
62
63            let longest = wrapped.iter().map(|(l, _)| l.chars().count()).max().unwrap_or(0);
64            let inner = longest + 2; // text + 2-col icon gutter
65            let box_w = (inner + 4) as u16; // "│ " + inner + " │"
66            let box_h = wrapped.len() as u16 + 2; // top + body + bottom
67
68            if y + box_h > area.y + area.height {
69                break;
70            }
71
72            let x = right_edge.saturating_sub(box_w);
73            let border = Style::default().fg(color).bg(bg);
74            let dash: String = "\u{2500}".repeat(inner + 2);
75
76            // Top border.
77            let top = Rect { x, y, width: box_w, height: 1 };
78            Clear.render(top, buf);
79            Paragraph::new(Line::from(Span::styled(format!("\u{256D}{dash}\u{256E}"), border)))
80                .style(Style::default().bg(bg))
81                .render(top, buf);
82
83            // Body rows.
84            for (i, (text, is_title)) in wrapped.iter().enumerate() {
85                let row = Rect { x, y: y + 1 + i as u16, width: box_w, height: 1 };
86                Clear.render(row, buf);
87                let gutter = if i == 0 {
88                    Span::styled(format!("{icon} "), border.add_modifier(Modifier::BOLD))
89                } else {
90                    Span::styled("  ".to_string(), Style::default().bg(bg))
91                };
92                let text_style = if *is_title {
93                    Style::default().fg(theme::TEXT()).bg(bg).add_modifier(Modifier::BOLD)
94                } else {
95                    Style::default().fg(theme::SUBTLE()).bg(bg)
96                };
97                let pad = longest.saturating_sub(text.chars().count());
98                let line = Line::from(vec![
99                    Span::styled("\u{2502} ".to_string(), border),
100                    gutter,
101                    Span::styled(text.clone(), text_style),
102                    Span::styled(" ".repeat(pad), Style::default().bg(bg)),
103                    Span::styled(" \u{2502}".to_string(), border),
104                ]);
105                Paragraph::new(line).style(Style::default().bg(bg)).render(row, buf);
106            }
107
108            // Bottom border.
109            let bot = Rect { x, y: y + 1 + wrapped.len() as u16, width: box_w, height: 1 };
110            Clear.render(bot, buf);
111            Paragraph::new(Line::from(Span::styled(format!("\u{2570}{dash}\u{256F}"), border)))
112                .style(Style::default().bg(bg))
113                .render(bot, buf);
114
115            y = bot.y + 2; // one-row gap between cards
116        }
117    }
118}
119
120/// Greedy word-wrap a plain string to `width` columns, hard-splitting any word
121/// longer than the line.
122fn wrap_plain(s: &str, width: usize) -> Vec<String> {
123    let width = width.max(1);
124    let mut lines: Vec<String> = Vec::new();
125    let mut cur = String::new();
126
127    for word in s.split_whitespace() {
128        if cur.is_empty() {
129            if word.chars().count() > width {
130                let mut chars: Vec<char> = word.chars().collect();
131                while chars.len() > width {
132                    lines.push(chars[..width].iter().collect());
133                    chars.drain(..width);
134                }
135                cur = chars.into_iter().collect();
136            } else {
137                cur = word.to_string();
138            }
139        } else if cur.chars().count() + 1 + word.chars().count() <= width {
140            cur.push(' ');
141            cur.push_str(word);
142        } else {
143            lines.push(std::mem::take(&mut cur));
144            cur = word.to_string();
145        }
146    }
147    if !cur.is_empty() || lines.is_empty() {
148        lines.push(cur);
149    }
150    lines
151}