tty-strings
Version:
Tools for working with strings displayed in the terminal
266 lines (261 loc) • 12.9 kB
text/typescript
import { parseAnsi, parseEscape, openEscapes, closeEscapes, type AnsiEscape } from './utils';
import charWidths from './charWidths';
import stringWidth from './stringWidth';
function filterStack(ansiStack: AnsiEscape<readonly [number, number]>[], ax: number, ay: number) {
return ansiStack.filter(([,,, [x, y]]) => (x < ax || (x === ax && y <= ay)));
}
function wrapLine(
ansiStack: AnsiEscape<readonly [number, number]>[],
string: string,
columns: number,
hard: boolean,
ltrim: boolean,
) {
if (string.trim() === '') return '';
// array to store each wrapped row as it is processed
const rows = [];
// the current row
let row = '',
// the current row length
rl = 0,
// word index of the active point in the ansi stack
ax = -Infinity,
// intraword index of the active point in the ansi stack
ay = 0,
// preserved space
space = '',
// index of the preserved spaces' active point in the ansi stack
sx = -Infinity,
// trimmed space
trimmed = '';
// split the input string into words and measure each one
const words = string.split(' ').map((word) => [word, stringWidth(word)] as const);
// loop through each word
for (const [ix, [word, len]] of words.entries()) {
// if this is not the first word
if (ix > 0) {
// increment the length of the current row if a space must be added before this word
if (rl > 0 || (!ltrim && ax < 0)) rl += 1;
// update preserved space with any new escapes found up through the end of the previous word
space += openEscapes(ansiStack.filter(([,,, [x, y]]) => (x > sx || (x === sx && y > ay))));
// set the preserved space's active point in the ansi stack to the end of the last word
sx = ix;
// update accumulated space
space += ' ';
}
// if hard wrap mode is enabled, words whose length exceeds `columns` must be wrapped across multiple rows
if (hard && (len > columns || (!ltrim && ax < 0 && rl + len > columns && rl < columns))) {
// determine if a linebreak should occur before this hard wrapped word (adapted from `wrap-ansi`)
const rc = (columns - rl),
// compare the row span if hard wrapped word starts on the next line vs the current line
brkNext = Math.floor((len - 1) / columns) < (1 + Math.floor((len - rc - 1) / columns));
// check if word should break starting on the next row
if (brkNext && !(!ltrim && ax < 0)) {
// finalize the current row
rows.push(row + trimmed + closeEscapes(filterStack(ansiStack, ax, ay)));
// start a new row
[rl, row] = [0, ''];
// clear preserved & trimmed space
[space, trimmed] = ['', ''];
}
// location within the current word
let iy = 0;
// match all ansi escape codes in this word
for (const [chunk, isEscape] of parseAnsi(word)) {
// check if chunk is an escape sequence
if (isEscape) {
// parse the matched ansi escape sequence
const closed = parseEscape(ansiStack, chunk, [ix, iy]);
if (closed?.length) {
if (ax < ix) {
// filter for active escapes in the current row
trimmed += closeEscapes(filterStack(closed, ax, ay));
// filter for active escapes in the current row or preserved space
space += closeEscapes(closed.filter(([,,, [cx]]) => cx < sx));
} else if (rl) {
// filter for currently active escapes
row += closeEscapes(filterStack(closed, ax, ay));
}
}
continue;
}
// store our current location within this word at the outset of this chunk
const sy = iy;
// see if the word index of the active point in the ansi stack needs to be updated
if (ax < ix) {
// check for edge case where large leading whitespace forces a line break before the first word
if (!ltrim && ax < 0 && rl === columns) {
// complete the current row of preserved leading whitespace
rows.push(row + space + closeEscapes(filterStack(ansiStack, ix, -1)));
// start a new row
[rl, row] = [0, ''];
}
row += rl
// non-empty row - append preserved space & any escape sequences opened within this word
? space + openEscapes(ansiStack.filter(([,,, [x]]) => x === ix))
// row is empty - initialize it with all active escape sequences
: openEscapes(ansiStack);
// set the active point in the ansi stack to the beginning of the current word
[ax, ay] = [ix, 0];
// clear preserved space
[sx, space] = [ix, ''];
// clear trimmed space
trimmed = '';
}
// loop through the characters in this chunk
for (const [char, w] of charWidths(chunk)) {
// check if a line break is needed
if (rl + w > columns) {
// finalize the current row
rows.push(row + closeEscapes(filterStack(ansiStack, ax, ay)));
// initialize a new row with all currently active escape sequences
[rl, row] = [0, openEscapes(ansiStack)];
}
// see if the intra-word index of the active point in the ansi stack needs to be updated
if (ay < sy) {
// if row is not empty, add all new escape sequences from the ansi stack
if (rl) row += openEscapes(ansiStack.filter(([,,, [x, y]]) => (x === ax && ay < y)));
// update the active point in the ansi stack to the beginning of the current chunk
ay = sy;
}
// add this character to the current row & update the row length
row += char;
rl += w;
// increment current location within this word
iy += w;
}
}
continue;
}
// index location within the current word
let iy = 0;
// match all ansi escape codes in this word
for (const [chunk, isEscape] of parseAnsi(word)) {
// check if chunk is an escape sequence
if (isEscape) {
// parse the matched ansi escape sequence
const closed = parseEscape(ansiStack, chunk, [ix, iy]);
if (closed?.length) {
if (ax < ix) {
// filter for active escapes in the current row
trimmed += closeEscapes(filterStack(closed, ax, ay));
// filter for active escapes in the current row or preserved space
space += closeEscapes(closed.filter(([,,, [cx]]) => cx < sx));
} else if (rl) {
// filter for currently active escapes
row += closeEscapes(filterStack(closed, ax, ay));
}
}
continue;
}
// see if the word index of the active point in the ansi stack needs to be updated
if (ax < ix) {
// check if there should be a line break before this word
if ((!ltrim && ax < 0) ? (rl === columns) : (rl + len > columns && rl > 0)) {
// finalize the current row
rows.push(
(!ltrim && ax < 0)
// row of preserved leading whitespace
? row + space + closeEscapes(filterStack(ansiStack, ix, -1))
// normal row
: row + trimmed + closeEscapes(filterStack(ansiStack, ax, ay)),
);
// start a new row
[rl, row] = [0, ''];
}
row += rl
// non-empty row - append preserved space & any escape sequences opened within this word
? space + openEscapes(ansiStack.filter(([,,, [x]]) => x === ix))
// row is empty - initialize it with all active escape sequences
: openEscapes(ansiStack);
// add the length of this word to the current row length
rl += len;
// set the active point in the ansi stack to the beginning of the current word
[ax, ay] = [ix, 0];
// clear preserved space
[sx, space] = [ix, ''];
// clear trimmed space
trimmed = '';
}
// see if the intra-word index of the active point in the ansi stack needs to be updated
if (ay < iy) {
// add any new opening escapes to the current row (rl will always be > 0 here)
row += openEscapes(ansiStack.filter(([,,, [x, y]]) => (x === ax && ay < y)));
// update the active point in the ansi stack to the beginning of the current chunk
ay = iy;
}
// add this chunk to the current row
row += chunk;
// update current location within this word
iy += chunk.length;
}
// check for edge case where large leading whitespace forces a line break before the first word
if (!ltrim && ax < 0 && rl === columns) {
// complete the current row of preserved leading whitespace
rows.push(row + space + closeEscapes(filterStack(ansiStack, ix, -1)));
// start a new row
[rl, row] = [0, ''];
// reset space with all currently active escapes prior to `sx`
space = openEscapes(filterStack(ansiStack, sx, ay));
// clear trimmed space
trimmed = '';
}
}
// finalize the last row if necessary
if (rl > 0) rows.push(row + trimmed + closeEscapes(filterStack(ansiStack, ax, ay)));
// update the indexes of any escape sequences that remain in the stack
for (const esc of ansiStack) esc[3] = [-1, 0];
// return wrapped rows
return ax >= 0 ? rows.join('\n') : '';
}
export interface WordWrapOptions {
/**
* By default, words that are longer than the specified column width will not be broken and will therefore
* extend past the specified column width. Setting this to `true` will enable hard wrapping, in which
* words longer than the column width will be broken and wrapped across multiple rows.
* @defaultValue `false`
*/
hard?: boolean
/**
* Trim leading whitespace from the beginning of each line. Setting this to `false` will preserve any
* leading whitespace found before each line in the input string.
* @defaultValue `true`
*/
trimLeft?: boolean
}
/**
* Word wrap text to a specified column width.
*
* @remarks
* Input string may contain ANSI escape codes. Style and hyperlink sequences will be wrapped, while all
* other types of control sequences will be ignored and will not be included in the output string.
*
* @example
* ```ts
* import { wordWrap } from 'tty-strings';
* import chalk from 'chalk';
*
* const text = 'The ' + chalk.bgGreen.magenta('quick brown 🦊 jumps over') + ' the 😴 🐶.';
* console.log(wordWrap(text, 20));
* ```
*
* @param string - Input text to word wrap.
* @param columns - Column width to wrap text to.
* @param options - Word wrap options object.
* @returns The word wrapped string.
*/
export default function wordWrap(string: string, columns: number, {
hard = false,
trimLeft = true,
}: WordWrapOptions = {}) {
// initialize an ansi escapes stack, items are in the form [seq, isLink, close, [ix, iy]]
const ansiStack: AnsiEscape<readonly [number, number]>[] = [];
// ensure input is a string type
return String(string)
// split into lines
.split(/\r?\n/g)
// wrap each line
.map((line) => wrapLine(ansiStack, line, columns, hard, trimLeft))
// rejoin each wrapped line
.join('\n');
}