Skip to main content

resq_tui/
lib.rs

1/*
2 * Copyright 2026 ResQ
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! Shared TUI components and themes for `ResQ` developer tools.
18//! Inspired by binsider architecture.
19
20pub 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
33// ---------------------------------------------------------------------------
34// UI Theme
35// ---------------------------------------------------------------------------
36
37/// Spinner animation frames for loading indicators.
38pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
39
40/// Standard `ResQ` TUI Theme.
41pub struct Theme {
42    /// Primary brand color (Cyan)
43    pub primary: Color,
44    /// Secondary supporting color (Blue)
45    pub secondary: Color,
46    /// Accent color for PID/Metadata (Magenta)
47    pub accent: Color,
48    /// Success state (Green)
49    pub success: Color,
50    /// Warning/Pending state (Yellow)
51    pub warning: Color,
52    /// Error/Critical state (Red)
53    pub error: Color,
54    /// Background color
55    pub bg: Color,
56    /// Foreground text color
57    pub fg: Color,
58    /// Highlight/Selection color
59    pub highlight: Color,
60    /// Inactive/Muted color (`DarkGray`)
61    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// ---------------------------------------------------------------------------
82// Shared Widgets
83// ---------------------------------------------------------------------------
84
85/// Renders a standardized header with service metadata and PID.
86#[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
129/// Renders a standardized footer with keyboard shortcuts.
130pub 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
157/// Renders a standardized tab bar.
158pub 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/// Renders a centered popup for help or errors.
175#[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    // Clear background
188    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/// Helper to create a centered rectangle for popups.
211#[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// ---------------------------------------------------------------------------
229// Common Utilities
230// ---------------------------------------------------------------------------
231
232/// Formats bytes into human-readable units.
233#[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/// Formats seconds into human-readable duration.
248#[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    // -----------------------------------------------------------------------
272    // format_bytes
273    // -----------------------------------------------------------------------
274
275    #[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        // Exactly at the KiB boundary
309        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    // -----------------------------------------------------------------------
323    // format_duration
324    // -----------------------------------------------------------------------
325
326    #[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    // -----------------------------------------------------------------------
359    // centered_rect
360    // -----------------------------------------------------------------------
361
362    #[test]
363    fn centered_rect_basic() {
364        let outer = Rect::new(0, 0, 100, 100);
365        let inner = centered_rect(50, 50, outer);
366        // The inner rect should be roughly centered and roughly 50% of the outer
367        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        // Should be contained within the outer rect
372        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        // At 100% it should be the full outer rect
381        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        // Should be much smaller than outer
390        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        // Check that margins are roughly equal on both sides
399        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        // Allow +-1 for rounding
404        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    // -----------------------------------------------------------------------
423    // Theme::default
424    // -----------------------------------------------------------------------
425
426    #[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    // -----------------------------------------------------------------------
442    // SPINNER_FRAMES
443    // -----------------------------------------------------------------------
444
445    #[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}