Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 100 additions & 57 deletions display/src/grapheme-image.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import GraphemeSplitter from 'grapheme-splitter'
import { isEqual } from 'lodash'
import stripAnsi from 'strip-ansi'

import {
Expand All @@ -24,6 +23,8 @@ export type Grapheme = {
textStyles?: Modifier[]
}

export type GraphemeImage = Grapheme[][]

const splitter = new GraphemeSplitter()

export function toGraphemeString(grapheme: string): $GraphemeString {
Expand All @@ -38,106 +39,148 @@ export function toGraphemeString(grapheme: string): $GraphemeString {
return first as $GraphemeString
}

function equalStyles(a: Grapheme, b: Grapheme): boolean {
type GraphemeStyle = Omit<Grapheme, 'grapheme'> &
Partial<Pick<Grapheme, 'grapheme'>>
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)
}
if (oldImage[0].length !== newImage[0].length) {
return fullImageCommands(newImage)
}

const commands: string[] = []
let command = ''
let prevWrittenGrapheme: Grapheme | null = null
let skipped = true
for (const [r, newRow] of newImage.entries()) {
Expand All @@ -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
}
60 changes: 49 additions & 11 deletions display/src/image-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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(''))
}

Expand Down Expand Up @@ -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
}
}