govctl 0.19.0

Project governance CLI for RFC, ADR, and Work Item management
use super::super::{panel_block, phase_style, status_style};
use crate::theme::status_icon;
use ratatui::{
    prelude::*,
    widgets::{
        HighlightSpacing, List, ListItem, ListState, Row, Scrollbar, ScrollbarOrientation,
        ScrollbarState, Table, TableState,
    },
};

pub(in crate::tui::ui) struct ResourceTable {
    rows: Vec<Row<'static>>,
    spec: ResourceTableSpec,
}

pub(in crate::tui::ui) struct ResourceTableSpec {
    pub(in crate::tui::ui) widths: Vec<Constraint>,
    pub(in crate::tui::ui) compact_widths: Vec<Constraint>,
    pub(in crate::tui::ui) headers: &'static [&'static str],
    pub(in crate::tui::ui) header_color: Color,
    pub(in crate::tui::ui) title: &'static str,
    pub(in crate::tui::ui) border_color: Color,
}

impl ResourceTable {
    fn new(rows: Vec<Row<'static>>, spec: ResourceTableSpec) -> Self {
        Self { rows, spec }
    }

    pub(in crate::tui::ui) fn from_indexed_items<T>(
        items: &[T],
        indices: &[usize],
        spec: ResourceTableSpec,
        row_for: impl FnMut(&T) -> Row<'static>,
    ) -> Self {
        let rows = indices
            .iter()
            .filter_map(|&idx| items.get(idx))
            .map(row_for)
            .collect();

        Self::new(rows, spec)
    }

    pub(in crate::tui::ui) fn render(self, frame: &mut Frame, area: Rect, state: &mut TableState) {
        let row_count = self.rows.len();
        let selected = state.selected().unwrap_or(0);
        let widths = if area.width < 90 {
            self.spec.compact_widths
        } else {
            self.spec.widths
        };
        let block = panel_block(self.spec.title)
            .title_bottom(
                Line::from(record_count_label(row_count))
                    .right_aligned()
                    .style(Style::default().fg(Color::DarkGray)),
            )
            .border_style(Style::default().fg(self.spec.border_color));
        let table = Table::new(self.rows, widths)
            .header(
                Row::new(self.spec.headers.to_vec())
                    .style(Style::default().bold().fg(self.spec.header_color))
                    .bottom_margin(1),
            )
            .row_highlight_style(Style::default().fg(Color::Cyan).bold())
            .highlight_symbol("")
            .highlight_spacing(HighlightSpacing::Always)
            .column_spacing(2)
            .block(block);
        frame.render_stateful_widget(table, area, state);
        render_scrollbar(
            frame,
            area,
            row_count,
            selected,
            area.height.saturating_sub(4),
        );
    }
}

pub(in crate::tui::ui) struct ResourceListRow<'a> {
    pub(in crate::tui::ui) id: &'a str,
    pub(in crate::tui::ui) title: &'a str,
    pub(in crate::tui::ui) status: &'a str,
    pub(in crate::tui::ui) tags: &'a [String],
}

impl ResourceListRow<'_> {
    pub(in crate::tui::ui) fn render(&self) -> Row<'static> {
        Row::new(vec![
            Line::from(self.id.to_string()),
            Line::from(self.title.to_string()),
            StatusText::new(self.status).render(),
            TagsCell::new(self.tags).render(),
        ])
    }
}

pub(in crate::tui::ui) struct ClauseListRow<'a> {
    pub(in crate::tui::ui) id: &'a str,
    pub(in crate::tui::ui) title: &'a str,
    pub(in crate::tui::ui) status: &'a str,
}

impl ClauseListRow<'_> {
    pub(in crate::tui::ui) fn render(&self) -> ListItem<'static> {
        ListItem::new(Line::from(vec![
            StatusText::new(self.status).icon_span(),
            Span::styled(self.id.to_string(), Style::default().fg(Color::Blue).bold()),
            Span::raw(""),
            Span::raw(self.title.to_string()),
        ]))
    }
}

