stuart-cli 0.3.0

A serial terminal TUI
use ratatui::{
    Frame,
    layout::{Alignment, Rect},
    style::{Color, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, Paragraph},
};

use crate::state::{App, Screen};

pub fn key_span(label: &'static str) -> Span<'static> {
    Span::styled(format!(" {} ", label), Style::default().reversed().bold())
}

pub fn action_span(label: &'static str) -> Span<'static> {
    Span::styled(label, Style::default().fg(Color::DarkGray))
}

pub fn sep_span() -> Span<'static> {
    Span::styled(" │ ", Style::default().fg(Color::DarkGray))
}

pub fn help_entry(key: &'static str, action: &'static str) -> [Span<'static>; 4] {
    [
        key_span(key),
        Span::raw(" "),
        action_span(action),
        sep_span(),
    ]
}

pub fn help_spans(entries: &[(&'static str, &'static str)]) -> Vec<Span<'static>> {
    let mut spans: Vec<Span> = entries.iter().flat_map(|(k, a)| help_entry(k, a)).collect();
    spans.pop();
    spans
}

pub fn wrap_spans_to_lines(spans: Vec<Span<'static>>, width: u16) -> Vec<Line<'static>> {
    let inner_width = width.saturating_sub(2) as usize;
    let mut lines: Vec<Line> = Vec::new();
    let mut current: Vec<Span> = Vec::new();
    let mut current_width: usize = 0;

    for span in spans {
        let span_width = span.width();
        if current_width + span_width > inner_width && !current.is_empty() {
            lines.push(Line::from(std::mem::take(&mut current)));
            current_width = 0;
        }
        current_width += span_width;
        current.push(span);
    }
    if !current.is_empty() {
        lines.push(Line::from(current));
    }
    if lines.is_empty() {
        lines.push(Line::default());
    }
    lines
}

pub fn help_bar_height(spans: Vec<Span<'static>>, width: u16) -> (u16, Vec<Line<'static>>) {
    let lines = wrap_spans_to_lines(spans, width);
    let height = lines.len() as u16 + 2;
    (height, lines)
}

fn stuart_span() -> Span<'static> {
    Span::styled(
        " stuart ",
        Style::default()
            .bold()
            .bg(Color::Rgb(211, 69, 21))
            .fg(Color::Gray),
    )
}

pub fn info_bar_left_spans(app: &App) -> Vec<Span<'static>> {
    if app.active_port.is_empty() || app.screen == Screen::PortSelect {
        vec![stuart_span()]
    } else {
        vec![
            stuart_span(),
            sep_span(),
            Span::styled(" on", Style::default().fg(Color::DarkGray)),
            Span::styled(format!(" {} ", app.active_port), Style::default().bold()),
            sep_span(),
            Span::styled(
                format!(" {} ", app.port_config.baud),
                Style::default().bold(),
            ),
            Span::styled("baud rate", Style::default().fg(Color::DarkGray)),
        ]
    }
}

pub fn info_bar_right_spans(app: &App) -> Vec<Span<'static>> {
    let mut spans: Vec<Span<'static>> = Vec::new();

    let line_count: usize = app
        .scrollback
        .iter()
        .flat_map(|l| l.split_inclusive('\n'))
        .flat_map(|l| l.strip_suffix('\n').or(Some(l)))
        .count();
    let max_offset = line_count.saturating_sub(app.viewport_height);
    let at_top = app.scroll_offset >= max_offset && max_offset > 0;

    if at_top {
        spans.push(Span::styled(
            " scrollback TOP ",
            Style::default().fg(Color::DarkGray),
        ));
    } else if app.scroll_offset > 0 {
        spans.push(Span::styled(
            format!(" scrollback +{} ", app.scroll_offset),
            Style::default().fg(Color::DarkGray),
        ));
    }

    if app.connection.is_none() && app.hold && app.screen == Screen::Terminal {
        if !spans.is_empty() {
            spans.push(sep_span());
        }
        spans.push(Span::styled(
            " reconnecting… ",
            Style::default().fg(Color::Yellow).bold(),
        ));
    }

    spans
}

pub fn info_bar_spans(app: &App) -> Vec<Span<'static>> {
    info_bar_left_spans(app)
}

pub fn draw_info_bar(app: &App, frame: &mut Frame, area: Rect) {
    let block = Block::new().borders(Borders::ALL);
    let inner = block.inner(area);
    frame.render_widget(block, area);

    let left = Paragraph::new(Line::from(info_bar_left_spans(app)));
    frame.render_widget(left, inner);

    let right_spans = info_bar_right_spans(app);
    if !right_spans.is_empty() {
        let right = Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right);
        frame.render_widget(right, inner);
    }
}

pub fn draw_error_popup(app: &App, frame: &mut Frame) {
    let Some(entry) = app.errors.last() else {
        return;
    };

    let label = if entry.count > 1 {
        format!("{} (x{})", entry.message, entry.count)
    } else {
        entry.message.clone()
    };

    let area = frame.area();
    let width = (label.len() as u16 + 4).max(24).min(area.width);
    let popup_area = Rect {
        x: (area.width.saturating_sub(width)) / 2,
        y: 0,
        width,
        height: 3,
    };

    frame.render_widget(Clear, popup_area);
    let paragraph = Paragraph::new(Span::styled(label, Style::default().fg(Color::Red))).block(
        Block::new()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::Red))
            .title(Span::styled(
                " error ",
                Style::default().fg(Color::Red).bold(),
            )),
    );
    frame.render_widget(paragraph, popup_area);
}