cargo-port 0.1.3

A TUI for inspecting and managing Rust projects
use ratatui::Frame;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use tui_pane::PaneFocusState;
use tui_pane::SECTION_HEADER_INDENT;
use tui_pane::SECTION_ITEM_INDENT;
use tui_pane::ViewportOverflow;
use tui_pane::active_border_color;
use tui_pane::keep_visible_scroll_offset;
use tui_pane::label_color;
use tui_pane::render_overflow_affordance;
use tui_pane::selection_state;
use tui_pane::text_default;
use tui_pane::title_color;
use unicode_width::UnicodeWidthStr;

use super::pane::SccachePane;
use super::pane::SccacheStatus;
use super::pane::SccacheTarget;
use super::stats;
use super::stats::ParsedStatLine;
use super::stats::ValueAlignment;
use crate::tui::app::App;
use crate::tui::overlays::PopupFrame;

const POPUP_MIN_WIDTH: u16 = 56;
const POPUP_HORIZONTAL_MARGIN: u16 = 4;
const POPUP_VERTICAL_MARGIN: u16 = 4;
const POPUP_BORDER_HEIGHT: u16 = 2;
const CONTENT_WIDTH_PADDING: usize = 2;

struct SccacheLines {
    lines:             Vec<Line<'static>>,
    line_targets:      Vec<Option<usize>>,
    selectable_values: Vec<SccacheTarget>,
}

pub fn render_sccache_popup(frame: &mut Frame<'_>, app: &mut App) {
    let SccacheLines {
        lines,
        line_targets,
        selectable_values,
    } = build_lines(&app.overlays.sccache_pane);
    let width =
        content_width(&lines).min(frame.area().width.saturating_sub(POPUP_HORIZONTAL_MARGIN));
    let height = popup_height(lines.len(), frame.area().height);
    let popup = PopupFrame {
        title: Some(" Sccache Stats ".to_string()),
        border_color: active_border_color(),
        width,
        height,
    }
    .render_with_areas(frame);
    let inner = popup.inner;

    let line_count = lines.len();
    let selectable_count = selectable_values.len();
    app.overlays
        .sccache_pane
        .set_line_targets(line_targets, selectable_values);
    let visible_height = usize::from(inner.height);
    let selected_line = app
        .overlays
        .sccache_pane
        .line_for_selection(app.overlays.sccache_pane.viewport.pos())
        .unwrap_or_else(|| app.overlays.sccache_pane.viewport.pos());
    let scroll_offset = keep_visible_scroll_offset(selected_line, visible_height, line_count);
    let viewport = app.overlays.sccache_pane.viewport_mut();
    viewport.set_len(selectable_count);
    viewport.set_content_area(inner);
    viewport.set_viewport_rows(visible_height);
    viewport.set_scroll_offset(scroll_offset);

    let paragraph = Paragraph::new(lines).scroll((u16::try_from(scroll_offset).unwrap_or(0), 0));
    frame.render_widget(paragraph, inner);
    render_overflow_affordance(
        frame,
        popup.outer,
        ViewportOverflow::new(line_count, scroll_offset, visible_height, scroll_offset),
        Style::default().fg(label_color()),
    );
}

fn build_lines(pane: &SccachePane) -> SccacheLines {
    let mut lines = Vec::new();
    let mut line_targets = Vec::new();
    let mut selectable_values = Vec::new();
    match pane.status() {
        SccacheStatus::Loading { source } => {
            push_header(&mut lines, &mut line_targets, "Status");
            push_item(&mut lines, &mut line_targets, "Loading sccache stats");
            push_source(
                &mut lines,
                &mut line_targets,
                &mut selectable_values,
                pane,
                source,
            );
        },
        SccacheStatus::NotConfigured => {
            push_header(&mut lines, &mut line_targets, "Status");
            push_item(
                &mut lines,
                &mut line_targets,
                "sccache is not configured for this process",
            );
            push_item(
                &mut lines,
                &mut line_targets,
                "Set RUSTC_WRAPPER=sccache to enable stats",
            );
        },
        SccacheStatus::Ready {
            source,
            lines: stat_rows,
        } => {
            push_header(&mut lines, &mut line_targets, "Configured");
            push_source(
                &mut lines,
                &mut line_targets,
                &mut selectable_values,
                pane,
                source,
            );
            lines.push(Line::from(""));
            line_targets.push(None);
            push_header(&mut lines, &mut line_targets, "Stats");
            push_stat_lines(
                &mut lines,
                &mut line_targets,
                &mut selectable_values,
                pane,
                stat_rows,
            );
        },
        SccacheStatus::Failed {
            source,
            lines: errors,
        } => {
            push_header(&mut lines, &mut line_targets, "Configured");
            push_source(
                &mut lines,
                &mut line_targets,
                &mut selectable_values,
                pane,
                source,
            );
            lines.push(Line::from(""));
            line_targets.push(None);
            push_header(&mut lines, &mut line_targets, "Error");
            push_stat_lines(
                &mut lines,
                &mut line_targets,
                &mut selectable_values,
                pane,
                errors,
            );
        },
    }
    SccacheLines {
        lines,
        line_targets,
        selectable_values,
    }
}