pub(in crate::tui::ui) struct StatusText<'a> {
    status: &'a str,
}

impl<'a> StatusText<'a> {
    pub(in crate::tui::ui) fn new(status: &'a str) -> Self {
        Self { status }
    }

    pub(in crate::tui::ui) fn render(&self) -> Line<'static> {
        Line::from(self.spans())
    }

    pub(in crate::tui::ui) fn spans<'b>(&self) -> Vec<Span<'b>> {
        vec![
            self.icon_span(),
            Span::styled(self.status.to_string(), status_style(self.status)),
        ]
    }

    pub(in crate::tui::ui) fn icon_span<'b>(&self) -> Span<'b> {
        Span::styled(
            format!("{} ", status_icon(self.status)),
            status_style(self.status),
        )
    }
}

pub(in crate::tui::ui) struct PhaseCell<'a> {
    phase: &'a str,
}

impl<'a> PhaseCell<'a> {
    pub(in crate::tui::ui) fn new(phase: &'a str) -> Self {
        Self { phase }
    }

    pub(in crate::tui::ui) fn render(&self) -> Line<'static> {
        Line::from(Span::styled(
            self.phase.to_string(),
            phase_style(self.phase),
        ))
    }
}

pub(in crate::tui::ui) struct TagsCell<'a> {
    tags: &'a [String],
}

impl<'a> TagsCell<'a> {
    pub(in crate::tui::ui) fn new(tags: &'a [String]) -> Self {
        Self { tags }
    }

    pub(in crate::tui::ui) fn render(&self) -> Line<'static> {
        Line::from(Span::styled(
            self.tags.join(" "),
            Style::default().fg(Color::Magenta),
        ))
    }
}

pub(in crate::tui::ui) struct SelectableList {
    title: String,
    border_color: Color,
    items: Vec<ListItem<'static>>,
}

impl SelectableList {
    pub(in crate::tui::ui) fn new(
        title: impl Into<String>,
        border_color: Color,
        items: Vec<ListItem<'static>>,
    ) -> Self {
        Self {
            title: title.into(),
            border_color,
            items,
        }
    }

    pub(in crate::tui::ui) fn render(self, frame: &mut Frame, area: Rect, state: &mut ListState) {
        let item_count = self.items.len();
        let selected = state.selected().unwrap_or(0);
        let list = List::new(self.items)
            .block(
                panel_block(&self.title)
                    .title_bottom(
                        Line::from(record_count_label(item_count))
                            .right_aligned()
                            .style(Style::default().fg(Color::DarkGray)),
                    )
                    .border_style(Style::default().fg(self.border_color)),
            )
            .highlight_style(Style::default().fg(Color::Cyan).bold())
            .highlight_symbol("")
            .highlight_spacing(HighlightSpacing::Always);

        frame.render_stateful_widget(list, area, state);
        render_scrollbar(
            frame,
            area,
            item_count,
            selected,
            area.height.saturating_sub(2),
        );
    }
}

fn render_scrollbar(
    frame: &mut Frame,
    area: Rect,
    content_length: usize,
    position: usize,
    viewport_length: u16,
) {
    if content_length == 0 || content_length <= viewport_length as usize || area.height < 3 {
        return;
    }
    let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
        .begin_symbol(None)
        .end_symbol(None)
        .thumb_style(Style::default().fg(Color::Cyan))
        .track_style(Style::default().fg(Color::DarkGray));
    let mut scrollbar_state = ScrollbarState::new(content_length)
        .position(position)
        .viewport_content_length(viewport_length as usize);
    frame.render_stateful_widget(
        scrollbar,
        area.inner(Margin {
            vertical: 1,
            horizontal: 0,
        }),
        &mut scrollbar_state,
    );
}

fn record_count_label(count: usize) -> String {
    let noun = if count == 1 { "RECORD" } else { "RECORDS" };
    format!(" {count} {noun} ")
}