tidev 0.2.0

A terminal-based AI coding agent
Documentation
use pulldown_cmark::Alignment;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthStr;

use super::line::push_owned_lines;
use super::wrap::{RtOptions, word_wrap_line};

#[derive(Clone, Debug)]
pub(super) struct TableRowState {
    pub(super) is_header: bool,
    pub(super) cells: Vec<Line<'static>>,
}

#[derive(Clone, Debug)]
pub(super) struct TableState {
    pub(super) prefix: Vec<Span<'static>>,
    pub(super) base_style: Style,
    pub(super) alignments: Vec<Alignment>,
    pub(super) rows: Vec<TableRowState>,
    pub(super) current_row: Option<TableRowState>,
    pub(super) in_head: bool,
}

impl TableState {
    pub(super) fn new(
        prefix: Vec<Span<'static>>,
        base_style: Style,
        alignments: Vec<Alignment>,
    ) -> Self {
        Self {
            prefix,
            base_style,
            alignments,
            rows: Vec::new(),
            current_row: None,
            in_head: false,
        }
    }

    pub(super) fn start_head(&mut self) {
        self.in_head = true;
    }

    pub(super) fn end_head(&mut self) {
        self.in_head = false;
    }

    pub(super) fn start_row(&mut self) {
        self.finish_row();
        self.current_row = Some(TableRowState {
            is_header: self.in_head,
            cells: Vec::new(),
        });
    }

    pub(super) fn finish_row(&mut self) {
        if let Some(row) = self.current_row.take() {
            self.rows.push(row);
        }
    }

    pub(super) fn push_cell(&mut self, cell: Line<'static>) {
        if let Some(row) = self.current_row.as_mut() {
            row.cells.push(cell);
        }
    }

    pub(super) fn render(mut self, wrap_width: Option<usize>) -> Vec<Line<'static>> {
        self.finish_row();
        if self.rows.is_empty() {
            return Vec::new();
        }

        let prefix_width = display_line_width(&Line::from(self.prefix.clone()));
        let available_width = wrap_width.map(|width| width.saturating_sub(prefix_width));

        let mut rows = std::mem::take(&mut self.rows);
        let header_index = rows.iter().position(|row| row.is_header).unwrap_or(0);
        let header_row = rows.remove(header_index);
        let body_rows = rows;

        let column_count = header_row
            .cells
            .len()
            .max(
                body_rows
                    .iter()
                    .map(|row| row.cells.len())
                    .max()
                    .unwrap_or(0),
            )
            .max(self.alignments.len());

        if column_count == 0 {
            return Vec::new();
        }

        let natural_widths = self.measure_column_widths(&header_row, &body_rows, column_count);
        let widths = match available_width {
            Some(available_width) => {
                let min_cell_width = 3usize;
                let min_total = table_border_overhead(column_count)
                    .saturating_add(column_count.saturating_mul(min_cell_width));

                if available_width < min_total && !body_rows.is_empty() {
                    return self.render_stacked_rows(&header_row, &body_rows, available_width);
                }

                let content_budget =
                    available_width.saturating_sub(table_border_overhead(column_count));
                match shrink_table_widths(natural_widths, content_budget, min_cell_width) {
                    Some(widths) => widths,
                    None => {
                        return self.render_stacked_rows(&header_row, &body_rows, available_width);
                    }
                }
            }
            None => natural_widths,
        };

        let mut out = Vec::new();
        out.push(self.render_border_line('', '', '', &widths));
        out.extend(self.render_row_block(&header_row, &widths, true));

        if !body_rows.is_empty() {
            out.push(self.render_border_line('', '', '', &widths));

            for (index, row) in body_rows.iter().enumerate() {
                out.extend(self.render_row_block(row, &widths, false));
                if index + 1 < body_rows.len() {
                    out.push(self.render_border_line('', '', '', &widths));
                }
            }
        }

