Last active
March 18, 2025 21:16
-
-
Save nberlette/b3b78115b31fad9a49124c5a815c02ec to your computer and use it in GitHub Desktop.
Advanced Unicode Table Rendering
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Table } from "./unicode_table.ts"; | |
// #region demo | |
/** | |
* @example | |
* ```ts | |
* // Example usage: | |
* const table = new Table() | |
* .data([ | |
* ["Name", "Age", "City"], | |
* ["Alice", "24", "New York"], | |
* ["Bob", "30", "Los Angeles"], | |
* ]) | |
* .rowBorder(0, "heavy") | |
* .columnBorder(2, "heavy"); | |
* | |
* // We'll add a new row: | |
* table.addRow("Charlie","45","Chicago"); | |
* | |
* // Move Bob's row to the end: | |
* table.moveRow(2, -1); | |
* | |
* // Insert a new row at index 1: | |
* table.insertRow(1, "Eve","99","Wonderland"); | |
* | |
* // Remove row 2: | |
* table.removeRow(2); | |
* | |
* // Update a cell: | |
* table.updateCell(1, 1, "100"); | |
* | |
* // print the table: | |
* console.log(table.render()); | |
* | |
* // or simply | |
* console.log(`${table}`); | |
* ``` | |
* | |
* @category Terminal Table | |
*/ | |
const table = new Table().data([ | |
["Name", "Age", "City"], | |
["Alice", "24", "New York"], | |
["Bob", "30", "Los Angeles"], | |
["Charlie", "40", "Boston"], | |
["David", "50", "San Francisco"], | |
]).border("light"); | |
table.rowBorder(0, "heavy").print(); | |
// ┏━━━━━━━━━┳━━━━━┳━━━━━━━━━━━━━━━┓ | |
// ┃ Name ┃ Age ┃ City ┃ | |
// ┡━━━━━━━━━╇━━━━━╇━━━━━━━━━━━━━━━┩ | |
// │ Alice │ 24 │ New York │ | |
// ├─────────┼─────┼───────────────┤ | |
// │ Bob │ 30 │ Los Angeles │ | |
// ├─────────┼─────┼───────────────┤ | |
// │ Charlie │ 40 │ Boston │ | |
// ├─────────┼─────┼───────────────┤ | |
// │ David │ 50 │ San Francisco │ | |
// └─────────┴─────┴───────────────┘ | |
table.columnBorder(2, "heavy").print(); | |
// ┏━━━━━━━━━┳━━━━━┳━━━━━━━━━━━━━━━┓ | |
// ┃ Name ┃ Age ┃ City ┃ | |
// ┡━━━━━━━━━╇━━━━━╋━━━━━━━━━━━━━━━┫ | |
// │ Alice │ 24 ┃ New York ┃ | |
// ├─────────┼─────╊━━━━━━━━━━━━━━━┫ | |
// │ Bob │ 30 ┃ Los Angeles ┃ | |
// ├─────────┼─────╊━━━━━━━━━━━━━━━┫ | |
// │ Charlie │ 40 ┃ Boston ┃ | |
// ├─────────┼─────╊━━━━━━━━━━━━━━━┫ | |
// │ David │ 50 ┃ San Francisco ┃ | |
// └─────────┴─────┺━━━━━━━━━━━━━━━┛ | |
table.columnBorder(2, "light").columnBorder(1, "heavy").print(); | |
// ┏━━━━━━━━━┳━━━━━┳━━━━━━━━━━━━━━━┓ | |
// ┃ Name ┃ Age ┃ City ┃ | |
// ┡━━━━━━━━━╋━━━━━╋━━━━━━━━━━━━━━━┩ | |
// │ Alice ┃ 24 ┃ New York │ | |
// ├─────────╊━━━━━╉───────────────┤ | |
// │ Bob ┃ 30 ┃ Los Angeles │ | |
// ├─────────╊━━━━━╉───────────────┤ | |
// │ Charlie ┃ 40 ┃ Boston │ | |
// ├─────────╊━━━━━╉───────────────┤ | |
// │ David ┃ 50 ┃ San Francisco │ | |
// └─────────┺━━━━━┹───────────────┘ | |
table | |
.columnBorder(1, "light") | |
.rowBorder(0, "light") | |
.cellBorder(2, 1, "heavy") | |
.cellBorder(2, 2, "heavy") | |
.cellBorder(3, 0, "none") | |
.cellBorder(3, 1, "none") | |
.cellBorder(4, 1, "heavy") | |
.print(); | |
// ┌─────────┬─────┬───────────────┐ | |
// │ Name │ Age │ City │ | |
// ├─────────┼─────┼───────────────┤ | |
// │ Alice │ 24 │ New York │ | |
// ├─────────╆━━━━━╈━━━━━━━━━━━━━━━┪ | |
// │ Bob ┃ 30 ┃ Los Angeles ┃ | |
// └─────────┺━━━━━╇━━━━━━━━━━━━━━━┩ | |
// Charlie 40 │ Boston │ | |
// ┌─────────┲━━━━━╅───────────────┤ | |
// │ David ┃ 50 ┃ San Francisco │ | |
// └─────────┺━━━━━┹───────────────┘ | |
// #endregion demo |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
The MIT License (MIT) | |
Copyright (c) 2024-2025 Nicholas Berlette. All rights reserved. | |
Permission is hereby granted, free of charge, to any person obtaining a copy | |
of this software and associated documentation files (the "Software"), to deal | |
in the Software without restriction, including without limitation the rights | |
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
copies of the Software, and to permit persons to whom the Software is | |
furnished to do so, subject to the following conditions: | |
The above copyright notice and this permission notice shall be included in all | |
copies or substantial portions of the Software. | |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
SOFTWARE. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* This module provides a powerful unicode table rendering class that supports: | |
* | |
* - Unicode box-drawing with partial merges (heavy vs light borders). | |
* - Highlighting/accenting particular rows, columns, or cells. | |
* - Chainable table data manipulation methods (add/remove/move/change). | |
* - JSON serialization via `toJSON()` method. | |
* - extensible rendering options, alignment, etc. | |
* - extends Array<string[]> for easy data manipulation and table creation. | |
* | |
* By default, all borders are "light". You can set specific rows/cols/cells to | |
* "heavy" to accent them, and the partial-merge logic ensures heavy borders do | |
* not bleed over into light-bordered neighbors. | |
* | |
* @category Unicode Table | |
* @module unicode-table | |
*/ | |
export enum BorderStyleMap { | |
none = 0, | |
light = 1, | |
heavy = 2, | |
} | |
export type BorderStyle = string & keyof typeof BorderStyleMap; | |
export type BorderValue<K extends BorderStyle = BorderStyle> = typeof BorderStyleMap[K]; | |
export type Border = BorderStyle | BorderValue; | |
export type Alignment = "left" | "center" | "right"; | |
export type Overflow = "clip" | "ellipsis" | "wrap" | "break" | "none"; | |
/** | |
* General rendering options for the table. | |
* | |
* @category Terminal Table | |
*/ | |
export interface TableRenderOptions extends Style { | |
/** If true, columns are auto-sized to fit the widest cell in each column. */ | |
autoSize: boolean; | |
/** Text alignment within each cell. */ | |
align: Alignment; | |
/** Border style for the table. */ | |
border: Border; | |
/** Text overflow handling. */ | |
overflow: Overflow; | |
/** Text wrapping within cells. */ | |
wrap: boolean; | |
/** Padding for each cell. */ | |
padding: number; | |
/** | |
* Controls the wrapping of the text in cells. | |
* | |
* If true, the text will be wrapped to fit within the cell width. If false, | |
* the text will either be truncated or cause the cell to expand, depending | |
* on the `autoSize` option. | |
* | |
* @default {false} | |
*/ | |
wrap?: boolean; | |
} | |
/** | |
* Style overrides for rows/columns/cells. | |
*/ | |
export interface Style { | |
/** | |
* Controls the border style for the row/column/cell. | |
* - `"none"` - disable border for this element | |
* - `"light"` - default border style (thin lines) | |
* - `"heavy"` - thicker border style | |
* @default {"light"} | |
*/ | |
border: Border; | |
/** | |
* Alignment for the row/column/cell. | |
* - `"left"` - left-aligned | |
* - `"center"` - center-aligned | |
* - `"right"` - right-aligned | |
* @default {"left"} | |
*/ | |
align?: Alignment; | |
/** | |
* Padding for the row/column/cell. | |
* @default {0} | |
*/ | |
padding?: number; | |
/** | |
* Text color function for the row/column/cell. | |
* | |
* This should be a function that receives the string to be colored, and then | |
* returns it with the necessary colors applied (typically as ANSI escapes). | |
* | |
* Defaults to a pass-through function that makes no changes to the text. | |
* | |
* @default {identity} | |
*/ | |
color?: (text: string) => string; | |
/** | |
* Background color function for the row/column/cell. | |
* | |
* This should be a function that receives the string to be colored, and then | |
* returns it with the necessary colors applied (typically as ANSI escapes). | |
* | |
* Defaults to a pass-through function that makes no changes to the text. | |
* | |
* @default {identity} | |
*/ | |
bgColor?: (text: string) => string; | |
} | |
export interface RowStyle extends Style { | |
/** | |
* The row's border style. | |
*/ | |
border?: Border; | |
/** | |
* The row's alignment. | |
*/ | |
align?: Alignment; | |
/** | |
* The row's padding. | |
*/ | |
padding?: number; | |
} | |
export interface ColumnStyle extends Style { | |
/** | |
* The column's border style. Overrides row styles. | |
*/ | |
border?: Border; | |
/** | |
* The column's alignment. Overrides row styles. | |
*/ | |
align?: Alignment; | |
/** | |
* The column's padding. Overrides row styles. | |
*/ | |
padding?: number; | |
/** | |
* The column's width. Overrides row styles. | |
*/ | |
width?: number; | |
/** | |
* The column's height. Overrides row styles. | |
*/ | |
height?: number; | |
/** | |
* The column's text wrapping. Overrides row styles. | |
*/ | |
wrap?: boolean; | |
/** | |
* The column's text overflow. Overrides row styles. | |
*/ | |
overflow?: Overflow; | |
/** | |
* The column's maximum width. Overrides row styles. | |
*/ | |
maxWidth?: number; | |
/** | |
* The column's minimum width. Overrides row styles. | |
*/ | |
minWidth?: number; | |
} | |
export interface CellStyle extends Style { | |
/** | |
* The cell's border style. Overrides row/column styles. | |
*/ | |
border?: Border; | |
/** | |
* The cell's alignment. Overrides row/column styles. | |
*/ | |
align?: Alignment; | |
/** | |
* The cell's padding. Overrides row/column styles. | |
*/ | |
padding?: number; | |
/** | |
* The cell's text color. Overrides row/column styles. | |
*/ | |
color?: (text: string) => string; | |
/** | |
* The cell's background color. Overrides row/column styles. | |
*/ | |
bgColor?: (text: string) => string; | |
/** | |
* The cell's width. Overrides row/column styles. | |
*/ | |
width?: number; | |
/** | |
* The cell's height. Overrides row/column styles. | |
*/ | |
height?: number; | |
/** | |
* The cell's text wrapping. Overrides row/column styles. | |
*/ | |
wrap?: boolean; | |
/** | |
* The cell's text overflow. Overrides row/column styles. | |
*/ | |
overflow?: Overflow; | |
/** | |
* The cell's maximum width. Overrides row/column styles. | |
*/ | |
maxWidth?: number; | |
/** | |
* The cell's minimum width. Overrides row/column styles. | |
*/ | |
minWidth?: number; | |
/** | |
* The cell's row span. Overrides row/column styles. | |
*/ | |
rowSpan?: number; | |
/** | |
* The cell's column span. Overrides row/column styles. | |
*/ | |
colSpan?: number; | |
} | |
/** | |
* The main Table class, featuring: | |
* - Unicode box-drawing with partial merges (heavy vs light). | |
* - Highlight/accent rows, columns, cells. | |
* - Chainable methods for data manipulation. | |
*/ | |
export class Table extends Array<string[]> { | |
#rowStyles: Record<number, Style> = {}; | |
#columnStyles: Record<number, Style> = {}; | |
#cellStyles: Record<string, Style> = {}; | |
#options: TableRenderOptions = { | |
autoSize: true, | |
align: "left", | |
border: 1, | |
padding: 1, | |
overflow: "clip", | |
wrap: false, | |
}; | |
constructor(...data: RowLike[]) { | |
super(); | |
if (data.length) this.data(...data); | |
} | |
/** | |
* Sets the "header" row of the table, which is row 0. If the table is empty, | |
* this will create a new row with the given columns. Otherwise, the columns | |
* are inserted at index (row) 0, replacing the existing header. | |
* | |
* If you do not want to replace the existing data in row 0, you can either call | |
* `table.unshift(columns)` instead, or use `table.insertRow(0, columns)`. | |
* Gets the first row of the table, or an empty array if it's empty. | |
* | |
* @example | |
* ```ts | |
* const table = new Table() | |
* // no initial header row... so let's create one! | |
* .data([["Alice", 24], ["Bob", 30], ["Nick", 31]]); | |
* | |
* // first, we add a dummy row so we don't obliterate poor Alice... | |
* table.unshift([]); | |
* | |
* // now we set the new headers and print to stdout. chainable api! yeah! | |
* table.header("Name", "Age").print(); | |
* | |
* // or, to retrieve the current header: | |
* const header = table.header(); | |
* console.log(header); // ["Name", "Age"] | |
* ``` | |
*/ | |
get header(): OverloadedGetter< | |
string[], | |
this, | |
| readonly [col0: Printable, ...restCols: RowLike] | |
| readonly [...columns: [RowLike] | RowLike] | |
> { | |
return overloaded( | |
() => this[0] ??= [], | |
(...columns) => { | |
if (this.length === 0) this[0] = []; | |
this.splice(0, 1, columns.flat().map((v) => String(v ?? ""))); | |
return this; | |
}, | |
this, | |
); | |
} | |
get border(): OverloadedGetter<BorderValue, this, [Border]> { | |
return overloaded( | |
() => this.#options.border, | |
(border) => { | |
this.#options.border = toBorderValue(border); | |
return this; | |
}, | |
this, | |
); | |
} | |
#columns: string[] | null = null; | |
get columns(): string[] { | |
return this.#columns ??= [...this[0] ?? []]; | |
} | |
set columns(cols: string[]) { | |
this.#columns = cols; | |
// normalize | |
for (let i = 0; i < this.length; i++) { | |
while (this[i].length < cols.length) this[i].push(""); | |
this[i].length = cols.length; | |
} | |
} | |
/** | |
* Sets the data for the table. You can pass multiple row arrays or a single | |
* 2D array. Returns `this` for chaining. | |
* | |
* @example | |
* ```ts | |
* table.data(["Name","Age"],["Alice","24"]); | |
* table.data([ | |
* ["Name","Age"], | |
* ["Alice","24"] | |
* ]); | |
* ``` | |
*/ | |
data(...data: RowLike[] | [RowLike[]]): this { | |
this.length = 0; | |
if (data.length) { | |
if ( | |
data.length === 1 && Array.isArray(data[0]) && Array.isArray(data[0][0]) | |
) { | |
[data] = data; | |
} | |
this.push(...data.map((row) => row.map?.((v) => String(v ?? "")) ?? [])); | |
} | |
return this; | |
} | |
/** | |
* Sets the row-level style override. By default is "light". | |
*/ | |
rowStyle(index: number, style: Style): this { | |
// allow negative indices | |
if (index < 0) index += this.length; | |
// clamp to valid range | |
index = clamp(index, 0, this.length - 1); | |
// apply the style | |
this.#rowStyles[index] = style; | |
return this; | |
} | |
/** | |
* Sets the row-level border override. By default is "light". | |
*/ | |
rowBorder(index: number, border: BorderStyle): this { | |
// allow negative indices | |
if (index < 0) index += this.length; | |
// clamp to valid range | |
index = clamp(index, 0, this.length - 1); | |
// apply the border style | |
this.#rowStyles[index] = { border }; | |
return this; | |
} | |
/** | |
* Sets the column-level border override. By default is "light". | |
*/ | |
columnBorder(colIndex: number, border: BorderStyle): this { | |
this.#columnStyles[colIndex] = { border }; | |
return this; | |
} | |
/** | |
* Sets a particular cell's border style override. Highest precedence. | |
*/ | |
cellBorder(rowIndex: number, colIndex: number, border: BorderStyle): this { | |
this.#cellStyles[`${rowIndex},${colIndex}`] = { border }; | |
return this; | |
} | |
/** | |
* Configures table rendering options (autoSize, align). | |
*/ | |
options(opts: Partial<TableRenderOptions>): this { | |
this.#options = { ...this.#options, ...opts }; | |
return this; | |
} | |
/** | |
* Adds a new row at the end. The row can be given as an array of cells | |
* or multiple string arguments. Returns this for chaining. | |
* | |
* @example | |
* ```ts | |
* table.addRow("Foo","Bar","Baz"); | |
* table.addRow(["Foo","Bar","Baz"]); | |
* ``` | |
*/ | |
addRow(...cells: RowLike): this { | |
let row = cells; | |
if (row.length === 1 && Array.isArray(row[0])) [row] = row; | |
this.push(row.flat().map((v) => String(v ?? ""))); | |
return this; | |
} | |
/** | |
* Adds multiple rows at the end. Returns this for chaining. | |
* | |
* @example | |
* ```ts | |
* table.addRows(["Foo","Bar","Baz"],["Alice","Bob","Eve"]); | |
* ``` | |
*/ | |
addRows(...rows: readonly (readonly Printable[])[]): this { | |
for (const row of rows) this.addRow(row); | |
return this; | |
} | |
/** | |
* Inserts a row at the specified index. Negative indices allowed | |
* (like Python) to insert from the end. Returns this. | |
* | |
* @example | |
* ```ts | |
* table.insertRow(0, "Name","Age"); | |
* table.insertRow(-1, ["Eve","30"]); | |
* ``` | |
*/ | |
insertRow(index: number, ...cells: RowLike): this { | |
let row = cells; | |
if (row.length === 1 && Array.isArray(row[0])) [row] = row; | |
const len = this.length; | |
if (index < 0) index = len + index + 1; // insert after | |
if (index < 0) index = 0; | |
if (index > len) index = len; | |
this.splice(index, 0, row.flat().map((v) => String(v ?? ""))); | |
return this; | |
} | |
/** | |
* Removes a row at the given index (0-based). Negative index counts from end. | |
* Returns this for chaining. | |
* | |
* @example | |
* ```ts | |
* table.removeRow(1); // remove second row | |
* table.removeRow(-1); // remove last row | |
* ``` | |
*/ | |
removeRow( | |
index: number, | |
...replacements: Restable<readonly (readonly Printable[])[]> | |
): this { | |
const len = this.length; | |
if (index < 0) index = len + index; | |
if (index >= 0 && index < len) { | |
this.splice(index, 1); | |
// also remove relevant rowStyles, cellStyles | |
Reflect.deleteProperty(this.#rowStyles, index); | |
// shift rowStyles above if needed | |
const newRowStyles: Record<number, Style> = {}; | |
for (const rk of Object.keys(this.#rowStyles)) { | |
const rNum = parseInt(rk, 10); | |
if (rNum > index) { | |
newRowStyles[rNum - 1] = this.#rowStyles[rNum]; | |
} else { | |
newRowStyles[rNum] = this.#rowStyles[rNum]; | |
} | |
} | |
this.#rowStyles = newRowStyles; | |
// cellStyles | |
const newCellStyles: Record<string, Style> = {}; | |
for (const ck of Object.keys(this.#cellStyles)) { | |
const [rStr, cStr] = ck.split(","); | |
const rNum = parseInt(rStr, 10); | |
const cNum = parseInt(cStr, 10); | |
if (rNum < index) { | |
newCellStyles[ck] = this.#cellStyles[ck]; | |
} else if (rNum > index) { | |
newCellStyles[`${rNum - 1},${cNum}`] = this.#cellStyles[ck]; | |
} | |
} | |
this.#cellStyles = newCellStyles; | |
} | |
if ( | |
replacements.length === 1 && Array.isArray(replacements[0]) && | |
Array.isArray(replacements[0][0]) | |
) { | |
[replacements] = replacements; | |
} | |
return this.addRows( | |
...replacements.map((r) => r.flat(2).map((v) => String(v ?? ""))), | |
); | |
} | |
/** | |
* Moves a row from `fromIndex` to `toIndex`. Negative indices allowed. | |
* Returns this. | |
* | |
* @example | |
* ```ts | |
* table.moveRow(2, 0); // move row at index 2 to index 0 | |
* ``` | |
*/ | |
moveRow(fromIndex: number, toIndex: number): this { | |
const len = this.length; | |
fromIndex = fromIndex < 0 ? len + fromIndex : fromIndex; | |
fromIndex = Math.max(0, Math.min(len - 1, fromIndex)); | |
toIndex = toIndex < 0 ? len + toIndex : toIndex; | |
toIndex = Math.max(0, Math.min(len - 1, toIndex)); | |
if (fromIndex >= 0 && fromIndex < len && toIndex >= 0 && toIndex < len) { | |
const row = this.splice(fromIndex, 1)[0]; | |
this.splice(toIndex, 0, row); | |
// handle rowStyles | |
const oldStyle = this.#rowStyles[fromIndex]; | |
if (oldStyle) { | |
Reflect.deleteProperty(this.#rowStyles, fromIndex); | |
this.#rowStyles[toIndex] = oldStyle; | |
} | |
// we also need to shift everything in between | |
const newRowStyles: Record<number, Style> = {}; | |
for (const rk of Object.keys(this.#rowStyles)) { | |
const r = parseInt(rk, 10); | |
if (r !== fromIndex) newRowStyles[r] = this.#rowStyles[r]; | |
} | |
this.#rowStyles = {}; | |
for (const idx of this.keys()) { | |
if (newRowStyles[idx]) this.#rowStyles[idx] = newRowStyles[idx]; | |
} | |
// handle cellStyles similarly | |
const movedCellStyles: Record<string, Style> = {}; | |
const staticCellStyles: Record<string, Style> = {}; | |
for (const ck of Object.keys(this.#cellStyles)) { | |
const [rr, cc] = ck.split(","); | |
const rNum = parseInt(rr, 10); | |
const cNum = parseInt(cc, 10); | |
if (rNum === fromIndex) { | |
movedCellStyles[`${cNum}`] = this.#cellStyles[ck]; | |
} else { | |
staticCellStyles[ck] = this.#cellStyles[ck]; | |
} | |
} | |
const newCellStyles = { ...staticCellStyles }; | |
// re-walk the table in new order, applying moved row's cell styles to new row index | |
for (const n of Object.keys(movedCellStyles)) { | |
const style = movedCellStyles[n]; | |
newCellStyles[`${toIndex},${n}`] = style; | |
} | |
this.#cellStyles = {}; | |
// Then re-sequence them according to the final row order | |
// final row order is just 0..(len-1). | |
for (const ck of Object.keys(newCellStyles)) { | |
const [rStr, cStr] = ck.split(","); | |
const rNum = parseInt(rStr, 10), cNum = parseInt(cStr, 10); | |
// We'll do a quick pass for each cell style | |
// and see if the row index is within range. | |
if (rNum < len && cNum < (this[rNum]?.length ?? 0)) { | |
this.#cellStyles[ck] = newCellStyles[ck]; | |
} | |
} | |
} | |
return this; | |
} | |
/** | |
* Updates a single cell's content, expanding rows if needed. Returns this. | |
* | |
* @example | |
* ```ts | |
* table.cell(2, 1, "Hello"); | |
* ``` | |
*/ | |
cell<R extends number, C extends number, V extends Printable>( | |
rowIndex: R, | |
colIndex: C, | |
value: V, | |
): this & { [r in R]: { [c in C]: V } }; | |
cell<R extends number, C extends number, V extends Printable>( | |
coords: readonly [row: R, col: C], | |
value: V, | |
): this & { [r in R]: { [c in C]: V } }; | |
cell<R extends number, C extends number, V extends Printable>( | |
coords: `${R}${"," | "x" | ":" | "." | ";" | "@" | " "}${C}`, | |
value: V, | |
): this & { [r in R]: { [c in C]: V } }; | |
cell(rowIndex: number, colIndex: number, value: Printable): this; | |
cell( | |
rowIndex: number | readonly [number, number] | string, | |
colIndex: Printable, | |
value?: Printable, | |
): this { | |
let row = 0, col = 0; | |
const _value = value ?? colIndex; | |
if (Array.isArray(rowIndex)) { | |
if (rowIndex.length === 2 && arguments.length === 2) { | |
[row, col, value] = [...rowIndex, colIndex]; | |
} else { | |
[row, col, value = _value] = rowIndex; | |
} | |
} else if (typeof rowIndex === "string" && colIndex != null) { | |
const re = /(?<=\d)\s*\D\s*(?=-?\d)/; | |
if (!re.test(rowIndex)) { | |
if (!isNaN(+rowIndex) && !isNaN(+(colIndex + ""))) { | |
[row, col] = [+rowIndex, +(colIndex + "")]; | |
} else { | |
throw new RangeError( | |
`Invalid or out-of-range cell coordinates: ${rowIndex}\n\nMaximum row index: ${this.length}\nMaximum col index: ${this.columns.length}\n`, | |
); | |
} | |
} | |
const [r, c] = rowIndex.split(re).map((n) => +n); | |
[row, col, value = _value] = [r, c, colIndex]; | |
} | |
if (!isFinite(row) || !isFinite(col)) { | |
throw new RangeError( | |
`Invalid or out-of-range cell coordinates: ${row}, ${col}\n\nArguments received: (${rowIndex}, ${colIndex}, ${value})\nMaximum row number: ${this.length}\nMaximum col number: ${this.columns.length}\n`, | |
); | |
} | |
if (row < 0) row += this.length; | |
if (col < 0) col += this.columns.length; | |
while (this.length <= row) this.push([]); | |
while (this[row].length <= col) this[row].push("--"); | |
while (this.columns.length <= col) this.columns = this.columns.concat(""); | |
this[row].length = this.columns.length; | |
this[row][col] = String(value ?? ""); | |
return this; | |
} | |
/** | |
* Adds a column at the end, optionally providing the cell data for each row. | |
* If fewer cells than rows are provided, the remainder are empty strings. | |
* | |
* @example | |
* ```ts | |
* table.addColumn("Foo","Bar","Baz"); | |
* ``` | |
*/ | |
addColumn(...cells: readonly string[] | readonly [readonly string[]]): this { | |
if (Array.isArray(cells[0]) && cells.length === 1) cells = cells[0]; | |
const rowCount = this.length; | |
const maxCols = this.reduce((a, r) => Math.max(a, r.length), 0); | |
const minCols = this.reduce((a, r) => Math.min(a, r.length), maxCols); | |
for (let r = 0; r < rowCount; r++) { | |
const val = cells[r] ?? ""; | |
this[r].length = minCols; // ensure all rows have the same length | |
this[r].push(val + ""); // add the new cell (coerced to a string) | |
} | |
return this; // chainable like all methods, foo! | |
} | |
/** | |
* Removes the column at `colIndex`. Negative index counts from end. | |
* Returns this. | |
* | |
* @example | |
* ```ts | |
* table.removeColumn(2); | |
* table.removeColumn(-1); // last column | |
* ``` | |
*/ | |
removeColumn(colIndex: number): this { | |
const rowCount = this.length; | |
if (rowCount === 0) return this; | |
const colCount = this[0].length; | |
if (colIndex < 0) colIndex = colCount + colIndex; | |
if (colIndex < 0 || colIndex >= colCount) return this; | |
// remove data | |
for (let r = 0; r < rowCount; r++) { | |
if (colIndex < this[r].length) this[r].splice(colIndex, 1); | |
} | |
// remove columnStyles | |
Reflect.deleteProperty(this.#columnStyles, colIndex); | |
const newColStyles: Record<number, Style> = {}; | |
for (const ck of Object.keys(this.#columnStyles)) { | |
const cNum = parseInt(ck); | |
if (cNum > colIndex) { | |
newColStyles[cNum - 1] = this.#columnStyles[cNum]; | |
} else { | |
newColStyles[cNum] = this.#columnStyles[cNum]; | |
} | |
} | |
this.#columnStyles = newColStyles; | |
// remove cellStyles | |
const newCellStyles: Record<string, Style> = {}; | |
for (const cellKey of Object.keys(this.#cellStyles)) { | |
const [rStr, cStr] = cellKey.split(","); | |
const rNum = parseInt(rStr); | |
const cNum = parseInt(cStr); | |
if (cNum < colIndex) { | |
newCellStyles[cellKey] = this.#cellStyles[cellKey]; | |
} else if (cNum > colIndex) { | |
newCellStyles[`${rNum},${cNum - 1}`] = this.#cellStyles[cellKey]; | |
} | |
} | |
this.#cellStyles = newCellStyles; | |
return this; | |
} | |
/** | |
* Moves a column from `fromIndex` to `toIndex`. Negative indices allowed. | |
* Returns this. | |
*/ | |
moveColumn(fromIndex: number, toIndex: number): this { | |
const rowCount = this.length; | |
if (!rowCount) return this; | |
const colCount = this[0].length; | |
if (fromIndex < 0) fromIndex = colCount + fromIndex; | |
if (toIndex < 0) toIndex = colCount + toIndex; | |
if (fromIndex < 0 || fromIndex >= colCount) return this; | |
if (toIndex < 0 || toIndex >= colCount) return this; | |
if (fromIndex === toIndex) return this; | |
// move data | |
for (let r = 0; r < rowCount; r++) { | |
const row = this[r]; | |
const val = row.splice(fromIndex, 1)[0]; | |
row.splice(toIndex, 0, val); | |
} | |
// handle columnStyles | |
const oldStyle = this.#columnStyles[fromIndex]; | |
if (oldStyle) { | |
delete this.#columnStyles[fromIndex]; | |
this.#columnStyles[toIndex] = oldStyle; | |
} | |
// fix up the indexing | |
const newColStyles: Record<number, Style> = {}; | |
Object.keys(this.#columnStyles).forEach((ck) => { | |
const cNum = parseInt(ck, 10); | |
newColStyles[cNum] = this.#columnStyles[cNum]; | |
}); | |
this.#columnStyles = {}; | |
for (let c = 0; c < colCount; c++) { | |
if (newColStyles[c]) { | |
this.#columnStyles[c] = newColStyles[c]; | |
} | |
} | |
// handle cellStyles | |
const moved: Record<string, Style> = {}; | |
const remain: Record<string, Style> = {}; | |
for (const ck of Object.keys(this.#cellStyles)) { | |
const [rStr, cStr] = ck.split(","); | |
const rNum = parseInt(rStr, 10); | |
const cNum = parseInt(cStr, 10); | |
if (cNum === fromIndex) { | |
moved[`${rNum}`] = this.#cellStyles[ck]; | |
} else { | |
remain[ck] = this.#cellStyles[ck]; | |
} | |
} | |
// re-inject them at toIndex | |
const combined: Record<string, Style> = { ...remain }; | |
for (const mk of Object.keys(moved)) { | |
combined[`${mk},${toIndex}`] = moved[mk]; | |
} | |
// finalize | |
this.#cellStyles = {}; | |
for (const ck of Object.keys(combined)) { | |
const [rStr, cStr] = ck.split(","); | |
const rNum = parseInt(rStr, 10); | |
const cNum = parseInt(cStr, 10); | |
if (rNum < rowCount && cNum < colCount) { | |
this.#cellStyles[ck] = combined[ck]; | |
} | |
} | |
return this; | |
} | |
renderLines(): string[] { | |
const rowCount = this.length; | |
if (!rowCount) return []; | |
const colCount = Math.max(...this.map((r) => r.length)); | |
const colWidths = new Array(colCount).fill(0); | |
for (let r = 0; r < rowCount; r++) { | |
for (let c = 0; c < this[r].length; c++) { | |
const len = String(this[r][c] ?? "").length; | |
if (len > colWidths[c]) colWidths[c] = len; | |
} | |
} | |
const lines: string[] = []; | |
for (let r = 0; r < rowCount; r++) { | |
// top boundary or separator | |
if (r === 0) { | |
lines.push( | |
this.#renderHorizontalBoundary(r, "top", colCount, colWidths), | |
); | |
} else { | |
lines.push( | |
this.#renderHorizontalSeparator(r - 1, r, colCount, colWidths), | |
); | |
} | |
// row content | |
lines.push(this.renderRow(r, colWidths)); | |
} | |
// bottom boundary | |
lines.push( | |
this.#renderHorizontalBoundary( | |
rowCount - 1, | |
"bottom", | |
colCount, | |
colWidths, | |
), | |
); | |
return lines; | |
} | |
/** | |
* Renders the table to a string using the partial merging logic | |
* so that heavy borders do not bleed into light rows or columns. | |
*/ | |
render(noFixup?: boolean): string { | |
if (noFixup) return this.renderLines().join("\n"); | |
// fixup the unicode characters in the table | |
const matrix = this.renderMatrix(); | |
for (let y = 0; y < matrix.length; y++) { | |
for (let x = 0; x < matrix[y].length; x++) { | |
fixUnicodeAtPoint(matrix, x, y); | |
} | |
} | |
return matrix.map((r) => r.join("")).join("\n"); | |
} | |
renderMatrix(): string[][] { | |
return this.renderLines().map((line) => line.split("")); | |
} | |
/** | |
* Converts the table to a JSON-friendly object, containing the raw `data`, | |
* plus `rowStyles`, `columnStyles`, `cellStyles`, and `options`. | |
*/ | |
toJSON(): { | |
data: string[][]; | |
rowStyles: { [x: number]: Style }; | |
columnStyles: { [x: number]: Style }; | |
cellStyles: { [x: string]: Style }; | |
options: { | |
autoSize: boolean; | |
align: "left" | "center" | "right"; | |
border: BorderStyle; | |
}; | |
} { | |
return { | |
data: this.map((r) => r.slice()), | |
rowStyles: { ...this.#rowStyles }, | |
columnStyles: { ...this.#columnStyles }, | |
cellStyles: { ...this.#cellStyles }, | |
options: { ...this.#options }, | |
}; | |
} | |
/** | |
* Returns the string representation of this table by calling `render()`. | |
*/ | |
override toString(): string { | |
return this.render(); | |
} | |
/** | |
* Prints the table to the given writer (default is `Deno.stdout`). | |
*/ | |
print(writer: GenericWriter = Deno.stdout): void { | |
const text = this.render() + "\n"; | |
const write = "writeSync" in writer ? writer.writeSync : writer.write; | |
if (typeof write !== "function") return console.error(text); | |
const data = new TextEncoder().encode(text); | |
write.call(writer, data); | |
} | |
/** | |
* Allows `[...table]` or `for (const row of table)` to iterate over | |
* the table's rows. Each iteration yields one array of cell strings. | |
*/ | |
override *[Symbol.iterator](): Generator<string[]> { | |
for (const row of this.values()) yield row; | |
} | |
/** | |
* Allows `[].concat(table)` to spread out the table's rows as if | |
* it were just an array of arrays. (e.g. `[[...],[...],...]`). | |
*/ | |
get [Symbol.isConcatSpreadable](): boolean { | |
return true; | |
} | |
// #region internal methods | |
#renderHorizontalBoundary( | |
rowIndex: number, | |
position: "top" | "bottom", | |
colCount: number, | |
colWidths: number[], | |
): string { | |
const parts: string[] = []; | |
for (let c = 0; c < colCount; c++) { | |
const ichar = this.#pickIntersectionCharForBoundary( | |
rowIndex, | |
position, | |
c, | |
); | |
parts.push(ichar); | |
const runStyle = this.#pickHorizontalRunStyleForBoundary( | |
rowIndex, | |
position, | |
c, | |
); | |
const runSize = | |
(this.#options.autoSize ? colWidths[c] : colWidths[c] || 0) + 2; | |
parts.push(runStyle.repeat(runSize)); | |
if (c === colCount - 1) { | |
const icharRight = this.#pickIntersectionCharForBoundary( | |
rowIndex, | |
position, | |
c + 1, | |
); | |
parts.push(icharRight); | |
} | |
} | |
return parts.join(""); | |
} | |
#renderHorizontalSeparator( | |
r1: number, | |
r2: number, | |
colCount: number, | |
colWidths: number[], | |
): string { | |
const parts: string[] = []; | |
for (let c = 0; c < colCount; c++) { | |
const ichar = this.#pickIntersectionCharBetweenRows(r1, r2, c); | |
parts.push(ichar); | |
const runStyle = this.#pickHorizontalRunBetweenRows(r1, r2, c); | |
const runSize = | |
(this.#options.autoSize ? colWidths[c] : colWidths[c] || 0) + 2; | |
parts.push(runStyle.repeat(runSize)); | |
if (c === colCount - 1) { | |
const icharRight = this.#pickIntersectionCharBetweenRows(r1, r2, c + 1); | |
parts.push(icharRight); | |
} | |
} | |
return parts.join(""); | |
} | |
renderRow(r: number, colWidths: number[], colCount = colWidths.length): string { | |
const parts: string[] = []; | |
for (let c = 0; c < colCount; c++) { | |
const vchar = this.#pickVerticalBoundaryChar(r, c); | |
parts.push(vchar); | |
const content = this[r][c] ?? ""; | |
const width = this.#options.autoSize ? colWidths[c] : colWidths[c] || 0; | |
parts.push(this.#alignCell(content, width, this.#options.align)); | |
if (c === colCount - 1) { | |
const vcharEnd = this.#pickVerticalBoundaryChar(r, c + 1); | |
parts.push(vcharEnd); | |
} | |
} | |
return parts.join(""); | |
} | |
#pickIntersectionCharForBoundary( | |
rowIndex: number, | |
position: "top" | "bottom", | |
colIndex: number, | |
): string { | |
const rowCount = this.length; | |
const colCount = Math.max(...this.map((r) => r.length)); | |
let up: BorderStyle = "none"; | |
let down: BorderStyle = "none"; | |
if (position === "top") { | |
if (existsRow(rowIndex, rowCount)) { | |
down = this.#getCellBorderStyle(rowIndex, colIndex); | |
} | |
} else { | |
// bottom | |
if (existsRow(rowIndex, rowCount)) { | |
up = this.#getCellBorderStyle(rowIndex, colIndex); | |
} | |
} | |
let left: BorderStyle = "none"; | |
let right: BorderStyle = "none"; | |
if (existsCol(colIndex - 1, colCount)) { | |
left = this.#getCellBorderStyle(rowIndex, colIndex - 1); | |
} | |
if (existsCol(colIndex, colCount)) { | |
right = this.#getCellBorderStyle(rowIndex, colIndex); | |
} | |
return pickIntersection(up, right, down, left); | |
} | |
#pickHorizontalRunStyleForBoundary( | |
rowIndex: number, | |
_position: "top" | "bottom", | |
colIndex: number, | |
): string { | |
const style = this.#getCellBorderStyle(rowIndex, colIndex); | |
return style === "heavy" ? "━" : style === "light" ? "─" : " "; | |
} | |
#pickIntersectionCharBetweenRows( | |
r1: number, | |
r2: number, | |
colIndex: number, | |
): string { | |
const rowCount = this.length; | |
const colCount = Math.max(...this.map((r) => r.length)); | |
let up: BorderStyle = "none", down: BorderStyle = "none"; | |
let left: BorderStyle = "none", right: BorderStyle = "none"; | |
if (existsRow(r1, rowCount)) { | |
up = this.#getCellBorderStyle(r1, colIndex); | |
} | |
if (existsRow(r2, rowCount)) { | |
down = this.#getCellBorderStyle(r2, colIndex); | |
} | |
if (existsCol(colIndex - 1, colCount)) { | |
left = mergeRunStyle( | |
this.#getCellBorderStyle(r1, colIndex - 1), | |
this.#getCellBorderStyle(r2, colIndex - 1), | |
); | |
} | |
if (existsCol(colIndex, colCount)) { | |
right = mergeRunStyle( | |
this.#getCellBorderStyle(r1, colIndex), | |
this.#getCellBorderStyle(r2, colIndex), | |
); | |
} | |
return pickIntersection(up, right, down, left); | |
} | |
#pickHorizontalRunBetweenRows(r1: number, r2: number, c: number): string { | |
const s1 = this.#getCellBorderStyle(r1, c); | |
const s2 = this.#getCellBorderStyle(r2, c); | |
const merged = mergeRunStyle(s1, s2); | |
return merged === "heavy" ? "━" : merged === "light" ? "─" : " "; | |
} | |
#pickVerticalBoundaryChar(rowIndex: number, colIndex: number): string { | |
const colCount = Math.max(...this.map((r) => r.length)); | |
let leftStyle: BorderStyle = "none", rightStyle: BorderStyle = "none"; | |
if (existsCol(colIndex - 1, colCount)) { | |
leftStyle = this.#getCellBorderStyle(rowIndex, colIndex - 1); | |
} | |
if (existsCol(colIndex, colCount)) { | |
rightStyle = this.#getCellBorderStyle(rowIndex, colIndex); | |
} | |
const merged = mergeRunStyle(leftStyle, rightStyle); | |
return merged === "heavy" ? "┃" : merged === "light" ? "│" : " "; | |
} | |
#getCellBorderStyle(rowIndex: number, colIndex: number): BorderStyle { | |
const value = this.#getCellBorderValue(rowIndex, colIndex); | |
return value === 2 ? "heavy" : value === 1 ? "light" : "none"; | |
} | |
#getCellBorderValue(rowIndex: number, colIndex: number): BorderValue { | |
if (!existsRow(rowIndex, this.length)) return 0; | |
const row_b = this.#rowStyles[rowIndex]?.border; | |
const col_b = this.#columnStyles[colIndex]?.border; | |
const cell_b = this.#cellStyles[`${rowIndex},${colIndex}`]?.border; | |
const table_b = this.#options.border ??= "light"; | |
const table = toBorderValue(table_b); | |
const cell = toBorderValue(cell_b ?? table_b); | |
const row = toBorderValue(row_b ?? table_b); | |
const col = toBorderValue(col_b ?? table_b); | |
if (cell_b) return cell; | |
if (row_b) return row; | |
if (col_b) return col; | |
return table; | |
} | |
#alignCell( | |
text: string, | |
width: number, | |
align: "left" | "center" | "right", | |
): string { | |
if (align === "left") { | |
return " " + text.padEnd(width) + " "; | |
} else if (align === "right") { | |
return " " + text.padStart(width) + " "; | |
} else { | |
const diff = width - text.length; | |
const left = Math.floor(diff / 2), right = diff - left; | |
return " ".repeat(left + 1) + text + " ".repeat(right + 1); | |
} | |
} | |
// #endregion internal methods | |
} | |
// #region helpers | |
/** A 2D array of strings for the table data. */ | |
type TableData = readonly (readonly string[])[]; | |
type MaybeThenable<T> = T | PromiseLike<T>; | |
interface GenericWriterSync { | |
writeSync(data: Uint8Array): number | null | void; | |
} | |
interface GenericWriterAsync { | |
write(data: Uint8Array): MaybeThenable<number | null | void>; | |
} | |
type GenericWriter = GenericWriterSync | GenericWriterAsync; | |
type Arrayable<T> = T | readonly T[]; | |
type Restable<T> = T | readonly [T]; | |
export type Printable = string | number | boolean | bigint | null | undefined; | |
export type RowLike<T extends Printable = Printable> = Restable<readonly T[]>; | |
type OverloadedGetter<T, TReturn, TArgs extends readonly any[] = [value: T]> = { | |
(): T; | |
(...args: TArgs): TReturn; | |
}; | |
function overloaded< | |
T, | |
This = void, | |
TArgs extends readonly any[] = [value: T], | |
TReturn = This, | |
>( | |
getter: (this: This) => T, | |
setter: (this: This, ...args: TArgs) => TReturn, | |
thisArg?: This, | |
): OverloadedGetter<T, TReturn, TArgs> { | |
return function (this: This | void, ...args: TArgs) { | |
const t = thisArg ?? this!; | |
return args.length > 0 ? setter.call(t, ...args) : getter.call(t); | |
} as OverloadedGetter<T, TReturn, TArgs>; | |
} | |
/** | |
* Returns true if `rowIndex` is within [0, rowCount). | |
* "none" if out of range. | |
*/ | |
function existsRow(rowIndex: number, rowCount: number): boolean { | |
return rowIndex >= 0 && rowIndex < rowCount; | |
} | |
/** | |
* Returns true if `colIndex` is within [0, colCount). | |
* "none" if out of range. | |
*/ | |
function existsCol(colIndex: number, colCount: number): boolean { | |
return colIndex >= 0 && colIndex < colCount; | |
} | |
// #region unicode border helpers | |
/** | |
* Merge two styles into one for a horizontal or vertical run. | |
* If either is heavy, the run is heavy, else light. | |
*/ | |
function mergeRunStyle(s1: Border, s2: Border): BorderStyle { | |
s1 = toBorderValue(s1), s2 = toBorderValue(s2); | |
switch (Math.max(s1, s2)) { | |
case 2: return "heavy"; | |
case 1: return "light"; | |
default: return "none"; | |
} | |
} | |
function toBorderValue<K extends BorderStyle | BorderValue>( | |
style: K, | |
): K extends BorderStyle ? BorderValue<K> : K; | |
function toBorderValue(style: Border): BorderValue; | |
function toBorderValue(style: Border): BorderValue { | |
return typeof style === "string" ? BorderStyleMap[style] ?? 0 : style; | |
} | |
const LIGHT_TOP = "┌┬┐├┼┤┍┯┑┝┿┥╒╤╕╞╪╡╿┮┭┡╀┽┾┩╇┞╄╃┦│┊┆╎╮╭╷"; | |
const HEAVY_TOP = "┏┳┓┣╋┫┎┰┒┠╂┨╽┱┲╁┟╆╈╅╉╊┢┪┃┋┇╏╻"; | |
const LIGHT_RIGHT = "┬┐┼┤┴┘┰┒╂┨┸┚╥╖╫╢╨╜╼┮┲╀╁┾╆┧╄╊┦┶┺╯╮─┈┄╴"; | |
const HEAVY_RIGHT = "┳┓╋┫┻┛┯┑┿┥┷┙╾┱┭┽┩╈╇╅╉╃┹┪━┉┅╸"; | |
const LIGHT_BOTTOM = "├┼┤└┴┘┝┿┥┕┷┙╽┽╁┾┟╆╈╅┧┵┶┢╵│┊┆╎"; | |
const HEAVY_BOTTOM = "┣╋┫┗┻┛┠╂┨┖┸┚╿┡╀┩╇┞╄╉╃╊┦┹┺╹┃╏┋┇"; | |
const LIGHT_LEFT = "┌┬├┼└┴┎┰┠╂┖┸╾┱┭┽╀╁┟╅┞╉╃┹┵─┈┄╰╭╶"; | |
const HEAVY_LEFT = "┏┳┣╋┗┻┍┯┝┿┕┷╼┮┲┡┾╆╈╇╄╊┢┶┺━┉┅╺"; | |
const INTERCHANGEABLE_BOX_CHARS = | |
"┌┬┐├┼┤└┴┘┏┳┓┣╋┫┗┻┛┍┯┑┝┿┥┕┷┙┎┰┒┠╂┨┖┸┚╼╾╽╿┮┱┲┭┡┽╀╁┾┩┟╆╈╇╅┧┞╄╉╊╃┦┢┶┹┺┵┪│┃━─┊┋┆┇─┈┉┄┅╴╸╵╹╶╺╷╻"; | |
const BORDER_CHARS = { | |
"1111": "┼", | |
"1112": "┽", | |
"1110": "├", | |
"1121": "╁", | |
"1122": "╅", | |
"1120": "┟", | |
"1101": "┴", | |
"1102": "┵", | |
"1100": "└", | |
"1211": "┾", | |
"1212": "┿", | |
"1210": "┝", | |
"1221": "╆", | |
"1222": "╈", | |
"1220": "┢", | |
"1201": "┶", | |
"1202": "┷", | |
"1200": "┕", | |
"1011": "┤", | |
"1012": "┥", | |
"1010": "│", | |
"1021": "┧", | |
"1022": "┪", | |
"1020": "╽", | |
"1001": "┘", | |
"1002": "┙", | |
"2111": "╀", | |
"2112": "╃", | |
"2110": "┞", | |
"2121": "╂", | |
"2122": "╉", | |
"2120": "┠", | |
"2101": "┸", | |
"2102": "┹", | |
"2100": "┖", | |
"2211": "╄", | |
"2212": "╇", | |
"2210": "┡", | |
"2221": "╊", | |
"2222": "╋", | |
"2220": "┣", | |
"2201": "┺", | |
"2202": "┻", | |
"2200": "┗", | |
"2011": "┦", | |
"2012": "┩", | |
"2010": "╿", | |
"2021": "┨", | |
"2022": "┫", | |
"2020": "┃", | |
"2001": "┚", | |
"2002": "┛", | |
"0000": " ", | |
"0001": "╴", | |
"0002": "╸", | |
"0010": "╷", | |
"0020": "╻", | |
"0100": "╶", | |
"0200": "╺", | |
"1000": "╵", | |
"2000": "╹", | |
"0111": "┬", | |
"0112": "┭", | |
"0121": "┰", | |
"0122": "┱", | |
"0120": "┎", | |
"0101": "─", | |
"0102": "╾", | |
"0211": "┮", | |
"0212": "┯", | |
"0210": "┍", | |
"0221": "┲", | |
"0222": "┳", | |
"0220": "┏", | |
"0201": "╼", | |
"0202": "━", | |
"0011": "┐", | |
"0021": "┒", | |
"0022": "┓", | |
"0110": "┌", | |
"0012": "┑", | |
} as const; | |
function pickIntersection( | |
up: BorderStyle = "none", | |
right: BorderStyle = "none", | |
down: BorderStyle = "none", | |
left: BorderStyle = "none", | |
) { | |
const u = toBorderValue(up), r = toBorderValue(right); | |
const d = toBorderValue(down), l = toBorderValue(left); | |
const key = `${u}${r}${d}${l}` as const; | |
return BORDER_CHARS[key] ?? BORDER_CHARS["0000"]; | |
} | |
function fixUnicodeAtPoint( | |
table: string[][], | |
x: number, | |
y: number, | |
): string[][] { | |
let char = table[y][x]; | |
if (INTERCHANGEABLE_BOX_CHARS.includes(char)) { | |
const ctx = getUnicodeCharContext(table, x, y); | |
char = table[y][x] = fixUnicodeCharContextual(char, ctx); | |
} | |
const k = Object.entries(BORDER_CHARS).find(([, v]) => v === char)?.[0]; | |
if (k) { | |
const t = table[y - 1]?.[x]?.[2]; | |
const r = table[y][x + 1]?.[3]; | |
const b = table[y + 1]?.[x]?.[0]; | |
const l = table[y][x - 1]?.[1]; | |
if ( | |
char === BORDER_CHARS["0112"] && t === "0" && b === "2" && l === "2" | |
) { | |
char = table[y][x] = BORDER_CHARS["0122"]; | |
} else if (char === "┛" && b === "2" && l === "2" && t === "2") { | |
char = table[y][x] = BORDER_CHARS["2022"]; | |
} else if (char === "┽" && t === "2" && b === "2" && r === "1") { | |
char = table[y][x] = BORDER_CHARS["2122"]; | |
} else if (char === BORDER_CHARS["0002"]) { | |
if (r === "0" && (t === "2" && b === "2" || t == "2" && b === "0")) { | |
char = | |
table[y][x] = | |
BORDER_CHARS[`${t}${0}${b}${2}` as keyof typeof BORDER_CHARS]; | |
} | |
} else if (char === BORDER_CHARS["0001"]) { | |
if (r === "0" && (t === "1" && b === "1" || t == "1" && b === "0")) { | |
char = | |
table[y][x] = | |
BORDER_CHARS[`${t}${0}${b}${l}` as keyof typeof BORDER_CHARS]; | |
} | |
} | |
} | |
return table; | |
} | |
function getUnicodeCharContext( | |
table: string[][], | |
x: number, | |
y: number, | |
): `${string}${string}${string}${string}` { | |
let ctx = ""; | |
// top | |
ctx += y > 0 ? table[y - 1][x] || "*" : "*"; | |
// right | |
ctx += x < table[y]?.length - 1 ? table[y][x + 1] || "*" : "*"; | |
// bottom | |
ctx += y < table.length - 1 ? table[y + 1][x] || "*" : "*"; | |
// left | |
ctx += x > 0 ? table[y][x - 1] || "*" : "*"; | |
return ctx; | |
} | |
function fixUnicodeCharContextual( | |
char: string, | |
ctx: string, | |
): string { | |
const [top, right, bottom, left] = ctx; | |
let key = ""; | |
// top | |
if (HEAVY_BOTTOM.includes(char) || HEAVY_TOP.includes(top)) { | |
key += "2"; | |
} else if (LIGHT_BOTTOM.includes(char) || LIGHT_TOP.includes(top)) { | |
key += "1"; | |
} else { | |
key += "0"; | |
} | |
// right | |
if (LIGHT_LEFT.includes(char) || LIGHT_RIGHT.includes(right)) { | |
key += "1"; | |
} else if (HEAVY_LEFT.includes(char) || HEAVY_RIGHT.includes(right)) { | |
key += "2"; | |
} else { | |
key += "0"; | |
} | |
// bottom | |
if (HEAVY_TOP.includes(char) || HEAVY_BOTTOM.includes(bottom)) { | |
key += "2"; | |
} else if (LIGHT_TOP.includes(char) || LIGHT_BOTTOM.includes(bottom)) { | |
key += "1"; | |
} else { | |
key += "0"; | |
} | |
// left | |
if (LIGHT_RIGHT.includes(char) || LIGHT_LEFT.includes(left)) { | |
key += "1"; | |
} else if (HEAVY_RIGHT.includes(char) || HEAVY_LEFT.includes(left)) { | |
key += "2"; | |
} else { | |
key += "0"; | |
} | |
if ((top === " " || top === "*") && key[0] === "1") { | |
key = "0" + key.slice(1); | |
} else if ((bottom === " " || bottom === "*") && key[2] === "1") { | |
key = key.slice(0, 2) + "0" + key.slice(3); | |
} | |
return BORDER_CHARS[key as keyof typeof BORDER_CHARS] || char; | |
} | |
// #endregion unicode border helpers | |
function clamp( | |
value: number, | |
min: number, | |
max: number, | |
): number { | |
return Math.max(min, Math.min(max, value)); | |
} | |
// #endregion helpers |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment