clap-tui 0.1.3

Auto-generate a TUI from clap commands
Documentation
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Clear, Paragraph};

use crate::config::TuiConfig;
use crate::input::AppState;

pub(crate) fn render_toast(
    frame: &mut Frame<'_>,
    state: &mut AppState,
    config: &TuiConfig,
    area: Rect,
) {
    let Some(toast) = state.notifications.toast.as_ref() else {
        return;
    };
    if area.width < 4 || area.height == 0 {
        return;
    }

    let height = area.height.min(3);
    let icon = toast_icon(toast.is_error);
    let text = format!(" {icon} {} ", toast.message);
    let width = u16::try_from(text.chars().count())
        .unwrap_or(area.width)
        .min(area.width);
    let x = area.x.saturating_add(area.width.saturating_sub(width));
    let y = area
        .y
        .saturating_add(area.height.saturating_sub(height))
        .max(area.y);
    let toast_area = Rect::new(x, y, width, height);

    let background = if toast.is_error {
        config.theme.error
    } else {
        config.theme.success
    };
    let toast_style = Style::default()
        .fg(config.theme.shell_bg)
        .bg(background)
        .add_modifier(Modifier::BOLD);
    let text_y = if height >= 3 {
        toast_area.y.saturating_add(1)
    } else {
        toast_area.y
    };
    let text_area = Rect::new(toast_area.x, text_y, toast_area.width, 1);

    frame.render_widget(Clear, toast_area);
    frame.render_widget(Block::default().style(toast_style), toast_area);
    frame.render_widget(
        Paragraph::new(Line::from(text)).style(toast_style),
        text_area,
    );
}

fn toast_icon(is_error: bool) -> &'static str {
    if is_error { "" } else { "" }
}

#[cfg(test)]
mod tests {
    use std::time::{Duration, Instant};

    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    use crate::input::{AppState, Toast};
    use crate::spec::CommandSpec;

    use super::render_toast;
    use crate::config::TuiConfig;

    fn state_with_toast(message: &str, is_error: bool) -> AppState {
        let mut state = AppState::new(CommandSpec {
            name: "tool".to_string(),
            version: None,
            about: None,
            help: String::new(),
            args: Vec::new(),
            subcommands: Vec::new(),
            ..CommandSpec::default()
        });
        state.notifications.toast = Some(Toast {
            message: message.to_string(),
            expires_at: Instant::now() + Duration::from_secs(30),
            is_error,
        });
        state
    }

    #[test]
    fn error_toast_uses_solid_error_background() {
        let mut state = state_with_toast("Clipboard unavailable", true);
        let config = TuiConfig::default();
        let mut terminal = Terminal::new(TestBackend::new(50, 6)).expect("terminal");

        terminal
            .draw(|frame| render_toast(frame, &mut state, &config, frame.area()))
            .expect("draw");

        let buffer = terminal.backend().buffer();
        let fill_cell = &buffer[(27, 4)];
        let text_cell = &buffer[(29, 4)];

        assert_eq!(fill_cell.bg, config.theme.error);
        assert_eq!(text_cell.bg, config.theme.error);
        assert_eq!(text_cell.fg, config.theme.shell_bg);
    }

    #[test]
    fn success_toast_uses_solid_success_background() {
        let mut state = state_with_toast("Copied command to clipboard", false);
        let config = TuiConfig::default();
        let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal");

        terminal
            .draw(|frame| render_toast(frame, &mut state, &config, frame.area()))
            .expect("draw");

        let buffer = terminal.backend().buffer();
        let fill_cell = &buffer[(30, 4)];
        let text_cell = &buffer[(32, 4)];

        assert_eq!(fill_cell.bg, config.theme.success);
        assert_eq!(text_cell.bg, config.theme.success);
        assert_eq!(text_cell.fg, config.theme.shell_bg);
    }

    #[test]
    fn toast_copy_uses_status_icon_without_text_label() {
        let config = TuiConfig::default();
        let mut state = state_with_toast("Copied command to clipboard", false);
        let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal");

        terminal
            .draw(|frame| render_toast(frame, &mut state, &config, frame.area()))
            .expect("draw");
        let rendered = buffer_text(terminal.backend());

        assert!(rendered.contains(""));
        assert!(!rendered.contains("Success"));
        assert!(!rendered.contains("Error"));
        assert!(!rendered.contains("feedback"));
    }

    fn buffer_text(backend: &TestBackend) -> String {
        backend
            .buffer()
            .content
            .iter()
            .map(ratatui::buffer::Cell::symbol)
            .collect::<String>()
    }
}