roxid-tui 2.4.0

Internal: Terminal UI components for roxid pipeline runner - not intended for direct use
Documentation
use ratatui::{
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
    Frame,
};

use crate::app::App;
use crate::ui::{components, layout};

pub fn render(app: &App, frame: &mut Frame) {
    if app.discovery_errors.is_empty() {
        let chunks = layout::create_layout(frame.area());
        components::render_header("Roxid Pipeline Runner", frame, chunks[0]);
        render_list(app, frame, chunks[1]);
        components::render_footer(
            "j/k: Navigate | Enter: Details | v: Variables | t: Tests | q: Quit",
            frame,
            chunks[2],
        );
    } else {
        let chunks = layout::create_layout_with_errors(frame.area());
        components::render_header("Roxid Pipeline Runner", frame, chunks[0]);
        render_list(app, frame, chunks[1]);
        components::render_discovery_errors(&app.discovery_errors, frame, chunks[2]);
        components::render_footer(
            "j/k: Navigate | Enter: Details | v: Variables | t: Tests | q: Quit",
            frame,
            chunks[3],
        );
    }
}

fn render_list(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) {
    if app.pipelines.is_empty() {
        let empty_msg = Paragraph::new(vec![
            Line::from("No valid pipeline YAML files found in current directory."),
            Line::from(""),
            Line::from("Pipeline files must have:"),
            Line::from("  - Extension: .yaml or .yml"),
            Line::from("  - Valid Azure DevOps YAML schema"),
        ])
        .style(Style::default().fg(Color::Yellow))
        .block(Block::default().borders(Borders::ALL).title("Pipelines"))
        .wrap(Wrap { trim: true });
        frame.render_widget(empty_msg, area);
        return;
    }

    let list_items: Vec<ListItem> = app
        .pipelines
        .iter()
        .enumerate()
        .map(|(i, pipeline)| {
            let is_selected = i == app.selected_index;
            let style = if is_selected {
                Style::default()
                    .fg(Color::Black)
                    .bg(Color::Cyan)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::White)
            };

            let indicator = if is_selected { "> " } else { "  " };
            let info = format!(
                " ({} stages, {} jobs, {} steps)",
                pipeline.stages_count, pipeline.jobs_count, pipeline.steps_count
            );

            let content = Line::from(vec![
                Span::styled(indicator, style),
                Span::styled(&pipeline.name, style),
                Span::styled(info, Style::default().fg(Color::DarkGray)),
            ]);
            ListItem::new(content)
        })
        .collect();

    let list = List::new(list_items)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title("Available Pipelines"),
        )
        .style(Style::default());

    frame.render_widget(list, area);
}