        out.push(self.render_border_line('', '', '', &widths));
        out
    }

    fn measure_column_widths(
        &self,
        header_row: &TableRowState,
        body_rows: &[TableRowState],
        column_count: usize,
    ) -> Vec<usize> {
        let mut widths = vec![1usize; column_count];

        for row in std::iter::once(header_row).chain(body_rows.iter()) {
            for (index, cell) in row.cells.iter().enumerate().take(column_count) {
                widths[index] = widths[index].max(display_line_width(cell).max(1));
            }
        }

        widths
    }

    fn render_row_block(
        &self,
        row: &TableRowState,
        widths: &[usize],
        is_header: bool,
    ) -> Vec<Line<'static>> {
        let wrapped_cells: Vec<Vec<Line<'static>>> = row
            .cells
            .iter()
            .enumerate()
            .map(|(index, cell)| {
                let width = widths.get(index).copied().unwrap_or(1).max(1);
                let wrapped = word_wrap_line(cell, RtOptions::new(width).break_words(true));
                let mut owned = Vec::new();
                push_owned_lines(&wrapped, &mut owned);
                if owned.is_empty() {
                    vec![Line::default()]
                } else {
                    owned
                }
            })
            .collect();

        let row_height = wrapped_cells.iter().map(Vec::len).max().unwrap_or(1);
        let row_style = if is_header {
            self.base_style.add_modifier(Modifier::BOLD)
        } else {
            self.base_style
        };

        let mut out = Vec::with_capacity(row_height);
        for line_index in 0..row_height {
            let mut spans = self.prefix.clone();
            spans.push(Span::raw(""));

            for (column_index, &width) in widths.iter().enumerate() {
                spans.push(Span::raw(" "));
                let cell_line = wrapped_cells
                    .get(column_index)
                    .and_then(|lines| lines.get(line_index))
                    .cloned()
                    .unwrap_or_default();
                spans.extend(pad_cell_spans(
                    cell_line,
                    width,
                    self.alignments
                        .get(column_index)
                        .copied()
                        .unwrap_or(Alignment::Left),
                ));
                spans.push(Span::raw(" "));
                spans.push(Span::raw(""));
            }

            out.push(Line::from_iter(spans).style(row_style));
        }

        out
    }

    fn render_border_line(
        &self,
        left: char,
        middle: char,
        right: char,
        widths: &[usize],
    ) -> Line<'static> {
        let mut spans = self.prefix.clone();
        spans.push(Span::raw(left.to_string()));

        for index in 0..widths.len() {
            spans.push(Span::raw("".repeat(widths[index] + 2)));
            if index + 1 < widths.len() {
                spans.push(Span::raw(middle.to_string()));
            }
        }

        spans.push(Span::raw(right.to_string()));
        Line::from_iter(spans).style(self.base_style)
    }

    fn render_stacked_rows(
        &self,
        header_row: &TableRowState,
        body_rows: &[TableRowState],
        available_width: usize,
    ) -> Vec<Line<'static>> {
        if body_rows.is_empty() {
            return Vec::new();
        }

        let card_width = available_width.saturating_sub(4).max(1);
        let mut out = Vec::new();
        for (row_index, row) in body_rows.iter().enumerate() {
            if row_index > 0 {
                out.push(Line::default());
            }

            out.push(self.render_border_line('', '', '', &[card_width]));
            out.extend(self.render_stacked_row(header_row, row, card_width));
            out.push(self.render_border_line('', '', '', &[card_width]));
        }

        out
    }

    fn render_stacked_row(
        &self,
        header_row: &TableRowState,
        row: &TableRowState,
        card_width: usize,
    ) -> Vec<Line<'static>> {
        let label_style = self.base_style.add_modifier(Modifier::BOLD);
        let mut out = Vec::new();
        let column_count = header_row.cells.len().max(row.cells.len());

        for index in 0..column_count {
            let label = header_row
                .cells
                .get(index)
                .map(line_to_plain_text)
                .filter(|value| !value.is_empty())
                .unwrap_or_else(|| format!("Column {}", index + 1));
            let value = row.cells.get(index).cloned().unwrap_or_default();

            let mut field = Line::from(vec![Span::styled(format!("{label}: "), label_style)]);
            field.spans.extend(value.spans);

            let wrapped = word_wrap_line(&field, RtOptions::new(card_width).break_words(true));
            let mut owned = Vec::new();
            push_owned_lines(&wrapped, &mut owned);

            for line in owned {
                let mut spans = self.prefix.clone();
                spans.push(Span::raw(""));
                spans.push(Span::raw(" "));
                spans.extend(pad_cell_spans(line, card_width, Alignment::Left));
                spans.push(Span::raw(" "));
                spans.push(Span::raw(""));
                out.push(Line::from_iter(spans).style(self.base_style));
            }
        }

        out
    }
}

fn table_border_overhead(column_count: usize) -> usize {
    column_count.saturating_mul(3).saturating_add(1)
}

fn shrink_table_widths(
    mut widths: Vec<usize>,
    target_total: usize,
    min_width: usize,
) -> Option<Vec<usize>> {
    let mut total: usize = widths.iter().sum();
    if total <= target_total {
        return Some(widths);
    }

    let minimum_total = widths.len().saturating_mul(min_width);
    if target_total < minimum_total {
        return None;
    }

    while total > target_total {
        let mut chosen_index = None;
        let mut chosen_room = 0usize;

        for (index, width) in widths.iter().enumerate() {
            let room = width.saturating_sub(min_width);
            if room > chosen_room {
                chosen_room = room;
                chosen_index = Some(index);
            }
        }

        let index = chosen_index?;

        if widths[index] <= min_width {
            return None;
        }

        widths[index] -= 1;
        total -= 1;
    }

    Some(widths)
}

fn line_to_plain_text(line: &Line<'_>) -> String {
    line.spans
        .iter()
        .map(|span| span.content.as_ref())
        .collect::<String>()
}

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

fn pad_cell_spans(cell: Line<'static>, width: usize, alignment: Alignment) -> Vec<Span<'static>> {
    let cell_width = display_line_width(&cell);
    let padding = width.saturating_sub(cell_width);
    let (left_pad, right_pad) = match alignment {
        Alignment::Center => (padding / 2, padding.saturating_sub(padding / 2)),
        Alignment::Right => (padding, 0),
        Alignment::Left | Alignment::None => (0, padding),
    };

    let mut spans = Vec::new();
    if left_pad > 0 {
        spans.push(Span::from(" ".repeat(left_pad)));
    }
    spans.extend(cell.spans);
    if right_pad > 0 {
        spans.push(Span::from(" ".repeat(right_pad)));
    }

    spans
}