Skip to main content
Module

x/cliffy/table/layout.ts

Command line framework for deno 🦕 Including Commandline-Interfaces, Prompts, CLI-Table, Arguments Parser and more...
Extremely Popular
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
import { Cell, Direction, ICell } from "./cell.ts";import { stripColor } from "./deps.ts";import { IRow, Row } from "./row.ts";import type { IBorderOptions, ITableSettings, Table } from "./table.ts";import { consumeWords, longest } from "./utils.ts";
/** Layout render settings. */interface IRenderSettings { padding: number[]; width: number[]; columns: number; hasBorder: boolean; hasHeaderBorder: boolean; hasBodyBorder: boolean; rows: Row<Cell>[];}
/** Table layout renderer. */export class TableLayout { /** * Table layout constructor. * @param table Table instance. * @param options Render options. */ public constructor( private table: Table, private options: ITableSettings, ) {}
/** Generate table string. */ public toString(): string { const opts: IRenderSettings = this.createLayout(); return opts.rows.length ? this.renderRows(opts) : ""; }
/** * Generates table layout including row and col span, converts all none * Cell/Row values to Cell's and Row's and returns the layout rendering * settings. */ protected createLayout(): IRenderSettings { Object.keys(this.options.chars).forEach((key: string) => { if (typeof this.options.chars[key as keyof IBorderOptions] !== "string") { this.options.chars[key as keyof IBorderOptions] = ""; } });
const hasBodyBorder: boolean = this.table.getBorder() || this.table.hasBodyBorder(); const hasHeaderBorder: boolean = this.table.hasHeaderBorder(); const hasBorder: boolean = hasHeaderBorder || hasBodyBorder;
const header: Row | undefined = this.table.getHeader(); const rows: Row<Cell>[] = this.spanRows( header ? [header, ...this.table] : this.table.slice(), ); const columns: number = Math.max(...rows.map((row) => row.length)); for (const row of rows) { const length: number = row.length; if (length < columns) { const diff = columns - length; for (let i = 0; i < diff; i++) { row.push(this.createCell(null, row)); } } }
const padding: number[] = []; const width: number[] = []; for (let colIndex = 0; colIndex < columns; colIndex++) { const minColWidth: number = Array.isArray(this.options.minColWidth) ? this.options.minColWidth[colIndex] : this.options.minColWidth; const maxColWidth: number = Array.isArray(this.options.maxColWidth) ? this.options.maxColWidth[colIndex] : this.options.maxColWidth; const colWidth: number = longest(colIndex, rows, maxColWidth); width[colIndex] = Math.min(maxColWidth, Math.max(minColWidth, colWidth)); padding[colIndex] = Array.isArray(this.options.padding) ? this.options.padding[colIndex] : this.options.padding; }
return { padding, width, rows, columns, hasBorder, hasBodyBorder, hasHeaderBorder, }; }
/** * Fills rows and cols by specified row/col span with a reference of the * original cell. * * @param _rows All table rows. * @param rowIndex Current row index. * @param colIndex Current col index. * @param rowSpan Current row span. * @param colSpan Current col span. */ protected spanRows( _rows: IRow[], rowIndex = 0, colIndex = 0, rowSpan: number[] = [], colSpan = 1, ): Row<Cell>[] { const rows: Row<Cell>[] = _rows as Row<Cell>[];
if (rowIndex >= rows.length && rowSpan.every((span) => span === 1)) { return rows; } else if ( rows[rowIndex] && colIndex >= rows[rowIndex].length && colIndex >= rowSpan.length && colSpan === 1 ) { return this.spanRows(rows, ++rowIndex, 0, rowSpan, 1); }
if (colSpan > 1) { colSpan--; rowSpan[colIndex] = rowSpan[colIndex - 1]; rows[rowIndex].splice(colIndex - 1, 0, rows[rowIndex][colIndex - 1]); return this.spanRows(rows, rowIndex, ++colIndex, rowSpan, colSpan); }
if (colIndex === 0) { rows[rowIndex] = this.createRow(rows[rowIndex] || []); }
if (rowSpan[colIndex] > 1) { rowSpan[colIndex]--; rows[rowIndex].splice(colIndex, 0, rows[rowIndex - 1][colIndex]); return this.spanRows(rows, rowIndex, ++colIndex, rowSpan, colSpan); }
rows[rowIndex][colIndex] = this.createCell( rows[rowIndex][colIndex] || null, rows[rowIndex], );
colSpan = rows[rowIndex][colIndex].getColSpan(); rowSpan[colIndex] = rows[rowIndex][colIndex].getRowSpan();
return this.spanRows(rows, rowIndex, ++colIndex, rowSpan, colSpan); }
/** * Create a new row from existing row or cell array. * @param row Original row. */ protected createRow(row: IRow): Row<Cell> { return Row.from(row) .border(this.table.getBorder(), false) .align(this.table.getAlign(), false) as Row<Cell>; }
/** * Create a new cell from existing cell or cell value. * @param cell Original cell. * @param row Parent row. */ protected createCell(cell: ICell | null, row: Row): Cell { return Cell.from(cell ?? "") .border(row.getBorder(), false) .align(row.getAlign(), false); }
/** * Render table layout. * @param opts Render options. */ protected renderRows(opts: IRenderSettings): string { let result = ""; const rowSpan: number[] = new Array(opts.columns).fill(1);
for (let rowIndex = 0; rowIndex < opts.rows.length; rowIndex++) { result += this.renderRow(rowSpan, rowIndex, opts); }
return result.slice(0, -1); }
/** * Render row. * @param rowSpan Current row span. * @param rowIndex Current row index. * @param opts Render options. * @param isMultiline Is multiline row. */ protected renderRow( rowSpan: number[], rowIndex: number, opts: IRenderSettings, isMultiline?: boolean, ): string { const row: Row<Cell> = opts.rows[rowIndex]; const prevRow: Row<Cell> | undefined = opts.rows[rowIndex - 1]; const nextRow: Row<Cell> | undefined = opts.rows[rowIndex + 1]; let result = "";
let colSpan = 1;
// border top row if (!isMultiline && rowIndex === 0 && row.hasBorder()) { result += this.renderBorderRow(undefined, row, rowSpan, opts); }
let isMultilineRow = false;
result += " ".repeat(this.options.indent || 0);
for (let colIndex = 0; colIndex < opts.columns; colIndex++) { if (colSpan > 1) { colSpan--; rowSpan[colIndex] = rowSpan[colIndex - 1]; continue; }
result += this.renderCell(colIndex, row, opts);
if (rowSpan[colIndex] > 1) { if (!isMultiline) { rowSpan[colIndex]--; } } else if (!prevRow || prevRow[colIndex] !== row[colIndex]) { rowSpan[colIndex] = row[colIndex].getRowSpan(); }
colSpan = row[colIndex].getColSpan();
if (rowSpan[colIndex] === 1 && row[colIndex].length) { isMultilineRow = true; } }
if (opts.columns > 0) { if (row[opts.columns - 1].getBorder()) { result += this.options.chars.right; } else if (opts.hasBorder) { result += " "; } }
result += "\n";
if (isMultilineRow) { // skip border return result + this.renderRow(rowSpan, rowIndex, opts, isMultilineRow); }
// border mid row if ( (rowIndex === 0 && opts.hasHeaderBorder) || (rowIndex < opts.rows.length - 1 && opts.hasBodyBorder) ) { result += this.renderBorderRow(row, nextRow, rowSpan, opts); }
// border bottom row if (rowIndex === opts.rows.length - 1 && row.hasBorder()) { result += this.renderBorderRow(row, undefined, rowSpan, opts); }
return result; }
/** * Render cell. * @param colIndex Current col index. * @param row Current row. * @param opts Render options. * @param noBorder Disable border. */ protected renderCell( colIndex: number, row: Row<Cell>, opts: IRenderSettings, noBorder?: boolean, ): string { let result = ""; const prevCell: Cell | undefined = row[colIndex - 1];
const cell: Cell = row[colIndex];
if (!noBorder) { if (colIndex === 0) { if (cell.getBorder()) { result += this.options.chars.left; } else if (opts.hasBorder) { result += " "; } } else { if (cell.getBorder() || prevCell?.getBorder()) { result += this.options.chars.middle; } else if (opts.hasBorder) { result += " "; } } }
let maxLength: number = opts.width[colIndex];
const colSpan: number = cell.getColSpan(); if (colSpan > 1) { for (let o = 1; o < colSpan; o++) { // add padding and with of next cell maxLength += opts.width[colIndex + o] + opts.padding[colIndex + o]; if (opts.hasBorder) { // add padding again and border with maxLength += opts.padding[colIndex + o] + 1; } } }
const { current, next } = this.renderCellValue(cell, maxLength);
row[colIndex].setValue(next);
if (opts.hasBorder) { result += " ".repeat(opts.padding[colIndex]); }
result += current;
if (opts.hasBorder || colIndex < opts.columns - 1) { result += " ".repeat(opts.padding[colIndex]); }
return result; }
/** * Render specified length of cell. Returns the rendered value and a new cell * with the rest value. * @param cell Cell to render. * @param maxLength Max length of content to render. */ protected renderCellValue( cell: Cell, maxLength: number, ): { current: string; next: Cell } { const length: number = Math.min( maxLength, stripColor(cell.toString()).length, ); let words: string = consumeWords(length, cell.toString());
// break word if word is longer than max length const breakWord = stripColor(words).length > length; if (breakWord) { words = words.slice(0, length); }
// get next content and remove leading space if breakWord is not true const next = cell.toString().slice(words.length + (breakWord ? 0 : 1)); const fillLength = maxLength - stripColor(words).length;
// Align content const align: Direction = cell.getAlign(); let current: string; if (fillLength === 0) { current = words; } else if (align === "left") { current = words + " ".repeat(fillLength); } else if (align === "center") { current = " ".repeat(Math.floor(fillLength / 2)) + words + " ".repeat(Math.ceil(fillLength / 2)); } else if (align === "right") { current = " ".repeat(fillLength) + words; } else { throw new Error("Unknown direction: " + align); }
return { current, next: cell.clone(next), }; }
/** * Render border row. * @param prevRow Previous row. * @param nextRow Next row. * @param rowSpan Current row span. * @param opts Render options. */ protected renderBorderRow( prevRow: Row<Cell> | undefined, nextRow: Row<Cell> | undefined, rowSpan: number[], opts: IRenderSettings, ): string { let result = "";
let colSpan = 1; for (let colIndex = 0; colIndex < opts.columns; colIndex++) { if (rowSpan[colIndex] > 1) { if (!nextRow) { throw new Error("invalid layout"); } if (colSpan > 1) { colSpan--; continue; } } result += this.renderBorderCell( colIndex, prevRow, nextRow, rowSpan, opts, ); colSpan = nextRow?.[colIndex].getColSpan() ?? 1; }
return result.length ? " ".repeat(this.options.indent) + result + "\n" : ""; }
/** * Render border cell. * @param colIndex Current index. * @param prevRow Previous row. * @param nextRow Next row. * @param rowSpan Current row span. * @param opts Render options. */ protected renderBorderCell( colIndex: number, prevRow: Row<Cell> | undefined, nextRow: Row<Cell> | undefined, rowSpan: number[], opts: IRenderSettings, ): string { // a1 | b1 // ------- // a2 | b2
const a1: Cell | undefined = prevRow?.[colIndex - 1]; const a2: Cell | undefined = nextRow?.[colIndex - 1]; const b1: Cell | undefined = prevRow?.[colIndex]; const b2: Cell | undefined = nextRow?.[colIndex];
const a1Border = !!a1?.getBorder(); const a2Border = !!a2?.getBorder(); const b1Border = !!b1?.getBorder(); const b2Border = !!b2?.getBorder();
const hasColSpan = (cell: Cell | undefined): boolean => (cell?.getColSpan() ?? 1) > 1; const hasRowSpan = (cell: Cell | undefined): boolean => (cell?.getRowSpan() ?? 1) > 1;
let result = "";
if (colIndex === 0) { if (rowSpan[colIndex] > 1) { if (b1Border) { result += this.options.chars.left; } else { result += " "; } } else if (b1Border && b2Border) { result += this.options.chars.leftMid; } else if (b1Border) { result += this.options.chars.bottomLeft; } else if (b2Border) { result += this.options.chars.topLeft; } else { result += " "; } } else if (colIndex < opts.columns) { if ((a1Border && b2Border) || (b1Border && a2Border)) { const a1ColSpan: boolean = hasColSpan(a1); const a2ColSpan: boolean = hasColSpan(a2); const b1ColSpan: boolean = hasColSpan(b1); const b2ColSpan: boolean = hasColSpan(b2);
const a1RowSpan: boolean = hasRowSpan(a1); const a2RowSpan: boolean = hasRowSpan(a2); const b1RowSpan: boolean = hasRowSpan(b1); const b2RowSpan: boolean = hasRowSpan(b2);
const hasAllBorder = a1Border && b2Border && b1Border && a2Border; const hasAllRowSpan = a1RowSpan && b1RowSpan && a2RowSpan && b2RowSpan; const hasAllColSpan = a1ColSpan && b1ColSpan && a2ColSpan && b2ColSpan;
if (hasAllRowSpan && hasAllBorder) { result += this.options.chars.middle; } else if (hasAllColSpan && hasAllBorder && a1 === b1 && a2 === b2) { result += this.options.chars.mid; } else if (a1ColSpan && b1ColSpan && a1 === b1) { result += this.options.chars.topMid; } else if (a2ColSpan && b2ColSpan && a2 === b2) { result += this.options.chars.bottomMid; } else if (a1RowSpan && a2RowSpan && a1 === a2) { result += this.options.chars.leftMid; } else if (b1RowSpan && b2RowSpan && b1 === b2) { result += this.options.chars.rightMid; } else { result += this.options.chars.midMid; } } else if (a1Border && b1Border) { if (hasColSpan(a1) && hasColSpan(b1) && a1 === b1) { result += this.options.chars.bottom; } else { result += this.options.chars.bottomMid; } } else if (b1Border && b2Border) { if (rowSpan[colIndex] > 1) { result += this.options.chars.left; } else { result += this.options.chars.leftMid; } } else if (b2Border && a2Border) { if (hasColSpan(a2) && hasColSpan(b2) && a2 === b2) { result += this.options.chars.top; } else { result += this.options.chars.topMid; } } else if (a1Border && a2Border) { if (hasRowSpan(a1) && a1 === a2) { result += this.options.chars.right; } else { result += this.options.chars.rightMid; } } else if (a1Border) { result += this.options.chars.bottomRight; } else if (b1Border) { result += this.options.chars.bottomLeft; } else if (a2Border) { result += this.options.chars.topRight; } else if (b2Border) { result += this.options.chars.topLeft; } else { result += " "; } }
const length = opts.padding[colIndex] + opts.width[colIndex] + opts.padding[colIndex];
if (rowSpan[colIndex] > 1 && nextRow) { result += this.renderCell( colIndex, nextRow, opts, true, ); if (nextRow[colIndex] === nextRow[nextRow.length - 1]) { if (b1Border) { result += this.options.chars.right; } else { result += " "; } return result; } } else if (b1Border && b2Border) { result += this.options.chars.mid.repeat(length); } else if (b1Border) { result += this.options.chars.bottom.repeat(length); } else if (b2Border) { result += this.options.chars.top.repeat(length); } else { result += " ".repeat(length); }
if (colIndex === opts.columns - 1) { if (b1Border && b2Border) { result += this.options.chars.rightMid; } else if (b1Border) { result += this.options.chars.bottomRight; } else if (b2Border) { result += this.options.chars.topRight; } else { result += " "; } }
return result; }}