Skip to content

Commit 9fc13f3

Browse files
committed
add implementation for display
1 parent f85f067 commit 9fc13f3

File tree

4 files changed

+495
-0
lines changed

4 files changed

+495
-0
lines changed

display/src/ansi.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { some } from 'lodash'
2+
3+
export const RESET = 'RESET' as const
4+
5+
export const MODIFIERS = [
6+
'BOLD',
7+
'DIM',
8+
'ITALIC',
9+
'UNDERLINE',
10+
'BLINK',
11+
'RAPID_BLINK',
12+
'REVERSE_VIDEO',
13+
'HIDDEN',
14+
'STRIKETHROUGH',
15+
'DOUBLE_UNDERLINE',
16+
] as const
17+
export type Modifier = (typeof MODIFIERS)[number]
18+
19+
export const COLORS = [
20+
'BLACK',
21+
'RED',
22+
'GREEN',
23+
'YELLOW',
24+
'BLUE',
25+
'MAGENTA',
26+
'CYAN',
27+
'WHITE',
28+
'BRIGHT_BLACK',
29+
'BRIGHT_RED',
30+
'BRIGHT_GREEN',
31+
'BRIGHT_YELLOW',
32+
'BRIGHT_BLUE',
33+
'BRIGHT_MAGENTA',
34+
'BRIGHT_CYAN',
35+
'BRIGHT_WHITE',
36+
] as const
37+
export type Color = (typeof COLORS)[number]
38+
39+
export const BACKGROUND_COLORS = [
40+
'BG_BLACK',
41+
'BG_RED',
42+
'BG_GREEN',
43+
'BG_YELLOW',
44+
'BG_BLUE',
45+
'BG_MAGENTA',
46+
'BG_CYAN',
47+
'BG_WHITE',
48+
'BG_BRIGHT_BLACK',
49+
'BG_BRIGHT_RED',
50+
'BG_BRIGHT_GREEN',
51+
'BG_BRIGHT_YELLOW',
52+
'BG_BRIGHT_BLUE',
53+
'BG_BRIGHT_MAGENTA',
54+
'BG_BRIGHT_CYAN',
55+
'BG_BRIGHT_WHITE',
56+
] as const
57+
export type BackgroundColor = (typeof BACKGROUND_COLORS)[number]
58+
59+
export const STYLES = [
60+
RESET,
61+
...MODIFIERS,
62+
...COLORS,
63+
...BACKGROUND_COLORS,
64+
] as const
65+
export type Style = (typeof STYLES)[number]
66+
export const STYLE = {
67+
RESET: 0,
68+
BOLD: 1,
69+
DIM: 2,
70+
ITALIC: 3,
71+
UNDERLINE: 4,
72+
BLINK: 5,
73+
RAPID_BLINK: 6,
74+
REVERSE_VIDEO: 7,
75+
HIDDEN: 8,
76+
STRIKETHROUGH: 9,
77+
DOUBLE_UNDERLINE: 21,
78+
BLACK: 30,
79+
RED: 31,
80+
GREEN: 32,
81+
YELLOW: 33,
82+
BLUE: 34,
83+
MAGENTA: 35,
84+
CYAN: 36,
85+
WHITE: 37,
86+
BG_BLACK: 40,
87+
BG_RED: 41,
88+
BG_GREEN: 42,
89+
BG_YELLOW: 43,
90+
BG_BLUE: 44,
91+
BG_MAGENTA: 45,
92+
BG_CYAN: 46,
93+
BG_WHITE: 47,
94+
BRIGHT_BLACK: 90,
95+
BRIGHT_RED: 91,
96+
BRIGHT_GREEN: 92,
97+
BRIGHT_YELLOW: 93,
98+
BRIGHT_BLUE: 94,
99+
BRIGHT_MAGENTA: 95,
100+
BRIGHT_CYAN: 96,
101+
BRIGHT_WHITE: 97,
102+
BG_BRIGHT_BLACK: 100,
103+
BG_BRIGHT_RED: 101,
104+
BG_BRIGHT_GREEN: 102,
105+
BG_BRIGHT_YELLOW: 103,
106+
BG_BRIGHT_BLUE: 104,
107+
BG_BRIGHT_MAGENTA: 105,
108+
BG_BRIGHT_CYAN: 106,
109+
BG_BRIGHT_WHITE: 107,
110+
} as const satisfies Record<Style, number>
111+
112+
export type RGB = [red: number, green: number, blue: number]
113+
114+
export function ansiCode(
115+
data:
116+
| { type: 'style'; style: Style }
117+
| { type: 'text' | 'background'; rgb: RGB },
118+
): string {
119+
if (data.type === 'style') {
120+
return `\x1b[${STYLE[data.style]}m`
121+
}
122+
123+
if (some(data.rgb, (v) => v > 255 || v < 0)) {
124+
throw new Error(
125+
`RGB values must be between 0 and 255. Got: ${JSON.stringify(data.rgb)}`,
126+
)
127+
}
128+
129+
const first = {
130+
text: 38,
131+
background: 48,
132+
}[data.type]
133+
return `\x1b[${first};2;${data.rgb[0]};${data.rgb[1]};${data.rgb[2]}m`
134+
}
135+
136+
export function moveCursor(row: number, column: number): string {
137+
return `\x1b[${row};${column}H`
138+
}
139+
140+
export const HIDE_CURSOR = '\x1b[?25l'
141+
export const SHOW_CURSOR = '\x1b[?25h'
142+
export const ENTER_ALT_BUFFER = '\x1b[?1049h'
143+
export const EXIT_ALT_BUFFER = '\x1b[?1049l'

