perf(ui): bypass ink object allocation in table renderer

This replaces Ink's toStyledCharacters with a custom fast regex-based
ANSI stripping and parsing approach in TableRenderer to significantly
reduce V8 heap allocations during layout calculations of long or
frequently updated text. A new StyleSpan abstraction handles the styled
chunks efficiently.
This commit is contained in:
Spencer
2026-03-25 04:02:59 +00:00
parent 73526416cf
commit 760dbc3e06
2 changed files with 297 additions and 36 deletions
+30 -36
View File
@@ -5,20 +5,19 @@
*/
import React, { useMemo } from 'react';
import { styledCharsToString } from '@alcalzone/ansi-tokenize';
import {
Text,
Box,
type StyledChar,
toStyledCharacters,
styledCharsWidth,
wordBreakStyledChars,
wrapStyledChars,
widestLineFromStyledChars,
} from 'ink';
import { Text, Box } from 'ink';
import { theme } from '../semantic-colors.js';
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
import { stripUnsafeCharacters } from './textUtils.js';
import {
type StyleSpan,
parseToStyleSpans,
styleSpansWidth,
styleSpansToString,
wordBreakStyleSpans,
wrapStyleSpans,
widestLineFromStyleSpans,
} from './styleSpans.js';
interface TableRendererProps {
headers: string[];
@@ -31,23 +30,21 @@ const COLUMN_PADDING = 2;
const TABLE_MARGIN = 2;
/**
* Parses markdown to StyledChar array by first converting to ANSI.
* This ensures character counts are accurate (markdown markers are removed
* and styles are applied to the character's internal style object).
* Parses markdown to StyleSpan array by first converting to ANSI.
*/
const parseMarkdownToStyledChars = (
const parseMarkdownToStyleSpans = (
text: string,
defaultColor?: string,
): StyledChar[] => {
): StyleSpan[] => {
const ansi = parseMarkdownToANSI(text, defaultColor);
return toStyledCharacters(ansi);
return parseToStyleSpans(ansi);
};
const calculateWidths = (styledChars: StyledChar[]) => {
const contentWidth = styledCharsWidth(styledChars);
const calculateWidths = (styleSpans: StyleSpan[]) => {
const contentWidth = styleSpansWidth(styleSpans);
const words: StyledChar[][] = wordBreakStyledChars(styledChars);
const maxWordWidth = widestLineFromStyledChars(words);
const words: StyleSpan[][] = wordBreakStyleSpans(styleSpans);
const maxWordWidth = widestLineFromStyleSpans(words);
return { contentWidth, maxWordWidth };
};
@@ -70,7 +67,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
const styledHeaders = useMemo(
() =>
headers.map((header) =>
parseMarkdownToStyledChars(
parseMarkdownToStyleSpans(
stripUnsafeCharacters(header),
theme.text.link,
),
@@ -82,7 +79,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
() =>
rows.map((row) =>
row.map((cell) =>
parseMarkdownToStyledChars(
parseMarkdownToStyleSpans(
stripUnsafeCharacters(cell),
theme.text.primary,
),
@@ -100,14 +97,14 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
// --- Define Constraints per Column ---
const constraints = Array.from({ length: numColumns }).map(
(_, colIndex) => {
const headerStyledChars = styledHeaders[colIndex] || [];
const headerStyleSpans = styledHeaders[colIndex] || [];
let { contentWidth: maxContentWidth, maxWordWidth } =
calculateWidths(headerStyledChars);
calculateWidths(headerStyleSpans);
styledRows.forEach((row) => {
const cellStyledChars = row[colIndex] || [];
const cellStyleSpans = row[colIndex] || [];
const { contentWidth: cellWidth, maxWordWidth: cellWordWidth } =
calculateWidths(cellStyledChars);
calculateWidths(cellStyleSpans);
maxContentWidth = Math.max(maxContentWidth, cellWidth);
maxWordWidth = Math.max(maxWordWidth, cellWordWidth);
@@ -176,28 +173,25 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
// --- Pre-wrap and Optimize Widths ---
const actualColumnWidths = new Array(numColumns).fill(0);
const wrapAndProcessRow = (row: StyledChar[][]) => {
const wrapAndProcessRow = (row: StyleSpan[][]) => {
const rowResult: ProcessedLine[][] = [];
// Ensure we iterate up to numColumns, filling with empty cells if needed
for (let colIndex = 0; colIndex < numColumns; colIndex++) {
const cellStyledChars = row[colIndex] || [];
const cellStyleSpans = row[colIndex] || [];
const allocatedWidth = finalContentWidths[colIndex];
const contentWidth = Math.max(1, allocatedWidth);
const wrappedStyledLines = wrapStyledChars(
cellStyledChars,
contentWidth,
);
const wrappedStyledLines = wrapStyleSpans(cellStyleSpans, contentWidth);
const maxLineWidth = widestLineFromStyledChars(wrappedStyledLines);
const maxLineWidth = widestLineFromStyleSpans(wrappedStyledLines);
actualColumnWidths[colIndex] = Math.max(
actualColumnWidths[colIndex],
maxLineWidth,
);
const lines = wrappedStyledLines.map((line) => ({
text: styledCharsToString(line),
width: styledCharsWidth(line),
text: styleSpansToString(line),
width: styleSpansWidth(line),
}));
rowResult.push(lines);
}
+267
View File
@@ -0,0 +1,267 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import ansiRegex from 'ansi-regex';
import { getCachedStringWidth } from './textUtils.js';
export interface StyleSpan {
text: string;
width: number;
ansiPrefix: string;
ansiSuffix: string;
}
export const parseToStyleSpans = (text: string): StyleSpan[] => {
const spans: StyleSpan[] = [];
const regex = ansiRegex();
let match;
let lastIndex = 0;
let currentAnsiState: string[] = [];
while ((match = regex.exec(text)) !== null) {
const ansiCode = match[0];
const matchStart = match.index;
if (matchStart > lastIndex) {
const chunk = text.slice(lastIndex, matchStart);
const ansiPrefix = currentAnsiState.join('');
spans.push({
text: chunk,
width: getCachedStringWidth(chunk),
ansiPrefix,
ansiSuffix: '\x1b[0m',
});
}
if (
ansiCode === '\x1b[0m' ||
ansiCode === '\x1b[39m' ||
ansiCode === '\x1b[49m'
) {
if (ansiCode === '\x1b[0m') {
currentAnsiState = [];
} else if (ansiCode === '\x1b[39m') {
currentAnsiState = currentAnsiState.filter(
// eslint-disable-next-line no-control-regex
(c) => !c.match(/\x1b\[3\d(?:;\d+)*m/),
);
} else if (ansiCode === '\x1b[49m') {
currentAnsiState = currentAnsiState.filter(
// eslint-disable-next-line no-control-regex
(c) => !c.match(/\x1b\[4\d(?:;\d+)*m/),
);
} else {
currentAnsiState.push(ansiCode);
}
} else {
currentAnsiState.push(ansiCode);
}
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
const chunk = text.slice(lastIndex);
if (chunk.length > 0) {
const ansiPrefix = currentAnsiState.join('');
spans.push({
text: chunk,
width: getCachedStringWidth(chunk),
ansiPrefix,
ansiSuffix: '\x1b[0m',
});
}
}
return spans;
};
export const styleSpansToString = (spans: StyleSpan[]): string => {
let result = '';
let currentAnsi = '';
for (const span of spans) {
if (span.ansiPrefix !== currentAnsi) {
if (currentAnsi !== '') {
result += '\x1b[0m';
}
result += span.ansiPrefix;
currentAnsi = span.ansiPrefix;
}
result += span.text;
}
if (currentAnsi !== '') {
result += '\x1b[0m';
}
return result;
};
export const styleSpansWidth = (spans: StyleSpan[]): number =>
spans.reduce((sum, span) => sum + span.width, 0);
export const wordBreakStyleSpans = (spans: StyleSpan[]): StyleSpan[][] => {
const words: StyleSpan[][] = [];
let currentWord: StyleSpan[] = [];
for (const span of spans) {
const text = span.text;
let i = 0;
while (i < text.length) {
if (text[i] === '\n' || text[i] === ' ') {
if (currentWord.length > 0) {
words.push(currentWord);
currentWord = [];
}
words.push([
{
text: text[i],
width: getCachedStringWidth(text[i]),
ansiPrefix: span.ansiPrefix,
ansiSuffix: span.ansiSuffix,
},
]);
i++;
} else {
let j = i;
while (j < text.length && text[j] !== '\n' && text[j] !== ' ') {
j++;
}
const chunk = text.substring(i, j);
currentWord.push({
text: chunk,
width: getCachedStringWidth(chunk),
ansiPrefix: span.ansiPrefix,
ansiSuffix: span.ansiSuffix,
});
i = j;
}
}
}
if (currentWord.length > 0) {
words.push(currentWord);
}
return words;
};
export const widestLineFromStyleSpans = (lines: StyleSpan[][]): number =>
lines.reduce((max, line) => Math.max(max, styleSpansWidth(line)), 0);
export const wrapWord = (
rowsRef: StyleSpan[][],
word: StyleSpan[],
columns: number,
) => {
let isFirstIteration = true;
for (const span of word) {
let remainingText = span.text;
while (remainingText.length > 0) {
let rowWidth = styleSpansWidth(rowsRef[rowsRef.length - 1]);
if (!isFirstIteration && rowWidth >= columns && columns > 0) {
rowsRef.push([]);
rowWidth = 0;
}
isFirstIteration = false;
let splitIndex = 0;
let chunkWidth = 0;
const chars = Array.from(remainingText);
for (let i = 0; i < chars.length; i++) {
const charWidth = getCachedStringWidth(chars[i]);
if (rowWidth + chunkWidth + charWidth > columns && columns > 0) {
if (chunkWidth === 0 && rowWidth === 0) {
splitIndex = 1;
chunkWidth += charWidth;
}
break;
}
chunkWidth += charWidth;
splitIndex++;
}
if (splitIndex === 0) {
rowsRef.push([]);
isFirstIteration = true; // reset to allow writing on next row
continue;
}
const chunkText = chars.slice(0, splitIndex).join('');
rowsRef[rowsRef.length - 1].push({
text: chunkText,
width: chunkWidth,
ansiPrefix: span.ansiPrefix,
ansiSuffix: span.ansiSuffix,
});
remainingText = chars.slice(splitIndex).join('');
}
}
};
export const wrapStyleSpans = (
spans: StyleSpan[],
columns: number,
): StyleSpan[][] => {
const rows: StyleSpan[][] = [[]];
const words = wordBreakStyleSpans(spans);
let isAtStartOfLogicalLine = true;
for (const word of words) {
if (word.length === 0) continue;
if (word[0].text === '\n') {
rows.push([]);
isAtStartOfLogicalLine = true;
continue;
}
const wordWidth = styleSpansWidth(word);
const rowWidth = styleSpansWidth(rows[rows.length - 1]);
if (rowWidth + wordWidth > columns) {
if (
!isAtStartOfLogicalLine &&
word[0].text === ' ' &&
word.length === 1
) {
continue;
}
if (!isAtStartOfLogicalLine) {
const lastRow = rows[rows.length - 1];
while (lastRow.length > 0 && lastRow[lastRow.length - 1].text === ' ') {
lastRow.pop();
}
}
if (wordWidth > columns) {
if (rowWidth > 0) {
rows.push([]);
}
wrapWord(rows, word, columns);
} else {
rows.push([]);
rows[rows.length - 1].push(...word);
}
} else {
rows[rows.length - 1].push(...word);
}
if (
isAtStartOfLogicalLine &&
!(word[0].text === ' ' && word.length === 1)
) {
isAtStartOfLogicalLine = false;
}
}
return rows;
};