import { fg, gray, yellow } from "ansis" import stringWidth from "string-width" export const accent = yellow.bold export const dimmed = fg(250) /** Values supported by the terminal table renderer. */ type TableCellValue = string | number | boolean | bigint | null | undefined type TableInput = { columns: readonly { header: string cell: (row: Row) => TableCellValue }[] rows: readonly Row[] } /** * Renders rows and column definitions as a bordered terminal table. * * @param input - Table columns and row values. */ export function renderTable(input: TableInput) { const { columns, rows } = input const { bodyLines, headerLines, widths } = prepareTable({ columns, rows }) const border = { top: drawBorder("├", "┬", "┐", "─"), middle: drawBorder("├", "┼", "┤", "─"), bottom: drawBorder("├", "┴", "┘", "─"), } const lines = [border.top, drawRow(headerLines, true)] if (bodyLines.length === 0) { lines.push(border.bottom) return lines.join("\n") } lines.push(border.middle) for (const [index, row] of bodyLines.entries()) { lines.push(drawRow(row, false)) lines.push(index === bodyLines.length - 1 ? border.bottom : border.middle) } return lines.join("\n") /** * Draws a horizontal border using the table's computed column widths. * * @param left - Character at the left edge. * @param middle - Character joining adjacent columns. * @param right - Character at the right edge. * @param fill - Character repeated across each column. */ function drawBorder( left: string, middle: string, right: string, fill: string, ) { return gray( left + widths.map((width) => fill.repeat(width + 2)).join(middle) + right, ) } /** * Renders one logical row, including cells containing multiple lines. * * @param cells - Lines for each cell in the row. * @param isHeader - Whether to apply header styling. */ function drawRow(cells: readonly (readonly string[])[], isHeader: boolean) { const rowLines: string[] = [] for ( let lineIndex = 0; lineIndex < Math.max(...cells.map((cell) => cell.length)); lineIndex += 1 ) { rowLines.push( gray("│") + cells .map((cell, columnIndex) => { const content = padCell( cell[lineIndex] ?? "", widths[columnIndex] ?? 0, ) return isHeader ? yellow(content) : content }) .join(gray("│")) + gray("│"), ) } return rowLines.join("\n") } } /** * Expands table values into lines and calculates each column's width. * * @param input - Table columns and rows to prepare. */ function prepareTable(input: TableInput) { const { columns, rows } = input const headerLines = columns.map((column) => [column.header]) const bodyLines = rows.map((row) => columns.map((column) => String(column.cell(row) ?? "").split("\n")), ) const widths = columns.map((_, columnIndex) => Math.max( stringWidth(headerLines[columnIndex]?.[0] ?? ""), ...bodyLines.flatMap((row) => (row[columnIndex] ?? [""]).map((line) => stringWidth(line)), ), ), ) return { bodyLines, headerLines, lineWidth: widths.reduce((sum, width) => sum + width + 2, 0) + Math.max(widths.length - 1, 0) * 2, widths, } } /** * Pads a table cell to a target visible width. * * @param value - Cell text to pad. * @param width - Target display width excluding outer spaces. */ function padCell(value: string, width: number) { return ` ${value}${" ".repeat(width - stringWidth(value) + 1)}` }