stynx_code_tui/widgets/
toast.rs1use 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 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()), ToastKind::Success => ("\u{2713}", theme::SUCCESS()), ToastKind::Warning => ("\u{25B3}", theme::WARNING()), ToastKind::Error => ("\u{2717}", theme::ERROR()), };
44
45 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 let text_w = (max_box_w as usize).saturating_sub(6).max(1);
53 let mut wrapped: Vec<(String, bool)> = Vec::new(); 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; let box_w = (inner + 4) as u16; let box_h = wrapped.len() as u16 + 2; 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 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 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 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; }
117 }
118}
119
120fn 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}