Skip to content

Commit

Permalink
fix(table): Wrap ANSI codes for multiline cells (#657)
Browse files Browse the repository at this point in the history
  • Loading branch information
lionel-rowe authored Aug 31, 2023
1 parent 4e63862 commit 5118a1c
Show file tree
Hide file tree
Showing 6 changed files with 278 additions and 10 deletions.
24 changes: 15 additions & 9 deletions table/_layout.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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]);
Expand All @@ -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()),
Expand All @@ -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
Expand All @@ -448,10 +457,7 @@ export class TableLayout {
throw new Error("Unknown direction: " + align);
}

return {
current,
next: cell.clone(next),
};
return { current, next };
}

/**
Expand Down
45 changes: 45 additions & 0 deletions table/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
16 changes: 16 additions & 0 deletions table/cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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.
Expand Down
41 changes: 40 additions & 1 deletion table/consume_words.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
}
72 changes: 72 additions & 0 deletions table/test/ansi_regex_source_test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
90 changes: 90 additions & 0 deletions table/test/ansi_wrapping_within_cell_test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}

0 comments on commit 5118a1c

Please sign in to comment.