Skip to content

Commit 9d7f58c

Browse files
committed
Optimize grapheme renderer performance
1 parent 9bf0f00 commit 9d7f58c

File tree

2 files changed

+149
-68
lines changed

2 files changed

+149
-68
lines changed

display/src/grapheme-image.ts

Lines changed: 100 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import GraphemeSplitter from 'grapheme-splitter'
2-
import { isEqual } from 'lodash'
32
import stripAnsi from 'strip-ansi'
43

54
import {
@@ -24,6 +23,8 @@ export type Grapheme = {
2423
textStyles?: Modifier[]
2524
}
2625

26+
export type GraphemeImage = Grapheme[][]
27+
2728
const splitter = new GraphemeSplitter()
2829

2930
export function toGraphemeString(grapheme: string): $GraphemeString {
@@ -38,106 +39,148 @@ export function toGraphemeString(grapheme: string): $GraphemeString {
3839
return first as $GraphemeString
3940
}
4041

41-
function equalStyles(a: Grapheme, b: Grapheme): boolean {
42-
type GraphemeStyle = Omit<Grapheme, 'grapheme'> &
43-
Partial<Pick<Grapheme, 'grapheme'>>
44-
const aStyles: GraphemeStyle = { textStyles: [], ...a }
45-
delete aStyles.grapheme
46-
const bStyles: GraphemeStyle = { textStyles: [], ...b }
47-
delete bStyles.grapheme
48-
return isEqual(aStyles, bStyles)
42+
type GraphemeColor =
43+
| { type: 'color'; color: Color | BackgroundColor }
44+
| { type: 'rgb'; rgb: RGB }
45+
46+
function colorsEqual(
47+
a: GraphemeColor | undefined,
48+
b: GraphemeColor | undefined,
49+
): boolean {
50+
if (!a && !b) {
51+
return true
52+
}
53+
54+
if (!a || !b) {
55+
return false
56+
}
57+
58+
if (a.type !== b.type) {
59+
return false
60+
}
61+
62+
if (a.type === 'color' && b.type === 'color') {
63+
return a.color === b.color
64+
}
65+
66+
if (a.type === 'rgb' && b.type === 'rgb') {
67+
return (
68+
a.rgb[0] === b.rgb[0] &&
69+
a.rgb[1] === b.rgb[1] &&
70+
a.rgb[2] === b.rgb[2]
71+
)
72+
}
73+
74+
return false
75+
}
76+
77+
function stylesEqual(a: Grapheme, b: Grapheme): boolean {
78+
if (!colorsEqual(a.textColor, b.textColor)) {
79+
return false
80+
}
81+
82+
if (!colorsEqual(a.backgroundColor, b.backgroundColor)) {
83+
return false
84+
}
85+
86+
const aStyles = a.textStyles ?? []
87+
const bStyles = b.textStyles ?? []
88+
if (aStyles.length !== bStyles.length) {
89+
return false
90+
}
91+
for (let i = 0; i < aStyles.length; i++) {
92+
if (aStyles[i] !== bStyles[i]) {
93+
return false
94+
}
95+
}
96+
97+
return true
4998
}
5099

51-
function graphemeCommands(grapheme: Grapheme): string[] {
52-
const commands: string[] = []
100+
function graphemeCommands(grapheme: Grapheme): string {
101+
let command = ''
53102
if (grapheme.textColor) {
54-
commands.push(
55-
ansiCode(
56-
grapheme.textColor.type === 'color'
57-
? {
58-
type: 'style',
59-
style: grapheme.textColor.color,
60-
}
61-
: {
62-
type: 'text',
63-
rgb: grapheme.textColor.rgb,
64-
},
65-
),
103+
command += ansiCode(
104+
grapheme.textColor.type === 'color'
105+
? {
106+
type: 'style',
107+
style: grapheme.textColor.color,
108+
}
109+
: {
110+
type: 'text',
111+
rgb: grapheme.textColor.rgb,
112+
},
66113
)
67114
}
68115
if (grapheme.backgroundColor) {
69-
commands.push(
70-
ansiCode(
71-
grapheme.backgroundColor.type === 'color'
72-
? {
73-
type: 'style',
74-
style: grapheme.backgroundColor.color,
75-
}
76-
: {
77-
type: 'text',
78-
rgb: grapheme.backgroundColor.rgb,
79-
},
80-
),
116+
command += ansiCode(
117+
grapheme.backgroundColor.type === 'color'
118+
? {
119+
type: 'style',
120+
style: grapheme.backgroundColor.color,
121+
}
122+
: {
123+
type: 'text',
124+
rgb: grapheme.backgroundColor.rgb,
125+
},
81126
)
82127
}
83128

84129
if (grapheme.textStyles) {
85130
for (const style of grapheme.textStyles) {
86-
commands.push(ansiCode({ type: 'style', style }))
131+
command += ansiCode({ type: 'style', style })
87132
}
88133
}
89134

90-
commands.push(grapheme.grapheme)
135+
command += grapheme.grapheme
91136

92-
return commands
137+
return command
93138
}
94139

95140
function graphemeDiffCommands(
96141
prevGrapheme: Grapheme | null,
97142
newGrapheme: Grapheme,
98-
): string[] {
143+
): string {
99144
if (!prevGrapheme) {
100145
return graphemeCommands(newGrapheme)
101146
}
102147

103-
if (equalStyles(prevGrapheme, newGrapheme)) {
104-
return [newGrapheme.grapheme]
148+
if (stylesEqual(prevGrapheme, newGrapheme)) {
149+
return newGrapheme.grapheme
105150
}
106151

107-
return [
108-
...ansiCode({ type: 'style', style: STYLE.RESET }),
109-
...graphemeCommands(newGrapheme),
110-
]
152+
return (
153+
ansiCode({ type: 'style', style: STYLE.RESET }) +
154+
graphemeCommands(newGrapheme)
155+
)
111156
}
112157

113-
export type GraphemeImage = Grapheme[][]
114-
115-
export function fullImageCommands(image: GraphemeImage): string[] {
116-
const commands: string[] = [moveCursor(0, 0)]
158+
export function fullImageCommands(image: GraphemeImage): string {
159+
let command = moveCursor(0, 0)
117160

118161
let lastGrapheme: Grapheme | null = null
119162
for (const row of image) {
120163
for (const grapheme of row) {
121-
commands.push(...graphemeDiffCommands(lastGrapheme, grapheme))
164+
command += graphemeDiffCommands(lastGrapheme, grapheme)
122165
lastGrapheme = grapheme
123166
}
124167
}
125168

126-
return commands
169+
return command
127170
}
128171

129172
export function diffImageCommands(
130173
oldImage: GraphemeImage,
131174
newImage: GraphemeImage,
132-
): string[] {
175+
): string {
133176
if (oldImage.length !== newImage.length) {
134177
return fullImageCommands(newImage)
135178
}
136179
if (oldImage[0].length !== newImage[0].length) {
137180
return fullImageCommands(newImage)
138181
}
139182

140-
const commands: string[] = []
183+
let command = ''
141184
let prevWrittenGrapheme: Grapheme | null = null
142185
let skipped = true
143186
for (const [r, newRow] of newImage.entries()) {
@@ -146,20 +189,20 @@ export function diffImageCommands(
146189
const prevFrameGrapheme = oldRow[c]
147190
if (
148191
newGrapheme.grapheme === prevFrameGrapheme.grapheme &&
149-
equalStyles(newGrapheme, prevFrameGrapheme)
192+
stylesEqual(newGrapheme, prevFrameGrapheme)
150193
) {
151194
skipped = true
152195
continue
153196
}
154197

155198
if (skipped) {
156-
commands.push(moveCursor(r, c))
199+
command += moveCursor(r, c)
157200
skipped = false
158201
}
159202

160-
commands.push(...graphemeDiffCommands(prevWrittenGrapheme, newGrapheme))
203+
command += graphemeDiffCommands(prevWrittenGrapheme, newGrapheme)
161204
prevWrittenGrapheme = newGrapheme
162205
}
163206
}
164-
return commands
207+
return command
165208
}

display/src/image-renderer.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export class Renderer {
4444
private timer: NodeJS.Timeout | null = null
4545
private interval: NodeJS.Timeout | null = null
4646
private inProgress: boolean = false
47+
private lastCursorPosition: { row: number; column: number } | null = null
48+
private cursorVisible: boolean = true
4749

4850
constructor({
4951
stdout,
@@ -102,10 +104,14 @@ export class Renderer {
102104
this.lastRefreshTime = 0
103105
this.lastFullRefreshTime = 0
104106
this.inProgress = false
107+
this.lastCursorPosition = null
108+
this.cursorVisible = true
105109
}
106110

107111
public start() {
108112
this.inProgress = true
113+
this.lastCursorPosition = null
114+
this.cursorVisible = true
109115
this.stdout.write(ENTER_ALT_BUFFER)
110116
this.onResize = () => {
111117
this.refreshScreen(false, true)
@@ -119,32 +125,62 @@ export class Renderer {
119125

120126
private forceRenderFrame(renderAll: boolean = false) {
121127
if (this.timer) {
122-
this.timer.close()
128+
clearTimeout(this.timer)
123129
this.timer = null
124130
}
125131

126132
const frame = this.getFrame(this.stdout.rows, this.stdout.columns)
127133

128134
const now = Date.now()
129-
// dt / 1000 > 1 / refreshAllFps
130135
if ((now - this.lastFullRefreshTime) * this.refreshAllFps > 1000) {
131136
renderAll = true
132137
}
133138

134-
const commands = renderAll
139+
const frameCommands = renderAll
135140
? fullImageCommands(frame.frame)
136141
: diffImageCommands(this.lastFrame, frame.frame)
137-
if (frame.cursor) {
138-
commands.push(moveCursor(frame.cursor.row, frame.cursor.column))
139-
commands.push(frame.cursor.visible ?? true ? SHOW_CURSOR : HIDE_CURSOR)
142+
143+
const commands: string[] = []
144+
if (frameCommands.length > 0) {
145+
commands.push(frameCommands)
146+
}
147+
148+
const cursor = frame.cursor
149+
if (cursor) {
150+
const desiredVisible = cursor.visible ?? true
151+
const cursorMoved =
152+
!this.lastCursorPosition ||
153+
this.lastCursorPosition.row !== cursor.row ||
154+
this.lastCursorPosition.column !== cursor.column
155+
156+
if (commands.length > 0 || cursorMoved) {
157+
commands.push(moveCursor(cursor.row, cursor.column))
158+
}
159+
160+
if (this.cursorVisible !== desiredVisible) {
161+
commands.push(desiredVisible ? SHOW_CURSOR : HIDE_CURSOR)
162+
this.cursorVisible = desiredVisible
163+
}
164+
165+
this.lastCursorPosition = { row: cursor.row, column: cursor.column }
140166
} else {
141-
commands.push(HIDE_CURSOR)
167+
this.lastCursorPosition = null
168+
if (this.cursorVisible) {
169+
commands.push(HIDE_CURSOR)
170+
this.cursorVisible = false
171+
}
142172
}
143-
this.lastRefreshTime = Date.now()
173+
174+
this.lastRefreshTime = now
144175
if (renderAll) {
145-
this.lastFullRefreshTime = Date.now()
176+
this.lastFullRefreshTime = now
146177
}
147178
this.lastFrame = frame.frame
179+
180+
if (commands.length === 0) {
181+
return
182+
}
183+
148184
this.stdout.write(commands.join(''))
149185
}
150186

@@ -176,15 +212,17 @@ export class Renderer {
176212

177213
public exit() {
178214
if (this.interval) {
179-
this.interval.close()
215+
clearInterval(this.interval)
180216
this.interval = null
181217
}
182218
if (this.timer) {
183-
this.timer.close()
219+
clearTimeout(this.timer)
184220
this.timer = null
185221
}
186222
this.stdout.removeListener('resize', this.onResize)
187223
this.stdout.write(EXIT_ALT_BUFFER)
224+
this.lastCursorPosition = null
225+
this.cursorVisible = true
188226
this.inProgress = false
189227
}
190228
}

0 commit comments

Comments
 (0)