Fix flicker issues by ensuring all actively changing content fits in the viewport (#1217)

This commit is contained in:
Jacob Richman
2025-06-19 20:17:23 +00:00
committed by GitHub
parent 10a83a6395
commit b0bc7c3d99
22 changed files with 1353 additions and 248 deletions

View File

@@ -5,7 +5,7 @@
*/
import React from 'react';
import { Text } from 'ink';
import { Text, Box } from 'ink';
import { common, createLowlight } from 'lowlight';
import type {
Root,
@@ -16,6 +16,7 @@ import type {
} from 'hast';
import { themeManager } from '../themes/theme-manager.js';
import { Theme } from '../themes/theme.js';
import { MaxSizedBox } from '../components/shared/MaxSizedBox.js';
// Configure themeing and parsing utilities.
const lowlight = createLowlight(common);
@@ -84,6 +85,8 @@ function renderHastNode(
return null;
}
const RESERVED_LINES_FOR_TRUNCATION_MESSAGE = 2;
/**
* Renders syntax-highlighted code for Ink applications using a selected theme.
*
@@ -94,6 +97,8 @@ function renderHastNode(
export function colorizeCode(
code: string,
language: string | null,
availableHeight?: number,
maxWidth?: number,
): React.ReactNode {
const codeToHighlight = code.replace(/\n$/, '');
const activeTheme = themeManager.getActiveTheme();
@@ -101,15 +106,33 @@ export function colorizeCode(
try {
// Render the HAST tree using the adapted theme
// Apply the theme's default foreground color to the top-level Text element
const lines = codeToHighlight.split('\n');
let lines = codeToHighlight.split('\n');
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
let hiddenLinesCount = 0;
// Optimizaiton to avoid highlighting lines that cannot possibly be displayed.
if (availableHeight && lines.length > availableHeight) {
const sliceIndex =
lines.length - availableHeight + RESERVED_LINES_FOR_TRUNCATION_MESSAGE;
if (sliceIndex > 0) {
hiddenLinesCount = sliceIndex;
lines = lines.slice(sliceIndex);
}
}
const getHighlightedLines = (line: string) =>
!language || !lowlight.registered(language)
? lowlight.highlightAuto(line)
: lowlight.highlight(language, line);
return (
<Text>
<MaxSizedBox
maxHeight={availableHeight}
maxWidth={maxWidth}
additionalHiddenLinesCount={hiddenLinesCount}
overflowDirection="top"
>
{lines.map((line, index) => {
const renderedNode = renderHastNode(
getHighlightedLines(line),
@@ -119,16 +142,17 @@ export function colorizeCode(
const contentToRender = renderedNode !== null ? renderedNode : line;
return (
<Text key={index}>
<Box key={index}>
<Text color={activeTheme.colors.Gray}>
{`${String(index + 1).padStart(padWidth, ' ')} `}
{`${String(index + 1 + hiddenLinesCount).padStart(padWidth, ' ')} `}
</Text>
<Text color={activeTheme.defaultColor}>{contentToRender}</Text>
{index < lines.length - 1 && '\n'}
</Text>
<Text color={activeTheme.defaultColor} wrap="wrap">
{contentToRender}
</Text>
</Box>
);
})}
</Text>
</MaxSizedBox>
);
} catch (error) {
console.error(
@@ -140,17 +164,20 @@ export function colorizeCode(
const lines = codeToHighlight.split('\n');
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
return (
<Text>
<MaxSizedBox
maxHeight={availableHeight}
maxWidth={maxWidth}
overflowDirection="top"
>
{lines.map((line, index) => (
<Text key={index}>
<Box key={index}>
<Text color={activeTheme.defaultColor}>
{`${String(index + 1).padStart(padWidth, ' ')} `}
</Text>
<Text color={activeTheme.colors.Gray}>{line}</Text>
{index < lines.length - 1 && '\n'}
</Text>
</Box>
))}
</Text>
</MaxSizedBox>
);
}
}

View File

@@ -12,7 +12,8 @@ import { colorizeCode } from './CodeColorizer.js';
interface MarkdownDisplayProps {
text: string;
isPending: boolean;
availableTerminalHeight: number;
availableTerminalHeight?: number;
terminalWidth: number;
}
// Constants for Markdown parsing and rendering
@@ -32,6 +33,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
}) => {
if (!text) return <></>;
@@ -65,6 +67,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
lang={codeBlockLang}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>,
);
inCodeBlock = false;
@@ -186,6 +189,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
lang={codeBlockLang}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
/>,
);
}
@@ -336,7 +340,8 @@ interface RenderCodeBlockProps {
content: string[];
lang: string | null;
isPending: boolean;
availableTerminalHeight: number;
availableTerminalHeight?: number;
terminalWidth: number;
}
const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
@@ -344,15 +349,17 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
lang,
isPending,
availableTerminalHeight,
terminalWidth,
}) => {
const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message
const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding
const MAX_CODE_LINES_WHEN_PENDING = Math.max(
0,
availableTerminalHeight - CODE_BLOCK_PADDING * 2 - RESERVED_LINES,
);
if (isPending) {
if (isPending && availableTerminalHeight !== undefined) {
const MAX_CODE_LINES_WHEN_PENDING = Math.max(
0,
availableTerminalHeight - CODE_BLOCK_PADDING * 2 - RESERVED_LINES,
);
if (content.length > MAX_CODE_LINES_WHEN_PENDING) {
if (MAX_CODE_LINES_WHEN_PENDING < MIN_LINES_FOR_MESSAGE) {
// Not enough space to even show the message meaningfully
@@ -366,6 +373,8 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
const colorizedTruncatedCode = colorizeCode(
truncatedContent.join('\n'),
lang,
availableTerminalHeight,
terminalWidth - CODE_BLOCK_PADDING * 2,
);
return (
<Box flexDirection="column" padding={CODE_BLOCK_PADDING}>
@@ -377,10 +386,20 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
}
const fullContent = content.join('\n');
const colorizedCode = colorizeCode(fullContent, lang);
const colorizedCode = colorizeCode(
fullContent,
lang,
availableTerminalHeight,
terminalWidth - CODE_BLOCK_PADDING * 2,
);
return (
<Box flexDirection="column" padding={CODE_BLOCK_PADDING}>
<Box
flexDirection="column"
padding={CODE_BLOCK_PADDING}
width={terminalWidth}
flexShrink={0}
>
{colorizedCode}
</Box>
);

View File

@@ -45,3 +45,25 @@ export function isBinary(
// If no NULL bytes were found in the sample, we assume it's text.
return false;
}
/*
* -------------------------------------------------------------------------
* Unicodeaware helpers (work at the codepoint level rather than UTF16
* code units so that surrogatepair emoji count as one "column".)
* ---------------------------------------------------------------------- */
export function toCodePoints(str: string): string[] {
// [...str] or Array.from both iterate by UTF32 code point, handling
// surrogate pairs correctly.
return Array.from(str);
}
export function cpLen(str: string): number {
return toCodePoints(str).length;
}
export function cpSlice(str: string, start: number, end?: number): string {
// Slice by codepoint indices and rejoin.
const arr = toCodePoints(str).slice(start, end);
return arr.join('');
}