From 9d7f58ceaf3bf20658790ca4e2ca55af52e55628 Mon Sep 17 00:00:00 2001 From: brandon chen <9735006+brandonkachen@users.noreply.github.com> Date: Mon, 27 Oct 2025 09:16:03 -0700 Subject: [PATCH] Optimize grapheme renderer performance --- display/src/grapheme-image.ts | 157 ++++++++++++++++++++++------------ display/src/image-renderer.ts | 60 ++++++++++--- 2 files changed, 149 insertions(+), 68 deletions(-) diff --git a/display/src/grapheme-image.ts b/display/src/grapheme-image.ts index 029ffd22c6..178da09bf8 100644 --- a/display/src/grapheme-image.ts +++ b/display/src/grapheme-image.ts @@ -1,5 +1,4 @@ import GraphemeSplitter from 'grapheme-splitter' -import { isEqual } from 'lodash' import stripAnsi from 'strip-ansi' import { @@ -24,6 +23,8 @@ export type Grapheme = { textStyles?: Modifier[] } +export type GraphemeImage = Grapheme[][] + const splitter = new GraphemeSplitter() export function toGraphemeString(grapheme: string): $GraphemeString { @@ -38,98 +39,140 @@ export function toGraphemeString(grapheme: string): $GraphemeString { return first as $GraphemeString } -function equalStyles(a: Grapheme, b: Grapheme): boolean { - type GraphemeStyle = Omit & - Partial> - const aStyles: GraphemeStyle = { textStyles: [], ...a } - delete aStyles.grapheme - const bStyles: GraphemeStyle = { textStyles: [], ...b } - delete bStyles.grapheme - return isEqual(aStyles, bStyles) +type GraphemeColor = + | { type: 'color'; color: Color | BackgroundColor } + | { type: 'rgb'; rgb: RGB } + +function colorsEqual( + a: GraphemeColor | undefined, + b: GraphemeColor | undefined, +): boolean { + if (!a && !b) { + return true + } + + if (!a || !b) { + return false + } + + if (a.type !== b.type) { + return false + } + + if (a.type === 'color' && b.type === 'color') { + return a.color === b.color + } + + if (a.type === 'rgb' && b.type === 'rgb') { + return ( + a.rgb[0] === b.rgb[0] && + a.rgb[1] === b.rgb[1] && + a.rgb[2] === b.rgb[2] + ) + } + + return false +} + +function stylesEqual(a: Grapheme, b: Grapheme): boolean { + if (!colorsEqual(a.textColor, b.textColor)) { + return false + } + + if (!colorsEqual(a.backgroundColor, b.backgroundColor)) { + return false + } + + const aStyles = a.textStyles ?? [] + const bStyles = b.textStyles ?? [] + if (aStyles.length !== bStyles.length) { + return false + } + for (let i = 0; i < aStyles.length; i++) { + if (aStyles[i] !== bStyles[i]) { + return false + } + } + + return true } -function graphemeCommands(grapheme: Grapheme): string[] { - const commands: string[] = [] +function graphemeCommands(grapheme: Grapheme): string { + let command = '' if (grapheme.textColor) { - commands.push( - ansiCode( - grapheme.textColor.type === 'color' - ? { - type: 'style', - style: grapheme.textColor.color, - } - : { - type: 'text', - rgb: grapheme.textColor.rgb, - }, - ), + command += ansiCode( + grapheme.textColor.type === 'color' + ? { + type: 'style', + style: grapheme.textColor.color, + } + : { + type: 'text', + rgb: grapheme.textColor.rgb, + }, ) } if (grapheme.backgroundColor) { - commands.push( - ansiCode( - grapheme.backgroundColor.type === 'color' - ? { - type: 'style', - style: grapheme.backgroundColor.color, - } - : { - type: 'text', - rgb: grapheme.backgroundColor.rgb, - }, - ), + command += ansiCode( + grapheme.backgroundColor.type === 'color' + ? { + type: 'style', + style: grapheme.backgroundColor.color, + } + : { + type: 'text', + rgb: grapheme.backgroundColor.rgb, + }, ) } if (grapheme.textStyles) { for (const style of grapheme.textStyles) { - commands.push(ansiCode({ type: 'style', style })) + command += ansiCode({ type: 'style', style }) } } - commands.push(grapheme.grapheme) + command += grapheme.grapheme - return commands + return command } function graphemeDiffCommands( prevGrapheme: Grapheme | null, newGrapheme: Grapheme, -): string[] { +): string { if (!prevGrapheme) { return graphemeCommands(newGrapheme) } - if (equalStyles(prevGrapheme, newGrapheme)) { - return [newGrapheme.grapheme] + if (stylesEqual(prevGrapheme, newGrapheme)) { + return newGrapheme.grapheme } - return [ - ...ansiCode({ type: 'style', style: STYLE.RESET }), - ...graphemeCommands(newGrapheme), - ] + return ( + ansiCode({ type: 'style', style: STYLE.RESET }) + + graphemeCommands(newGrapheme) + ) } -export type GraphemeImage = Grapheme[][] - -export function fullImageCommands(image: GraphemeImage): string[] { - const commands: string[] = [moveCursor(0, 0)] +export function fullImageCommands(image: GraphemeImage): string { + let command = moveCursor(0, 0) let lastGrapheme: Grapheme | null = null for (const row of image) { for (const grapheme of row) { - commands.push(...graphemeDiffCommands(lastGrapheme, grapheme)) + command += graphemeDiffCommands(lastGrapheme, grapheme) lastGrapheme = grapheme } } - return commands + return command } export function diffImageCommands( oldImage: GraphemeImage, newImage: GraphemeImage, -): string[] { +): string { if (oldImage.length !== newImage.length) { return fullImageCommands(newImage) } @@ -137,7 +180,7 @@ export function diffImageCommands( return fullImageCommands(newImage) } - const commands: string[] = [] + let command = '' let prevWrittenGrapheme: Grapheme | null = null let skipped = true for (const [r, newRow] of newImage.entries()) { @@ -146,20 +189,20 @@ export function diffImageCommands( const prevFrameGrapheme = oldRow[c] if ( newGrapheme.grapheme === prevFrameGrapheme.grapheme && - equalStyles(newGrapheme, prevFrameGrapheme) + stylesEqual(newGrapheme, prevFrameGrapheme) ) { skipped = true continue } if (skipped) { - commands.push(moveCursor(r, c)) + command += moveCursor(r, c) skipped = false } - commands.push(...graphemeDiffCommands(prevWrittenGrapheme, newGrapheme)) + command += graphemeDiffCommands(prevWrittenGrapheme, newGrapheme) prevWrittenGrapheme = newGrapheme } } - return commands + return command } diff --git a/display/src/image-renderer.ts b/display/src/image-renderer.ts index be5b981aa5..af3e5ec97f 100644 --- a/display/src/image-renderer.ts +++ b/display/src/image-renderer.ts @@ -44,6 +44,8 @@ export class Renderer { private timer: NodeJS.Timeout | null = null private interval: NodeJS.Timeout | null = null private inProgress: boolean = false + private lastCursorPosition: { row: number; column: number } | null = null + private cursorVisible: boolean = true constructor({ stdout, @@ -102,10 +104,14 @@ export class Renderer { this.lastRefreshTime = 0 this.lastFullRefreshTime = 0 this.inProgress = false + this.lastCursorPosition = null + this.cursorVisible = true } public start() { this.inProgress = true + this.lastCursorPosition = null + this.cursorVisible = true this.stdout.write(ENTER_ALT_BUFFER) this.onResize = () => { this.refreshScreen(false, true) @@ -119,32 +125,62 @@ export class Renderer { private forceRenderFrame(renderAll: boolean = false) { if (this.timer) { - this.timer.close() + clearTimeout(this.timer) this.timer = null } const frame = this.getFrame(this.stdout.rows, this.stdout.columns) const now = Date.now() - // dt / 1000 > 1 / refreshAllFps if ((now - this.lastFullRefreshTime) * this.refreshAllFps > 1000) { renderAll = true } - const commands = renderAll + const frameCommands = renderAll ? fullImageCommands(frame.frame) : diffImageCommands(this.lastFrame, frame.frame) - if (frame.cursor) { - commands.push(moveCursor(frame.cursor.row, frame.cursor.column)) - commands.push(frame.cursor.visible ?? true ? SHOW_CURSOR : HIDE_CURSOR) + + const commands: string[] = [] + if (frameCommands.length > 0) { + commands.push(frameCommands) + } + + const cursor = frame.cursor + if (cursor) { + const desiredVisible = cursor.visible ?? true + const cursorMoved = + !this.lastCursorPosition || + this.lastCursorPosition.row !== cursor.row || + this.lastCursorPosition.column !== cursor.column + + if (commands.length > 0 || cursorMoved) { + commands.push(moveCursor(cursor.row, cursor.column)) + } + + if (this.cursorVisible !== desiredVisible) { + commands.push(desiredVisible ? SHOW_CURSOR : HIDE_CURSOR) + this.cursorVisible = desiredVisible + } + + this.lastCursorPosition = { row: cursor.row, column: cursor.column } } else { - commands.push(HIDE_CURSOR) + this.lastCursorPosition = null + if (this.cursorVisible) { + commands.push(HIDE_CURSOR) + this.cursorVisible = false + } } - this.lastRefreshTime = Date.now() + + this.lastRefreshTime = now if (renderAll) { - this.lastFullRefreshTime = Date.now() + this.lastFullRefreshTime = now } this.lastFrame = frame.frame + + if (commands.length === 0) { + return + } + this.stdout.write(commands.join('')) } @@ -176,15 +212,17 @@ export class Renderer { public exit() { if (this.interval) { - this.interval.close() + clearInterval(this.interval) this.interval = null } if (this.timer) { - this.timer.close() + clearTimeout(this.timer) this.timer = null } this.stdout.removeListener('resize', this.onResize) this.stdout.write(EXIT_ALT_BUFFER) + this.lastCursorPosition = null + this.cursorVisible = true this.inProgress = false } }