clap-tui 0.1.0

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

use crate::config::TuiConfig;
use crate::spec::CommandSpec;

use super::{screen::ScreenView, styles};

pub(crate) fn has_header_content(_command: &CommandSpec) -> bool {
    true
}

pub(crate) fn header_height(command: &CommandSpec, compact: bool) -> u16 {
    if compact {
        1 + u16::from(
            command
                .about
                .as_ref()
                .is_some_and(|about| !about.is_empty()),
        )
    } else {
        2 + u16::from(
            command
                .about
                .as_ref()
                .is_some_and(|about| !about.is_empty()),
        )
    }
}

pub(crate) fn render_header(
    frame: &mut Frame<'_>,
    config: &TuiConfig,
    area: Rect,
    _focused: bool,
    vm: &ScreenView<'_>,
) {
    if area.height == 0 || area.width == 0 {
        return;
    }
    frame.render_widget(
        Paragraph::new(header_lines(config, vm, area.height))
            .style(styles::surface(config, styles::Surface::Workspace))
            .block(
                ratatui::widgets::Block::default()
                    .style(styles::surface(config, styles::Surface::Workspace)),
            ),
        area,
    );
}

fn header_lines(config: &TuiConfig, vm: &ScreenView<'_>, height: u16) -> Vec<Line<'static>> {
    let mut breadcrumb = Vec::new();
    breadcrumb.push(Span::styled(
        vm.root.name.clone(),
        Style::default()
            .fg(config.theme.result_accent)
            .add_modifier(Modifier::BOLD),
    ));
    for (index, segment) in vm.selected_path.as_slice().iter().enumerate() {
        breadcrumb.push(Span::styled(" > ", Style::default().fg(config.theme.dim)));
        let is_last = index + 1 == vm.selected_path.as_slice().len();
        breadcrumb.push(Span::styled(
            segment.clone(),
            if is_last {
                Style::default()
                    .fg(config.theme.text)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(config.theme.metadata)
            },
        ));
    }

    let mut lines = vec![Line::from(breadcrumb)];
    if let Some(about) = vm.command.about.as_ref().filter(|about| !about.is_empty()) {
        lines.push(Line::from(Span::styled(
            about.clone(),
            Style::default().fg(config.theme.metadata),
        )));
    }
    while lines.len() < usize::from(height) {
        lines.push(Line::default());
    }
    lines
}

#[cfg(test)]
mod tests {
    use super::{header_height, header_lines};
    use crate::config::TuiConfig;
    use crate::spec::{CommandPath, CommandSpec};
    use crate::ui::screen::ScreenView;

    fn command(name: &str, about: Option<&str>) -> CommandSpec {
        CommandSpec {
            name: name.to_string(),
            version: None,
            about: about.map(str::to_string),
            help: String::new(),
            args: Vec::new(),
            subcommands: Vec::new(),
            ..CommandSpec::default()
        }
    }

    fn view(selected: &CommandSpec) -> ScreenView<'_> {
        let root = Box::leak(Box::new(command("tool", Some("Root command"))));
        ScreenView {
            command: selected,
            root,
            selected_path: CommandPath::from(vec!["serve".to_string()]),
            tree_rows: Vec::new(),
            sidebar_scroll: 0,
            active_args: Vec::new(),
            authoritative_argv: vec!["tool".to_string(), "serve".to_string()],
            rendered_command: None,
            validation: crate::pipeline::ValidationState::default(),
            effective_values: std::collections::BTreeMap::new(),
            field_semantics: std::collections::BTreeMap::new(),
            inputs: None,
        }
    }

    #[test]
    fn header_renders_breadcrumb_context_on_first_line() {
        let cmd = command("serve", Some("Run server"));
        let lines = header_lines(&TuiConfig::default(), &view(&cmd), 3);

        let combined = lines[0]
            .spans
            .iter()
            .map(|span| span.content.to_string())
            .collect::<String>();
        assert!(combined.contains("tool"));
        assert!(combined.contains("serve"));
    }

    #[test]
    fn header_renders_description_on_second_line() {
        let cmd = command("serve", Some("Run server"));
        let lines = header_lines(&TuiConfig::default(), &view(&cmd), 3);

        assert_eq!(lines[1].spans[0].content.as_ref(), "Run server");
    }

    #[test]
    fn header_surfaces_breadcrumb_text_for_nested_commands() {
        let cmd = command("deploy", Some("Ship the selected release"));
        let lines = header_lines(&TuiConfig::default(), &view(&cmd), 3);

        let combined = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.to_string()))
            .collect::<String>();
        assert!(combined.contains("tool"));
        assert!(combined.contains("serve"));
    }

    #[test]
    fn header_keeps_path_line_when_about_is_missing() {
        let cmd = command("serve", None);
        let lines = header_lines(&TuiConfig::default(), &view(&cmd), 2);

        assert_eq!(lines.len(), 2);
        assert!(lines[1].spans.is_empty());
    }

    #[test]
    fn roomy_header_leaves_a_spacer_row_below_context() {
        let cmd = command("serve", Some("Run server"));
        let lines = header_lines(&TuiConfig::default(), &view(&cmd), 3);

        assert!(lines[2].spans.is_empty());
    }

    #[test]
    fn header_height_adds_room_for_description_and_spacer() {
        let cmd = command("serve", Some("Run server"));

        assert_eq!(header_height(&cmd, true), 2);
        assert_eq!(header_height(&cmd, false), 3);
    }
}