diff --git a/table/_layout.ts b/table/_layout.ts index b9c3988f..998b4d28 100644 --- a/table/_layout.ts +++ b/table/_layout.ts @@ -1,9 +1,9 @@ import type { Column } from "./column.ts"; import { Cell, CellType, Direction } from "./cell.ts"; -import { consumeWords } from "./consume_words.ts"; +import { consumeChars, consumeWords } from "./consume_words.ts"; import { Row, RowType } from "./row.ts"; import type { BorderOptions, Table, TableSettings } from "./table.ts"; -import { longest, strLength } from "./_utils.ts"; +import { getUnclosedAnsiRuns, longest, strLength } from "./_utils.ts"; /** Layout render settings. */ interface RenderSettings { @@ -391,7 +391,7 @@ export class TableLayout { const { current, next } = this.renderCellValue(cell, maxLength); - row[colIndex].setValue(next.getValue()); + row[colIndex].setValue(next); if (opts.hasBorder) { result += " ".repeat(opts.padding[colIndex]); @@ -415,7 +415,7 @@ export class TableLayout { protected renderCellValue( cell: Cell, maxLength: number, - ): { current: string; next: Cell } { + ): { current: string; next: string } { const length: number = Math.min( maxLength, strLength(cell.toString()), @@ -425,11 +425,20 @@ export class TableLayout { // break word if word is longer than max length const breakWord = strLength(words) > length; if (breakWord) { - words = words.slice(0, length); + words = consumeChars(length, words); } // get next content and remove leading space if breakWord is not true + // calculate from words.length _before_ any handling of unclosed ANSI codes const next = cell.toString().slice(words.length + (breakWord ? 0 : 1)); + + words = cell.unclosedAnsiRuns + words; + + const { currentSuffix, nextPrefix } = getUnclosedAnsiRuns(words); + + words += currentSuffix; + cell.unclosedAnsiRuns = nextPrefix; + const fillLength = maxLength - strLength(words); // Align content @@ -448,10 +457,7 @@ export class TableLayout { throw new Error("Unknown direction: " + align); } - return { - current, - next: cell.clone(next), - }; + return { current, next }; } /** diff --git a/table/_utils.ts b/table/_utils.ts index 4dd962da..ba723b4d 100644 --- a/table/_utils.ts +++ b/table/_utils.ts @@ -39,3 +39,48 @@ export function longest( export const strLength = (str: string): number => { return unicodeWidth(stripColor(str)); }; + +/** Regex `source` to match any relevant ANSI code. */ +export const ansiRegexSource = + // deno-lint-ignore no-control-regex + /\x1b\[(?:(?<_0>0)|(?<_22>1|2|22)|(?<_23>3|23)|(?<_24>4|24)|(?<_27>7|27)|(?<_28>8|28)|(?<_29>9|29)|(?<_39>30|31|32|33|34|35|36|37|38;2;\d+;\d+;\d+|38;5;\d+|39|90|91|92|93|94|95|96|97)|(?<_49>40|41|42|43|44|45|46|47|48;2;\d+;\d+;\d+|48;5;\d+|49|100|101|102|103|104|105|106|107))m/ + .source; + +/** + * Get unclosed ANSI runs in a string. + * + * @param text - A string segment possibly containing unclosed ANSI runs. + */ +export function getUnclosedAnsiRuns(text: string) { + type Token = { kind: string; content: string }; + const tokens: Token[] = []; + for (const { groups } of text.matchAll(new RegExp(ansiRegexSource, "g"))) { + const [_kind, content] = Object.entries(groups!).find(([_, val]) => val)!; + tokens.push({ kind: _kind.slice(1), content }); + } + + let unclosed: Token[] = []; + for (const token of tokens) { + // Subsequent ANSI codes of a given kind automatically "close" previous + // codes of the same kind, so we remove the previous ones. + // E.g. in the string `${bg_red} A ${bg_yellow} B ${close_bg} C`, "B" only + // has a single background color (yellow), and "C" has no background color. + unclosed = [...unclosed.filter((y) => y.kind !== token.kind), token]; + } + + unclosed = unclosed.filter(({ content, kind }) => content !== kind); + + const currentSuffix = unclosed + .map(({ kind }) => `\x1b[${kind}m`).reverse().join(""); + const nextPrefix = unclosed.map(({ content }) => `\x1b[${content}m`).join(""); + + return { + /** The suffix to be appended to the text to close all unclosed runs. */ + currentSuffix, + /** + * The prefix to be appended to the next segment to continue unclosed + * runs if the input text forms the first segment of a multi-line string. + */ + nextPrefix, + }; +} diff --git a/table/cell.ts b/table/cell.ts index 5bf15801..206fc938 100644 --- a/table/cell.ts +++ b/table/cell.ts @@ -17,6 +17,11 @@ interface CellOptions { rowSpan?: number; /** Cell cell alignment direction. */ align?: Direction; + /** + * Any unterminated ANSI formatting overflowed from previous lines of a + * multi-line cell. + */ + unclosedAnsiRuns?: string; } /** @@ -43,6 +48,17 @@ export class Cell { return this.toString().length; } + /** + * Any unterminated ANSI formatting overflowed from previous lines of a + * multi-line cell. + */ + public get unclosedAnsiRuns() { + return this.options.unclosedAnsiRuns ?? ""; + } + public set unclosedAnsiRuns(val: string) { + this.options.unclosedAnsiRuns = val; + } + /** * Create a new cell. If value is a cell, the value and all options of the cell * will be copied to the new cell. diff --git a/table/consume_words.ts b/table/consume_words.ts index 4fc6a139..f665ea91 100644 --- a/table/consume_words.ts +++ b/table/consume_words.ts @@ -1,4 +1,4 @@ -import { strLength } from "./_utils.ts"; +import { ansiRegexSource, strLength } from "./_utils.ts"; /** * Consumes the maximum amount of words from a string which is not longer than @@ -34,3 +34,42 @@ export function consumeWords(length: number, content: string): string { return consumed; } + +/** + * Consumes the maximum amount of chars from a string which is not longer than + * given length, ignoring ANSI codes when calculating the length. + * This function returns at least one char. + * + * ```ts + * import { consumeChars } from "./consume_words.ts"; + * + * const str = consumeChars(9, "\x1b[31mThis is an example."); // returns: "\x1b[31mThis is a" + * ``` + * + * @param length The maximum length of the returned string. + * @param content The content from which the string should be consumed. + */ +export function consumeChars(length: number, content: string): string { + let consumed = ""; + const chars = [ + ...content.split("\n")[0].matchAll( + new RegExp(`(?:${ansiRegexSource})+|.`, "gu"), + ), + ] + .map(([match]) => match); + + for (const char of chars) { + // consume minimum one char + if (consumed) { + const nextLength = strLength(char); + const consumedLength = strLength(consumed); + if (consumedLength + nextLength > length) { + break; + } + } + + consumed += char; + } + + return consumed; +} diff --git a/table/test/ansi_regex_source_test.ts b/table/test/ansi_regex_source_test.ts new file mode 100644 index 00000000..6c87e831 --- /dev/null +++ b/table/test/ansi_regex_source_test.ts @@ -0,0 +1,72 @@ +import { ansiRegexSource } from "../_utils.ts"; +import { assertEquals } from "../../dev_deps.ts"; + +Deno.test(`table - ansiRegexSource`, () => { + const DIGITS = String.raw`\d+`; + // All open and close ANSI codes taken from calls to `code(...)` in + // https://deno.land/std@0.196.0/fmt/colors.ts + const ansiCodes = [ + { open: [0], close: 0 }, + { open: [1], close: 22 }, + { open: [2], close: 22 }, + { open: [3], close: 23 }, + { open: [4], close: 24 }, + { open: [7], close: 27 }, + { open: [8], close: 28 }, + { open: [9], close: 29 }, + { open: [30], close: 39 }, + { open: [31], close: 39 }, + { open: [32], close: 39 }, + { open: [33], close: 39 }, + { open: [34], close: 39 }, + { open: [35], close: 39 }, + { open: [36], close: 39 }, + { open: [37], close: 39 }, + { open: [90], close: 39 }, + { open: [91], close: 39 }, + { open: [92], close: 39 }, + { open: [93], close: 39 }, + { open: [94], close: 39 }, + { open: [95], close: 39 }, + { open: [96], close: 39 }, + { open: [97], close: 39 }, + { open: [40], close: 49 }, + { open: [41], close: 49 }, + { open: [42], close: 49 }, + { open: [43], close: 49 }, + { open: [44], close: 49 }, + { open: [45], close: 49 }, + { open: [46], close: 49 }, + { open: [47], close: 49 }, + { open: [100], close: 49 }, + { open: [101], close: 49 }, + { open: [102], close: 49 }, + { open: [103], close: 49 }, + { open: [104], close: 49 }, + { open: [105], close: 49 }, + { open: [106], close: 49 }, + { open: [107], close: 49 }, + { open: [38, 5, DIGITS], close: 39 }, + { open: [48, 5, DIGITS], close: 49 }, + { open: [38, 2, DIGITS, DIGITS, DIGITS], close: 39 }, + { open: [48, 2, DIGITS, DIGITS, DIGITS], close: 49 }, + ]; + + const expect = String.raw`\x1b\[(?:${ + [...new Set(ansiCodes.map(({ close }) => close))] + .map((kind) => { + const opens = ansiCodes + .filter(({ close }) => close === kind) + .map(({ open }) => open.join(";")); + + return String.raw`(?<_${kind}>${ + [...new Set([String(kind), ...opens])].sort((a, b) => + a.localeCompare(b, "en-US", { numeric: true }) + ).join("|") + })`; + }) + .join("|") + })m`; + + assertEquals(ansiRegexSource, expect); +}); diff --git a/table/test/ansi_wrapping_within_cell_test.ts b/table/test/ansi_wrapping_within_cell_test.ts new file mode 100644 index 00000000..62615420 --- /dev/null +++ b/table/test/ansi_wrapping_within_cell_test.ts @@ -0,0 +1,90 @@ +import { Table } from "../table.ts"; +import { assertEquals } from "../../dev_deps.ts"; + +const tests: { + description: string; + content: string[]; + width: number; + expect: string; +}[] = [ + { + description: "wrapping of ANSI codes within cell", + // colors.red("Hello, world!") + content: ["\x1b[31mHello, world!\x1b[39m"], + width: 6, + expect: ` +┌────────┐ +│ \x1b[31mHello,\x1b[39m │ +│ \x1b[31mworld!\x1b[39m │ +└────────┘`.slice(1), + }, + { + description: "wrapping of chained ANSI codes within cell", + // colors.cyan.bold.underline("Hello, world!") + content: ["\x1b[4m\x1b[1m\x1b[36mHello, world!\x1b[39m\x1b[22m\x1b[24m"], + width: 6, + expect: ` +┌────────┐ +│ \x1b[4m\x1b[1m\x1b[36mHello,\x1b[39m\x1b[22m\x1b[24m │ +│ \x1b[4m\x1b[1m\x1b[36mworld!\x1b[39m\x1b[22m\x1b[24m │ +└────────┘`.slice(1), + }, + { + description: "wrapping of chained ANSI codes with intra-word line breaking", + // colors.cyan("Wrapping over multiple lines") + content: ["\x1b[36mWrapping over multiple lines\x1b[39m"], + width: 6, + expect: ` +┌────────┐ +│ \x1b[36mWrappi\x1b[39m │ +│ \x1b[36mng\x1b[39m │ +│ \x1b[36mover\x1b[39m │ +│ \x1b[36mmultip\x1b[39m │ +│ \x1b[36mle\x1b[39m │ +│ \x1b[36mlines\x1b[39m │ +└────────┘`.slice(1), + }, + { + description: "wrapping of nested ANSI color codes within cell", + // colors.red(`Red ${colors.yellow("and yellow")} text`) + content: [ + "\x1b[31mRed \x1b[33mand yellow\x1b[31m text\x1b[39m", + "Cell 2", + ], + width: 12, + expect: ` +┌──────────────┬────────┐ +│ \x1b[31mRed \x1b[33mand\x1b[39m │ Cell 2 │ +│ \x1b[33myellow\x1b[31m text\x1b[39m │ │ +└──────────────┴────────┘`.slice(1), + }, + { + description: "wrapping of nested mixed ANSI codes within cell", + // colors.rgb24(`Colorful ${colors.underline("and underlined")} text`, 0xff8800) + content: [ + "\x1b[38;2;255;136;0mColorful \x1b[4mand underlined\x1b[24m text\x1b[39m", + ], + width: 15, + expect: ` +┌─────────────────┐ +│ \x1b[38;2;255;136;0mColorful \x1b[4mand\x1b[24m\x1b[39m │ +│ \x1b[38;2;255;136;0m\x1b[4munderlined\x1b[24m text\x1b[39m │ +└─────────────────┘`.slice(1), + }, +]; + +for (const { description, content, width, expect } of tests) { + const actual = Table.from([content]) + .border(true) + .columns([{ maxWidth: width, minWidth: width }]) + .toString(); + + // // Uncomment for visual checking of output + // console.log(`actual\n${actual}`); + // console.log(`expect\n${expect}`); + // console.log(JSON.stringify({ actual, expect }, null, '\t')); + + Deno.test(`table - ${description}`, () => { + assertEquals(actual, expect); + }); +}