1pub use crossterm;
21pub use ratatui;
22
23pub mod terminal;
24
25use ratatui::{
26 layout::{Alignment, Constraint, Layout, Rect},
27 style::{Color, Modifier, Style, Stylize},
28 text::{Line, Span},
29 widgets::{Block, BorderType, Borders, Paragraph, Tabs},
30 Frame,
31};
32
33pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
39
40pub struct Theme {
42 pub primary: Color,
44 pub secondary: Color,
46 pub accent: Color,
48 pub success: Color,
50 pub warning: Color,
52 pub error: Color,
54 pub bg: Color,
56 pub fg: Color,
58 pub highlight: Color,
60 pub inactive: Color,
62}
63
64impl Default for Theme {
65 fn default() -> Self {
66 Self {
67 primary: Color::Cyan,
68 secondary: Color::Blue,
69 accent: Color::Magenta,
70 success: Color::Green,
71 warning: Color::Yellow,
72 error: Color::Red,
73 bg: Color::Black,
74 fg: Color::White,
75 highlight: Color::Rgb(50, 50, 50),
76 inactive: Color::DarkGray,
77 }
78 }
79}
80
81#[allow(clippy::too_many_arguments)]
87pub fn draw_header(
88 frame: &mut Frame,
89 area: Rect,
90 title: &str,
91 status: &str,
92 status_color: Color,
93 pid: Option<i32>,
94 url: &str,
95 theme: &Theme,
96) {
97 let pid_info = pid.map_or_else(|| "PID: ?".to_string(), |p| format!("PID: {p}"));
98
99 let header_content = Line::from(vec![
100 Span::styled(
101 format!(" 🔬 {} ", title.to_uppercase()),
102 Style::default()
103 .fg(theme.primary)
104 .add_modifier(Modifier::BOLD),
105 ),
106 Span::raw(" │ "),
107 Span::styled(status, Style::default().fg(status_color)),
108 Span::raw(" │ "),
109 Span::styled(pid_info, Style::default().fg(theme.accent)),
110 Span::raw(" │ ").fg(theme.inactive),
111 Span::styled(
112 url,
113 Style::default()
114 .fg(theme.secondary)
115 .add_modifier(Modifier::ITALIC),
116 ),
117 ]);
118
119 let header = Paragraph::new(header_content).block(
120 Block::default()
121 .borders(Borders::ALL)
122 .border_type(BorderType::Rounded)
123 .border_style(Style::default().fg(theme.primary)),
124 );
125
126 frame.render_widget(header, area);
127}
128
129pub fn draw_footer(frame: &mut Frame, area: Rect, keys: &[(&str, &str)], theme: &Theme) {
131 let mut spans = Vec::with_capacity(keys.len() * 2);
132 for (k, v) in keys {
133 spans.push(Span::styled(
134 format!(" {k} "),
135 Style::default()
136 .fg(theme.bg)
137 .bg(theme.primary)
138 .add_modifier(Modifier::BOLD),
139 ));
140 spans.push(Span::styled(
141 format!(" {v} "),
142 Style::default().fg(theme.fg),
143 ));
144 spans.push(Span::raw(" "));
145 }
146
147 let footer = Paragraph::new(Line::from(spans)).block(
148 Block::default()
149 .borders(Borders::ALL)
150 .border_type(BorderType::Rounded)
151 .border_style(Style::default().fg(theme.primary)),
152 );
153
154 frame.render_widget(footer, area);
155}
156
157pub fn draw_tabs(frame: &mut Frame, area: Rect, titles: Vec<&str>, selected: usize) {
159 let theme = Theme::default();
160
161 let tabs = Tabs::new(titles)
162 .block(
163 Block::default()
164 .borders(Borders::ALL)
165 .border_type(BorderType::Rounded),
166 )
167 .select(selected)
168 .style(Style::default().fg(theme.primary))
169 .highlight_style(Style::default().fg(theme.warning).bold().underlined());
170
171 frame.render_widget(tabs, area);
172}
173
174#[allow(clippy::too_many_arguments)]
176pub fn draw_popup(
177 frame: &mut Frame,
178 area: Rect,
179 title: &str,
180 lines: &[Line],
181 percent_x: u16,
182 percent_y: u16,
183 theme: &Theme,
184) {
185 let popup_area = centered_rect(percent_x, percent_y, area);
186
187 frame.render_widget(
189 Block::default().style(Style::default().bg(theme.bg)),
190 popup_area,
191 );
192
193 let block = Block::default()
194 .borders(Borders::ALL)
195 .border_type(BorderType::Rounded)
196 .border_style(Style::default().fg(theme.primary))
197 .title(format!(" {title} "))
198 .style(Style::default().bg(theme.bg));
199
200 let inner = block.inner(popup_area);
201 frame.render_widget(block, popup_area);
202
203 let paragraph = Paragraph::new(lines.to_vec())
204 .alignment(Alignment::Left)
205 .style(Style::default().fg(theme.fg));
206
207 frame.render_widget(paragraph, inner);
208}
209
210#[must_use]
212pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
213 let popup_layout = Layout::vertical([
214 Constraint::Percentage((100 - percent_y) / 2),
215 Constraint::Percentage(percent_y),
216 Constraint::Percentage((100 - percent_y) / 2),
217 ])
218 .split(r);
219
220 Layout::horizontal([
221 Constraint::Percentage((100 - percent_x) / 2),
222 Constraint::Percentage(percent_x),
223 Constraint::Percentage((100 - percent_x) / 2),
224 ])
225 .split(popup_layout[1])[1]
226}
227
228#[must_use]
234#[allow(clippy::cast_precision_loss)]
235pub fn format_bytes(bytes: u64) -> String {
236 if bytes < 1024 {
237 format!("{bytes} B")
238 } else if bytes < 1024 * 1024 {
239 format!("{:.1} KiB", bytes as f64 / 1024.0)
240 } else if bytes < 1024 * 1024 * 1024 {
241 format!("{:.1} MiB", bytes as f64 / (1024.0 * 1024.0))
242 } else {
243 format!("{:.2} GiB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
244 }
245}
246
247#[must_use]
249pub fn format_duration(seconds: u64) -> String {
250 let days = seconds / 86400;
251 let hours = (seconds % 86400) / 3600;
252 let minutes = (seconds % 3600) / 60;
253 let secs = seconds % 60;
254
255 if days > 0 {
256 format!("{days}d {hours}h {minutes}m")
257 } else if hours > 0 {
258 format!("{hours}h {minutes}m {secs}s")
259 } else if minutes > 0 {
260 format!("{minutes}m {secs}s")
261 } else {
262 format!("{secs}s")
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use ratatui::layout::Rect;
270
271 #[test]
276 fn format_bytes_zero() {
277 assert_eq!(format_bytes(0), "0 B");
278 }
279
280 #[test]
281 fn format_bytes_bytes_range() {
282 assert_eq!(format_bytes(1), "1 B");
283 assert_eq!(format_bytes(512), "512 B");
284 assert_eq!(format_bytes(1023), "1023 B");
285 }
286
287 #[test]
288 fn format_bytes_kib_range() {
289 assert_eq!(format_bytes(1024), "1.0 KiB");
290 assert_eq!(format_bytes(1536), "1.5 KiB");
291 assert_eq!(format_bytes(1024 * 1023), "1023.0 KiB");
292 }
293
294 #[test]
295 fn format_bytes_mib_range() {
296 assert_eq!(format_bytes(1024 * 1024), "1.0 MiB");
297 assert_eq!(format_bytes(1024 * 1024 * 500), "500.0 MiB");
298 }
299
300 #[test]
301 fn format_bytes_gib_range() {
302 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GiB");
303 assert_eq!(format_bytes(2 * 1024 * 1024 * 1024), "2.00 GiB");
304 }
305
306 #[test]
307 fn format_bytes_boundary_kib() {
308 assert_eq!(format_bytes(1024), "1.0 KiB");
310 }
311
312 #[test]
313 fn format_bytes_boundary_mib() {
314 assert_eq!(format_bytes(1024 * 1024), "1.0 MiB");
315 }
316
317 #[test]
318 fn format_bytes_boundary_gib() {
319 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GiB");
320 }
321
322 #[test]
327 fn format_duration_zero() {
328 assert_eq!(format_duration(0), "0s");
329 }
330
331 #[test]
332 fn format_duration_seconds_only() {
333 assert_eq!(format_duration(1), "1s");
334 assert_eq!(format_duration(59), "59s");
335 }
336
337 #[test]
338 fn format_duration_minutes_and_seconds() {
339 assert_eq!(format_duration(60), "1m 0s");
340 assert_eq!(format_duration(61), "1m 1s");
341 assert_eq!(format_duration(3599), "59m 59s");
342 }
343
344 #[test]
345 fn format_duration_hours_minutes_seconds() {
346 assert_eq!(format_duration(3600), "1h 0m 0s");
347 assert_eq!(format_duration(3661), "1h 1m 1s");
348 assert_eq!(format_duration(86399), "23h 59m 59s");
349 }
350
351 #[test]
352 fn format_duration_days() {
353 assert_eq!(format_duration(86400), "1d 0h 0m");
354 assert_eq!(format_duration(90061), "1d 1h 1m");
355 assert_eq!(format_duration(172_800), "2d 0h 0m");
356 }
357
358 #[test]
363 fn centered_rect_basic() {
364 let outer = Rect::new(0, 0, 100, 100);
365 let inner = centered_rect(50, 50, outer);
366 assert!(inner.x > 0, "inner.x should be > 0, got {}", inner.x);
368 assert!(inner.y > 0, "inner.y should be > 0, got {}", inner.y);
369 assert!(inner.width > 0, "inner.width should be > 0");
370 assert!(inner.height > 0, "inner.height should be > 0");
371 assert!(inner.x + inner.width <= outer.width);
373 assert!(inner.y + inner.height <= outer.height);
374 }
375
376 #[test]
377 fn centered_rect_full_size() {
378 let outer = Rect::new(0, 0, 100, 50);
379 let inner = centered_rect(100, 100, outer);
380 assert_eq!(inner.width, outer.width);
382 assert_eq!(inner.height, outer.height);
383 }
384
385 #[test]
386 fn centered_rect_small_percent() {
387 let outer = Rect::new(0, 0, 200, 200);
388 let inner = centered_rect(10, 10, outer);
389 assert!(inner.width < outer.width / 2);
391 assert!(inner.height < outer.height / 2);
392 }
393
394 #[test]
395 fn centered_rect_is_actually_centered() {
396 let outer = Rect::new(0, 0, 100, 100);
397 let inner = centered_rect(50, 50, outer);
398 let left_margin = inner.x;
400 let right_margin = outer.width - (inner.x + inner.width);
401 let top_margin = inner.y;
402 let bottom_margin = outer.height - (inner.y + inner.height);
403 assert!(
405 left_margin.abs_diff(right_margin) <= 1,
406 "horizontal centering off: left={left_margin}, right={right_margin}"
407 );
408 assert!(
409 top_margin.abs_diff(bottom_margin) <= 1,
410 "vertical centering off: top={top_margin}, bottom={bottom_margin}"
411 );
412 }
413
414 #[test]
415 fn centered_rect_zero_area() {
416 let outer = Rect::new(0, 0, 0, 0);
417 let inner = centered_rect(50, 50, outer);
418 assert_eq!(inner.width, 0);
419 assert_eq!(inner.height, 0);
420 }
421
422 #[test]
427 fn theme_default_colors() {
428 let theme = Theme::default();
429 assert_eq!(theme.primary, Color::Cyan);
430 assert_eq!(theme.secondary, Color::Blue);
431 assert_eq!(theme.accent, Color::Magenta);
432 assert_eq!(theme.success, Color::Green);
433 assert_eq!(theme.warning, Color::Yellow);
434 assert_eq!(theme.error, Color::Red);
435 assert_eq!(theme.bg, Color::Black);
436 assert_eq!(theme.fg, Color::White);
437 assert_eq!(theme.highlight, Color::Rgb(50, 50, 50));
438 assert_eq!(theme.inactive, Color::DarkGray);
439 }
440
441 #[test]
446 #[allow(clippy::const_is_empty)]
447 fn spinner_frames_not_empty() {
448 assert!(!SPINNER_FRAMES.is_empty());
449 }
450
451 #[test]
452 fn spinner_frames_all_single_char() {
453 for frame in SPINNER_FRAMES {
454 assert_eq!(
455 frame.chars().count(),
456 1,
457 "frame '{frame}' is not single char"
458 );
459 }
460 }
461}