├── .gitignore
├── bin
├── font.js
├── logger.js
├── postcreate.js
├── postdist.js
└── postnpm.js
├── components
├── Banner.tsx
├── Bar.tsx
├── Block.tsx
├── Canvas.tsx
├── Frame.tsx
├── Input.tsx
├── List.tsx
├── ListTable.tsx
├── Scrollbar.tsx
├── Separator.tsx
├── Spinner.tsx
├── Text.tsx
└── View.tsx
├── create
├── Create.tsx
├── readme.md
└── template
│ ├── App.tsx
│ └── package.json
├── eslint.config.js
├── examples
├── Banner.tsx
├── Canvas.tsx
├── Example.tsx
├── Inline.tsx
├── Pong.tsx
├── Prompt.tsx
├── Speed.tsx
├── Todo.tsx
└── Visualizer.tsx
├── hooks
├── useAnimation.ts
├── useBell.ts
├── useChildrenSize.ts
├── useClipboard.ts
├── useExit.ts
├── useInput.ts
├── useMouse.ts
├── useSize.ts
└── useWordWrap.ts
├── index.ts
├── input.ts
├── media
├── Banner.png
├── Bar-1.png
├── Bar-2.gif
├── Block.png
├── Canvas-1.png
├── Canvas-2.png
├── Frame.png
├── Input-1.gif
├── Input-2.gif
├── List.gif
├── ListTable.gif
├── Scrollbar.png
├── Separator.png
├── Spinner.gif
├── Text.png
├── Trail.gif
├── View.gif
├── demo.gif
├── exampleAnimate.gif
├── exampleHello.png
├── exampleInput.gif
├── logo.gif
└── useAnimation.gif
├── mediacreators
├── Banner.tsx
├── Bar-1.tsx
├── Bar-2.tsx
├── Block.tsx
├── Canvas-1.tsx
├── Canvas-2.tsx
├── Frame.tsx
├── Input-1.tsx
├── Input-2.tsx
├── List.tsx
├── ListTable.tsx
├── Scrollbar.tsx
├── Separator.tsx
├── Spinner.tsx
├── Text.tsx
├── Trail.tsx
├── View.tsx
├── demo.tsx
├── exampleAnimate.tsx
├── exampleHello.tsx
├── exampleInput.tsx
├── logo.tsx
└── useAnimation.tsx
├── package.json
├── prettier.config.mjs
├── readme.md
├── reconciler.ts
├── renderer.ts
├── screen.ts
├── term.ts
├── tsconfig.json
├── utils
├── chunk.ts
└── log.ts
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .create/
3 | .dist/
4 | .npm/
5 | node_modules/
6 |
--------------------------------------------------------------------------------
/bin/font.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | // prettier-ignore
4 | const letters = [
5 | [ // !
6 | 0b00000100,
7 | 0b00000100,
8 | 0b00000100,
9 | 0b00000000,
10 | 0b00000100,
11 | 0b00000000
12 | ], [ // "#
13 | 0b10101010,
14 | 0b10101110,
15 | 0b00001010,
16 | 0b00001110,
17 | 0b00001010,
18 | 0b00000000
19 | ], [ // $%
20 | 0b11101010,
21 | 0b10000010,
22 | 0b11100100,
23 | 0b00101000,
24 | 0b11101010,
25 | 0b01000000
26 | ], [ // &'
27 | 0b01100100,
28 | 0b10000100,
29 | 0b01000000,
30 | 0b10100000,
31 | 0b11100000,
32 | 0b00000000
33 | ], [ // ()
34 | 0b00101000,
35 | 0b01000100,
36 | 0b01000100,
37 | 0b01000100,
38 | 0b00101000,
39 | 0b00000000
40 | ], [ // *+
41 | 0b00000000,
42 | 0b01000100,
43 | 0b11101110,
44 | 0b01000100,
45 | 0b10100000,
46 | 0b00000000
47 | ], [ // ,-
48 | 0b00000000,
49 | 0b00000000,
50 | 0b00001110,
51 | 0b00000000,
52 | 0b01000000,
53 | 0b10000000
54 | ], [ // ./
55 | 0b00000010,
56 | 0b00000100,
57 | 0b00000100,
58 | 0b00000100,
59 | 0b01001000,
60 | 0b00000000
61 | ], [ // 01
62 | 0b11100100,
63 | 0b10101100,
64 | 0b10100100,
65 | 0b10100100,
66 | 0b11101110,
67 | 0b00000000
68 | ], [ // 23
69 | 0b11101110,
70 | 0b00100010,
71 | 0b11101110,
72 | 0b10000010,
73 | 0b11101110,
74 | 0b00000000
75 | ], [ // 45
76 | 0b10101110,
77 | 0b10101000,
78 | 0b11101110,
79 | 0b00100010,
80 | 0b00101110,
81 | 0b00000000
82 | ], [ // 67
83 | 0b11101110,
84 | 0b10000010,
85 | 0b11100010,
86 | 0b10100010,
87 | 0b11100010,
88 | 0b00000000
89 | ], [ // 89
90 | 0b11101110,
91 | 0b10101010,
92 | 0b11101110,
93 | 0b10100010,
94 | 0b11101110,
95 | 0b00000000
96 | ], [ // :;
97 | 0b00000000,
98 | 0b01000100,
99 | 0b00000000,
100 | 0b00000000,
101 | 0b01000100,
102 | 0b00001000
103 | ], [ // <=
104 | 0b00100000,
105 | 0b01001110,
106 | 0b10000000,
107 | 0b01001110,
108 | 0b00100000,
109 | 0b00000000
110 | ], [ // >?
111 | 0b10001110,
112 | 0b01000010,
113 | 0b00100100,
114 | 0b01000000,
115 | 0b10000100,
116 | 0b00000000
117 | ], [ // @A
118 | 0b01001110,
119 | 0b10101010,
120 | 0b10001110,
121 | 0b11101010,
122 | 0b01001010,
123 | 0b00000000
124 | ], [ // BC
125 | 0b11101110,
126 | 0b10101000,
127 | 0b11001000,
128 | 0b10101000,
129 | 0b11101110,
130 | 0b00000000
131 | ], [ // DE
132 | 0b11001110,
133 | 0b10101000,
134 | 0b10101100,
135 | 0b10101000,
136 | 0b11001110,
137 | 0b00000000
138 | ], [ // FG
139 | 0b11101110,
140 | 0b10001000,
141 | 0b11001000,
142 | 0b10001010,
143 | 0b10001110,
144 | 0b00000000
145 | ], [ // HI
146 | 0b10101110,
147 | 0b10100100,
148 | 0b11100100,
149 | 0b10100100,
150 | 0b10101110,
151 | 0b00000000
152 | ], [ // JK
153 | 0b11101010,
154 | 0b00101010,
155 | 0b00101100,
156 | 0b10101010,
157 | 0b11101010,
158 | 0b00000000
159 | ], [ // LM
160 | 0b10001010,
161 | 0b10001110,
162 | 0b10001010,
163 | 0b10001010,
164 | 0b11101010,
165 | 0b00000000
166 | ], [ // NO
167 | 0b11001110,
168 | 0b10101010,
169 | 0b10101010,
170 | 0b10101010,
171 | 0b10101110,
172 | 0b00000000
173 | ], [ // PQ
174 | 0b11101110,
175 | 0b10101010,
176 | 0b11101010,
177 | 0b10001010,
178 | 0b10001110,
179 | 0b00000010
180 | ], [ // RS
181 | 0b11101110,
182 | 0b10101000,
183 | 0b11001110,
184 | 0b10100010,
185 | 0b10101110,
186 | 0b00000000
187 | ], [ // TU
188 | 0b11101010,
189 | 0b01001010,
190 | 0b01001010,
191 | 0b01001010,
192 | 0b01001110,
193 | 0b00000000
194 | ], [ // VW
195 | 0b10101010,
196 | 0b10101010,
197 | 0b10101010,
198 | 0b10101110,
199 | 0b01001010,
200 | 0b00000000
201 | ], [ // XY
202 | 0b10101010,
203 | 0b10101010,
204 | 0b01000100,
205 | 0b10100100,
206 | 0b10100100,
207 | 0b00000000
208 | ], [ // Z[
209 | 0b11100110,
210 | 0b00100100,
211 | 0b01000100,
212 | 0b10000100,
213 | 0b11100110,
214 | 0b00000000
215 | ], [ // \]
216 | 0b10001100,
217 | 0b01000100,
218 | 0b01000100,
219 | 0b01000100,
220 | 0b00101100,
221 | 0b00000000
222 | ], [ // ^_
223 | 0b01000000,
224 | 0b10100000,
225 | 0b00000000,
226 | 0b00000000,
227 | 0b00001110,
228 | 0b00000000
229 | ], [ // `{
230 | 0b10000110,
231 | 0b01000100,
232 | 0b00001000,
233 | 0b00000100,
234 | 0b00000110,
235 | 0b00000000
236 | ], [ // |}
237 | 0b01001100,
238 | 0b01000100,
239 | 0b01000010,
240 | 0b01000100,
241 | 0b01001100,
242 | 0b00000000
243 | ], [ // ~
244 | 0b01010000,
245 | 0b10100000,
246 | 0b00000000,
247 | 0b00000000,
248 | 0b00000000,
249 | 0b00000000
250 | ]
251 | ]
252 |
253 | console.log(Buffer.from(letters.flat()).toString('base64'))
254 |
--------------------------------------------------------------------------------
/bin/logger.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import { rmSync } from 'node:fs'
3 | import { createServer } from 'node:net'
4 | import { tmpdir } from 'node:os'
5 | import { join } from 'node:path'
6 | import process from 'node:process'
7 |
8 | const filename = join(tmpdir(), 'node-log.sock')
9 |
10 | try {
11 | rmSync(filename)
12 | } catch {
13 | //
14 | }
15 |
16 | createServer(stream => {
17 | stream.on('data', data => {
18 | data = data.toString()
19 | if (data === '\0') return process.stdout.write('\x1bc')
20 |
21 | process.stdout.write(data)
22 | })
23 | }).listen(filename)
24 |
--------------------------------------------------------------------------------
/bin/postcreate.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import { execSync } from 'node:child_process'
3 | import { readFileSync, writeFileSync, chmodSync } from 'node:fs'
4 |
5 | const makeJs = () => {
6 | const file = `.create/index.js`
7 | writeFileSync(file, `#!/usr/bin/env node\n${readFileSync(file, 'utf8')}`)
8 | chmodSync(file, '755')
9 | }
10 |
11 | const makeJson = () => {
12 | const json = JSON.parse(readFileSync('package.json', 'utf8'))
13 |
14 | const keys = ['name', 'version', 'description', 'keywords', 'author', 'repository', 'homepage', 'license']
15 | const jsonNew = {
16 | ...Object.fromEntries(Object.entries(json).filter(([key]) => keys.includes(key))),
17 | ...{
18 | name: 'create-react-curse',
19 | description: 'Create React-Curse app',
20 | keywords: [...json.keywords, 'react-curse'],
21 | bin: 'index.js'
22 | }
23 | }
24 | writeFileSync('.create/package.json', JSON.stringify(jsonNew, null, 2))
25 | }
26 |
27 | makeJs()
28 | makeJson()
29 | execSync('cp -r create/{template,README.md} .create')
30 |
--------------------------------------------------------------------------------
/bin/postdist.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import { readFileSync, writeFileSync, chmodSync, unlinkSync } from 'node:fs'
3 | import { argv } from 'node:process'
4 |
5 | let [, , arg] = argv
6 | if (arg === undefined) {
7 | const json = JSON.parse(readFileSync('package.json', 'utf8'))
8 | arg = json.main.replace(/\.[jt]sx?$/, '')
9 | }
10 |
11 | const src = '.dist/index.cjs'
12 | const dest = `.dist/${arg}.cjs`
13 | writeFileSync(dest, `#!/usr/bin/env node\n${readFileSync(src, 'utf8')}`)
14 | unlinkSync(src)
15 | chmodSync(dest, '755')
16 |
--------------------------------------------------------------------------------
/bin/postnpm.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import { readFileSync, writeFileSync } from 'node:fs'
3 |
4 | const makeJson = () => {
5 | const json = JSON.parse(readFileSync('package.json', 'utf8'))
6 |
7 | const keys = [
8 | 'name',
9 | 'version',
10 | 'description',
11 | 'keywords',
12 | 'author',
13 | 'repository',
14 | 'homepage',
15 | 'main',
16 | 'license',
17 | 'type',
18 | 'dependencies'
19 | ]
20 | const jsonNew = {
21 | ...Object.fromEntries(Object.entries(json).filter(([key]) => keys.includes(key))),
22 | ...{
23 | main: 'index.js'
24 | }
25 | }
26 | writeFileSync('.npm/package.json', JSON.stringify(jsonNew, null, 2))
27 | }
28 |
29 | const makeReadme = () => {
30 | const data = readFileSync('README.md', 'utf8')
31 |
32 | const dataNew = data
33 | .split('\n')
34 | .map(i => i.replace('media/', 'https://raw.githubusercontent.com/infely/react-curse/HEAD/media/'))
35 | .join('\n')
36 | writeFileSync('.npm/README.md', dataNew)
37 | }
38 |
39 | makeJson()
40 | makeReadme()
41 |
--------------------------------------------------------------------------------
/components/Banner.tsx:
--------------------------------------------------------------------------------
1 | import chunk from '../utils/chunk'
2 | import Text, { type TextProps } from './Text'
3 | import React, { useMemo } from 'react'
4 |
5 | const FONT =
6 | 'BAQEAAQAqq4KDgoA6oLkKOpAZIRAoOAAKERERCgAAETuRKAAAAAOAECAAgQEBEgA5KykpO4A7iLugu4ArqjuIi4A7oLiouIA7qruou4AAEQAAEQIIE6ATiAAjkIkQIQATqqO6koA7qjIqO4AzqisqM4A7ojIio4ArqTkpK4A6iosquoAio6KiuoAzqqqqq4A7qrqio4C7qjOoq4A6kpKSk4AqqqqrkoAqqpEpKQA5iREhOYAjERERCwAQKAAAA4AhkQIBAYATERCREwAUKAAAAAA'
7 |
8 | const letters = chunk(Buffer.from(FONT, 'base64'), 6)
9 |
10 | const Letter = ({ children }: { children: string }) => {
11 | const text = useMemo(() => {
12 | let code = children.toUpperCase().charCodeAt(0)
13 | if (code >= 123 && code <= 126) code -= 26
14 | const font = letters[Math.floor((code - 32) / 2)]
15 | if (!font) return
16 |
17 | const bits = code % 2 === 0 ? 4 : 0
18 | return chunk(font, 2)
19 | .map(([top, bot]) => {
20 | return [3, 2, 1, 0]
21 | .map(i => {
22 | const b = Math.pow(2, i + bits)
23 | const code = 0 | (top & b && 0x04) | (bot & b && 0x08)
24 | return code ? String.fromCharCode(0x257c + code) : ' '
25 | })
26 | .join('')
27 | })
28 | .join('\n')
29 | }, [children])
30 |
31 | return <>{text}>
32 | }
33 |
34 | interface BannerProps extends TextProps {
35 | children: string
36 | }
37 |
38 | export default function Banner({ children, ...props }: BannerProps): JSX.Element | null {
39 | if (children === undefined || children === null) return null
40 |
41 | const lines = children.toString().split('\n')
42 | const length = Math.max(...lines.map((i: string) => i.length))
43 |
44 | return (
45 |
46 | {lines.map((line: string, key: number) => (
47 |
48 | {line.split('').map((char: string, key: number) => (
49 |
50 | {char}
51 |
52 | ))}
53 |
54 | ))}
55 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/components/Bar.tsx:
--------------------------------------------------------------------------------
1 | import Text, { type TextProps } from './Text'
2 | import React from 'react'
3 |
4 | const getSize = (offset: number, size: number) => {
5 | offset = Math.round(offset * 8)
6 | size = Math.round(size * 8)
7 | if (offset < 0) {
8 | size += offset
9 | offset = 0
10 | }
11 | size = Math.max(0, size)
12 |
13 | return [offset, size]
14 | }
15 |
16 | const getSections = (offset: number, size: number) => [
17 | size >= 8 || ((offset + size) % 8 === 0 && size > 0 && size < 8),
18 | size >= 8,
19 | (size >= 8 || (offset % 8 === 0 && size < 8)) && (offset + size) % 8 !== 0
20 | ]
21 |
22 | const Vertical = (y: number, height: number, props: object) => {
23 | const [offset, size] = getSize(y, height)
24 | const sections = getSections(offset, size)
25 |
26 | const char = (value: number) => {
27 | if (value > 7) return String.fromCharCode(0x2588)
28 | return String.fromCharCode(0x2588 - Math.min(8, value))
29 | }
30 |
31 | return (
32 |
33 | {sections[0] && {char(offset % 8)}}
34 | {sections[1] &&
35 | [...Array(Math.floor(((offset % 8) + size) / 8) - 1)].map((_, key) => (
36 |
37 | {char(8)}
38 |
39 | ))}
40 | {sections[2] && {char((offset + size) % 8)}}
41 |
42 | )
43 | }
44 |
45 | const Horizontal = (x: number, width: number, props: object) => {
46 | const [offset, size] = getSize(x, width)
47 | const sections = getSections(offset, size)
48 |
49 | const char = (value: number) => {
50 | if (value <= 0) return ' '
51 | return String.fromCharCode(0x2590 - Math.min(8, value))
52 | }
53 |
54 | return (
55 |
56 | {sections[0] && {char(offset % 8)}}
57 | {sections[1] && {char(8).repeat(Math.floor(((offset % 8) + size) / 8) - 1)}}
58 | {sections[2] && {char((offset + size) % 8)}}
59 |
60 | )
61 | }
62 |
63 | interface BarProps extends TextProps {
64 | type: 'vertical' | 'horizontal'
65 | y?: number
66 | x?: number
67 | height?: number
68 | width?: number
69 | }
70 |
71 | export default function Bar({ type = 'vertical', y, x, height, width, ...props }: BarProps): JSX.Element | null {
72 | if (type === 'vertical') return Vertical(y || 0, height || 0, { x, width, ...props })
73 | if (type === 'horizontal') return Horizontal(x || 0, width || 0, { y, height, ...props })
74 |
75 | return null
76 | }
77 |
--------------------------------------------------------------------------------
/components/Block.tsx:
--------------------------------------------------------------------------------
1 | import useSize from '../hooks/useSize'
2 | import Text, { type TextProps } from './Text'
3 | import React from 'react'
4 |
5 | interface BlockProps extends TextProps {
6 | width?: number | undefined
7 | align?: 'left' | 'center' | 'right'
8 | children: any
9 | }
10 |
11 | export default function Block({ width = undefined, align = 'left', children, ...props }: BlockProps) {
12 | const handle = (line: any, key: any = undefined) => {
13 | if (typeof line === 'object') return line
14 | if (line === '\n') return
15 |
16 | let x: number | string = 0
17 | switch (align) {
18 | case 'center':
19 | width ??= useSize().width
20 | x = Math.round(width / 2 - line.length / 2)
21 | break
22 | case 'right':
23 | x = `100%-${line.length}`
24 | break
25 | }
26 | return (
27 |
28 | {line}
29 |
30 | )
31 | }
32 |
33 | if (Array.isArray(children)) return children.map(handle)
34 | return handle(children)
35 | }
36 |
--------------------------------------------------------------------------------
/components/Canvas.tsx:
--------------------------------------------------------------------------------
1 | import { Color } from '../screen'
2 | import chunk from '../utils/chunk'
3 | import Text from './Text'
4 | import React, { useEffect, useMemo, useRef } from 'react'
5 |
6 | class CanvasClass {
7 | // prettier-ignore
8 | MODES = {
9 | '1x1': { map: [[0x1]], table: [0x20, 0x88] },
10 | '1x2': { map: [[0x1], [0x2]], table: [0x20, 0x80, 0x84, 0x88] },
11 | '2x2': { map: [[0x1, 0x4], [0x2, 0x8]], table: [0x20, 0x98, 0x96, 0x8c, 0x9d, 0x80, 0x9e, 0x9b, 0x97, 0x9a, 0x84, 0x99, 0x90, 0x9c, 0x9f, 0x88] },
12 | '2x4': { map: [[0x1, 0x8], [0x2, 0x10], [0x4, 0x20], [0x40, 0x80]] }
13 | }
14 |
15 | mode: { w: number; h: number }
16 | multicolor: boolean
17 | w: number
18 | h: number
19 | buffer: Buffer
20 | colors: Color[]
21 |
22 | constructor(width: number, height: number, mode = { w: 1, h: 2 }) {
23 | this.mode = mode
24 | this.multicolor = mode.w === 1 && mode.h === 2
25 | this.w = Math.ceil(width / this.mode.w) * this.mode.w
26 | this.h = Math.ceil(height / this.mode.h) * this.mode.h
27 |
28 | const size = ((this.w / this.mode.w) * this.h) / this.mode.h
29 | this.buffer = Buffer.alloc(size)
30 | this.colors = [...Array(size * (this.multicolor ? 2 : 1))]
31 | }
32 | clear() {
33 | this.buffer.fill(0)
34 | this.colors.fill(0)
35 | }
36 | set(x: number, y: number, color: Color) {
37 | if (x < 0 || x >= this.w || y < 0 || y >= this.h) return
38 | const index = (this.w / this.mode.w) * Math.floor(y / this.mode.h) + Math.floor(x / this.mode.w)
39 | this.buffer[index] |= this.MODES[`${this.mode.w}x${this.mode.h}`].map[y % this.mode.h][x % this.mode.w]
40 |
41 | if (color) this.colors[this.multicolor ? this.w * y + x : index] = color
42 | }
43 | line(x0: number, y0: number, x1: number, y1: number, color: Color) {
44 | const dx = x1 - x0
45 | const dy = y1 - y0
46 | const adx = Math.abs(dx)
47 | const ady = Math.abs(dy)
48 | let eps = 0
49 | const sx = dx > 0 ? 1 : -1
50 | const sy = dy > 0 ? 1 : -1
51 | if (adx > ady) {
52 | for (let x = x0, y = y0; sx < 0 ? x >= x1 : x <= x1; x += sx) {
53 | this.set(x, y, color)
54 | eps += ady
55 | if (eps << 1 >= adx) {
56 | y += sy
57 | eps -= adx
58 | }
59 | }
60 | } else {
61 | for (let x = x0, y = y0; sy < 0 ? y >= y1 : y <= y1; y += sy) {
62 | this.set(x, y, color)
63 | eps += adx
64 | if (eps << 1 >= ady) {
65 | x += sx
66 | eps -= ady
67 | }
68 | }
69 | }
70 | }
71 | render() {
72 | return [...this.buffer].map((i, index) => {
73 | const table = this.MODES[`${this.mode.w}x${this.mode.h}`].table
74 | let res = String.fromCharCode(table ? (i && 0x2500) + table[i] : 0x2800 + i)
75 |
76 | let colors: Color[] = []
77 | if (res !== ' ') {
78 | if (this.multicolor) {
79 | const y = Math.floor(index / this.w) * this.mode.h
80 | const x = (index % this.w) * this.mode.w
81 | const color1 = this.colors[this.w * y + x]
82 | const color2 = this.colors[this.w * (y + 1) + x]
83 | if (res === '\u2588' && color1 !== color2) {
84 | res = '\u2580'
85 | colors = [color1, color2]
86 | } else {
87 | colors = [color1 || color2]
88 | }
89 | } else {
90 | colors = [this.colors[index]]
91 | }
92 | }
93 |
94 | return [res, colors]
95 | })
96 | }
97 | }
98 |
99 | interface Point {
100 | x: number
101 | y: number
102 | color?: Color
103 | }
104 |
105 | export const Point = (_props: Point) => <>>
106 |
107 | interface Line {
108 | x: number
109 | y: number
110 | dx: number
111 | dy: number
112 | color?: Color
113 | }
114 |
115 | export const Line = (_props: Line) => <>>
116 |
117 | interface CanvasProps {
118 | mode?: { w: number; h: number }
119 | width: number
120 | height: number
121 | children: any[]
122 | }
123 |
124 | export default function Canvas({ mode = { w: 1, h: 2 }, width, height, children, ...props }: CanvasProps) {
125 | const canvas = useRef(new CanvasClass(width, height, mode))
126 |
127 | useEffect(() => {
128 | canvas.current = new CanvasClass(width, height, mode)
129 | }, [width, height, mode])
130 |
131 | const text = useMemo(() => {
132 | canvas.current.clear()
133 |
134 | React.Children.forEach(children, i => {
135 | if (i.type === Point) {
136 | const { x, y, color } = i.props
137 | canvas.current.set(x, y, color)
138 | } else if (i.type === Line) {
139 | const { x, y, dx, dy, color } = i.props
140 | canvas.current.line(x, y, dx, dy, color)
141 | }
142 | })
143 |
144 | return canvas.current.render()
145 | }, [children])
146 |
147 | return (
148 |
149 | {chunk(text, canvas.current.w / canvas.current.mode.w).map((line, y) => (
150 |
151 | {line.map(
152 | ([char, [color, background]], x: number) =>
153 | char !== ' ' && (
154 |
161 | {char}
162 |
163 | )
164 | )}
165 |
166 | ))}
167 |
168 | )
169 | }
170 |
--------------------------------------------------------------------------------
/components/Frame.tsx:
--------------------------------------------------------------------------------
1 | import useChildrenSize from '../hooks/useChildrenSize'
2 | import Text, { type TextProps } from './Text'
3 | import React from 'react'
4 |
5 | interface FrameProps extends TextProps {
6 | children: any
7 | type?: 'single' | 'double' | 'rounded'
8 | height?: number
9 | width?: number
10 | }
11 |
12 | export default function Frame({ children, type = 'single', height: _height, width: _width, ...props }: FrameProps) {
13 | const frames = {
14 | single: '┌─┐│└┘',
15 | double: '╔═╗║╚╝',
16 | rounded: '╭─╮│╰╯'
17 | }[type]
18 |
19 | const size = _height === undefined || _width === undefined ? useChildrenSize(children) : undefined
20 | const height = _height ?? size!.height
21 | const width = _width ?? size!.width
22 |
23 | const { color } = props
24 |
25 | return (
26 |
27 |
28 | {frames[0]}
29 | {frames[1].repeat(width)}
30 | {frames[2]}
31 |
32 | {[...Array(height)].map((_, key) => (
33 |
34 | {frames[3]}
35 | {' '.repeat(width)}
36 | {frames[3]}
37 |
38 | ))}
39 |
40 | {children}
41 |
42 |
43 | {frames[4]}
44 | {frames[1].repeat(width)}
45 | {frames[5]}
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/components/Input.tsx:
--------------------------------------------------------------------------------
1 | import useInput from '../hooks/useInput'
2 | import Renderer from '../renderer'
3 | import { Color } from '../screen'
4 | import Text, { type TextProps } from './Text'
5 | import React, { useEffect, useMemo, useRef, useState } from 'react'
6 |
7 | const mutate = (value: string, pos: number, str: string, multiline: boolean): [string, number, string | null] => {
8 | const edit = (value: string, pos: number, callback: (string: string) => string) => {
9 | const left = callback(value.substring(0, pos))
10 | const right = value.substring(pos)
11 | return [left, right].join('')
12 | }
13 |
14 | const arr = Renderer.input.parse(str) // str.split('')
15 | for (const input of arr) {
16 | switch (input) {
17 | case '\x01': // C-a
18 | case '\x1b\x5b\x31\x7e': {
19 | // home
20 | if (pos > 0) pos = 0
21 | break
22 | }
23 | case '\x05': // C-e
24 | case '\x1b\x5b\x34\x7e': {
25 | // end
26 | if (pos < value.length) pos = value.length
27 | break
28 | }
29 | case '\x02': // C-b
30 | case '\x1b\x5b\x44': {
31 | // left
32 | if (pos > 0) pos -= 1
33 | break
34 | }
35 | case '\x06': // C-f
36 | case '\x1b\x5b\x43': {
37 | // right
38 | if (pos < value.length) pos += 1
39 | break
40 | }
41 | case '\x1b': {
42 | // esc
43 | return [value, pos, 'cancel']
44 | }
45 | case '\x04': // C-d
46 | case '\x0d': {
47 | // cr
48 | if (input === '\x0d' && multiline) {
49 | value = edit(value, pos, i => i + '\n')
50 | pos += 1
51 | break
52 | }
53 | return [value, pos, 'submit']
54 | }
55 | case '\x08': // C-h
56 | case '\x7f': {
57 | // backspace
58 | if (pos < 1) break
59 | value = edit(value, pos, i => i.substring(0, i.length - 1))
60 | pos -= 1
61 | break
62 | }
63 | case '\x15': {
64 | // C-u
65 | if (pos < 1) break
66 | value = edit(value, pos, () => '')
67 | pos = 0
68 | break
69 | }
70 | case '\x0b': {
71 | // C-k
72 | if (pos > value.length - 1) break
73 | value = value.substring(0, pos)
74 | break
75 | }
76 | case '\x1b\x62': // M-b
77 | case '\x17': {
78 | // C-w
79 | if (pos < 1) break
80 | const index = value.substring(0, pos).trimEnd().lastIndexOf(' ')
81 | if (input === '\x17') value = edit(value, pos, i => (index !== -1 ? i.substring(0, index + 1) : ''))
82 | pos = Math.max(0, index + 1)
83 | break
84 | }
85 | case '\x1b\x66': {
86 | // M-f
87 | if (pos > value.length - 1) break
88 | const nextWordIndex = value.substring(pos).match(/\s(\w)/)?.index ?? -1
89 | pos = nextWordIndex === -1 ? value.length : pos + nextWordIndex + 1
90 | break
91 | }
92 | case '\x1b\x64': {
93 | // M-d
94 | const nextEndIndex = value.substring(pos).match(/\w(\b)/)?.index ?? -1
95 | value = value.substring(0, pos) + (nextEndIndex !== -1 ? value.substring(pos + nextEndIndex + 1) : '')
96 | break
97 | }
98 | case '\x1b\x5b\x41': {
99 | // up
100 | if (!multiline) break
101 |
102 | const currentLine = value.substring(0, pos).lastIndexOf('\n')
103 | if (currentLine === -1) break
104 |
105 | const targetLine = value.substring(0, currentLine).lastIndexOf('\n')
106 | pos = targetLine + Math.min(pos - currentLine, currentLine - targetLine)
107 | break
108 | }
109 | case '\x1b\x5b\x42': {
110 | // down
111 | if (!multiline) break
112 |
113 | let targetLine_ = value.substring(pos).indexOf('\n')
114 | if (targetLine_ === -1) break
115 |
116 | targetLine_ += pos + 1
117 | let nextLine = value.substring(targetLine_).indexOf('\n')
118 | nextLine = (nextLine !== -1 ? targetLine_ + nextLine : value.length) + 1
119 | const currentLine_ = value.substring(0, pos).lastIndexOf('\n')
120 | pos = targetLine_ + Math.min(pos - currentLine_ - 1, nextLine - targetLine_ - 1)
121 | break
122 | }
123 | default: {
124 | if (input.charCodeAt(0) < 32) break
125 | value = edit(value, pos, i => i + input)
126 | pos += 1
127 | }
128 | }
129 | }
130 | return [value, pos, null]
131 | }
132 |
133 | interface InputProps extends TextProps {
134 | focus?: boolean
135 | type?: 'text' | 'password' | 'hidden'
136 | initialValue?: string
137 | cursorBackground?: Color
138 | onCancel?: () => void
139 | onChange?: (_: any) => void
140 | onSubmit?: (_: any) => void
141 | }
142 |
143 | export default function Input({
144 | focus = true,
145 | type = 'text',
146 | initialValue = '',
147 | cursorBackground = undefined,
148 | onCancel = () => {},
149 | onChange = (_: string) => {},
150 | onSubmit = (_: string) => {},
151 | width = undefined,
152 | height = undefined,
153 | ...props
154 | }: InputProps) {
155 | const [value, setValue] = useState(initialValue)
156 | const [pos, setPos] = useState(initialValue.length)
157 | const offset = useRef({ y: 0, x: 0 })
158 |
159 | const multiline = useMemo(() => {
160 | return typeof height === 'number' && height > 1
161 | }, [height])
162 |
163 | useInput(
164 | (_, raw) => {
165 | if (raw === undefined) return
166 | if (!focus) return
167 |
168 | const [valueNew, posNew, action] = mutate(value, pos, raw(), multiline)
169 | switch (action) {
170 | case 'cancel':
171 | onCancel()
172 | break
173 | case 'submit':
174 | onSubmit(valueNew)
175 | setValue('')
176 | setPos(0)
177 | break
178 | default:
179 | setValue(valueNew)
180 | setPos(posNew)
181 | }
182 | },
183 | [focus, value, pos, onCancel, onSubmit]
184 | )
185 |
186 | useEffect(() => {
187 | onChange(value)
188 | }, [value])
189 |
190 | if (type === 'hidden') return null
191 |
192 | const text = useMemo(() => {
193 | if (type === 'password') return '*'.repeat(value.length)
194 | return value
195 | }, [value, type])
196 |
197 | const { y: yo, x: xo } = useMemo(() => {
198 | if (typeof width !== 'number') return offset.current
199 |
200 | let posLine = pos
201 | let valueLine = value
202 | if (multiline && typeof height === 'number') {
203 | const line = value.substring(0, pos).split('\n').length - 1
204 | if (offset.current.y < line - height + 1) offset.current.y = line - height + 1
205 | if (offset.current.y > line) offset.current.y = line
206 |
207 | const currentLine = value.substring(0, pos).lastIndexOf('\n')
208 | posLine = pos - (currentLine !== -1 ? currentLine + 1 : 0)
209 | const nextLine = value.substring(pos).indexOf('\n')
210 | valueLine = value.substring(currentLine + 1, nextLine !== -1 ? pos + nextLine : value.length)
211 | }
212 |
213 | if (!multiline && offset.current.x + valueLine.length + 1 > width)
214 | offset.current.x = Math.max(0, valueLine.length - width + 1)
215 | if (offset.current.x < posLine - width + 1) offset.current.x = posLine - width + 1
216 | if (offset.current.x > posLine) offset.current.x = posLine
217 |
218 | return offset.current
219 | }, [value, pos, width])
220 |
221 | return (
222 |
223 |
224 | {text.substring(0, pos)}
225 | {focus && (
226 | <>
227 |
228 | {(text[pos] !== '\n' && text[pos]) || ' '}
229 |
230 | {text[pos] === '\n' && '\n'}
231 | >
232 | )}
233 | {text.length > pos && text.substring(pos + (focus ? 1 : 0))}
234 |
235 |
236 | )
237 | }
238 |
--------------------------------------------------------------------------------
/components/List.tsx:
--------------------------------------------------------------------------------
1 | import useInput from '../hooks/useInput'
2 | import useSize from '../hooks/useSize'
3 | import { Color } from '../screen'
4 | import Scrollbar from './Scrollbar'
5 | import Text from './Text'
6 | import React, { useEffect, useMemo, useState } from 'react'
7 |
8 | export const getYO = (offset: number, limit: number, y: number) => {
9 | if (offset <= y - limit) return y - limit + 1
10 | if (offset > y) return y
11 | return offset
12 | }
13 |
14 | export const inputHandler =
15 | (
16 | vi: boolean,
17 | pos: ListPos,
18 | setPos: (_: any) => void,
19 | height: number,
20 | dataLength: number,
21 | onChange: (_: any) => void
22 | ) =>
23 | (input: string) => {
24 | let y: undefined | number
25 | let yo: undefined | number
26 | if (((vi && input === 'k') || input === '\x1b\x5b\x41') /* up */ && pos.y > 0) y = pos.y - 1
27 | if (((vi && input === 'j') || input === '\x1b\x5b\x42') /* down */ && pos.y < dataLength - 1) y = pos.y + 1
28 | if (((vi && input === '\x02') /* C-b */ || input === '\x1b\x5b\x35\x7e') /* pageup */ && pos.y > 0)
29 | y = Math.max(0, pos.y - height)
30 | if (((vi && input === '\x06') /* C-f */ || input === '\x1b\x5b\x36\x7e') /* pagedown */ && pos.y < dataLength - 1)
31 | y = Math.min(dataLength - 1, pos.y + height)
32 | if (vi && input === '\x15' /* C-u */ && pos.y > 0) y = Math.max(0, pos.y - Math.floor(height / 2))
33 | if (vi && input === '\x04' /* C-d */ && pos.y < dataLength - 1)
34 | y = Math.min(dataLength - 1, pos.y + Math.floor(height / 2))
35 | if (((vi && input === 'g') || input === '\x1b\x5b\x31\x7e') /* home */ && pos.y > 0) y = 0
36 | if (((vi && input === 'G') || input === '\x1b\x5b\x34\x7e') /* end */ && pos.y < dataLength - 1) y = dataLength - 1
37 | if (y !== undefined) yo = getYO(pos.yo, height, y)
38 |
39 | if (vi && input === 'H') y = pos.yo
40 | if (vi && input === 'M') y = pos.yo + Math.floor(height / 2)
41 | if (vi && input === 'L') y = pos.yo + height - 1
42 |
43 | if (y !== undefined) {
44 | let newPos = { ...pos, y }
45 | if (yo !== undefined) newPos = { ...newPos, yo }
46 | setPos(newPos)
47 | onChange(newPos)
48 | }
49 | }
50 |
51 | export interface ListPos {
52 | y: number
53 | x: number
54 | yo: number
55 | xo: number
56 | x1: number
57 | x2: number
58 | xm?: number
59 | }
60 |
61 | export interface ListBase {
62 | focus?: boolean
63 | initialPos?: ListPos
64 | height?: number
65 | width?: number
66 | renderItem?: (_: any) => React.ReactNode
67 | scrollbar?: boolean
68 | scrollbarBackground?: Color
69 | scrollbarColor?: Color
70 | vi?: boolean
71 | pass?: any
72 | onChange?: (pos: ListPos) => void
73 | onSubmit?: (pos: ListPos) => void
74 | }
75 |
76 | interface ListProps extends ListBase {
77 | data?: any[]
78 | }
79 |
80 | export default function List({
81 | focus = true,
82 | initialPos = { y: 0, x: 0, yo: 0, xo: 0, x1: 0, x2: 0 },
83 | data = [''],
84 | renderItem = (_: any) => ,
85 | height: _height = undefined,
86 | width: _width = undefined,
87 | scrollbar = undefined,
88 | scrollbarBackground = undefined,
89 | scrollbarColor = undefined,
90 | vi = true,
91 | pass = undefined,
92 | onChange = (_: ListPos) => {},
93 | onSubmit = (_: ListPos) => {}
94 | }: ListProps) {
95 | const size = _height === undefined || _width === undefined ? useSize() : undefined
96 | const height = _height ?? size!.height
97 | const width = _width ?? size!.width
98 |
99 | const [pos, setPos] = useState({ ...{ y: 0, x: 0, yo: 0, xo: 0, x1: 0, x2: 0 }, ...initialPos })
100 |
101 | const isScrollbarRequired = useMemo(() => {
102 | return scrollbar === undefined ? data.length > height : scrollbar
103 | }, [scrollbar, data.length, height])
104 |
105 | useEffect(() => {
106 | let newPos: undefined | ListPos
107 | let { y } = initialPos
108 | if (y > 0 && y >= data.length) {
109 | y = data.length - 1
110 | onChange({ ...pos, y })
111 | }
112 | if (y !== pos.y) {
113 | y = Math.max(0, y)
114 | newPos = { ...(newPos || pos), y, yo: getYO(pos.yo, height - 1, y) }
115 | }
116 | if (newPos) {
117 | setPos(newPos)
118 | onChange(newPos)
119 | }
120 | }, [initialPos.y])
121 |
122 | useEffect(() => {
123 | if (pos.y > 0 && pos.y > data.length - 1) {
124 | const y = Math.max(0, data.length - 1)
125 | const newPos = { ...pos, y, yo: getYO(pos.yo, data.length, y) }
126 | setPos(newPos)
127 | onChange(newPos)
128 | }
129 | }, [data])
130 |
131 | useInput(
132 | (input: string) => {
133 | if (!focus) return
134 |
135 | inputHandler(vi, pos, setPos, height, data.length, onChange)(input)
136 |
137 | if (input === '\x0d' /* cr */) onSubmit(pos)
138 | },
139 | [focus, vi, pos, setPos, height, data, onChange, onSubmit]
140 | )
141 |
142 | return (
143 |
144 | {data
145 | .filter((_: any, index: number) => index >= pos.yo && index < height + pos.yo)
146 | .map((row: any, index: number) => (
147 |
148 | {renderItem({
149 | focus,
150 | item: row,
151 | selected: index + pos.yo === pos.y,
152 | pass
153 | })}
154 |
155 | ))}
156 | {isScrollbarRequired && (
157 |
158 |
165 |
166 | )}
167 |
168 | )
169 | }
170 |
--------------------------------------------------------------------------------
/components/ListTable.tsx:
--------------------------------------------------------------------------------
1 | import useInput from '../hooks/useInput'
2 | import useSize from '../hooks/useSize'
3 | import { getYO, inputHandler, type ListBase, type ListPos } from './List'
4 | import Scrollbar from './Scrollbar'
5 | import Text from './Text'
6 | import React, { useEffect, useMemo, useState } from 'react'
7 |
8 | const getX = (index: number, widths: number[]) => {
9 | const [x1, x2] = widths.reduce((acc, i, k) => [acc[0] + (k < index ? i : 0), acc[1] + (k <= index ? i : 0)], [0, 0])
10 | return { x1, x2 }
11 | }
12 |
13 | const getXO = (offsetX: number, limit: number, x1: number, x2: number) => {
14 | if (x1 <= offsetX) return x1
15 | if (x2 >= offsetX + limit) return x2 - limit + 1
16 | return offsetX
17 | }
18 |
19 | interface ListTableProps extends ListBase {
20 | mode?: 'cell' | 'row'
21 | head?: any[]
22 | renderHead?: (_: any) => React.ReactNode
23 | data?: any[][]
24 | }
25 |
26 | export default function List({
27 | mode = 'cell',
28 | focus = true,
29 | initialPos = { y: 0, x: 0, yo: 0, xo: 0, x1: 0, x2: 0 },
30 | height: _height = undefined,
31 | width: _width = undefined,
32 | head = [''],
33 | renderHead = (_: any) => ,
34 | data = [['']],
35 | renderItem = (_: any) => ,
36 | scrollbar = undefined,
37 | scrollbarBackground = undefined,
38 | scrollbarColor = undefined,
39 | vi = true,
40 | pass = undefined,
41 | onChange = (_pos: ListPos) => {},
42 | onSubmit = (_pos: ListPos) => {}
43 | }: ListTableProps) {
44 | const size = _height === undefined || _width === undefined ? useSize() : undefined
45 | const height = _height ?? size!.height
46 | const width = _width ?? size!.width
47 |
48 | const [pos, setPos] = useState({ ...{ y: 0, x: 0, yo: 0, xo: 0, x1: 0, x2: 0 }, ...initialPos })
49 |
50 | const isScrollbarRequired = useMemo(() => {
51 | return scrollbar === undefined ? data.length > height - 1 : scrollbar
52 | }, [scrollbar, data.length, height])
53 |
54 | const widths = useMemo(() => {
55 | const widths = data
56 | .reduce(
57 | (acc: number[], row: string[]) => {
58 | row.forEach((i, k) => (acc[k] = Math.max(acc[k], (i?.toString() || 'null').length)))
59 | return acc
60 | },
61 | head.map((i: any) => i.toString().length)
62 | )
63 | .map((i, index) => i + (index <= head.length - 2 ? 2 : 0))
64 |
65 | const sum = widths.reduce((acc, i) => acc + i, 0)
66 | if (sum >= width - 1) return widths.map(i => Math.min(32, i))
67 |
68 | // const left = width - sum - 2
69 | // if (sum > 0) widths[widths.length - 1] += left
70 |
71 | return widths
72 | }, [data, head, width])
73 |
74 | const isCropped = useMemo(() => {
75 | const sum = widths.reduce((acc, i) => acc + i, 0)
76 | return sum - pos.xo >= width + (isScrollbarRequired ? -1 : 0)
77 | }, [widths, pos.xo, width, isScrollbarRequired])
78 |
79 | const dataFiltered = useMemo(() => {
80 | return data.filter((_: any, index: number) => index >= pos.yo && index < height + pos.yo)
81 | }, [data, pos.yo, height])
82 |
83 | useEffect(() => {
84 | let newPos: undefined | ListPos
85 | let { y, x } = initialPos
86 | if (y > 0 && y >= data.length) {
87 | y = data.length - 1
88 | onChange({ ...pos, y })
89 | }
90 | if (y !== pos.y) {
91 | y = Math.max(0, y)
92 | newPos = { ...(newPos || pos), y, yo: getYO(pos.yo, height - 1, y) }
93 | }
94 | if (initialPos.xm) {
95 | let acc = 0
96 | x = widths.map(i => (acc += i)).findIndex(i => i >= Math.min(acc, (initialPos.xm ?? 0) + pos.xo))
97 | }
98 | if (x > 0 && x >= head.length) {
99 | x = head.length - 1
100 | onChange({ ...pos, x })
101 | }
102 | if (x !== pos.x) {
103 | const { x1, x2 } = getX(x, widths)
104 | newPos = { ...(newPos || pos), x, xo: getXO(pos.xo, width + (isScrollbarRequired ? -1 : 0), x1, x2) }
105 | }
106 | if (newPos) {
107 | setPos(newPos)
108 | onChange(newPos)
109 | }
110 | }, [initialPos.y, initialPos.x, initialPos.xm])
111 |
112 | useEffect(() => {
113 | if (pos.y > 0 && head.length > 0 && pos.y > data.length - 1) {
114 | const y = Math.max(0, data.length - 1)
115 | const newPos = { ...pos, y, yo: getYO(pos.yo, data.length, y) }
116 | setPos(newPos)
117 | onChange(newPos)
118 | }
119 | if (pos.x > 0 && head.length > 0 && pos.x > head.length - 1) {
120 | const newPos = { ...pos, x: head.length - 1 }
121 | setPos(newPos)
122 | onChange(newPos)
123 | }
124 | }, [head, data])
125 |
126 | useInput(
127 | (input: string) => {
128 | if (!focus) return
129 |
130 | inputHandler(vi, pos, setPos, height - 1, data.length, onChange)(input)
131 |
132 | let x: undefined | number
133 | switch (mode) {
134 | case 'cell':
135 | if (((vi && input === 'h') || input === '\x1b\x5b\x44') /* left */ && pos.x > 0) x = pos.x - 1
136 | if (((vi && input === 'l') || input === '\x1b\x5b\x43') /* right */ && pos.x < head.length - 1) x = pos.x + 1
137 | if (vi && input === '^' && pos.x > 0) x = 0
138 | if (vi && input === '$' && pos.x < head.length - 1) x = head.length - 1
139 | if (x !== undefined) {
140 | const { x1, x2 } = getX(x, widths)
141 | const xo = getXO(pos.xo, width + (isScrollbarRequired ? -1 : 0), x1, x2)
142 | const newPos = { ...pos, x, xo, x1, x2 }
143 | setPos(newPos)
144 | onChange(newPos)
145 | }
146 | break
147 | case 'row':
148 | if (((vi && input === 'h') || input === '\x1b\x5b\x44') /* left */ && pos.x > 0) x = pos.x - 1
149 | if (((vi && input === 'l') || input === '\x1b\x5b\x43') /* right */ && pos.x < head.length - 1) x = pos.x + 1
150 | if (x !== undefined) {
151 | const { x1, x2 } = getX(x, widths)
152 | const newPos = { ...pos, x, xo: x1, x1, x2 }
153 | setPos(newPos)
154 | onChange(newPos)
155 | }
156 | break
157 | }
158 |
159 | if (input === '\x0d' /* cr */) onSubmit({ ...pos, ...getX(pos.x, widths) })
160 | },
161 | [focus, vi, pos, width, height, head, data, widths, isScrollbarRequired, setPos, onChange, onSubmit]
162 | )
163 |
164 | return (
165 |
166 |
167 | {renderHead({
168 | focus,
169 | item: head,
170 | widths,
171 | pass
172 | })}
173 |
174 |
175 | {dataFiltered.map((item: any, index: number) => (
176 |
177 | {renderItem({
178 | mode,
179 | focus,
180 | item,
181 | y: pos.y,
182 | x: pos.x,
183 | widths,
184 | index: index + pos.yo,
185 | pass
186 | })}
187 |
188 | ))}
189 |
190 | {isCropped && (
191 |
192 | ~
193 |
194 | )}
195 | {isScrollbarRequired && (
196 |
197 |
204 |
205 | )}
206 |
207 | )
208 | }
209 |
--------------------------------------------------------------------------------
/components/Scrollbar.tsx:
--------------------------------------------------------------------------------
1 | import { Color } from '../screen'
2 | import Bar from './Bar'
3 | import Text from './Text'
4 | import React from 'react'
5 |
6 | interface ScrollbarProps {
7 | type?: 'vertical' | 'horizontal'
8 | offset: number
9 | limit: number
10 | length: number
11 | background?: Color
12 | color?: Color
13 | }
14 |
15 | export default function Scrollbar({
16 | type = 'vertical',
17 | offset,
18 | limit,
19 | length,
20 | background = undefined,
21 | color = undefined
22 | }: ScrollbarProps) {
23 | length ||= limit
24 | offset = (limit / length) * offset
25 | let size = limit / (length / limit)
26 | if (size < 1) {
27 | offset *= (length - limit / size) / (length - limit)
28 | size = 1
29 | }
30 |
31 | return (
32 |
33 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/components/Separator.tsx:
--------------------------------------------------------------------------------
1 | import useSize from '../hooks/useSize'
2 | import Text, { type TextProps } from './Text'
3 | import React from 'react'
4 |
5 | interface SeparatorProps extends TextProps {
6 | type?: 'vertical' | 'horizontal'
7 | height?: number
8 | width?: number
9 | }
10 |
11 | export default function Separator({ type = 'vertical', height: _height, width: _width, ...props }: SeparatorProps) {
12 | const size = _height === undefined || _width === undefined ? useSize() : undefined
13 | const height = _height ?? size!.height
14 | const width = _width ?? size!.width
15 |
16 | if (type === 'vertical' && height < 1) return null
17 | if (type === 'horizontal' && width < 1) return null
18 |
19 | return (
20 |
21 | {type === 'vertical' &&
22 | [...Array(height)].map((_, key) => (
23 |
24 | │
25 |
26 | ))}
27 | {type === 'horizontal' && '─'.repeat(width)}
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/components/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import useAnimation from '../hooks/useAnimation'
2 | import Text, { type TextProps } from './Text'
3 | import React from 'react'
4 |
5 | interface SpinnerProps extends TextProps {
6 | children?: any
7 | }
8 |
9 | export default function Spinner({ children, ...props }: SpinnerProps) {
10 | children ??= ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
11 | const { ms, interpolate } = useAnimation(Infinity)
12 | const frame = Math.floor(interpolate(0, children.length, 0, 500, ms % 500))
13 | const color = 255 - Math.abs(Math.floor(interpolate(-16, 16, 0, 1500, ms % 1500)))
14 |
15 | return (
16 |
17 | {children[frame]}
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/components/Text.tsx:
--------------------------------------------------------------------------------
1 | import { Modifier } from '../screen'
2 | import React, { type PropsWithChildren } from 'react'
3 |
4 | export interface TextProps extends Modifier {
5 | readonly absolute?: boolean
6 | readonly x?: number | string
7 | readonly y?: number | string
8 | readonly width?: number | string
9 | readonly height?: number | string
10 | readonly block?: boolean
11 | }
12 |
13 | export default function Text({ children, ...props }: PropsWithChildren) {
14 | // @ts-ignore
15 | return {children}
16 | }
17 |
--------------------------------------------------------------------------------
/components/View.tsx:
--------------------------------------------------------------------------------
1 | import useChildrenSize from '../hooks/useChildrenSize'
2 | import useInput from '../hooks/useInput'
3 | import useSize from '../hooks/useSize'
4 | import Scrollbar from './Scrollbar'
5 | import Text, { type TextProps } from './Text'
6 | import React, { useState } from 'react'
7 |
8 | interface ViewProps extends TextProps {
9 | focus?: boolean
10 | height?: number
11 | scrollbar?: boolean
12 | vi?: boolean
13 | children: any
14 | }
15 |
16 | export default function View({
17 | focus = true,
18 | height: _height,
19 | scrollbar = undefined,
20 | vi = true,
21 | children,
22 | ...props
23 | }: ViewProps) {
24 | const height = _height ?? useSize().height
25 | const [yo, setYo] = useState(0)
26 | const { height: length } = useChildrenSize(children)
27 |
28 | useInput(
29 | (input: string) => {
30 | if (!focus) return
31 |
32 | if (((vi && input === 'k') || input === '\x1b\x5b\x41') /* up */ && yo > 0) setYo(yo - 1)
33 | if (((vi && input === 'j') || input === '\x1b\x5b\x42') /* down */ && yo < length - height) setYo(yo + 1)
34 | if (((vi && input === '\x02') /* C-b */ || input === '\x1b\x5b\x35\x7e') /* pageup */ && yo > 0)
35 | setYo(Math.max(0, yo - height))
36 | if (((vi && input === '\x06') /* C-f */ || input === '\x1b\x5b\x36\x7e') /* pagedown */ && yo < length - height)
37 | setYo(Math.min(length - height, yo + height))
38 | if (vi && input === '\x15' /* C-u */ && yo > 0) setYo(Math.max(0, yo - Math.floor(height / 2)))
39 | if (vi && input === '\x04' /* C-d */ && yo < length - height)
40 | setYo(Math.min(length - height, yo + Math.floor(height / 2)))
41 | if (((vi && input === 'g') || input === '\x1b\x5b\x31\x7e') /* home */ && yo > 0) setYo(0)
42 | if (((vi && input === 'G') || input === '\x1b\x5b\x34\x7e') /* end */ && yo < length - height)
43 | setYo(length - height)
44 | },
45 | [focus, yo, length, height]
46 | )
47 |
48 | const isScrollbarRequired = scrollbar === undefined ? length > height : scrollbar
49 |
50 | return (
51 |
52 | {children}
53 | {isScrollbarRequired && (
54 |
55 |
56 |
57 | )}
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/create/Create.tsx:
--------------------------------------------------------------------------------
1 | import ReactCurse, { Input, Spinner, Text, useAnimation, useInput } from '..'
2 | import { exec } from 'node:child_process'
3 | import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'
4 | import { join } from 'node:path'
5 | import { cwd } from 'node:process'
6 | import React, { useState } from 'react'
7 |
8 | const pwd = cwd()
9 |
10 | const install = (value: string) => {
11 | if (!value) return
12 |
13 | const dest = join(pwd, value)
14 | if (existsSync(dest)) return
15 |
16 | mkdirSync(dest)
17 | const src = join(__dirname, 'template')
18 | const files = readdirSync(src)
19 | for (const file of files) {
20 | copyFileSync(join(src, file), join(dest, file))
21 | }
22 |
23 | const cp = exec(`cd ${dest} && npm i`)
24 | return new Promise(resolve => {
25 | cp.on('exit', () => {
26 | resolve(true)
27 | })
28 | })
29 | }
30 |
31 | const Logo = ({ text }: { text: string }) => {
32 | const { interpolate } = useAnimation(1000)
33 | const w = Math.floor(interpolate(0, 22))
34 |
35 | return (
36 |
37 |
38 | {text.replace(/[^\s]/g, '#')}
39 |
40 |
41 | {text.substring(0, w)}
42 |
43 |
44 | )
45 | }
46 |
47 | const App = () => {
48 | const [focus, setFocus] = useState(1)
49 | const [value, setValue] = useState(null)
50 |
51 | const onSubmit = async (value: string) => {
52 | setFocus(2)
53 |
54 | const res = await install(value)
55 | setFocus(res ? 3 : -1)
56 |
57 | setTimeout(ReactCurse.exit, 1000 / 60)
58 | }
59 |
60 | useInput()
61 |
62 | return (
63 | <>
64 |
65 |
66 |
67 |
68 | {focus === 1 && ? }
69 | {focus !== 1 && {'✔ '}}
70 | Where would you like to create your app
71 | {pwd}/
72 | {focus === 1 && }
73 | {focus !== 1 && {value}}
74 |
75 | {focus >= 2 && (
76 |
77 | {focus === 2 && (
78 | <>
79 | Installing...
80 | >
81 | )}
82 | {focus > 2 && (
83 | <>
84 | {'✔ '}
85 | Done
86 | >
87 | )}
88 |
89 | )}
90 | {focus === 3 && (
91 | <>
92 |
93 |
94 | {'# '}cd {value}
95 |
96 |
97 | {'# '}npm start
98 |
99 |
100 | Enjoy!
101 | >
102 | )}
103 | {focus === -1 && Canceled}
104 | >
105 | )
106 | }
107 |
108 | ReactCurse.inline()
109 |
--------------------------------------------------------------------------------
/create/readme.md:
--------------------------------------------------------------------------------
1 | # create-react-curse
2 |
3 | Generate a [react-curse](https://www.npmjs.com/package/react-curse) app
4 |
--------------------------------------------------------------------------------
/create/template/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import ReactCurse, { Text, useInput } from 'react-curse'
3 |
4 | const App = () => {
5 | const [counter, setCounter] = useState(0)
6 |
7 | useInput(
8 | input => {
9 | if (input === 'q') ReactCurse.exit()
10 | else setCounter(counter + 1)
11 | },
12 | [counter]
13 | )
14 |
15 | return (
16 |
17 |
18 | Counter: {counter.toString()}
19 |
20 |
21 | Press q to exit or any key to increment the counter
22 |
23 |
24 | Edit App.tsx
25 |
26 |
27 | )
28 | }
29 |
30 | ReactCurse.render()
31 |
--------------------------------------------------------------------------------
/create/template/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-curse-app",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "license": "MIT",
6 | "type": "module",
7 | "scripts": {
8 | "start": "npx esbuild App.tsx --outfile=.dist/index.js --bundle --platform=node --format=esm --external:'./node_modules/*' --sourcemap && node --enable-source-maps .dist",
9 | "dist": "npx esbuild App.tsx --outfile=.dist/index.cjs --bundle --platform=node --define:'process.env.NODE_ENV=\"production\"' --minify --tree-shaking=true"
10 | },
11 | "dependencies": {
12 | "react-curse": "^1.0.0"
13 | },
14 | "devDependencies": {
15 | "@types/node": "^18.11.18",
16 | "@types/react": "^18.0.27"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import pluginJs from '@eslint/js'
2 | import pluginReact from 'eslint-plugin-react'
3 | // import globals from 'globals'
4 | import tseslint from 'typescript-eslint'
5 |
6 | /** @type {import('eslint').Linter.Config[]} */
7 | export default [
8 | { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] },
9 | // { languageOptions: { globals: globals.browser } },
10 | pluginJs.configs.recommended,
11 | ...tseslint.configs.recommended,
12 | {
13 | ...pluginReact.configs.flat.recommended,
14 | settings: {
15 | react: {
16 | version: 'detect'
17 | }
18 | }
19 | },
20 | {
21 | rules: {
22 | '@typescript-eslint/no-explicit-any': ['off']
23 | }
24 | }
25 | ]
26 |
--------------------------------------------------------------------------------
/examples/Banner.tsx:
--------------------------------------------------------------------------------
1 | import ReactCurse, { Text, useInput } from '..'
2 | import Banner from '../components/Banner'
3 | import React, { useEffect, useState } from 'react'
4 |
5 | const lorem = [...Array(3)]
6 | .map((_, offset) => [...Array(32)].map((_, index) => String.fromCharCode((offset + 1) * 32 + index)).join(''))
7 | .join('\n')
8 |
9 | const getTime = () => new Date().toTimeString().substring(0, 8)
10 |
11 | const Clock = (props: any) => {
12 | const [time, setTime] = useState(getTime())
13 |
14 | useEffect(() => {
15 | const interval = setInterval(() => setTime(getTime()), 1000)
16 |
17 | return () => clearInterval(interval)
18 | }, [])
19 |
20 | useInput((input: string) => {
21 | if (input === 'q') ReactCurse.exit()
22 | })
23 |
24 | return {time}
25 | }
26 |
27 | ReactCurse.render(
28 | <>
29 |
30 |
31 |
32 | 0x20{'\n'.repeat(3)}0x40{'\n'.repeat(3)}0x60
33 |
34 | {lorem}
35 |
36 | >
37 | )
38 |
--------------------------------------------------------------------------------
/examples/Canvas.tsx:
--------------------------------------------------------------------------------
1 | import ReactCurse, { Text, useInput, useSize } from '..'
2 | import Canvas, { Line } from '../components/Canvas'
3 | import React, { useEffect, useMemo, useState } from 'react'
4 |
5 | const CELL = 4
6 | const COLORS = ['Red', 'Green', 'Blue']
7 | const DATA = [...Array(COLORS.length)].map(() => {
8 | let prev = 0
9 | return [...Array(1024)].map(() => {
10 | prev += Math.round(Math.random() * 2 - 1)
11 | prev = Math.max(0, Math.min(4, prev))
12 | return prev
13 | })
14 | })
15 |
16 | const Graph = ({ mode, play }: any) => {
17 | const { width } = useSize()
18 |
19 | const h = useMemo(() => CELL * mode.h, [mode])
20 | const w = useMemo(() => CELL * mode.w, [mode])
21 | const c = useMemo(() => h * 4, [h])
22 | const [lines, setLines] = useState([])
23 | const [offset, setOffset] = useState(0)
24 |
25 | useEffect(() => {
26 | const interval = setInterval(() => {
27 | if (!play) return
28 |
29 | setOffset(offset + 1)
30 | }, 1000 / 60)
31 |
32 | return () => clearInterval(interval)
33 | }, [offset, play])
34 |
35 | useEffect(() => {
36 | setLines(
37 | DATA.map((line, colorIndex) => {
38 | return line
39 | .map((i, index) => {
40 | if (index - (offset * mode.w) / w > (width * mode.w) / w) return
41 | return {
42 | x: index * w - offset * mode.w,
43 | y: c - (line[index - 1] || 0) * h,
44 | dx: index * w + w - offset * mode.w,
45 | dy: c - i * h,
46 | color: COLORS[colorIndex]
47 | }
48 | })
49 | .filter(i => i)
50 | }).flat()
51 | )
52 | }, [mode, offset])
53 |
54 | return (
55 |
60 | )
61 | }
62 |
63 | const App = () => {
64 | const [mode, setMode] = useState({ w: 1, h: 2 })
65 | const [play, setPlay] = useState(true)
66 |
67 | useInput((input: string) => {
68 | if (input === '\x10\x0d') ReactCurse.exit()
69 | if (input === 'q') ReactCurse.exit()
70 |
71 | if (input === '1') setMode({ w: 1, h: 1 })
72 | if (input === '2') setMode({ w: 1, h: 2 })
73 | if (input === '3') setMode({ w: 2, h: 2 })
74 | if (input === '4') setMode({ w: 2, h: 4 })
75 |
76 | if (input === ' ') setPlay(play => !play)
77 | })
78 |
79 | return (
80 | <>
81 |
82 | {COLORS.map((color, key) => (
83 |
84 | Line {key + 1}{' '}
85 |
86 | ))}
87 |
88 |
89 |
90 | {[...Array(5)].map((_, key) => (
91 |
92 | {key.toString()}
93 |
94 | ))}
95 |
96 |
97 |
98 | >
99 | )
100 | }
101 |
102 | ReactCurse.render()
103 |
--------------------------------------------------------------------------------
/examples/Example.tsx:
--------------------------------------------------------------------------------
1 | import ReactCurse, { Text, Input, useInput, useMouse } from '..'
2 | import React, { useState } from 'react'
3 |
4 | const App = () => {
5 | const [focus, setFocus] = useState(true)
6 | const [value, setValue] = useState('')
7 | const [result, setResult] = useState('')
8 | const [cancel, setCancel] = useState('')
9 | const [event, setEvent] = useState({})
10 | const [keys, setKeys] = useState('')
11 |
12 | const onChange = (input: string) => {
13 | setValue(input)
14 | }
15 |
16 | const onSubmit = (input: string) => {
17 | setFocus(false)
18 | setResult(input)
19 | }
20 |
21 | const onCancel = () => {
22 | setFocus(false)
23 | setCancel(`${+new Date()}`)
24 | }
25 |
26 | useInput(
27 | input => {
28 | if (input === '\x10\x0d') ReactCurse.exit()
29 | if (input === 'q') ReactCurse.exit()
30 |
31 | if (input === '\x0d') setFocus(true)
32 | if (!focus) setKeys(i => i + input)
33 | },
34 | [focus]
35 | )
36 |
37 | useMouse(event => {
38 | setEvent(event)
39 | })
40 |
41 | return (
42 |
43 |
53 |
54 |
55 | KEYS: {Buffer.from(keys).toString('hex')}
56 |
57 |
58 | EVENT: {JSON.stringify(event)}
59 |
60 |
61 | CANCEL: {cancel}
62 |
63 |
64 | RESULT: {result}
65 |
66 |
67 | VALUE: {value}
68 |
69 |
70 |
71 | )
72 | }
73 |
74 | ReactCurse.render()
75 |
--------------------------------------------------------------------------------
/examples/Inline.tsx:
--------------------------------------------------------------------------------
1 | import ReactCurse, { Text } from '..'
2 | import React from 'react'
3 |
4 | const App = () => {
5 | return (
6 | <>
7 | Line 1
8 |
9 | Line 3 33
10 |
11 |
12 |
13 |
14 | Line 4
15 | >
16 | )
17 | }
18 |
19 | ReactCurse.inline()
20 |
--------------------------------------------------------------------------------
/examples/Pong.tsx:
--------------------------------------------------------------------------------
1 | import ReactCurse, { Banner, Canvas, Point, Line, useSize, useInput } from '..'
2 | import React, { useEffect, useState } from 'react'
3 |
4 | const Game = () => {
5 | const { width, height } = useSize()
6 |
7 | const [scores, setScores] = useState([0, 0])
8 | const [y, setY] = useState(height - 4)
9 | const [ball, setBall] = useState({ x: Math.floor(width / 2), y: height, dx: 1, dy: 1 })
10 |
11 | useEffect(() => {
12 | const interval = setInterval(() => {
13 | setBall(({ x, y, dx, dy }) => {
14 | x += dx
15 | y += dy
16 | if (x <= 1 || x >= width - 2) {
17 | dx = -dx
18 | scores[0]++
19 | setScores(scores)
20 | }
21 | if (y <= 0 || y >= height * 2 - 1) {
22 | dy = -dy
23 | scores[1]++
24 | setScores(scores)
25 | }
26 | return { x, y, dx, dy }
27 | })
28 | }, 1000 / 60)
29 |
30 | return () => clearInterval(interval)
31 | }, [scores])
32 |
33 | useInput((input: string) => {
34 | if (input === '\x10\x0d') ReactCurse.exit()
35 | if (input === 'q') ReactCurse.exit()
36 |
37 | if (input === 'k') setY(y => y - 1)
38 | if (input === 'j') setY(y => y + 1)
39 | })
40 |
41 | return (
42 | <>
43 |
44 | {scores.join(' ')}
45 |
46 |
51 | >
52 | )
53 | }
54 |
55 | ReactCurse.render()
56 |
--------------------------------------------------------------------------------
/examples/Prompt.tsx:
--------------------------------------------------------------------------------
1 | import ReactCurse, { Block, Frame, Input, Text, useAnimation, useInput } from '..'
2 | import React, { useState } from 'react'
3 |
4 | const InputText = ({ text, type, color }: { text: string; type: any; color: any }) => {
5 | const [focus, setFocus] = useState(true)
6 | const [value, setValue] = useState('')
7 |
8 | const onSubmit = (input: string) => {
9 | setFocus(false)
10 | setValue(input)
11 | ReactCurse.exit(input)
12 | }
13 |
14 | return (
15 | <>
16 |
17 | {text}
18 |
19 | :
20 | {focus && }
21 | {!focus && {value}}
22 | >
23 | )
24 | }
25 |
26 | const InputList = ({ items, color }: { items: string[]; color: any }) => {
27 | const [, setFocus] = useState(true)
28 | const [selected, setSelected] = useState(0)
29 |
30 | useInput(
31 | (input: string) => {
32 | if (input === 'q') ReactCurse.exit()
33 |
34 | if (input === 'k') setSelected(i => Math.max(0, i - 1))
35 | if (input === 'j') setSelected(i => Math.min(items.length - 1, i + 1))
36 | if (input === '\x0d') {
37 | ReactCurse.exit(items[selected])
38 | setFocus(false)
39 | }
40 | },
41 | [selected]
42 | )
43 |
44 | return (
45 | <>
46 |
47 | Please select an option:
48 |
49 | {items.map((i, key) => (
50 |
51 | {selected === key ? '>' : ' '} {i}
52 |
53 | ))}
54 | >
55 | )
56 | }
57 |
58 | const Spinner = ({ text }: { text: string }) => {
59 | const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] // ['|', '/', '-', '\\']
60 |
61 | const { ms, interpolate } = useAnimation(Infinity)
62 | const frame = Math.floor(interpolate(0, frames.length, 0, 500, ms % 500))
63 | const color = 255 - Math.abs(Math.floor(interpolate(-16, 16, 0, 1500, ms % 1500)))
64 |
65 | return (
66 | <>
67 | {frames[frame]} {text}
68 | >
69 | )
70 | }
71 |
72 | ;(async () => {
73 | await ReactCurse.frame(
74 |
75 |
76 | hello world
77 |
78 |
79 | )
80 |
81 | const res1 = await ReactCurse.prompt()
82 | console.log(`Answer 1: ${res1}`)
83 |
84 | const res2 = await ReactCurse.prompt()
85 | console.log(`Answer 2: ${res2}`)
86 |
87 | const res3 = await ReactCurse.prompt()
88 | console.log(`Answer 3: ${res3}`)
89 |
90 | const res4 = await ReactCurse.prompt()
91 | console.log(`Answer 3: ${res4}`)
92 |
93 | ReactCurse.inline()
94 | await new Promise(resolve => setTimeout(resolve, 500))
95 |
96 | process.exit()
97 | })()
98 |
--------------------------------------------------------------------------------
/examples/Speed.tsx:
--------------------------------------------------------------------------------
1 | import ReactCurse, { Text, useInput } from '..'
2 | import React, { useEffect, useState } from 'react'
3 |
4 | const TEXT = ''
5 | const width = process.stdout.columns
6 | const height = process.stdout.rows
7 |
8 | const rand = () => {
9 | return [...Array(128)].map(() => [
10 | // 512
11 | width / 2,
12 | 0,
13 | Math.floor(Math.random() * 256),
14 | Math.random() * 2 - 1, // 3 - 1.5
15 | Math.random() * 1 // 1.5
16 | ])
17 | }
18 |
19 | const App = () => {
20 | const [texts, setTexts] = useState(rand())
21 |
22 | useEffect(() => {
23 | const interval = setInterval(() => {
24 | setTexts(texts =>
25 | texts
26 | .map(([x, y, color, dx, dy]) => {
27 | x += dx
28 | y += dy
29 | if (x >= width - 1 - TEXT.length || x <= 0) dx = -dx
30 | if (y >= height - 1 || y <= 0) {
31 | dy = -dy
32 | if (dy < 0) dy *= 0.9
33 | }
34 | return [x, y, color, dx, dy]
35 | })
36 | .filter(i => Math.round(i[4] * 10))
37 | )
38 | }, 1000 / 60)
39 |
40 | return () => clearInterval(interval)
41 | }, [])
42 |
43 | useInput((input: string) => {
44 | if (input === 'q') ReactCurse.exit()
45 | })
46 |
47 | return (
48 | <>
49 | {texts.map(([x, y, color], key) => (
50 |
51 | {TEXT}
52 |
53 | ))}
54 | >
55 | )
56 | }
57 |
58 | ReactCurse.render()
59 |
--------------------------------------------------------------------------------
/examples/Todo.tsx:
--------------------------------------------------------------------------------
1 | import ReactCurse, { Block, Input, List, Text, useInput, useSize } from '..'
2 | import useAnimation, { useTrail } from '../hooks/useAnimation'
3 | import React, { useCallback, useState } from 'react'
4 |
5 | const Task = ({ title, completed, selected }: { title: string; completed: boolean; selected: boolean }) => {
6 | const { interpolateColor } = useAnimation(250)
7 |
8 | return (
9 |
10 | {completed ? ' ' : ' '}
11 | {title}
12 |
13 | )
14 | }
15 |
16 | const Tasks = ({ focus, setFocus }: { focus: boolean; setFocus: (focus: boolean) => void }) => {
17 | const { height, width } = useSize()
18 | const [pos, setPos] = useState<{ y: number }>({ y: 0 })
19 | const [tasks, setTasks] = useState(() =>
20 | [...Array(32)].map((_, i) => ({ id: i + 1, title: `Task ${i + 1}`, completed: Math.random() >= 0.5 }))
21 | )
22 |
23 | useInput(
24 | input => {
25 | if (focus) return
26 | if (input === 'D') setTasks(tasks.filter((_, index) => index !== pos.y))
27 | if (input === ' ') setTasks(tasks.map((i, index) => (index === pos.y ? { ...i, completed: !i.completed } : i)))
28 | if (input === '\x0d') setFocus(true)
29 | },
30 | [pos, tasks]
31 | )
32 |
33 | const onSubmit = useCallback(
34 | (title: string) => {
35 | setFocus(false)
36 | setTasks(tasks => [...tasks, { id: tasks.length + 1, title, completed: false }])
37 | },
38 | [tasks]
39 | )
40 |
41 | return (
42 | <>
43 | {
48 | return
49 | }}
50 | onChange={setPos}
51 | />
52 | setFocus(false)} />
53 | >
54 | )
55 | }
56 |
57 | const Fade = ({ children }: { children: React.ReactNode }) => {
58 | const { interpolateColor } = useAnimation(1000)
59 |
60 | const color = interpolateColor('#3c3836', '#ebdbb2', 500)
61 | if (color === '#3c3836') return null
62 |
63 | return (
64 |
65 | {children}
66 |
67 | )
68 | }
69 |
70 | const App = () => {
71 | const { width } = useSize()
72 | const [show, setShow] = useState(true)
73 | const [focus, setFocus] = useState(false)
74 |
75 | const { interpolate, interpolateColor } = useAnimation(500)
76 | const x = Math.round(interpolate(0, width))
77 | const background = interpolateColor('#282828', '#3c3836')
78 |
79 | useInput(
80 | input => {
81 | if (input === '\x10\x0d') ReactCurse.exit()
82 | if (focus) return
83 |
84 | if (input === 'q') ReactCurse.exit()
85 | if (input === 't') setShow(i => !i)
86 | },
87 | [focus]
88 | )
89 |
90 | return (
91 |
92 |
93 | hello
94 |
95 |
96 | {show && }
97 |
98 |
99 | world
100 |
101 |
102 | )
103 | }
104 |
105 | ReactCurse.render()
106 |
--------------------------------------------------------------------------------
/examples/Visualizer.tsx:
--------------------------------------------------------------------------------
1 | import ReactCurse, { Bar, Text, Trail, useAnimation, useSize } from '..'
2 | import React, { useMemo } from 'react'
3 |
4 | const App = () => {
5 | const { height, width } = useSize()
6 |
7 | const { ms, interpolate } = useAnimation(2000)
8 |
9 | const lines = useMemo(() => {
10 | return [...Array(Math.floor(width / 2))].map(_ => ({
11 | h: Math.floor(Math.random() * height),
12 | t: Math.floor(Math.random() * 250) + 250,
13 | c: 255 - Math.floor(Math.random() * 16)
14 | }))
15 | }, [])
16 |
17 | return (
18 | <>
19 | {lines.map(({ h, t, c }, key) => {
20 | h = ms < 1000 ? interpolate(0, h, 0, t, ms % t) : interpolate(h, 0, 1000, 2000)
21 | return (
22 |
23 |
24 |
25 | )
26 | })}
27 | >
28 | )
29 | }
30 |
31 | const Line = ({ h, c }) => {
32 | const { height } = useSize()
33 | const { interpolate } = useAnimation(500)
34 |
35 | h = interpolate(h, 0)
36 |
37 | return
38 | }
39 |
40 | const App2 = () => {
41 | const { height, width } = useSize()
42 |
43 | const lines = useMemo(() => {
44 | return [...Array(Math.floor(width / 2))].map(_ => ({
45 | h: Math.floor(Math.random() * height),
46 | c: 255 - Math.floor(Math.random() * 16)
47 | }))
48 | }, [])
49 |
50 | return (
51 |
52 | {lines.map((props, key) => (
53 |
54 |
55 |
56 | ))}
57 |
58 | )
59 | }
60 |
61 | ReactCurse.render()
62 |
--------------------------------------------------------------------------------
/hooks/useAnimation.ts:
--------------------------------------------------------------------------------
1 | import Renderer from '../renderer'
2 | import { useState, useEffect, useRef } from 'react'
3 |
4 | const interpolate = (toLow: number, toHigh: number, fromLow: number, fromHigh: number, value: number) => {
5 | const res = toLow + ((((toHigh - toLow) / 100) * 100) / (fromHigh - fromLow)) * (value - fromLow)
6 | return Math.max(Math.min(toLow, toHigh), Math.min(Math.max(toLow, toHigh), res))
7 | }
8 |
9 | const interpolateColor = (toLow: string, toHigh: string, fromLow: number, fromHigh: number, value: number) => {
10 | if (!toLow.startsWith('#')) return toHigh
11 | if (!toHigh.startsWith('#')) return toHigh
12 |
13 | const toLowColor = Renderer.term.parseHexColor(toLow)
14 | return (
15 | '#' +
16 | Buffer.from(
17 | Renderer.term.parseHexColor(toHigh).map((i: number, index: number) => {
18 | return Math.round(interpolate(toLowColor[index], i, fromLow, fromHigh, value))
19 | })
20 | ).toString('hex')
21 | )
22 | }
23 |
24 | export const Trail = ({ delay, children }: { delay: number; children: any }): JSX.Element => {
25 | return useTrail(delay, children)
26 | }
27 |
28 | export const useTrail = (delay: number, children: any[], key: string = 'key'): any => {
29 | const ms = useRef(0)
30 | const timeout = useRef()
31 | const [keys, setKeys] = useState([])
32 |
33 | useEffect(() => {
34 | const keysNew = children.map((i: any) => i[key])
35 | const keyOld = keys.find(key => !keysNew.includes(key))
36 | if (keyOld) {
37 | setKeys(keys.filter(i => i !== keyOld))
38 | return
39 | }
40 |
41 | const keyNew = keysNew.find((i: any) => !keys.includes(i))
42 | if (!keyNew) return
43 |
44 | const at = Date.now()
45 | const nextAt = Math.max(0, delay - (at - ms.current))
46 |
47 | clearTimeout(timeout.current)
48 | timeout.current = setTimeout(() => {
49 | ms.current = Date.now()
50 | setKeys([...keys, keyNew])
51 | }, nextAt)
52 | }, [children.map((i: any) => i[key]).join('\n'), keys])
53 |
54 | return children.filter((i: any) => keys.includes(i[key]))
55 | }
56 |
57 | interface useAnimation {
58 | ms: number
59 | interpolate: (toLow: number, toHigh: number, fromLow?: number, fromHigh?: number, value?: number) => number
60 | interpolateColor: (toLow: string, toHigh: string, fromLow?: number, fromHigh?: number, value?: number) => string
61 | }
62 |
63 | export default (time = Infinity, fps = 60): useAnimation => {
64 | if (time <= 0 || fps <= 0) return { ms: 0, interpolate: i => i, interpolateColor: i => i }
65 |
66 | const at = useRef(Date.now())
67 | const interval = useRef()
68 | const [ms, setMs] = useState(0)
69 |
70 | useEffect(() => {
71 | const frameMs = 1000 / Math.min(60, fps)
72 |
73 | interval.current = setInterval(() => {
74 | const msNew = Date.now() - at.current
75 | if (msNew >= time) clearInterval(interval.current)
76 | setMs(Math.min(msNew, time))
77 | }, frameMs)
78 |
79 | return () => {
80 | clearInterval(interval.current)
81 | }
82 | }, [])
83 |
84 | return {
85 | ms,
86 | interpolate: (toLow, toHigh, fromLow = 0, fromHigh = time, value = ms) => {
87 | return interpolate(toLow, toHigh, fromLow, fromHigh, value)
88 | },
89 | interpolateColor: (toLow, toHigh, fromLow = 0, fromHigh = time, value = ms) => {
90 | return interpolateColor(toLow, toHigh, fromLow, fromHigh, value)
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/hooks/useBell.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process'
2 |
3 | /**
4 | * @deprecated
5 | */
6 | export default () => {
7 | process.stdout.write('\x07')
8 | }
9 |
--------------------------------------------------------------------------------
/hooks/useChildrenSize.ts:
--------------------------------------------------------------------------------
1 | import { type ReactElement, useEffect, useState } from 'react'
2 |
3 | const render = (element: ReactElement | ReactElement[] | any) => {
4 | if (Array.isArray(element)) return element.map(i => render(i)).join('')
5 |
6 | const { children } = (element as ReactElement).props ?? { children: element }
7 | if (Array.isArray(children) || children.props) return render(children)
8 |
9 | return children.toString()
10 | }
11 |
12 | const getSize = (children: ReactElement | ReactElement[] | any) => {
13 | const string = render(children).split('\n')
14 | const width = string.reduce((acc: number, i: string) => Math.max(acc, i.length), 0)
15 | const height = string.length
16 |
17 | return { width, height }
18 | }
19 |
20 | export default (children: ReactElement | ReactElement[] | any) => {
21 | const [size, setSize] = useState(getSize(children))
22 |
23 | useEffect(() => {
24 | setSize(getSize(children))
25 | }, [children])
26 |
27 | return size
28 | }
29 |
--------------------------------------------------------------------------------
/hooks/useClipboard.ts:
--------------------------------------------------------------------------------
1 | import { spawnSync } from 'node:child_process'
2 | import { platform } from 'node:process'
3 |
4 | export default (): [() => string, (input: string) => string] => {
5 | const getClipboard = () => {
6 | switch (platform) {
7 | case 'darwin':
8 | return spawnSync('pbpaste', [], { encoding: 'utf8' }).stdout
9 | }
10 |
11 | return ''
12 | }
13 |
14 | const setClipboard = (input: any) => {
15 | if (typeof input !== 'string') input = input.toString()
16 |
17 | switch (platform) {
18 | case 'darwin':
19 | spawnSync('pbcopy', [], { input })
20 | break
21 | default:
22 | input = ''
23 | }
24 |
25 | return input
26 | }
27 |
28 | return [getClipboard, setClipboard]
29 | }
30 |
--------------------------------------------------------------------------------
/hooks/useExit.ts:
--------------------------------------------------------------------------------
1 | import Renderer from '../renderer'
2 | import process from 'node:process'
3 |
4 | /**
5 | * @deprecated
6 | */
7 | export default (code: number | any = 0) => {
8 | if (typeof code === 'number') process.exit(code)
9 |
10 | Renderer.term.setResult(code)
11 | }
12 |
--------------------------------------------------------------------------------
/hooks/useInput.ts:
--------------------------------------------------------------------------------
1 | import Renderer from '../renderer'
2 | import { type DependencyList, useEffect } from 'react'
3 |
4 | export default (callback: (input: string, raw: () => string) => void = () => {}, deps: DependencyList = []) => {
5 | useEffect(() => {
6 | if (!process.stdin.isRaw) process.stdin.setRawMode?.(true)
7 | }, [])
8 |
9 | useEffect(() => {
10 | const handler = (input: string, raw: () => string) => {
11 | if (input === '\x03') process.exit()
12 | if (input.startsWith('\x1b\x5b\x4d')) return
13 |
14 | callback(input, raw)
15 | }
16 |
17 | Renderer.input.on(handler)
18 | return () => {
19 | Renderer.input.off(handler)
20 | }
21 | }, deps)
22 | }
23 |
--------------------------------------------------------------------------------
/hooks/useMouse.ts:
--------------------------------------------------------------------------------
1 | import Renderer from '../renderer'
2 | import { type DependencyList, useEffect } from 'react'
3 |
4 | interface Event {
5 | type: 'mousedown' | 'mouseup' | 'wheeldown' | 'wheelup'
6 | x: number
7 | y: number
8 | }
9 |
10 | export default (callback: (event: Event) => void, deps: DependencyList = []) => {
11 | useEffect(() => {
12 | if (!process.stdin.isRaw) process.stdin.setRawMode?.(true)
13 | Renderer.term.enableMouse()
14 | }, [])
15 |
16 | useEffect(() => {
17 | const handler = (input: string) => {
18 | if (!input.startsWith('\x1b\x5b\x4d')) return
19 |
20 | const b = input.charCodeAt(3)
21 | const type = (1 << 6) & b ? (1 & b ? 'wheelup' : 'wheeldown') : (3 & b) === 3 ? 'mouseup' : 'mousedown'
22 |
23 | const x = input.charCodeAt(4) - 0o41
24 | const y = input.charCodeAt(5) - 0o41
25 |
26 | callback({ type, x, y })
27 | }
28 |
29 | Renderer.input.on(handler)
30 | return () => {
31 | Renderer.input.off(handler)
32 | }
33 | }, deps)
34 | }
35 |
--------------------------------------------------------------------------------
/hooks/useSize.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process'
2 | import { useEffect, useState } from 'react'
3 |
4 | const subscribers = new Set<(size: { width: number; height: number }) => void>()
5 |
6 | const getSize = () => {
7 | const { columns: width, rows: height } = process.stdout
8 | return { width, height }
9 | }
10 |
11 | process.stdout.on('resize', () => {
12 | const size = getSize()
13 | subscribers.forEach((_, fn) => fn(size))
14 | })
15 |
16 | export default () => {
17 | const [size, setSize] = useState(getSize())
18 |
19 | useEffect(() => {
20 | subscribers.add(setSize)
21 |
22 | return () => {
23 | subscribers.delete(setSize)
24 | }
25 | }, [])
26 |
27 | return size
28 | }
29 |
--------------------------------------------------------------------------------
/hooks/useWordWrap.ts:
--------------------------------------------------------------------------------
1 | import useSize from './useSize'
2 |
3 | export default (text: string, _width: number | undefined = undefined) => {
4 | const width = _width ?? useSize().width
5 |
6 | return text
7 | .split('\n')
8 | .map((line: string) => {
9 | if (line.length <= width) return line
10 |
11 | return line
12 | .split(' ')
13 | .reduce(
14 | (acc, i) => {
15 | if (acc[acc.length - 1].length + i.length > width) acc.push('')
16 | acc[acc.length - 1] += `${i} `
17 | return acc
18 | },
19 | ['']
20 | )
21 | .map(i => i.trimEnd())
22 | .join('\n')
23 | })
24 | .join('\n')
25 | }
26 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './renderer'
2 | export { default as Banner } from './components/Banner'
3 | export { default as Bar } from './components/Bar'
4 | export { default as Block } from './components/Block'
5 | export { default as Canvas, Point, Line } from './components/Canvas'
6 | export { default as Frame } from './components/Frame'
7 | export { default as Input } from './components/Input'
8 | export { default as List, type ListPos } from './components/List'
9 | export { default as ListTable } from './components/ListTable'
10 | export { default as Scrollbar } from './components/Scrollbar'
11 | export { default as Separator } from './components/Separator'
12 | export { default as Spinner } from './components/Spinner'
13 | export { default as Text } from './components/Text'
14 | export { default as View } from './components/View'
15 | export { default as useAnimation, useTrail, Trail } from './hooks/useAnimation'
16 | export { default as useBell } from './hooks/useBell'
17 | export { default as useChildrenSize } from './hooks/useChildrenSize'
18 | export { default as useClipboard } from './hooks/useClipboard'
19 | export { default as useExit } from './hooks/useExit'
20 | export { default as useInput } from './hooks/useInput'
21 | export { default as useMouse } from './hooks/useMouse'
22 | export { default as useSize } from './hooks/useSize'
23 | export { default as useWordWrap } from './hooks/useWordWrap'
24 | export { default as log } from './utils/log'
25 |
--------------------------------------------------------------------------------
/input.ts:
--------------------------------------------------------------------------------
1 | import EventEmitter from 'node:events'
2 |
3 | export default class Input {
4 | ee: EventEmitter
5 | queue: string[] = []
6 |
7 | constructor() {
8 | this.ee = new EventEmitter()
9 | }
10 | terminate() {
11 | this.ee.removeAllListeners()
12 | }
13 |
14 | #onData = (key: Buffer) => {
15 | const raw = key.toString()
16 | const chunks = this.parse(raw)
17 |
18 | if (chunks.length > 1) this.queue = chunks.slice(1)
19 | this.ee.emit('data', chunks[0], () => {
20 | this.queue = []
21 | return raw
22 | })
23 | }
24 |
25 | parse(input: string) {
26 | const chars = input.split('')
27 |
28 | let res: any
29 | const chunks: string[] = []
30 | while ((res = chars.shift())) {
31 | if (['\x10', '\x1b'].includes(res)) {
32 | // length >= 2, example: M-a (1b 61)
33 | res += chars.shift() || ''
34 |
35 | if (res.endsWith('\x5b')) {
36 | // length >= 3, example: arrowup (1b 5b 41)
37 | res += chars.shift() || ''
38 |
39 | if (res.endsWith('\x31') || res.endsWith('\x34') || res.endsWith('\x35') || res.endsWith('\x36')) {
40 | // length >= 4, example: pageup (1b 5b 35 7e, 1b 5b 36 7e)
41 | res += chars.shift() || ''
42 | } else if (res.endsWith('\x4d')) {
43 | // length >= 4, example: mousedown (1b 5b 4d 20 21 21, 1b 5b 4d 20 c3 80 21)
44 | res += chars.shift() || ''
45 | res += chars.shift() || ''
46 | res += chars.shift() || ''
47 | }
48 | }
49 | }
50 |
51 | chunks.push(res)
52 | }
53 |
54 | return chunks
55 | }
56 | on(callback: (input: string, raw: () => string) => void) {
57 | if (this.ee.listenerCount('data') === 0) process.stdin.on('data', this.#onData)
58 | this.ee.on('data', callback)
59 | }
60 | off(callback: (input: string, raw: () => string) => void) {
61 | this.ee.off('data', callback)
62 | if (this.ee.listenerCount('data') === 0) process.stdin.off('data', this.#onData)
63 | }
64 | render() {
65 | const chunk = this.queue.shift()
66 | if (chunk) setTimeout(() => this.ee.emit('data', chunk), 0)
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/media/Banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Banner.png
--------------------------------------------------------------------------------
/media/Bar-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Bar-1.png
--------------------------------------------------------------------------------
/media/Bar-2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Bar-2.gif
--------------------------------------------------------------------------------
/media/Block.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Block.png
--------------------------------------------------------------------------------
/media/Canvas-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Canvas-1.png
--------------------------------------------------------------------------------
/media/Canvas-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Canvas-2.png
--------------------------------------------------------------------------------
/media/Frame.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Frame.png
--------------------------------------------------------------------------------
/media/Input-1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Input-1.gif
--------------------------------------------------------------------------------
/media/Input-2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Input-2.gif
--------------------------------------------------------------------------------
/media/List.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/List.gif
--------------------------------------------------------------------------------
/media/ListTable.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/ListTable.gif
--------------------------------------------------------------------------------
/media/Scrollbar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Scrollbar.png
--------------------------------------------------------------------------------
/media/Separator.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Separator.png
--------------------------------------------------------------------------------
/media/Spinner.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Spinner.gif
--------------------------------------------------------------------------------
/media/Text.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Text.png
--------------------------------------------------------------------------------
/media/Trail.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/Trail.gif
--------------------------------------------------------------------------------
/media/View.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/View.gif
--------------------------------------------------------------------------------
/media/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/demo.gif
--------------------------------------------------------------------------------
/media/exampleAnimate.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/exampleAnimate.gif
--------------------------------------------------------------------------------
/media/exampleHello.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/exampleHello.png
--------------------------------------------------------------------------------
/media/exampleInput.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/exampleInput.gif
--------------------------------------------------------------------------------
/media/logo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/logo.gif
--------------------------------------------------------------------------------
/media/useAnimation.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infely/react-curse/93d2cf3d8ea482a7e5bea290855ecf4c61eb1733/media/useAnimation.gif
--------------------------------------------------------------------------------
/mediacreators/Banner.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3@1 */
2 | import ReactCurse, { Banner, useInput } from '..'
3 | import React from 'react'
4 |
5 | const App = () => {
6 | useInput()
7 |
8 | return {'12:34:56' /* new Date().toTimeString().substring(0, 8) */}
9 | }
10 |
11 | ReactCurse.render()
12 |
--------------------------------------------------------------------------------
/mediacreators/Bar-1.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3@1 */
2 | import ReactCurse, { Bar, useInput } from '..'
3 | import React from 'react'
4 |
5 | const App = () => {
6 | useInput()
7 |
8 | return (
9 | <>
10 | {[...Array(24)].map((_, index) => (
11 |
12 | ))}
13 | >
14 | )
15 | }
16 |
17 | ReactCurse.render()
18 |
--------------------------------------------------------------------------------
/mediacreators/Bar-2.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3
2 | Hide
3 | Type@0 npm_start
4 | Enter
5 | Sleep 40ms
6 |
7 | Show
8 | Sleep 2s */
9 | import ReactCurse, { Bar, Text, useAnimation, useInput } from '..'
10 | import React from 'react'
11 |
12 | const App = () => {
13 | useInput()
14 | const { interpolate } = useAnimation(2000)
15 |
16 | return (
17 | <>
18 |
19 | {''}
20 |
21 |
22 | {''}{' '}
23 |
24 | {' '.repeat(22)}
25 |
26 |
27 | >
28 | )
29 | }
30 |
31 | ReactCurse.render()
32 |
--------------------------------------------------------------------------------
/mediacreators/Block.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3@1 */
2 | import ReactCurse, { Block } from '..'
3 | import React from 'react'
4 |
5 | const App = () => {
6 | setTimeout(() => {}, 1000)
7 |
8 | return (
9 | <>
10 | left
11 | center
12 | right
13 | >
14 | )
15 | }
16 |
17 | process.stdout.write('\x1bc')
18 | ReactCurse.inline()
19 |
--------------------------------------------------------------------------------
/mediacreators/Canvas-1.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3@1 */
2 | import ReactCurse, { Canvas, Point, Line, useInput } from '..'
3 | import React from 'react'
4 |
5 | const App = () => {
6 | useInput()
7 |
8 | return (
9 |
13 | )
14 | }
15 |
16 | ReactCurse.render()
17 |
--------------------------------------------------------------------------------
/mediacreators/Canvas-2.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3@1
2 | Set FontFamily "Apple Symbols"
3 | Set FontSize 21
4 |
5 | Hide
6 | Type@0 npm_start
7 | Enter
8 | Sleep 250ms
9 |
10 | Show
11 | Sleep 250ms */
12 | import ReactCurse, { Canvas, Point, Line, useInput } from '..'
13 | import React from 'react'
14 |
15 | const App = () => {
16 | useInput()
17 |
18 | return (
19 |
23 | )
24 | }
25 |
26 | ReactCurse.render()
27 |
--------------------------------------------------------------------------------
/mediacreators/Frame.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3@1 */
2 | import ReactCurse, { Frame, useInput } from '..'
3 | import React from 'react'
4 |
5 | const App = () => {
6 | useInput()
7 |
8 | return (
9 | <>
10 |
11 | single border type
12 |
13 |
14 | double border type
15 |
16 |
17 | rounded border type
18 |
19 | >
20 | )
21 | }
22 |
23 | ReactCurse.render()
24 |
--------------------------------------------------------------------------------
/mediacreators/Input-1.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3
2 | Hide
3 | Type@0 npm_start
4 | Enter
5 | Sleep 250ms
6 |
7 | Show
8 | Sleep 250ms
9 | Type hello world
10 | Left 11
11 | Sleep 1s */
12 | import ReactCurse, { Input } from '..'
13 | import React from 'react'
14 |
15 | const App = () => {
16 | return
17 | }
18 |
19 | ReactCurse.render()
20 |
--------------------------------------------------------------------------------
/mediacreators/Input-2.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3
2 | Hide
3 | Type@0 npm_start
4 | Enter
5 | Sleep 250ms
6 |
7 | Show
8 | Sleep 250ms
9 | Type hello
10 | Enter
11 | Type world
12 | Enter
13 | Enter
14 | Up@200ms 4
15 | Down@200ms 4
16 | Sleep 1s */
17 | import ReactCurse, { Input } from '..'
18 | import React from 'react'
19 |
20 | const App = () => {
21 | return
22 | }
23 |
24 | ReactCurse.render()
25 |
--------------------------------------------------------------------------------
/mediacreators/List.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x8
2 | Hide
3 | Type@0 npm_start
4 | Enter
5 | Sleep 250ms
6 |
7 | Show
8 | Sleep 500ms
9 | Type@100ms jjj
10 | Sleep 500ms
11 | Type@100ms kkk
12 | Sleep 1000ms */
13 | import ReactCurse, { List, Text } from '..'
14 | import React from 'react'
15 |
16 | const App = () => {
17 | const items = [...Array(8)].map((_, index) => ({ id: index + 1, title: `Task ${index + 1}` }))
18 | return (
19 | {item.title}}
22 | />
23 | )
24 | }
25 |
26 | ReactCurse.render()
27 |
--------------------------------------------------------------------------------
/mediacreators/ListTable.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x8
2 | Hide
3 | Type@0 npm_start
4 | Enter
5 | Sleep 250ms
6 |
7 | Show
8 | Sleep 500ms
9 | Type@200ms jjl
10 | Sleep 500ms
11 | Type@200ms kkh
12 | Sleep 1000ms */
13 | import ReactCurse, { ListTable, Text } from '..'
14 | import React from 'react'
15 |
16 | const App = () => {
17 | const head = ['id', 'title']
18 | const items = [...Array(8)].map((_, index) => [index + 1, `Task ${index + 1}`])
19 | return (
20 |
23 | item.map((i: string, key: string) => (
24 |
25 | {i}
26 |
27 | ))
28 | }
29 | data={items}
30 | renderItem={({ item, x, y, index }) =>
31 | item.map((text: string, key: string) => (
32 |
33 | {text}
34 |
35 | ))
36 | }
37 | />
38 | )
39 | }
40 |
41 | ReactCurse.render()
42 |
--------------------------------------------------------------------------------
/mediacreators/Scrollbar.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3@1 */
2 | import ReactCurse, { Scrollbar, useInput } from '..'
3 | import React from 'react'
4 |
5 | const App = () => {
6 | useInput()
7 |
8 | return
9 | }
10 |
11 | ReactCurse.render()
12 |
--------------------------------------------------------------------------------
/mediacreators/Separator.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3@1 */
2 | import ReactCurse, { Separator, useInput } from '..'
3 | import React from 'react'
4 |
5 | const App = () => {
6 | useInput()
7 |
8 | return (
9 | <>
10 |
11 |
12 | >
13 | )
14 | }
15 |
16 | ReactCurse.render()
17 |
--------------------------------------------------------------------------------
/mediacreators/Spinner.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3
2 | Hide
3 | Type@0 npm_start
4 | Enter
5 | Sleep 40ms
6 |
7 | Show
8 | Sleep 1.5s */
9 | import ReactCurse, { Spinner, useInput } from '..'
10 | import React from 'react'
11 |
12 | const App = () => {
13 | useInput()
14 |
15 | return (
16 | <>
17 |
18 | -\|/
19 | >
20 | )
21 | }
22 |
23 | ReactCurse.render()
24 |
--------------------------------------------------------------------------------
/mediacreators/Text.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3@1 */
2 | import ReactCurse, { Text, useInput } from '..'
3 | import React from 'react'
4 |
5 | const App = () => {
6 | useInput()
7 |
8 | return (
9 | <>
10 |
11 | hello world
12 |
13 |
14 | hello world
15 |
16 |
17 | hello world
18 |
19 |
20 |
21 | hello world
22 |
23 |
24 | hello world
25 |
26 |
27 | hello world
28 |
29 |
30 | >
31 | )
32 | }
33 |
34 | ReactCurse.render()
35 |
--------------------------------------------------------------------------------
/mediacreators/Trail.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x8
2 | Hide
3 | Type@0 npm_start
4 | Enter
5 | Sleep 40ms
6 |
7 | Show
8 | Sleep 2s */
9 | import ReactCurse, { Text, Trail, useInput } from '..'
10 | import React from 'react'
11 |
12 | const App = () => {
13 | useInput()
14 |
15 | const items = [...Array(8)].map((_, index) => ({ id: index + 1, title: `Task ${index + 1}` }))
16 | return (
17 |
18 | {items.map(({ id, title }) => (
19 |
20 | {title}
21 |
22 | ))}
23 |
24 | )
25 | }
26 |
27 | ReactCurse.render()
28 |
--------------------------------------------------------------------------------
/mediacreators/View.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x8
2 | Hide
3 | Type@0 npm_start
4 | Enter
5 | Sleep 250ms
6 |
7 | Show
8 | Sleep 750ms
9 | Type@20ms jjjjjjjjjj
10 | Sleep 250ms
11 | Type@20ms kkkkkkkkkk */
12 | import ReactCurse, { View } from '..'
13 | import json from '../package.json'
14 | import React from 'react'
15 |
16 | const App = () => {
17 | return {JSON.stringify(json, null, 2)}
18 | }
19 |
20 | ReactCurse.render()
21 |
--------------------------------------------------------------------------------
/mediacreators/demo.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x16
2 | Set Theme { "name": "gruvbox", "black": "#32302f", "red": "#cc241d", "green": "#98971a", "yellow": "#d79921", "blue": "#458588", "magenta": "#b16286", "cyan": "#689d6a", "white": "#f2e5bc", "brightBlack": "#1d2021", "brightRed": "#fb4934", "brightGreen": "#b8bb26", "brightYellow": "#fabd2f", "brightBlue": "#83a598", "brightMagenta": "#d3869b", "brightCyan": "#8ec07c", "brightWhite": "#f9f5d7", "background": "#282828", "foreground": "#ecdbb2", "selection": "#413e3d", "cursor": "#928374" }
3 |
4 | Hide
5 | Type@0 'npm start --src=examples/Todo.tsx'
6 | Enter
7 | Sleep 400ms
8 |
9 | Show
10 | Sleep 2s
11 |
12 | Hide
13 | Ctrl+c
14 | Type@0 'clear; npm start --src=examples/Visualizer.tsx'
15 | Enter
16 | Sleep 400ms
17 |
18 | Show
19 | Sleep 1s
20 |
21 | Hide
22 | Ctrl+c
23 | Type@0 'clear; npm start --src=examples/Speed.tsx'
24 | Enter
25 | Sleep 400ms
26 |
27 | Show
28 | Sleep 2s
29 |
30 | Hide
31 | Ctrl+c
32 | Type@0 'clear; npm start --src=examples/Pong.tsx'
33 | Enter
34 | Sleep 400ms
35 |
36 | Show
37 | Sleep 250ms
38 | Type kkk
39 | Sleep 500ms
40 | Type@500ms jj */
41 |
--------------------------------------------------------------------------------
/mediacreators/exampleAnimate.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3
2 | Hide
3 | Type@0 npm_start
4 | Enter
5 | Sleep 40ms
6 |
7 | Show
8 | Sleep 2s */
9 | import ReactCurse, { Text, useAnimation, useInput } from '..'
10 | import React from 'react'
11 |
12 | const App = () => {
13 | useInput()
14 | const { interpolate, interpolateColor } = useAnimation(1000)
15 |
16 | return
17 | }
18 |
19 | ReactCurse.render()
20 |
--------------------------------------------------------------------------------
/mediacreators/exampleHello.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3@1 */
2 | import ReactCurse, { Text, useInput } from '..'
3 | import React from 'react'
4 |
5 | const App = ({ text }: { text: string }) => {
6 | useInput()
7 |
8 | return {text}
9 | }
10 |
11 | ReactCurse.render()
12 |
--------------------------------------------------------------------------------
/mediacreators/exampleInput.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3x10
2 | Hide
3 | Type@0 npm_start
4 | Enter
5 | Sleep 250ms
6 |
7 | Show
8 | Sleep 250ms
9 | Type@250ms kkjkkkjjjj
10 | Sleep 250ms */
11 | import ReactCurse, { Text, useInput } from '..'
12 | import React, { useState } from 'react'
13 |
14 | const App = () => {
15 | const [counter, setCounter] = useState(0)
16 |
17 | useInput(
18 | input => {
19 | if (input === 'k') setCounter(counter + 1)
20 | if (input === 'j') setCounter(counter - 1)
21 | if (input === 'q') ReactCurse.exit()
22 | },
23 | [counter]
24 | )
25 |
26 | return (
27 |
28 | counter: {counter.toString()}
29 |
30 | )
31 | }
32 |
33 | ReactCurse.render()
34 |
--------------------------------------------------------------------------------
/mediacreators/logo.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 48x7
2 | Set Theme { "green": "#98971a", "background": "#ffffff", "foreground": "#ecdbb2" }
3 |
4 | Hide
5 | Type@0 npm_start
6 | Enter
7 | Sleep 40ms
8 |
9 | Show
10 | Sleep 10s */
11 | import ReactCurse, { Bar, Text, useAnimation, useInput } from '..'
12 | import React from 'react'
13 |
14 | const splitLine = (line: string) => {
15 | const chunks = {}
16 | let chunksAt = 0
17 | line.split('').forEach((value, x: number) => {
18 | if (value !== ' ') return (chunksAt = x + 1)
19 | if (chunks[chunksAt] === undefined) chunks[chunksAt] = ['']
20 | chunks[chunksAt] += value
21 | })
22 | return Object.entries(chunks)
23 | }
24 |
25 | const mask = `
26 | ### ### ### ### ### ### # # ### ### ###
27 | # # # # # # # # # # # # # #
28 | ## ## ### # # ### # # # ## ### ##
29 | # # # # # # # # # # # # # #
30 | # # ### # # ### # ### ### # # ### ###
31 | `.trim()
32 |
33 | const w = Math.max(...mask.split('\n').map(i => i.length))
34 |
35 | // prettier-ignore
36 | const lines = [
37 | [[w + 32, w], [w * 3 + 32, w * 2]],
38 | [[w + 8, w], [w * 3 + 8, w * 2]],
39 | [[w + 16, w], [w * 3 + 16, w * 2]],
40 | [[w, w], [w * 3, w * 2]],
41 | [[w + 24, w], [w * 3 + 24, w * 2]],
42 | ]
43 |
44 | const App = () => {
45 | useInput(input => input === '\x10\x0d' && ReactCurse.exit())
46 |
47 | const l = 10000
48 | const { ms, interpolate } = useAnimation(l)
49 |
50 | return (
51 |
52 |
53 | {mask}
54 |
55 |
56 | {[
57 | [0, l - 1250],
58 | [l - 1200, l - 600],
59 | [l - 550, l - 500]
60 | ].find(([from, to]) => ms >= from && ms < to) && (
61 |
62 | {lines.map((line, key) => (
63 |
64 | {line.map(([x, width], key) => (
65 |
66 | ))}
67 |
68 | ))}
69 |
70 | )}
71 |
72 |
73 | {mask.split('\n').map((line, key) => {
74 | return (
75 |
76 | {splitLine(line).map(([x, str], key) => (
77 |
78 | {str as string}
79 |
80 | ))}
81 | {' '.repeat(w - line.length)}
82 |
83 | )
84 | })}
85 |
86 |
87 | )
88 | }
89 |
90 | ReactCurse.render()
91 |
--------------------------------------------------------------------------------
/mediacreators/useAnimation.tsx:
--------------------------------------------------------------------------------
1 | /* @vhs 80x3
2 | Hide
3 | Type@0 npm_start
4 | Enter
5 | Sleep 40ms
6 |
7 | Show
8 | Sleep 2s */
9 | import ReactCurse, { Text, useAnimation, useInput } from '..'
10 | import React from 'react'
11 |
12 | const App = () => {
13 | useInput()
14 |
15 | const { ms, interpolate, interpolateColor } = useAnimation(1000, 4)
16 | const rounded = Math.floor(ms / 250) * 250
17 | const color = interpolateColor('#000', '#0f8', 0, 1000, rounded)
18 | return (
19 | <>
20 | ms: {Math.floor(ms / 250) * 250}
21 | interpolate: {Math.round(interpolate(0, 80, 0, 1000, rounded))}
22 |
23 | interpolateColor: {color}
24 |
25 | >
26 | )
27 | }
28 |
29 | ReactCurse.render()
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-curse",
3 | "version": "1.0.17",
4 | "description": "A curses-like blazingly fast react renderer",
5 | "keywords": [
6 | "ansi",
7 | "ascii",
8 | "blessed",
9 | "cli",
10 | "console",
11 | "cursed",
12 | "curses",
13 | "gui",
14 | "ncurses",
15 | "ranger",
16 | "react",
17 | "renderer",
18 | "term",
19 | "terminal",
20 | "tmux",
21 | "tui",
22 | "unicode",
23 | "vim",
24 | "xterm"
25 | ],
26 | "author": {
27 | "name": "Oleksandr Vasyliev",
28 | "email": "infely@gmail.com",
29 | "url": "https://github.com/infely"
30 | },
31 | "repository": "infely/react-curse",
32 | "homepage": "https://github.com/infely/react-curse",
33 | "main": "index.ts",
34 | "license": "MIT",
35 | "type": "module",
36 | "scripts": {
37 | "start": "npx esbuild ${npm_config_src:=examples/Example.tsx} --outfile=.dist/index.js --bundle --platform=node --format=esm --external:'./node_modules/*' --sourcemap && node --enable-source-maps .dist",
38 | "npm": "npx esbuild index.ts --outdir=.npm --bundle --platform=node --format=esm --packages=external",
39 | "postnpm": "tsc --emitDeclarationOnly --declaration --jsx react --target esnext --esModuleInterop --moduleResolution node index.ts --outdir .npm && bin/postnpm.js",
40 | "create": "npx esbuild create/Create.tsx --outfile=.create/index.js --bundle --platform=node --define:'process.env.NODE_ENV=\"production\"' --minify --tree-shaking=true",
41 | "postcreate": "bin/postcreate.js",
42 | "dist": "npx esbuild ${npm_config_src:=examples/Example.tsx} --outfile=.dist/index.cjs --bundle --platform=node --define:'process.env.NODE_ENV=\"production\"' --minify --tree-shaking=true",
43 | "logger": "bin/logger.js"
44 | },
45 | "dependencies": {
46 | "esbuild": "^0.25.5",
47 | "react": "18.3.1",
48 | "react-reconciler": "0.29.2"
49 | },
50 | "devDependencies": {
51 | "@eslint/js": "^9.28.0",
52 | "@trivago/prettier-plugin-sort-imports": "^5.2.2",
53 | "@types/eslint__js": "8.42.3",
54 | "@types/node": "^22.15.29",
55 | "@types/react": "18.3.1",
56 | "@types/react-reconciler": "0.28.9",
57 | "eslint": "^9.28.0",
58 | "eslint-plugin-react": "^7.37.5",
59 | "prettier": "^3.5.3",
60 | "typescript": "^5.8.3",
61 | "typescript-eslint": "^8.33.1"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/prettier.config.mjs:
--------------------------------------------------------------------------------
1 | import sortImports from '@trivago/prettier-plugin-sort-imports'
2 |
3 | export default {
4 | arrowParens: 'avoid',
5 | printWidth: 120,
6 | semi: false,
7 | singleQuote: true,
8 | trailingComma: 'none',
9 | plugins: [sortImports]
10 | }
11 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # react-curse
2 |
3 |
4 |
5 |

6 |
7 |
8 |
9 | A curses-like blazingly fast react renderer
10 |
11 | - It is fast, intuitive and easy to use
12 | - It draws only changed characters
13 | - It uses a small amount of SSH traffic
14 |
15 | See it in action:
16 |
17 | 
18 |
19 | Still here? Let's go deeper:
20 |
21 | - It has fancy components that are ready to use or can be tree-shaked from your final bundle
22 | - It supports keyboard and mouse
23 | - It works in fullscreen and inline modes
24 | - It has cool hooks like animation with trail
25 | - It is solely dependent on react
26 | - It can generate an all-in-one bundle around 100 kb
27 |
28 | You can easily build full-scale terminal UI applications like:
29 |
30 | ## Apps that use it
31 |
32 | - [mngr](https://github.com/infely/mngr) - Database manager supports mongodb, mysql/mariadb, postgresql, sqlite and json-server
33 | - [nfi](https://github.com/infely/nfi) - Simple nerd fonts icons cheat sheet that allows you to quickly find and copy glyph to clipboard
34 |
35 | ## Installation
36 |
37 | Just run `npm init react-curse` answer a few questions and you are ready to go
38 |
39 | ## Examples
40 |
41 | #### Hello world
42 |
43 | ```jsx
44 | import React from 'react'
45 | import ReactCurse, { Text } from 'react-curse'
46 |
47 | const App = ({ text }) => {
48 | return {text}
49 | }
50 |
51 | ReactCurse.render()
52 | ```
53 |
54 | 
55 |
56 | #### How to handle input
57 |
58 | ```jsx
59 | import React, { useState } from 'react'
60 | import ReactCurse, { Text, useInput, exit } from 'react-curse'
61 |
62 | const App = () => {
63 | const [counter, setCounter] = useState(0)
64 |
65 | useInput(
66 | input => {
67 | if (input === 'k') setCounter(counter + 1)
68 | if (input === 'j') setCounter(counter - 1)
69 | if (input === 'q') exit()
70 | },
71 | [counter]
72 | )
73 |
74 | return (
75 |
76 | counter: {counter}
77 |
78 | )
79 | }
80 |
81 | ReactCurse.render()
82 | ```
83 |
84 | 
85 |
86 | #### How to animate
87 |
88 | ```jsx
89 | import React from 'react'
90 | import ReactCurse, { useAnimation } from 'react-curse'
91 |
92 | const App = () => {
93 | const { interpolate, interpolateColor } = useAnimation(1000)
94 |
95 | return
96 | }
97 |
98 | ReactCurse.render()
99 | ```
100 |
101 | 
102 |
103 | ## Contents
104 |
105 | - [Components](#components)
106 | - [``](#text)
107 | - [``](#input)
108 | - [``](#banner)
109 | - [``](#bar)
110 | - [``](#block)
111 | - [`