display/src/grapheme-image.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import GraphemeSplitter from 'grapheme-splitter'
2+
import { isEqual } from 'lodash'
3+
import stripAnsi from 'strip-ansi'
4+
5+
import {
6+
type Color,
7+
type BackgroundColor,
8+
type RGB,
9+
type Modifier,
10+
ansiCode,
11+
moveCursor,
12+
} from './ansi'
13+
14+
type $GraphemeString = string & { readonly _brand: 'GraphemeString' }
15+
16+
export type Grapheme = {
17+
grapheme: $GraphemeString
18+
textColor?: { type: 'color'; color: Color } | { type: 'rgb'; rgb: RGB }
19+
backgroundColor?:
20+
| { type: 'color'; color: BackgroundColor }
21+
| { type: 'rgb'; rgb: RGB }
22+
textStyles?: Modifier[]
23+
}
24+
25+
const splitter = new GraphemeSplitter()
26+
27+
export function toGraphemeString(grapheme: string): $GraphemeString {
28+
const stripped = stripAnsi(grapheme)
29+
const numGraphemes = splitter.countGraphemes(stripped)
30+
if (numGraphemes === 0) {
31+
return ' ' as $GraphemeString
32+
}
33+
34+
return splitter.iterateGraphemes(stripped).next().value as $GraphemeString
35+
}
36+
37+
function equalStyles(a: Grapheme, b: Grapheme): boolean {
38+
type GraphemeStyle = Omit<Grapheme, 'grapheme'> &
39+
Partial<Pick<Grapheme, 'grapheme'>>
40+
const aStyles: GraphemeStyle = { ...a }
41+
delete aStyles.grapheme
42+
const bStyles: GraphemeStyle = { ...b }
43+
delete bStyles.grapheme
44+
return isEqual(aStyles, bStyles)
45+
}
46+
47+
function graphemeCommands(grapheme: Grapheme): string[] {
48+
const commands: string[] = []
49+
if (grapheme.textColor) {
50+
commands.push(
51+
ansiCode(
52+
grapheme.textColor.type === 'color'
53+
? {
54+
type: 'style',
55+
style: grapheme.textColor.color,
56+
}
57+
: {
58+
type: 'text',
59+
rgb: grapheme.textColor.rgb,
60+
},
61+
),
62+
)
63+
}
64+
if (grapheme.backgroundColor) {
65+
commands.push(
66+
ansiCode(
67+
grapheme.backgroundColor.type === 'color'
68+
? {
69+
type: 'style',
70+
style: grapheme.backgroundColor.color,
71+
}
72+
: {
73+
type: 'text',
74+
rgb: grapheme.backgroundColor.rgb,
75+
},
76+
),
77+
)
78+
}
79+
80+
if (grapheme.textStyles) {
81+
for (const style of grapheme.textStyles) {
82+
commands.push(ansiCode({ type: 'style', style }))
83+
}
84+
}
85+
86+
commands.push(grapheme.grapheme)
87+
88+
return commands
89+
}
90+
91+
function graphemeDiffCommands(
92+
prevGrapheme: Grapheme | null,
93+
newGrapheme: Grapheme,
94+
): string[] {
95+
if (!prevGrapheme) {
96+
return graphemeCommands(newGrapheme)
97+
}
98+
99+
if (equalStyles(prevGrapheme, newGrapheme)) {
100+
return [newGrapheme.grapheme]
101+
}
102+
103+
return [
104+
...ansiCode({ type: 'style', style: 'RESET' }),
105+
...graphemeCommands(newGrapheme),
106+
]
107+
}
108+
109+
export type GraphemeImage = Grapheme[][]
110+
111+
export function fullImageCommands(image: GraphemeImage): string[] {
112+
const commands: string[] = [moveCursor(0, 0)]
113+
114+
let lastGrapheme: Grapheme | null = null
115+
for (const row of image) {
116+
for (const grapheme of row) {
117+
commands.push(...graphemeDiffCommands(lastGrapheme, grapheme))
118+
lastGrapheme = grapheme
119+
}
120+
}
121+
122+
return commands
123+
}
124+
125+
export function diffImageCommands(
126+
oldImage: GraphemeImage,
127+
newImage: GraphemeImage,
128+
): string[] {
129+
if (oldImage.length !== newImage.length) {
130+
return fullImageCommands(newImage)
131+
}
132+
if (oldImage[0].length !== newImage[0].length) {
133+
return fullImageCommands(newImage)
134+
}
135+
136+
const commands: string[] = []
137+
let prevGrapheme: Grapheme | null = null
138+
let skipped = false
139+
for (const [r, row] of oldImage.entries()) {
140+
const oldRow = oldImage[r]
141+
for (const [c, grapheme] of row.entries()) {
142+
const oldGrapheme = oldRow[c]
143+
if (isEqual(grapheme, oldGrapheme)) {
144+
skipped = true
145+
continue
146+
}
147+
148+
if (skipped) {
149+
commands.push(moveCursor(r, c))
150+
skipped = false
151+
}
152+
153+
commands.push(...graphemeDiffCommands(prevGrapheme, grapheme))
154+
prevGrapheme = grapheme
155+
}
156+
}
157+
return commands
158+
}

0 commit comments

Comments
 (0)