├── .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 | 56 | {lines.map((props, key) => ( 57 | 58 | ))} 59 | 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 | 47 | 48 | 49 | 50 | 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 | 10 | 11 | 12 | 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 | 20 | 21 | 22 | 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 | ![](media/demo.gif) 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 | ![](media/exampleHello.png) 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 | ![](media/exampleInput.gif) 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 | ![](media/exampleAnimate.gif) 102 | 103 | ## Contents 104 | 105 | - [Components](#components) 106 | - [``](#text) 107 | - [``](#input) 108 | - [``](#banner) 109 | - [``](#bar) 110 | - [``](#block) 111 | - [``](#canvas), [``](#point), [``](#line) 112 | - [``](#frame) 113 | - [``](#list) 114 | - [``](#listtable) 115 | - [``](#Scrollbar) 116 | - [``](#separator) 117 | - [``](#spinner) 118 | - [``](#view) 119 | - [Hooks](#hooks) 120 | - [`useAnimation`](#useanimation), [`useTrail`](#usetrail), [``](#trail) 121 | - [`useChildrenSize`](#usechildrensize) 122 | - [`useClipboard`](#useclipboard) 123 | - [`useInput`](#useinput) 124 | - [`useMouse`](#usemouse) 125 | - [`useSize`](#usesize) 126 | - [`useWordWrap`](#useWordWrap) 127 | - [API](#api) 128 | - [`render`](#render) 129 | - [`inline`](#inline) 130 | - [`bell`](#bell) 131 | - [`exit`](#exit) 132 | 133 | ## Components 134 | 135 | ### `` 136 | 137 | Base component\ 138 | The only component required to do anything\ 139 | Every other component uses this one to draw 140 | 141 | ##### y?, x?: `number` | `string` 142 | 143 | Position from top left corner relative to parent\ 144 | Content will be cropped by parent\ 145 | See `absolute` to avoid this behavior\ 146 | Example: `32, '100%', '100%-8'` 147 | 148 | ##### height?, width?: `number` | `string` 149 | 150 | Size of block, will be cropped by parent\ 151 | See `absolute` to avoid this behavior 152 | 153 | ##### absolute?: `boolean` 154 | 155 | Makes position and size ignoring parent container 156 | 157 | ##### background?, color?: `number` | `string` 158 | 159 | Background and foreground color\ 160 | Example: `31, 'Red', '#f04020', '#f42'` 161 | 162 | ##### clear?: `boolean` 163 | 164 | Clears block before drawing content\ 165 | `height` and `width` 166 | 167 | ##### block?: `boolean` 168 | 169 | Moves cursor to a new line after its content relative to parent 170 | 171 | ##### bold?, dim?, italic?, underline?, blinking?, inverse?, strikethrough?: `boolean` 172 | 173 | Text modifiers 174 | 175 | #### Examples 176 | 177 | ```jsx 178 | hello world 179 | hello world 180 | hello world 181 | 182 | hello world 183 | hello world 184 | hello world 185 | 186 | ``` 187 | 188 | ![](media/Text.png) 189 | 190 | ### `` 191 | 192 | Text input component with cursor movement and text scroll support\ 193 | If its height is more than 1, then it switches to multiline, like textarea\ 194 | Most terminal shortcuts are supported 195 | 196 | ##### focus?: `boolean` = `true` 197 | 198 | Makes it active 199 | 200 | ##### type?: `'text'` | `'password'` | `'hidden'` = `‘text'` 201 | 202 | ##### initialValue?: `string` 203 | 204 | ##### cursorBackground?: `number` | `string` 205 | 206 | ##### onCancel?: `() => void` 207 | 208 | ##### onChange?: `(string) => void` 209 | 210 | ##### onSubmit?: `(string) => void` 211 | 212 | #### Examples 213 | 214 | ```jsx 215 | 216 | ``` 217 | 218 | ![](media/Input-1.gif) 219 | 220 | ![](media/Input-2.gif) 221 | 222 | ### `` 223 | 224 | Displays big text 225 | 226 | ##### y?, x?: `number` | `string` 227 | 228 | ##### background?, color?: `number` | `string` 229 | 230 | ##### children: `string` 231 | 232 | #### Examples 233 | 234 | ```jsx 235 | {new Date().toTimeString().substring(0, 8)} 236 | ``` 237 | 238 | ![](media/Banner.png) 239 | 240 | ### `` 241 | 242 | Displays vertical or horizontal bar with 1/8 character resolution 243 | 244 | ##### type: `'vertical'` | `'horizontal'` 245 | 246 | ##### y & height, x & width: `number` 247 | 248 | #### Examples 249 | 250 | ```jsx 251 | <> 252 | {[...Array(24)].map((_, index) => ( 253 | 254 | ))} 255 | 256 | ``` 257 | 258 | ![](media/Bar-1.png) 259 | 260 | Compare to `` 261 | 262 | ![](media/Bar-2.gif) 263 | 264 | ### `` 265 | 266 | Aligns content 267 | 268 | ##### width?: `number` 269 | 270 | ##### align?: `'left'` | `'center'` | `'right'` = `'left'` 271 | 272 | #### Examples 273 | 274 | ```jsx 275 | left 276 | center 277 | right 278 | ``` 279 | 280 | ![](media/Block.png) 281 | 282 | ### `` 283 | 284 | Create a canvas for drawing with one these modes 285 | 286 | ##### mode: `{ h: 1, w: 1 }` | `{ h: 2, w: 1 }` | `{ h: 2, w: 2 }` | `{ h: 4, w: 2 }` 287 | 288 | Pixels per character 289 | 290 | ##### height, width: `number` 291 | 292 | Size in pixels 293 | 294 | ##### children: (`Point` | `Line`)`[]` 295 | 296 | #### `` 297 | 298 | Draws a point at the coordinates 299 | 300 | ##### y, x: `number` 301 | 302 | ##### color?: `number` | `string` 303 | 304 | #### `` 305 | 306 | Draws a line using coordinates 307 | 308 | ##### y, x, dy, dx: `number` 309 | 310 | ##### color?: `number` | `string` 311 | 312 | #### Examples 313 | 314 | ```jsx 315 | 316 | 317 | 318 | 319 | ``` 320 | 321 | ![](media/Canvas-1.png) 322 | 323 | Braille's font demo (`{ h: 4, w: 2 }`) 324 | 325 | ![](media/Canvas-2.png) 326 | 327 | ### `` 328 | 329 | Draws frame around its content 330 | 331 | ##### children: `string` 332 | 333 | ##### type?: `'single'` | `'double'` | `'rounded'` = `'single'` 334 | 335 | ##### height?, width?: `number` 336 | 337 | #### Examples 338 | 339 | ```jsx 340 | single border type 341 | double border type 342 | rounded border type 343 | ``` 344 | 345 | ![](media/Frame.png) 346 | 347 | ### `` 348 | 349 | Creates a list with navigation support\ 350 | Vim shortcuts are supported 351 | 352 | ##### focus?: `boolean` 353 | 354 | ##### initialPos?: { y: `number` } 355 | 356 | ##### data?: `any[]` 357 | 358 | ##### renderItem?: `(object) => JSX.Element` 359 | 360 | ##### height?, width?: `number` 361 | 362 | ##### scrollbar?: `boolean` 363 | 364 | ##### scrollbarBackground?: `boolean` 365 | 366 | ##### scrollbarColor?: `boolean` 367 | 368 | ##### vi?: `boolean` = `true` 369 | 370 | ##### pass?: `any` 371 | 372 | ##### onChange?: `(object) => void` 373 | 374 | ##### onSubmit?: `(object) => void` 375 | 376 | #### Examples 377 | 378 | ```jsx 379 | const items = [...Array(8)].map((_, index) => ({ id: index + 1, title: `Task ${index + 1}` })) 380 | return ( 381 | {item.title}} 384 | /> 385 | ) 386 | ``` 387 | 388 | ![](media/List.gif) 389 | 390 | ### ``: `` 391 | 392 | Creates a table with navigation support\ 393 | Vim shortcuts are supported 394 | 395 | ##### mode?: `'cell'` | `'row'` = `'cell'` 396 | 397 | ##### head?: `any[]` 398 | 399 | ##### renderHead?: `(object) => JSX.Element` 400 | 401 | ##### data?: `any[][]` 402 | 403 | #### Examples 404 | 405 | ```jsx 406 | const head = ['id', 'title'] 407 | const items = [...Array(8)].map((_, index) => [index + 1, `Task ${index + 1}`]) 408 | return ( 409 | 412 | item.map((i, key) => ( 413 | 414 | {i} 415 | 416 | )) 417 | } 418 | data={items} 419 | renderItem={({ item, x, y, index }) => 420 | item.map((text, key) => ( 421 | 422 | {text} 423 | 424 | )) 425 | } 426 | /> 427 | ) 428 | ``` 429 | 430 | ![](media/ListTable.gif) 431 | 432 | ### `` 433 | 434 | Draws a scrollbar with 1/8 character resolution 435 | 436 | ##### type?: `'vertical'` | `'horizontal'` = `'vertical'` 437 | 438 | ##### offset: `number` 439 | 440 | ##### limit: `number` 441 | 442 | ##### length: `number` 443 | 444 | ##### background?, color?: `number` | `string` 445 | 446 | #### Examples 447 | 448 | ```jsx 449 | 450 | ``` 451 | 452 | ![](media/Scrollbar.png) 453 | 454 | ### `` 455 | 456 | Draws a vertical or horizontal line 457 | 458 | ##### type: `'vertical'` | `'horizontal'` 459 | 460 | ##### height, width: `number` 461 | 462 | #### Examples 463 | 464 | ```jsx 465 | 466 | 467 | ``` 468 | 469 | ![](media/Separator.png) 470 | 471 | ### `` 472 | 473 | Draws an animated spinner 474 | 475 | ##### children?: `string` 476 | 477 | #### Examples 478 | 479 | ```jsx 480 | 481 | -\|/ 482 | ``` 483 | 484 | ![](media/Spinner.gif) 485 | 486 | ### `` 487 | 488 | Creates a scrollable viewport\ 489 | Vim shortcuts are supported 490 | 491 | ##### focus?: `boolean` 492 | 493 | ##### height?: `number` 494 | 495 | ##### scrollbar?: `boolean` 496 | 497 | ##### vi?: `boolean` = `true` 498 | 499 | ##### children: `any` 500 | 501 | #### Examples 502 | 503 | ```jsx 504 | {JSON.stringify(json, null, 2)} 505 | ``` 506 | 507 | ![](media/View.gif) 508 | 509 | ## hooks 510 | 511 | ### `useAnimation` 512 | 513 | ##### (time: `number`, fps?: `'number'` = `60`) => `object` 514 | 515 | Creates a timer for a specified duration\ 516 | That gives you time and interpolation functions each frame of animation 517 | 518 | #### return 519 | 520 | ##### ms: `number` 521 | 522 | ##### interpolate: (from: `number`, to: `number`, delay?: `number`) 523 | 524 | ##### interpolateColor: (from: `string`, to: `string`: delay?: `number`) 525 | 526 | #### Examples 527 | 528 | ```jsx 529 | const { ms } = useAnimation(1000, 4) 530 | return ms // 0, 250, 500, 750, 1000 531 | ``` 532 | 533 | ```jsx 534 | const { interpolate } = useAnimation(1000, 4) 535 | return interpolate(0, 80) // 0, 20, 40, 60, 80 536 | ``` 537 | 538 | ```jsx 539 | const { interpolateColor } = useAnimation(1000, 4) 540 | return interpolateColor('#000', '#0f8') // #000, #042, #084, #0c6, #0f8 541 | ``` 542 | 543 | ![](media/useAnimation.gif) 544 | 545 | #### `` 546 | 547 | Mutate array of items to show one by one with latency 548 | 549 | ##### delay: `number` 550 | 551 | ##### children: `JSX.Element[]` 552 | 553 | #### Examples 554 | 555 | ```jsx 556 | const items = [...Array(8)].map((_, index) => ({ id: index + 1, title: `Task ${index + 1}` })) 557 | return ( 558 | 559 | {items.map(({ id, title }) => ( 560 | 561 | {title} 562 | 563 | ))} 564 | 565 | ) 566 | ``` 567 | 568 | ![](media/Trail.gif) 569 | 570 | #### `useTrail` 571 | 572 | ##### (delay: `number`, items: `JSX.Element[]`, key?: `string` = `'key'`) => `JSX.Element[]` 573 | 574 | Same as `` but hook\ 575 | You can pass it to `data` property of `` component for example 576 | 577 | #### Examples 578 | 579 | ```jsx 580 | 581 | ``` 582 | 583 | ### `useChildrenSize` 584 | 585 | ##### (value: `string`) => `object` 586 | 587 | Gives you content size 588 | 589 | #### return 590 | 591 | ##### height, width: `number` 592 | 593 | #### Examples 594 | 595 | ```jsx 596 | useChildrenSize('1\n22\n333') // { height: 3, width: 3 } 597 | ``` 598 | 599 | ### `useClipboard` 600 | 601 | #### () => `array` 602 | 603 | Allows you to work with the system clipboard 604 | 605 | #### return 606 | 607 | ##### getClipboard: `() => string` 608 | 609 | ##### setClipboard: `(value: string) => void` 610 | 611 | #### Examples 612 | 613 | ```jsx 614 | const { getClipboard, setClipboard } = useClipboard() 615 | const string = getClipboard() 616 | setClipboard(string.toUpperCase()) // copied 617 | ``` 618 | 619 | ### `useInput` 620 | 621 | ##### (callback: `(string) => void`, dependencies: `any[]`) => `void` 622 | 623 | Allows you to handle keyboard input 624 | 625 | #### Examples 626 | 627 | ```jsx 628 | set[(counter, setCounter)] = useState(0) 629 | 630 | useInput( 631 | input => { 632 | if (input === 'k') setCounter(counter + 1) 633 | if (input === 'j') setCounter(counter - 1) 634 | }, 635 | [counter] 636 | ) 637 | ``` 638 | 639 | ### `useMouse` 640 | 641 | ##### (callback: `(object) => void`, dependencies: `any[]`) 642 | 643 | Allows you to handle mouse input 644 | 645 | #### Examples 646 | 647 | ```jsx 648 | set[(counter, setCounter)] = useState(0) 649 | 650 | useMouse( 651 | event => { 652 | if (event.type === 'wheelup') setCounter(counter + 1) 653 | if (event.type === 'wheeldown') setCounter(counter - 1) 654 | }, 655 | [counter] 656 | ) 657 | ``` 658 | 659 | ### `useSize` 660 | 661 | ##### () => `object` 662 | 663 | Gives you terminal size\ 664 | Updates when size is changing 665 | 666 | #### return 667 | 668 | ##### height, width: `number` 669 | 670 | #### Examples 671 | 672 | ```jsx 673 | useSize() // { height: 24, width: 80 } 674 | ``` 675 | 676 | ### `useWordWrap` 677 | 678 | ##### (text: `string`, width?: `number`) => `object` 679 | 680 | Gives your text a word wrap 681 | 682 | #### return 683 | 684 | ##### height, width: `number` 685 | 686 | #### Examples 687 | 688 | ```jsx 689 | useWordWrap('hello world', 5) // hello\nworld 690 | ``` 691 | 692 | ## API 693 | 694 | ### `render` (children: `JSX.Element`) => `void` 695 | 696 | Renders your fullscreen application to `stdout` 697 | 698 | ### `inline` (children: `JSX.Element`) => `void` 699 | 700 | Renders your inline application to `stdout` 701 | 702 | ### `bell` 703 | 704 | #### () => `void` 705 | 706 | Makes a terminal bell 707 | 708 | ```jsx 709 | bell() // ding 710 | ``` 711 | 712 | ### `exit` 713 | 714 | ##### (code: `number` = `0`) => `void` 715 | 716 | Allows you to exit from an application that waits for user input or has timers 717 | 718 | #### Examples 719 | 720 | ```jsx 721 | useInput(input => { 722 | if (input === 'q') exit() 723 | }) 724 | ``` 725 | -------------------------------------------------------------------------------- /reconciler.ts: -------------------------------------------------------------------------------- 1 | import { type TextProps } from './components/Text' 2 | import Reconciler from 'react-reconciler' 3 | 4 | export class TextElement { 5 | props: TextProps 6 | parent: null | TextElement 7 | children: TextElement[] 8 | constructor(props: object = {}) { 9 | this.props = props 10 | this.parent = null 11 | this.children = [] 12 | } 13 | terminate() { 14 | this.children = [] 15 | } 16 | appendChild(child: any) { 17 | this.children = [...this.children, child] 18 | } 19 | commitUpdate(nextProps: any) { 20 | this.props = nextProps 21 | } 22 | insertBefore(child: any, beforeChild: any) { 23 | const index = this.children.indexOf(beforeChild) 24 | if (index !== -1) this.children.splice(index, 0, child) 25 | } 26 | removeChild(child: any) { 27 | const index = this.children.indexOf(child) 28 | if (index !== -1) this.children.splice(index, 1) 29 | } 30 | } 31 | 32 | export class TextInstance { 33 | value: string 34 | constructor(value: string) { 35 | this.value = value 36 | } 37 | commitTextUpdate(value: string) { 38 | this.value = value 39 | } 40 | toString() { 41 | return this.value 42 | } 43 | } 44 | 45 | export default (resetAfterCommit: () => void) => { 46 | // prettier-ignore 47 | const reconciler = Reconciler({ 48 | supportsMutation: true, 49 | appendChild(parentInstance: any, child: any) { parentInstance.appendChild(child) }, 50 | appendChildToContainer(container: any, child: any) { container.appendChild(child) }, 51 | appendInitialChild(parentInstance: any, child: any) { parentInstance.appendChild(child) }, 52 | clearContainer() {}, 53 | commitTextUpdate(textInstance: any, _oldText: any, newText: any) { textInstance.commitTextUpdate(newText) }, 54 | commitUpdate(instance: any, _updatePayload: any, _type: any, _prevProps: any, nextProps: any) { instance.commitUpdate(nextProps) }, 55 | createInstance(type: any, props: any) { if (type === 'text') { return new TextElement(props) } else { throw new Error('must be ') } }, 56 | createTextInstance(text: string) { return new TextInstance(text) }, 57 | detachDeletedInstance() {}, 58 | finalizeInitialChildren() { return false }, 59 | getChildHostContext() { return {} }, 60 | getPublicInstance(instance: any) { return instance }, 61 | getRootHostContext(rootContainer: any) { return rootContainer }, 62 | insertBefore(parentInstance: any, child: any, beforeChild: any) { parentInstance.insertBefore(child, beforeChild) }, 63 | insertInContainerBefore(container: any, child: any, beforeChild: any) { container.insertBefore(child, beforeChild) }, 64 | prepareForCommit() { return null }, 65 | prepareUpdate() { return true }, 66 | removeChild(parentInstance: any, child: any) { parentInstance.removeChild(child) }, 67 | removeChildFromContainer(container: any, child: any) { container.removeChild(child) }, 68 | resetAfterCommit() { resetAfterCommit() }, 69 | shouldSetTextContent() { return false }, 70 | } as any) 71 | 72 | return reconciler 73 | } 74 | -------------------------------------------------------------------------------- /renderer.ts: -------------------------------------------------------------------------------- 1 | import Input from './input' 2 | import Reconciler, { TextElement } from './reconciler' 3 | import Screen from './screen' 4 | import Term from './term' 5 | import { spawnSync, type SpawnSyncOptions, type SpawnSyncReturns } from 'node:child_process' 6 | import { type ReactElement } from 'react' 7 | 8 | class Renderer { 9 | container: TextElement 10 | screen: Screen 11 | input: Input 12 | term: Term 13 | reconciler: any 14 | callback: (value: any) => void 15 | throttleAt = 0 16 | throttleTimeout: NodeJS.Timeout 17 | 18 | #throttle = () => { 19 | const at = Date.now() 20 | const nextAt = Math.max(0, 1000 / 60 - (at - this.throttleAt)) 21 | clearTimeout(this.throttleTimeout) 22 | this.throttleTimeout = setTimeout(() => { 23 | this.throttleAt = at 24 | this.screen.render(this.container.children) 25 | this.term.render(this.screen.buffer) 26 | this.input.render() 27 | }, nextAt) 28 | } 29 | 30 | render(reactElement: ReactElement, options = { fullscreen: true, print: false }) { 31 | this.container = new TextElement() 32 | this.screen = new Screen() 33 | this.input = new Input() 34 | this.term = new Term() 35 | this.reconciler = Reconciler(this.#throttle) 36 | 37 | this.term.init(options.fullscreen, options.print) 38 | this.reconciler.updateContainer( 39 | reactElement, 40 | this.reconciler.createContainer(this.container, 0, null, false, null, '', () => {}, null) 41 | ) 42 | } 43 | inline(reactElement: ReactElement, options = { fullscreen: false, print: false }) { 44 | this.render(reactElement, options) 45 | } 46 | prompt(reactElement: ReactElement, options = { fullscreen: false, print: false }): Promise { 47 | this.render(reactElement, options) 48 | 49 | return new Promise(resolve => { 50 | this.callback = resolve 51 | }) 52 | } 53 | print(reactElement: ReactElement, options = { fullscreen: false, print: true }) { 54 | this.render(reactElement, options) 55 | 56 | return new Promise(resolve => { 57 | this.callback = resolve 58 | }) 59 | } 60 | frame(reactElement: ReactElement, options = { fullscreen: false, print: true }) { 61 | this.render(reactElement, options) 62 | 63 | return new Promise(resolve => { 64 | this.callback = (value: any) => { 65 | process.stdout.write(value) 66 | resolve(value) 67 | } 68 | }) 69 | } 70 | terminate(value: any) { 71 | this.container.terminate() 72 | this.input.terminate() 73 | this.term.terminate() 74 | this.callback(value) 75 | } 76 | spawnSync( 77 | command: string, 78 | args: ReadonlyArray, 79 | options: SpawnSyncOptions 80 | ): SpawnSyncReturns { 81 | const res = spawnSync(command, args, options) 82 | this.term.reinit() 83 | this.term.render(this.screen.buffer) 84 | return res 85 | } 86 | bell() { 87 | process.stdout.write('\x07') 88 | } 89 | exit(code: number | any = 0) { 90 | if (typeof code === 'number') process.exit(code) 91 | this.term.setResult(code) 92 | } 93 | } 94 | 95 | export default new Renderer() 96 | -------------------------------------------------------------------------------- /screen.ts: -------------------------------------------------------------------------------- 1 | import { type TextProps } from './components/Text' 2 | import { type TextElement } from './reconciler' 3 | import { type ReactElement } from 'react' 4 | 5 | export type Color = 6 | | number 7 | | string 8 | | 'Black' 9 | | 'Red' 10 | | 'Green' 11 | | 'Yellow' 12 | | 'Blue' 13 | | 'Magenta' 14 | | 'Cyan' 15 | | 'White' 16 | | 'BrightBlack' 17 | | 'BrightRed' 18 | | 'BrightGreen' 19 | | 'BrightYellow' 20 | | 'BrightBlue' 21 | | 'BrightMagenta' 22 | | 'BrightCyan' 23 | | 'BrightWhite' 24 | 25 | export interface Modifier { 26 | background?: Color 27 | color?: Color 28 | clear?: boolean 29 | bold?: boolean 30 | dim?: boolean 31 | italic?: boolean 32 | underline?: boolean 33 | blinking?: boolean 34 | inverse?: boolean 35 | strikethrough?: boolean 36 | } 37 | 38 | export type Char = [string, Modifier] 39 | 40 | interface Bounds { 41 | x: number 42 | y: number 43 | x1: number 44 | y1: number 45 | x2: number 46 | y2: number 47 | } 48 | 49 | class Screen { 50 | buffer: Char[][] 51 | cursor = { x: 0, y: 0 } 52 | size = { x1: 0, y1: 0, x2: 0, y2: 0 } 53 | constructor() { 54 | this.buffer = this.generateBuffer() 55 | } 56 | generateBuffer() { 57 | this.size = { x1: 0, y1: 0, x2: process.stdout.columns, y2: process.stdout.rows } 58 | return [...Array(this.size.y2)].map(() => [...Array(this.size.x2)].map(() => [' ', {}] as Char)) 59 | } 60 | clearBuffer() { 61 | this.buffer = this.generateBuffer() 62 | this.cursor = { x: 0, y: 0 } 63 | } 64 | render(elements: TextElement[]) { 65 | this.clearBuffer() 66 | this.renderElement(elements, { ...this.cursor, ...this.size }) 67 | } 68 | stringAt(value: string, limit: number) { 69 | const percent = parseFloat(value) 70 | let diff = '' 71 | 72 | const index = value.search(/%[+-]\d+$/) 73 | if (index !== -1) diff = value.substring(index + 1) 74 | if (!value.endsWith('%' + diff) || isNaN(percent)) throw new Error('must be percent') 75 | 76 | return Math.round((limit / 100) * percent) + parseInt(diff || '0') 77 | } 78 | renderElement(element: ReactElement | ReactElement[] | any, prevBounds: Bounds, prevProps: TextProps = {}) { 79 | if (Array.isArray(element)) return element.forEach(i => this.renderElement(i, prevBounds, prevProps)) 80 | 81 | const { children, ...props } = element.props ?? { children: element } 82 | 83 | if (typeof props.x === 'string') 84 | props.x = this.stringAt(props.x, props.absolute ? this.buffer[0].length : prevBounds.x2 - prevBounds.x) 85 | if (typeof props.y === 'string') 86 | props.y = this.stringAt(props.y, props.absolute ? this.buffer.length : prevBounds.y2 - prevBounds.y) 87 | if (typeof props.width === 'string') 88 | props.width = this.stringAt(props.width, props.absolute ? this.buffer[0].length : prevBounds.x2 - prevBounds.x) 89 | if (typeof props.height === 'string') 90 | props.height = this.stringAt(props.height, props.absolute ? this.buffer.length : prevBounds.y2 - prevBounds.y) 91 | if (props.width !== undefined && isNaN(props.width)) props.width = 0 92 | if (props.height !== undefined && isNaN(props.height)) props.height = 0 93 | const x = props.x !== undefined ? (props.absolute ? 0 : prevBounds.x) + props.x : this.cursor.x 94 | const y = props.y !== undefined ? (props.absolute ? 0 : prevBounds.y) + props.y : this.cursor.y 95 | const x1 = 96 | props.x !== undefined 97 | ? props.absolute 98 | ? props.x 99 | : Math.max(prevBounds.x, prevBounds.x + props.x) 100 | : prevBounds.x1 101 | const y1 = 102 | props.y !== undefined 103 | ? props.absolute 104 | ? props.y 105 | : Math.max(prevBounds.y, prevBounds.y + props.y) 106 | : prevBounds.y1 107 | const x2 = 108 | props.width !== undefined 109 | ? Math.min(props.absolute ? this.buffer[0].length : prevBounds.x2, props.width + x) 110 | : props.absolute 111 | ? this.buffer[0].length 112 | : prevBounds.x2 113 | const y2 = 114 | props.height !== undefined 115 | ? Math.min(props.absolute ? this.buffer.length : prevBounds.y2, props.height + y) 116 | : props.absolute 117 | ? this.buffer.length 118 | : prevBounds.y2 119 | const bounds = { x, y, x1, y1, x2, y2 } 120 | this.cursor.x = bounds.x 121 | this.cursor.y = bounds.y 122 | 123 | const modifiers = Object.fromEntries( 124 | ['color', 'background', 'bold', 'dim', 'italic', 'underline', 'blinking', 'inverse', 'strikethrough'] 125 | .map(i => [i, props[i] ?? prevProps[i]]) 126 | .filter(i => i[1]) 127 | ) 128 | if ((props.background || props.clear) && (props.width || props.height)) 129 | this.fill(bounds, props.absolute ? bounds : prevBounds, modifiers) 130 | 131 | if (Array.isArray(children) || children?.props) { 132 | this.renderElement(element.children, bounds, modifiers) 133 | } else if (children) { 134 | const text = children.toString() 135 | if (text.includes('\n')) { 136 | const lines = children.toString().split('\n') 137 | lines.forEach((line: string, index: number) => { 138 | this.renderElement(line, bounds, modifiers) 139 | if (index < lines.length - 1) this.carret(prevBounds) 140 | }) 141 | } else { 142 | this.cursor.x = this.put(text, bounds, modifiers) 143 | } 144 | } 145 | 146 | if (props.block) this.carret(prevBounds) 147 | if (props.width || props.height) { 148 | this.cursor.x = props.block ? prevBounds.x : bounds.x2 149 | this.cursor.y = props.block ? bounds.y2 : prevBounds.y 150 | } 151 | } 152 | fill(bounds: Bounds, prevBounds: Bounds, modifiers: TextProps) { 153 | for (let y = bounds.y; y < bounds.y2; y++) { 154 | if (y < Math.max(0, prevBounds.y1) || y >= Math.min(prevBounds.y2, this.buffer.length)) continue 155 | for (let x = bounds.x; x < bounds.x2; x++) { 156 | if (x < Math.max(0, prevBounds.x1) || x >= Math.min(prevBounds.x2, this.buffer[y].length)) continue 157 | 158 | this.buffer[y][x] = [' ', modifiers] 159 | } 160 | } 161 | } 162 | put(text: string, bounds: Bounds, modifiers: TextProps) { 163 | const { x, y } = bounds 164 | 165 | let i: number 166 | for (i = 0; i < text.length; i++) { 167 | if (y < Math.max(0, bounds.y1) || y >= Math.min(this.buffer.length, bounds.y2)) break 168 | if (x + i < Math.max(0, bounds.x1) || x + i >= Math.min(this.buffer[y].length, bounds.x2)) continue 169 | 170 | this.buffer[y][x + i] = [text[i], modifiers] 171 | } 172 | 173 | return x + i 174 | } 175 | carret(bounds: Bounds) { 176 | this.cursor.x = bounds.x ?? 0 177 | this.cursor.y++ 178 | } 179 | } 180 | 181 | export default Screen 182 | -------------------------------------------------------------------------------- /term.ts: -------------------------------------------------------------------------------- 1 | import Renderer from './renderer' 2 | import { type Char, type Color, type Modifier } from './screen' 3 | 4 | const ESC = '\x1B' 5 | 6 | class Term { 7 | fullscreen = true 8 | print = false 9 | isResized = false 10 | isMouseEnabled = false 11 | prevBuffer: Char[][] | undefined 12 | prevModifier: Modifier = {} 13 | nextWritePrefix = '' 14 | cursor = { x: 0, y: 0 } 15 | maxCursor = { x: 0, y: 0 } 16 | result: any 17 | init(fullscreen: boolean, print: boolean) { 18 | this.fullscreen = fullscreen 19 | this.print = print 20 | 21 | process.stdout.on('resize', () => { 22 | this.isResized = true 23 | }) 24 | 25 | process.on('exit', this.onExit) 26 | 27 | if (fullscreen) { 28 | this.append(`${ESC}[?1049h`) // enables the alternative buffer 29 | this.append(`${ESC}c`) // clear screen 30 | } 31 | this.append(`${ESC}[?25l`) // make cursor invisible 32 | } 33 | reinit() { 34 | this.prevModifier = {} 35 | this.prevBuffer = undefined 36 | this.append(`${ESC}[?1049h${ESC}c${ESC}[?25l`) // enables the alternative buffer, clear screen, make cursor invisible 37 | } 38 | onExit = (code: number) => { 39 | if (code !== 0) return 40 | 41 | process.stdout.write(this.terminate()) 42 | process.exit(0) 43 | } 44 | terminate() { 45 | process.off('exit', this.onExit) 46 | 47 | const sequence: string[] = [] 48 | if (this.fullscreen) { 49 | sequence.push(`${ESC}[?1049l`) // disables the alternative buffer 50 | } else { 51 | const y = this.maxCursor.y - this.cursor.y 52 | if (y > 0) sequence.push(`${ESC}[${y}B`) // moves cursor down 53 | const x = this.maxCursor.x - this.cursor.x + 1 54 | if (x > 0) sequence.push(`${ESC}[${x}C`) // moves cursor right 55 | sequence.push(`\n`) 56 | } 57 | sequence.push(`${ESC}[?25h`) // make cursor visible 58 | if (this.isMouseEnabled) sequence.push(`${ESC}[?1000l`) // disable mouse 59 | return sequence.join('') 60 | } 61 | append(value: string) { 62 | this.nextWritePrefix += value 63 | } 64 | setResult(result: any) { 65 | this.result = result 66 | } 67 | enableMouse() { 68 | this.append(`${ESC}[?1000h${ESC}[?1005h`) // enable mouse 69 | this.isMouseEnabled = true 70 | } 71 | // termGetCursor() { 72 | // process.stdin.setRawMode(true) 73 | // process.stdout.write('\x1b[6n') 74 | // return new Promise(resolve => { 75 | // process.stdin.on('data', data => { 76 | // const [x, y] = data 77 | // .toString() 78 | // .slice(2, -1) 79 | // .split(';') 80 | // .reverse() 81 | // .map(i => parseInt(i) - 1) 82 | // resolve({ x, y }) 83 | // // process.stdin.unref() 84 | // // process.stdin.setRawMode(false) 85 | // }) 86 | // }) 87 | // } 88 | parseHexColor(color: string) { 89 | if (!color.match(/^([\da-f]{6})|([\da-f]{3})$/i)) return 90 | 91 | return ( 92 | color.length === 4 93 | ? color 94 | .substring(1, 4) 95 | .split('') 96 | .map(i => i + i) 97 | : (color.substring(1, 7).match(/.{2}/g) as any) 98 | ).map((i: string) => parseInt(i, 16)) 99 | } 100 | parseColor(color: Color | string | number, offset = 0) { 101 | if (typeof color === 'number') { 102 | if (color < 0 || color > 255) throw new Error('color not found') 103 | return `${38 + offset};5;${color}` 104 | } 105 | 106 | if (color.startsWith('#')) { 107 | const [r, g, b] = this.parseHexColor(color) 108 | return `${38 + offset};2;${r};${g};${b}` 109 | } 110 | 111 | const names = { 112 | black: 30, 113 | red: 31, 114 | green: 32, 115 | yellow: 33, 116 | blue: 34, 117 | magenta: 35, 118 | cyan: 36, 119 | white: 37, 120 | brightblack: 90, 121 | brightred: 91, 122 | brightgreen: 92, 123 | brightyellow: 93, 124 | brightblue: 94, 125 | brightmagenta: 95, 126 | brightcyan: 96, 127 | brightwhite: 97 128 | } 129 | const colorFromName = names[color.toLowerCase()] 130 | if (colorFromName === undefined) throw new Error('color not found') 131 | return colorFromName + offset 132 | } 133 | createModifierSequence(modifier: Modifier) { 134 | if (JSON.stringify(modifier) === '{}') return '0' 135 | 136 | const { prevModifier } = this 137 | 138 | const sequence: (number | string)[] = [] 139 | 140 | if (modifier.color !== prevModifier.color) sequence.push(modifier.color ? this.parseColor(modifier.color) : 39) 141 | if (modifier.background !== prevModifier.background) 142 | sequence.push(modifier.background ? this.parseColor(modifier.background, 10) : 49) 143 | if (modifier.bold !== prevModifier.bold) sequence.push(modifier.bold ? 1 : modifier.dim ? '22;2' : 22) 144 | if (modifier.dim !== prevModifier.dim) sequence.push(modifier.dim ? 2 : modifier.bold ? '22;1' : 22) 145 | if (modifier.italic !== prevModifier.italic) sequence.push(modifier.italic ? 3 : 23) 146 | if (modifier.underline !== prevModifier.underline) sequence.push(modifier.underline ? 4 : 24) 147 | if (modifier.blinking !== prevModifier.blinking) sequence.push(modifier.blinking ? 5 : 25) 148 | if (modifier.inverse !== prevModifier.inverse) sequence.push(modifier.inverse ? 7 : 27) 149 | if (modifier.strikethrough !== prevModifier.strikethrough) sequence.push(modifier.strikethrough ? 9 : 29) 150 | 151 | return sequence.join(';') 152 | } 153 | isIcon(char: string) { 154 | const code = char.charCodeAt(0) 155 | return (code >= 9211 && code <= 9214) || [9829, 9889, 11096].includes(code) || (code >= 57344 && code <= 64838) 156 | } 157 | render(buffer: Char[][]) { 158 | let result = '' 159 | 160 | const { isResized } = this 161 | if (isResized) { 162 | result += `${ESC}[H` // moves cursor to home position 163 | this.cursor = { x: 0, y: 0 } 164 | this.isResized = false 165 | } 166 | 167 | for (let y = 0; y < buffer.length; y++) { 168 | const line = buffer[y] 169 | const prevLine = this.prevBuffer?.[y] 170 | let includesEmoji = false 171 | let includesIcon = false 172 | 173 | const diffLine = isResized 174 | ? line 175 | : line 176 | .map((i: Char, x: number) => { 177 | const [prevChar, prevModifier] = prevLine && prevLine[x] ? prevLine[x] : [' ', {}] 178 | const [char, modifier] = i 179 | return this.isResized || prevChar !== char || JSON.stringify(prevModifier) !== JSON.stringify(modifier) 180 | ? i 181 | : null 182 | }) 183 | .filter(i => i !== undefined) 184 | 185 | const chunks = {} 186 | let chunksAt = 0 187 | diffLine.forEach((value, x: number) => { 188 | if (value === null) return (chunksAt = x + 1) 189 | 190 | const [char, modifier] = value 191 | if (chunks[chunksAt] === undefined) chunks[chunksAt] = ['', ''] 192 | if (JSON.stringify(modifier) !== JSON.stringify(this.prevModifier)) { 193 | chunks[chunksAt][1] += `\x1b[${this.createModifierSequence(modifier)}m` 194 | this.prevModifier = modifier 195 | } 196 | chunks[chunksAt][0] += char 197 | chunks[chunksAt][1] += char 198 | }) 199 | 200 | Object.entries(chunks).map(([index, value]) => { 201 | const [str, strWithModifiers] = value as [string, string] 202 | const x = parseInt(index) 203 | if (/\p{Emoji}/u.test(str)) includesEmoji = true 204 | if (!includesIcon && str.split('').find((i: string) => this.isIcon(i))) includesIcon = true 205 | 206 | if (x === 0 && y === this.cursor.y + 1) { 207 | result += '\n' 208 | } else { 209 | if (!this.fullscreen && y > this.cursor.y && y > this.maxCursor.y) { 210 | const diff = y - this.maxCursor.y 211 | result += '\n'.repeat(diff) 212 | this.cursor = { y: this.cursor.y + diff, x: 0 } 213 | } 214 | 215 | if (y !== this.cursor.y && x !== this.cursor.x) { 216 | result += `${ESC}[${y + 1};${x + 1}H` // moves cursor to position 217 | } else if (y > this.cursor.y) { 218 | const diff = y - this.cursor.y 219 | result += `${ESC}[${diff > 1 ? diff : ''}B` // moves cursor down 220 | } else if (y < this.cursor.y) { 221 | const diff = this.cursor.y - y 222 | result += `${ESC}[${diff > 1 ? diff : ''}A` // moves cursor up 223 | } else if (x > this.cursor.x) { 224 | if (includesEmoji || includesIcon) { 225 | result += `${ESC}[G${ESC}[${x > 1 ? x : ''}C` // moves cursor to column, moves cursor right 226 | } else { 227 | const diff = x - this.cursor.x 228 | result += `${ESC}[${diff > 1 ? diff : ''}C` // moves cursor right 229 | } 230 | } else if (x < this.cursor.x) { 231 | if (includesEmoji) { 232 | result += `${ESC}[G${ESC}[${x > 1 ? x : ''}C` // moves cursor left 233 | } else { 234 | const diff = this.cursor.x - x 235 | result += `${ESC}[${diff > 1 ? diff : ''}D` // moves cursor left 236 | } 237 | } 238 | } 239 | result += strWithModifiers 240 | 241 | this.cursor = { x: x + str.length, y } 242 | }) 243 | // if (this.cursor.x > buffer[y].length - 1) { 244 | // this.cursor = { x: 0, y: 0 } 245 | // result += `${ESC}[H` // moves cursor to home position 246 | // } 247 | if (this.cursor.x > this.maxCursor.x) this.maxCursor.x = this.cursor.x 248 | if (this.cursor.y > this.maxCursor.y) this.maxCursor.y = this.cursor.y 249 | } 250 | this.prevBuffer = buffer 251 | 252 | if (this.nextWritePrefix) { 253 | result = this.nextWritePrefix + result 254 | this.nextWritePrefix = '' 255 | } 256 | 257 | if (this.result !== undefined || this.print) { 258 | result += this.terminate() 259 | } 260 | 261 | if (result) { 262 | if (this.print) return Renderer.terminate(result) 263 | 264 | process.stdout.write(result) 265 | } 266 | 267 | if (this.result !== undefined) { 268 | Renderer.terminate(this.result) 269 | } 270 | } 271 | } 272 | 273 | export default Term 274 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "jsx": "preserve" 5 | }, 6 | "files": [] 7 | } 8 | -------------------------------------------------------------------------------- /utils/chunk.ts: -------------------------------------------------------------------------------- 1 | export default function chunk(arr: any, size: number, cache: any[] = []) { 2 | const tmp = [...arr] 3 | while (tmp.length) cache.push(tmp.splice(0, size)) 4 | return cache 5 | } 6 | -------------------------------------------------------------------------------- /utils/log.ts: -------------------------------------------------------------------------------- 1 | import { createConnection } from 'node:net' 2 | import { tmpdir } from 'node:os' 3 | import { join } from 'node:path' 4 | import { inspect } from 'node:util' 5 | 6 | let socket: any 7 | 8 | const connect = () => { 9 | return new Promise((resolve, reject) => { 10 | socket = createConnection(join(tmpdir(), 'node-log.sock')) 11 | .on('connect', function () { 12 | this.write('\0') 13 | resolve(this) 14 | }) 15 | .on('error', () => { 16 | reject(undefined) 17 | }) 18 | }) 19 | } 20 | 21 | const toString = (data: any[]) => data.map(i => inspect(i, undefined, null, true)).join(' ') 22 | 23 | export default async function log(...rest: any) { 24 | try { 25 | if (socket === undefined) socket = await connect() 26 | socket.write(toString(rest) + '\n') 27 | } catch { 28 | socket = undefined 29 | console.log(...rest) 30 | } 31 | } 32 | --------------------------------------------------------------------------------