mermaid_cli/render/widgets/
status_banner.rs1use ratatui::buffer::Buffer;
16use ratatui::layout::Rect;
17use ratatui::style::{Color, Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{Paragraph, Widget};
20
21use crate::domain::{StatusKind, StatusLine};
22use crate::render::theme::Theme;
23
24pub struct StatusBannerWidget<'a> {
25 pub theme: &'a Theme,
26 pub status: &'a StatusLine,
27}
28
29impl<'a> Widget for StatusBannerWidget<'a> {
30 fn render(self, area: Rect, buf: &mut Buffer) {
31 if area.height == 0 || area.width == 0 {
32 return;
33 }
34
35 let fg = match self.status.kind {
38 StatusKind::Info => self.theme.colors.info.to_color(),
39 StatusKind::Warn => self.theme.colors.warning.to_color(),
40 StatusKind::Error => self.theme.colors.error.to_color(),
41 StatusKind::Persistent => self.theme.colors.text_disabled.to_color(),
42 };
43
44 let glyph = match self.status.kind {
47 StatusKind::Error => "✗ ",
48 StatusKind::Warn => "! ",
49 _ => "● ",
50 };
51
52 let max_body = area.width.saturating_sub(3) as usize;
59 let text = &self.status.text;
60 let truncated = if text.len() > max_body {
61 let cut = text.floor_char_boundary(max_body.saturating_sub(1));
62 format!("{}…", &text[..cut])
63 } else {
64 text.to_string()
65 };
66
67 let line = Line::from(vec![
68 Span::styled(glyph, Style::new().fg(fg).add_modifier(Modifier::BOLD)),
69 Span::styled(truncated, Style::new().fg(Color::White)),
70 ]);
71 Paragraph::new(line).render(area, buf);
72 }
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78 use crate::render::theme::Theme;
79 use ratatui::Terminal;
80 use ratatui::backend::TestBackend;
81
82 fn render_to_string(banner: StatusBannerWidget<'_>, width: u16) -> String {
83 let backend = TestBackend::new(width, 1);
84 let mut terminal = Terminal::new(backend).expect("terminal");
85 terminal
86 .draw(|f| {
87 let area = Rect::new(0, 0, width, 1);
88 banner.render(area, f.buffer_mut());
89 })
90 .expect("draw");
91 let buf = terminal.backend().buffer();
92 let mut out = String::new();
93 for x in 0..buf.area.width {
94 out.push_str(buf[(x, 0)].symbol());
95 }
96 out
97 }
98
99 #[test]
100 fn info_status_renders_bullet_and_text() {
101 let theme = Theme::dark();
102 let status = StatusLine {
103 text: "Current model: ollama/qwen3-coder:30b".to_string(),
104 kind: StatusKind::Info,
105 shown_at: std::time::SystemTime::now(),
106 };
107 let banner = StatusBannerWidget {
108 theme: &theme,
109 status: &status,
110 };
111 let rendered = render_to_string(banner, 80);
112 assert!(rendered.contains("●"));
113 assert!(rendered.contains("ollama/qwen3-coder:30b"));
114 }
115
116 #[test]
117 fn error_status_uses_error_glyph() {
118 let theme = Theme::dark();
119 let status = StatusLine {
120 text: "MCP server foo errored: exit 1".to_string(),
121 kind: StatusKind::Error,
122 shown_at: std::time::SystemTime::now(),
123 };
124 let banner = StatusBannerWidget {
125 theme: &theme,
126 status: &status,
127 };
128 let rendered = render_to_string(banner, 80);
129 assert!(rendered.contains("✗"));
130 assert!(rendered.contains("exit 1"));
131 }
132
133 #[test]
137 fn long_cjk_text_truncates_without_panic() {
138 let theme = Theme::dark();
139 let text: String = "你好世界".repeat(30);
141 let status = StatusLine {
142 text,
143 kind: StatusKind::Info,
144 shown_at: std::time::SystemTime::now(),
145 };
146 let banner = StatusBannerWidget {
147 theme: &theme,
148 status: &status,
149 };
150 let _ = render_to_string(banner, 10);
153 }
154}