cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Tabular text rendering for CLI list commands.
//!
//! Computes column widths from the **visual width** of each cell (using
//! `unicode-width`) so that multi-byte symbols (✓, ●, ◐, …) and ANSI colour
//! codes do not disturb alignment.
//!
//! The last column is the title/body column.  It is truncated with `…`
//! if the rendered line would exceed the terminal width.

/// Strip ANSI escape sequences from a string to get the printable content.
fn strip_ansi(s: &str) -> String {
    // Simple state-machine: skip everything between ESC[ and the final
    // letter [A-Za-z].
    let mut out = String::with_capacity(s.len());
    let mut in_escape = false;
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c == '\x1b' {
            // Consume the '[' that follows ESC (CSI sequence)
            if chars.next() == Some('[') {
                in_escape = true;
            }
        } else if in_escape {
            if c.is_ascii_alphabetic() {
                in_escape = false;
            }
        } else {
            out.push(c);
        }
    }
    out
}

/// Visual display width of a string, ignoring ANSI escape codes.
pub fn display_width(s: &str) -> usize {
    use unicode_width::UnicodeWidthStr;
    strip_ansi(s).as_str().width()
}

/// Detect the current terminal width.  Falls back to 120 if detection fails.
pub fn terminal_width() -> usize {
    use terminal_size::{terminal_size, Width};
    terminal_size()
        .map(|(Width(w), _)| w as usize)
        .unwrap_or(120)
}

/// A single cell in a table row.
#[derive(Clone)]
pub struct Cell {
    /// The rendered string to display (may contain ANSI codes).
    pub rendered: String,
    /// Visual width of the cell (ANSI-stripped).
    pub width: usize,
}

impl Cell {
    pub fn new(rendered: impl Into<String>) -> Self {
        let rendered = rendered.into();
        let width = display_width(&rendered);
        Self { rendered, width }
    }
}

impl From<String> for Cell {
    fn from(s: String) -> Self {
        Self::new(s)
    }
}

impl From<&str> for Cell {
    fn from(s: &str) -> Self {
        Self::new(s)
    }
}

/// Column gap (spaces between columns).
const GAP: usize = 2;

/// Pad a rendered cell to `target_width` visual columns.
///
/// Since the string may contain ANSI codes, we compute how many extra spaces
/// are needed based on visual width, not byte/char length.
fn pad_cell(cell: &Cell, target_width: usize) -> String {
    let padding = target_width.saturating_sub(cell.width);
    format!("{}{}", cell.rendered, " ".repeat(padding))
}

/// Truncate a plain string to `max_width` visual columns, appending `…`.
fn truncate(s: &str, max_width: usize) -> String {
    use unicode_width::UnicodeWidthChar;
    if max_width == 0 {
        return String::new();
    }
    let ellipsis_width = 1; // '…' is 1 visual column
    let budget = max_width.saturating_sub(ellipsis_width);
    let mut width = 0;
    let mut result = String::new();
    for c in s.chars() {
        let cw = c.width().unwrap_or(0);
        if width + cw > budget {
            result.push('');
            return result;
        }
        result.push(c);
        width += cw;
    }
    result // no truncation needed
}

/// A table that renders rows with auto-computed column widths.
///
/// - All columns except the last are padded to their maximum observed width.
/// - The last column (title) is truncated if the full line would exceed
///   `term_width`.
pub struct Table {
    rows: Vec<Vec<Cell>>,
    term_width: usize,
}

impl Table {
    pub fn new(term_width: usize) -> Self {
        Self {
            rows: Vec::new(),
            term_width,
        }
    }

    /// Append a row.  All rows must have the same number of columns.
    pub fn push(&mut self, cells: Vec<Cell>) {
        self.rows.push(cells);
    }

    /// Print all rows to stdout.
    pub fn print(&self) {
        if self.rows.is_empty() {
            return;
        }
        let ncols = self.rows[0].len();
        if ncols == 0 {
            return;
        }

        let mut col_widths: Vec<usize> = vec![0; ncols];
        for row in &self.rows {
            for (i, cell) in row.iter().enumerate() {
                if col_widths[i] < cell.width {
                    col_widths[i] = cell.width;
                }
            }
        }

        // Budget for the last (title) column:
        // term_width − sum(fixed_col_widths + GAPs)
        let fixed_width: usize = col_widths[..ncols - 1].iter().map(|w| w + GAP).sum();
        let title_budget = self.term_width.saturating_sub(fixed_width);

        for row in &self.rows {
            let mut line = String::new();
            for (i, cell) in row.iter().enumerate() {
                if i == ncols - 1 {
                    // Last column: truncate to budget (no trailing pad needed).
                    let plain = strip_ansi(&cell.rendered);
                    line.push_str(&truncate(&plain, title_budget));
                } else {
                    line.push_str(&pad_cell(cell, col_widths[i]));
                    // Gap between columns.
                    line.push_str(&" ".repeat(GAP));
                }
            }
            println!("{line}");
        }
    }
}