fn push_header(
    lines: &mut Vec<Line<'static>>,
    line_targets: &mut Vec<Option<usize>>,
    text: &'static str,
) {
    lines.push(Line::from(vec![
        Span::raw(SECTION_HEADER_INDENT),
        Span::styled(
            format!("{text}:"),
            Style::default()
                .fg(title_color())
                .add_modifier(Modifier::BOLD),
        ),
    ]));
    line_targets.push(None);
}

fn push_source(
    lines: &mut Vec<Line<'static>>,
    line_targets: &mut Vec<Option<usize>>,
    selectable_values: &mut Vec<SccacheTarget>,
    pane: &SccachePane,
    source: &str,
) {
    let target = push_target(
        line_targets,
        selectable_values,
        "Configured",
        source.to_string(),
    );
    let selection = selection_state(&pane.viewport, target, PaneFocusState::Active);
    lines.push(Line::from(vec![
        Span::styled(SECTION_ITEM_INDENT, selection.patch(Style::default())),
        Span::styled(
            source.to_string(),
            selection.patch(Style::default().fg(text_default())),
        ),
    ]));
}

fn push_item(
    lines: &mut Vec<Line<'static>>,
    line_targets: &mut Vec<Option<usize>>,
    text: impl Into<String>,
) {
    lines.push(Line::from(vec![
        Span::raw(SECTION_ITEM_INDENT),
        Span::styled(text.into(), Style::default().fg(text_default())),
    ]));
    line_targets.push(None);
}

fn push_subheader(
    lines: &mut Vec<Line<'static>>,
    line_targets: &mut Vec<Option<usize>>,
    text: String,
) {
    lines.push(Line::from(vec![
        Span::raw(SECTION_HEADER_INDENT),
        Span::styled(
            text,
            Style::default()
                .fg(title_color())
                .add_modifier(Modifier::BOLD),
        ),
    ]));
    line_targets.push(None);
}

fn push_stat_lines(
    lines: &mut Vec<Line<'static>>,
    line_targets: &mut Vec<Option<usize>>,
    selectable_values: &mut Vec<SccacheTarget>,
    pane: &SccachePane,
    raw_lines: &[String],
) {
    let parsed = stats::parse_stat_lines(raw_lines);
    let alignment = ValueAlignment::for_lines(&parsed);
    for line in parsed {
        push_parsed_stat_line(
            lines,
            line_targets,
            selectable_values,
            pane,
            line,
            alignment,
        );
    }
}

fn push_parsed_stat_line(
    lines: &mut Vec<Line<'static>>,
    line_targets: &mut Vec<Option<usize>>,
    selectable_values: &mut Vec<SccacheTarget>,
    pane: &SccachePane,
    parsed: ParsedStatLine,
    alignment: ValueAlignment,
) {
    match parsed {
        ParsedStatLine::Field { label, value } => {
            let copy_value = value.clone();
            let target = push_target(line_targets, selectable_values, &label, copy_value);
            let selection = selection_state(&pane.viewport, target, PaneFocusState::Active);
            let label_width = stat_label_width();
            lines.push(Line::from(vec![
                Span::styled(SECTION_ITEM_INDENT, selection.patch(Style::default())),
                Span::styled(
                    format!("{label:<label_width$} "),
                    selection.patch(Style::default().fg(label_color())),
                ),
                Span::styled(
                    alignment.format(&value),
                    selection.patch(Style::default().fg(text_default())),
                ),
            ]));
        },
        ParsedStatLine::Subheader { text, context: _ } => push_subheader(lines, line_targets, text),
        ParsedStatLine::Text(text) => push_item(lines, line_targets, text),
    }
}

fn push_target(
    line_targets: &mut Vec<Option<usize>>,
    selectable_values: &mut Vec<SccacheTarget>,
    label: &str,
    value: String,
) -> usize {
    let target = selectable_values.len();
    line_targets.push(Some(target));
    selectable_values.push(SccacheTarget {
        label: label.to_string(),
        value,
    });
    target
}

const fn stat_label_width() -> usize { 36 }

fn content_width(lines: &[Line<'_>]) -> u16 {
    lines
        .iter()
        .map(line_width)
        .max()
        .and_then(|width| u16::try_from(width.saturating_add(CONTENT_WIDTH_PADDING)).ok())
        .unwrap_or(POPUP_MIN_WIDTH)
        .max(POPUP_MIN_WIDTH)
}

fn line_width(line: &Line<'_>) -> usize {
    line.spans
        .iter()
        .map(|span| span.content.as_ref().width())
        .sum()
}

fn popup_height(row_count: usize, area_height: u16) -> u16 {
    let content_height = u16::try_from(row_count).unwrap_or(u16::MAX);
    let max_height = area_height.saturating_sub(POPUP_VERTICAL_MARGIN);
    content_height
        .saturating_add(POPUP_BORDER_HEIGHT)
        .min(max_height)
        .max(POPUP_BORDER_HEIGHT.saturating_add(1))
}