Repository: infely/react-curse
Branch: master
Commit: f4dc11e3085e
Files: 79
Total size: 122.1 KB
Directory structure:
gitextract_vg983zb_/
├── .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
│ └── tsconfig.json
├── eslint.config.js
├── examples/
│ ├── Banner.tsx
│ ├── Canvas.tsx
│ ├── Chat.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
├── 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.js
├── readme.md
├── reconciler.ts
├── renderer.ts
├── screen.ts
├── term.ts
├── tsconfig.json
└── utils/
├── chunk.ts
└── log.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
.DS_Store
.create/
.dist/
.npm/
node_modules/
================================================
FILE: bin/font.js
================================================
#!/usr/bin/env node
// prettier-ignore
const letters = [
[ // !
0b00000100,
0b00000100,
0b00000100,
0b00000000,
0b00000100,
0b00000000
], [ // "#
0b10101010,
0b10101110,
0b00001010,
0b00001110,
0b00001010,
0b00000000
], [ // $%
0b11101010,
0b10000010,
0b11100100,
0b00101000,
0b11101010,
0b01000000
], [ // &'
0b01100100,
0b10000100,
0b01000000,
0b10100000,
0b11100000,
0b00000000
], [ // ()
0b00101000,
0b01000100,
0b01000100,
0b01000100,
0b00101000,
0b00000000
], [ // *+
0b00000000,
0b01000100,
0b11101110,
0b01000100,
0b10100000,
0b00000000
], [ // ,-
0b00000000,
0b00000000,
0b00001110,
0b00000000,
0b01000000,
0b10000000
], [ // ./
0b00000010,
0b00000100,
0b00000100,
0b00000100,
0b01001000,
0b00000000
], [ // 01
0b11100100,
0b10101100,
0b10100100,
0b10100100,
0b11101110,
0b00000000
], [ // 23
0b11101110,
0b00100010,
0b11101110,
0b10000010,
0b11101110,
0b00000000
], [ // 45
0b10101110,
0b10101000,
0b11101110,
0b00100010,
0b00101110,
0b00000000
], [ // 67
0b11101110,
0b10000010,
0b11100010,
0b10100010,
0b11100010,
0b00000000
], [ // 89
0b11101110,
0b10101010,
0b11101110,
0b10100010,
0b11101110,
0b00000000
], [ // :;
0b00000000,
0b01000100,
0b00000000,
0b00000000,
0b01000100,
0b00001000
], [ // <=
0b00100000,
0b01001110,
0b10000000,
0b01001110,
0b00100000,
0b00000000
], [ // >?
0b10001110,
0b01000010,
0b00100100,
0b01000000,
0b10000100,
0b00000000
], [ // @A
0b01001110,
0b10101010,
0b10001110,
0b11101010,
0b01001010,
0b00000000
], [ // BC
0b11101110,
0b10101000,
0b11001000,
0b10101000,
0b11101110,
0b00000000
], [ // DE
0b11001110,
0b10101000,
0b10101100,
0b10101000,
0b11001110,
0b00000000
], [ // FG
0b11101110,
0b10001000,
0b11001000,
0b10001010,
0b10001110,
0b00000000
], [ // HI
0b10101110,
0b10100100,
0b11100100,
0b10100100,
0b10101110,
0b00000000
], [ // JK
0b11101010,
0b00101010,
0b00101100,
0b10101010,
0b11101010,
0b00000000
], [ // LM
0b10001010,
0b10001110,
0b10001010,
0b10001010,
0b11101010,
0b00000000
], [ // NO
0b11001110,
0b10101010,
0b10101010,
0b10101010,
0b10101110,
0b00000000
], [ // PQ
0b11101110,
0b10101010,
0b11101010,
0b10001010,
0b10001110,
0b00000010
], [ // RS
0b11101110,
0b10101000,
0b11001110,
0b10100010,
0b10101110,
0b00000000
], [ // TU
0b11101010,
0b01001010,
0b01001010,
0b01001010,
0b01001110,
0b00000000
], [ // VW
0b10101010,
0b10101010,
0b10101010,
0b10101110,
0b01001010,
0b00000000
], [ // XY
0b10101010,
0b10101010,
0b01000100,
0b10100100,
0b10100100,
0b00000000
], [ // Z[
0b11100110,
0b00100100,
0b01000100,
0b10000100,
0b11100110,
0b00000000
], [ // \]
0b10001100,
0b01000100,
0b01000100,
0b01000100,
0b00101100,
0b00000000
], [ // ^_
0b01000000,
0b10100000,
0b00000000,
0b00000000,
0b00001110,
0b00000000
], [ // `{
0b10000110,
0b01000100,
0b00001000,
0b00000100,
0b00000110,
0b00000000
], [ // |}
0b01001100,
0b01000100,
0b01000010,
0b01000100,
0b01001100,
0b00000000
], [ // ~
0b01010000,
0b10100000,
0b00000000,
0b00000000,
0b00000000,
0b00000000
]
]
console.log(Buffer.from(letters.flat()).toString('base64'))
================================================
FILE: bin/logger.js
================================================
#!/usr/bin/env node
import { rmSync } from 'node:fs'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
const filename = join(tmpdir(), 'node-log.sock')
try {
rmSync(filename)
} catch {
//
}
createServer(stream => {
stream.on('data', data => {
data = data.toString()
if (data === '\0') return process.stdout.write('\x1bc')
process.stdout.write(data)
})
}).listen(filename)
================================================
FILE: bin/postcreate.js
================================================
#!/usr/bin/env node
import { execSync } from 'node:child_process'
import { readFileSync, writeFileSync, chmodSync } from 'node:fs'
const makeJs = () => {
const file = `.create/index.js`
writeFileSync(file, `#!/usr/bin/env node\n${readFileSync(file, 'utf8')}`)
chmodSync(file, '755')
}
const makeJson = () => {
const json = JSON.parse(readFileSync('package.json', 'utf8'))
const keys = ['name', 'version', 'description', 'keywords', 'author', 'repository', 'homepage', 'license']
const jsonNew = {
...Object.fromEntries(Object.entries(json).filter(([key]) => keys.includes(key))),
...{
name: 'create-react-curse',
description: 'Create React-Curse app',
keywords: [...json.keywords, 'react-curse'],
bin: 'index.js'
}
}
writeFileSync('.create/package.json', JSON.stringify(jsonNew, null, 2))
}
makeJs()
makeJson()
execSync('cp -r create/{template,README.md} .create')
================================================
FILE: bin/postdist.js
================================================
#!/usr/bin/env node
import { readFileSync, writeFileSync, chmodSync, unlinkSync } from 'node:fs'
import { argv } from 'node:process'
let [, , arg] = argv
if (arg === undefined) {
const json = JSON.parse(readFileSync('package.json', 'utf8'))
arg = json.main.replace(/\.[jt]sx?$/, '')
}
const src = '.dist/index.cjs'
const dest = `.dist/${arg}.cjs`
writeFileSync(dest, `#!/usr/bin/env node\n${readFileSync(src, 'utf8')}`)
unlinkSync(src)
chmodSync(dest, '755')
================================================
FILE: bin/postnpm.js
================================================
#!/usr/bin/env node
import { readFileSync, writeFileSync } from 'node:fs'
const makeJson = () => {
const json = JSON.parse(readFileSync('package.json', 'utf8'))
const keys = [
'name',
'version',
'description',
'keywords',
'author',
'repository',
'homepage',
'main',
'license',
'type',
'dependencies'
]
const jsonNew = {
...Object.fromEntries(Object.entries(json).filter(([key]) => keys.includes(key))),
...{
main: 'index.js'
}
}
writeFileSync('.npm/package.json', JSON.stringify(jsonNew, null, 2))
}
const makeReadme = () => {
const data = readFileSync('README.md', 'utf8')
const dataNew = data
.split('\n')
.map(i => i.replace('media/', 'https://raw.githubusercontent.com/infely/react-curse/HEAD/media/'))
.join('\n')
writeFileSync('.npm/README.md', dataNew)
}
makeJson()
makeReadme()
================================================
FILE: components/Banner.tsx
================================================
import chunk from '../utils/chunk'
import Text, { type TextProps } from './Text'
import { useMemo } from 'react'
const FONT =
'BAQEAAQAqq4KDgoA6oLkKOpAZIRAoOAAKERERCgAAETuRKAAAAAOAECAAgQEBEgA5KykpO4A7iLugu4ArqjuIi4A7oLiouIA7qruou4AAEQAAEQIIE6ATiAAjkIkQIQATqqO6koA7qjIqO4AzqisqM4A7ojIio4ArqTkpK4A6iosquoAio6KiuoAzqqqqq4A7qrqio4C7qjOoq4A6kpKSk4AqqqqrkoAqqpEpKQA5iREhOYAjERERCwAQKAAAA4AhkQIBAYATERCREwAUKAAAAAA'
const letters = chunk(Buffer.from(FONT, 'base64'), 6)
const Letter = ({ children }: { children: string }) => {
const text = useMemo(() => {
let code = children.toUpperCase().charCodeAt(0)
if (code >= 123 && code <= 126) code -= 26
const font = letters[Math.floor((code - 32) / 2)]
if (!font) return
const bits = code % 2 === 0 ? 4 : 0
return chunk(font, 2)
.map(([top, bot]) => {
return [3, 2, 1, 0]
.map(i => {
const b = Math.pow(2, i + bits)
const code = 0 | (top & b && 0x04) | (bot & b && 0x08)
return code ? String.fromCharCode(0x257c + code) : ' '
})
.join('')
})
.join('\n')
}, [children])
return <>{text}>
}
export interface BannerProps extends TextProps {
children: string
}
export default function Banner({ children, ...props }: BannerProps) {
if (children === undefined || children === null) return null
const lines = children.toString().split('\n')
const length = Math.max(...lines.map((i: string) => i.length))
return (
{lines.map((line: string, key: number) => (
{line.split('').map((char: string, key: number) => (
{char}
))}
))}
)
}
================================================
FILE: components/Bar.tsx
================================================
import Text, { TextProps } from './Text'
const getSize = (offset: number, size: number) => {
offset = Math.round(offset * 8)
size = Math.round(size * 8)
if (offset < 0) {
size += offset
offset = 0
}
size = Math.max(0, size)
return [offset, size]
}
const getSections = (offset: number, size: number) => [
size >= 8 || ((offset + size) % 8 === 0 && size > 0 && size < 8),
size >= 8,
(size >= 8 || (offset % 8 === 0 && size < 8)) && (offset + size) % 8 !== 0
]
const Vertical = (y: number, height: number, props: object) => {
const [offset, size] = getSize(y, height)
const sections = getSections(offset, size)
const char = (value: number) => {
if (value > 7) return String.fromCharCode(0x2588)
return String.fromCharCode(0x2588 - Math.min(8, value))
}
return (
{sections[0] && {char(offset % 8)} }
{sections[1] &&
[...Array(Math.floor(((offset % 8) + size) / 8) - 1)].map((_, key) => (
{char(8)}
))}
{sections[2] && {char((offset + size) % 8)} }
)
}
const Horizontal = (x: number, width: number, props: object) => {
const [offset, size] = getSize(x, width)
const sections = getSections(offset, size)
const char = (value: number) => {
if (value <= 0) return ' '
return String.fromCharCode(0x2590 - Math.min(8, value))
}
return (
{sections[0] && {char(offset % 8)} }
{sections[1] && {char(8).repeat(Math.floor(((offset % 8) + size) / 8) - 1)} }
{sections[2] && {char((offset + size) % 8)} }
)
}
export interface BarProps extends TextProps {
type: 'vertical' | 'horizontal'
y?: number
x?: number
height?: number
width?: number
}
export default function Bar({ type = 'vertical', y, x, height, width, ...props }: BarProps) {
if (type === 'vertical') return Vertical(y || 0, height || 0, { x, width, ...props })
if (type === 'horizontal') return Horizontal(x || 0, width || 0, { y, height, ...props })
return null
}
================================================
FILE: components/Block.tsx
================================================
import useSize from '../hooks/useSize'
import Text, { TextProps } from './Text'
export interface BlockProps extends TextProps {
width?: number | undefined
align?: 'left' | 'center' | 'right'
children: any
}
export default function Block({ width = undefined, align = 'left', children, ...props }: BlockProps) {
const handle = (line: any, key: any = undefined) => {
if (typeof line === 'object') return line
if (line === '\n') return
let x: number | string = 0
switch (align) {
case 'center':
width ??= useSize().width
x = Math.round(width / 2 - line.length / 2)
break
case 'right':
x = `100%-${line.length}`
break
}
return (
{line}
)
}
if (Array.isArray(children)) return children.map(handle)
return handle(children)
}
================================================
FILE: components/Canvas.tsx
================================================
import { Color } from '../screen'
import chunk from '../utils/chunk'
import Text, { TextProps } from './Text'
import { Children, useEffect, useMemo, useRef } from 'react'
class CanvasClass {
// prettier-ignore
MODES = {
'1x1': { map: [[0x1]], table: [0x20, 0x88] },
'1x2': { map: [[0x1], [0x2]], table: [0x20, 0x80, 0x84, 0x88] },
'2x2': { map: [[0x1, 0x4], [0x2, 0x8]], table: [0x20, 0x98, 0x96, 0x8c, 0x9d, 0x80, 0x9e, 0x9b, 0x97, 0x9a, 0x84, 0x99, 0x90, 0x9c, 0x9f, 0x88] },
'2x4': { map: [[0x1, 0x8], [0x2, 0x10], [0x4, 0x20], [0x40, 0x80]] }
}
mode: { w: number; h: number }
multicolor: boolean
w: number
h: number
buffer: Buffer
colors: Color[]
constructor(width: number, height: number, mode = { w: 1, h: 2 }) {
this.mode = mode
this.multicolor = mode.w === 1 && mode.h === 2
this.w = Math.ceil(width / this.mode.w) * this.mode.w
this.h = Math.ceil(height / this.mode.h) * this.mode.h
const size = ((this.w / this.mode.w) * this.h) / this.mode.h
this.buffer = Buffer.alloc(size)
this.colors = [...Array(size * (this.multicolor ? 2 : 1))]
}
clear() {
this.buffer.fill(0)
this.colors.fill(0)
}
set(x: number, y: number, color: Color) {
if (x < 0 || x >= this.w || y < 0 || y >= this.h) return
const index = (this.w / this.mode.w) * Math.floor(y / this.mode.h) + Math.floor(x / this.mode.w)
this.buffer[index] |= (this.MODES as any)[`${this.mode.w}x${this.mode.h}`].map[y % this.mode.h][x % this.mode.w]
if (color) this.colors[this.multicolor ? this.w * y + x : index] = color
}
line(x0: number, y0: number, x1: number, y1: number, color: Color) {
const dx = x1 - x0
const dy = y1 - y0
const adx = Math.abs(dx)
const ady = Math.abs(dy)
let eps = 0
const sx = dx > 0 ? 1 : -1
const sy = dy > 0 ? 1 : -1
if (adx > ady) {
for (let x = x0, y = y0; sx < 0 ? x >= x1 : x <= x1; x += sx) {
this.set(x, y, color)
eps += ady
if (eps << 1 >= adx) {
y += sy
eps -= adx
}
}
} else {
for (let x = x0, y = y0; sy < 0 ? y >= y1 : y <= y1; y += sy) {
this.set(x, y, color)
eps += adx
if (eps << 1 >= ady) {
x += sx
eps -= ady
}
}
}
}
render() {
return [...this.buffer].map((i, index) => {
const table = (this.MODES as any)[`${this.mode.w}x${this.mode.h}`].table
let res = String.fromCharCode(table ? (i && 0x2500) + table[i] : 0x2800 + i)
let colors: Color[] = []
if (res !== ' ') {
if (this.multicolor) {
const y = Math.floor(index / this.w) * this.mode.h
const x = (index % this.w) * this.mode.w
const color1 = this.colors[this.w * y + x]
const color2 = this.colors[this.w * (y + 1) + x]
if (res === '\u2588' && color1 !== color2) {
res = '\u2580'
colors = [color1, color2]
} else {
colors = [color1 || color2]
}
} else {
colors = [this.colors[index]]
}
}
return [res, colors]
})
}
}
interface Point {
x: number
y: number
color?: Color
}
export const Point = (_props: Point) => <>>
interface Line {
x: number
y: number
dx: number
dy: number
color?: Color
}
export const Line = (_props: Line) => <>>
interface CanvasProps extends TextProps {
mode?: { w: number; h: number }
width: number
height: number
children: any[]
}
export default function Canvas({ mode = { w: 1, h: 2 }, width, height, children, ...props }: CanvasProps) {
const canvas = useRef(new CanvasClass(width, height, mode))
useEffect(() => {
canvas.current = new CanvasClass(width, height, mode)
}, [width, height, mode])
const text = useMemo(() => {
canvas.current.clear()
Children.forEach(children, i => {
if (i.type === Point) {
const { x, y, color } = i.props
canvas.current.set(x, y, color)
} else if (i.type === Line) {
const { x, y, dx, dy, color } = i.props
canvas.current.line(x, y, dx, dy, color)
}
})
return canvas.current.render()
}, [children])
return (
{chunk(text, canvas.current.w / canvas.current.mode.w).map((line: any, y) => (
{line.map(
([char, [color, background]]: any, x: number) =>
char !== ' ' && (
{char}
)
)}
))}
)
}
================================================
FILE: components/Frame.tsx
================================================
import useChildrenSize from '../hooks/useChildrenSize'
import Text, { TextProps } from './Text'
const FRAMES = {
single: '┌─┐│└┘',
double: '╔═╗║╚╝',
rounded: '╭─╮│╰╯'
} as const
export interface FrameProps extends TextProps {
type?: 'single' | 'double' | 'rounded'
height?: number
width?: number
children: React.ReactNode
}
export default function Frame({ type = 'single', height: _height, width: _width, children, ...props }: FrameProps) {
const frames = FRAMES[type]
const size = _height === undefined || _width === undefined ? useChildrenSize(children) : undefined
const height = _height ?? size!.height
const width = _width ?? size!.width
const { color } = props
return (
{frames[0]}
{frames[1].repeat(width)}
{frames[2]}
{[...Array(height)].map((_, key) => (
{frames[3]}
{' '.repeat(width)}
{frames[3]}
))}
{children}
{frames[4]}
{frames[1].repeat(width)}
{frames[5]}
)
}
================================================
FILE: components/Input.tsx
================================================
import useInput from '../hooks/useInput'
import Renderer from '../renderer'
import { Color } from '../screen'
import Text, { TextProps } from './Text'
import { useEffect, useMemo, useRef, useState } from 'react'
const mutate = (value: string, pos: number, str: string, multiline: boolean): [string, number, string | null] => {
const edit = (value: string, pos: number, callback: (string: string) => string) => {
const left = callback(value.substring(0, pos))
const right = value.substring(pos)
return [left, right].join('')
}
const arr = Renderer.input.parse(str) // str.split('')
for (const input of arr) {
switch (input) {
case '\x01': // C-a
case '\x1b\x5b\x31\x7e': {
// home
if (pos > 0) pos = 0
break
}
case '\x05': // C-e
case '\x1b\x5b\x34\x7e': {
// end
if (pos < value.length) pos = value.length
break
}
case '\x02': // C-b
case '\x1b\x5b\x44': {
// left
if (pos > 0) pos -= 1
break
}
case '\x06': // C-f
case '\x1b\x5b\x43': {
// right
if (pos < value.length) pos += 1
break
}
case '\x1b': {
// esc
return [value, pos, 'cancel']
}
case '\x04': // C-d
case '\x0d': {
// cr
if (input === '\x0d' && multiline) {
value = edit(value, pos, i => i + '\n')
pos += 1
break
}
return [value, pos, 'submit']
}
case '\x08': // C-h
case '\x7f': {
// backspace
if (pos < 1) break
value = edit(value, pos, i => i.substring(0, i.length - 1))
pos -= 1
break
}
case '\x15': {
// C-u
if (pos < 1) break
value = edit(value, pos, () => '')
pos = 0
break
}
case '\x0b': {
// C-k
if (pos > value.length - 1) break
value = value.substring(0, pos)
break
}
case '\x1b\x62': // M-b
case '\x17': {
// C-w
if (pos < 1) break
const index = value.substring(0, pos).trimEnd().lastIndexOf(' ')
if (input === '\x17') value = edit(value, pos, i => (index !== -1 ? i.substring(0, index + 1) : ''))
pos = Math.max(0, index + 1)
break
}
case '\x1b\x66': {
// M-f
if (pos > value.length - 1) break
const nextWordIndex = value.substring(pos).match(/\s(\w)/)?.index ?? -1
pos = nextWordIndex === -1 ? value.length : pos + nextWordIndex + 1
break
}
case '\x1b\x64': {
// M-d
const nextEndIndex = value.substring(pos).match(/\w(\b)/)?.index ?? -1
value = value.substring(0, pos) + (nextEndIndex !== -1 ? value.substring(pos + nextEndIndex + 1) : '')
break
}
case '\x1b\x5b\x41': {
// up
if (!multiline) break
const currentLine = value.substring(0, pos).lastIndexOf('\n')
if (currentLine === -1) break
const targetLine = value.substring(0, currentLine).lastIndexOf('\n')
pos = targetLine + Math.min(pos - currentLine, currentLine - targetLine)
break
}
case '\x1b\x5b\x42': {
// down
if (!multiline) break
let targetLine_ = value.substring(pos).indexOf('\n')
if (targetLine_ === -1) break
targetLine_ += pos + 1
let nextLine = value.substring(targetLine_).indexOf('\n')
nextLine = (nextLine !== -1 ? targetLine_ + nextLine : value.length) + 1
const currentLine_ = value.substring(0, pos).lastIndexOf('\n')
pos = targetLine_ + Math.min(pos - currentLine_ - 1, nextLine - targetLine_ - 1)
break
}
default: {
if (input.charCodeAt(0) < 32) break
value = edit(value, pos, i => i + input)
pos += 1
}
}
}
return [value, pos, null]
}
interface InputProps extends TextProps {
focus?: boolean
type?: 'text' | 'password' | 'hidden'
initialValue?: string
cursorBackground?: Color
onCancel?: () => void
onChange?: (_: any) => void
onSubmit?: (_: any) => void
}
export default function Input({
focus = true,
type = 'text',
initialValue = '',
cursorBackground = undefined,
onCancel = () => {},
onChange = (_: string) => {},
onSubmit = (_: string) => {},
width = undefined,
height = undefined,
...props
}: InputProps) {
const [value, setValue] = useState(initialValue)
const [pos, setPos] = useState(initialValue.length)
const offset = useRef({ y: 0, x: 0 })
const multiline = useMemo(() => {
return typeof height === 'number' && height > 1
}, [height])
useInput(
(_, raw) => {
if (raw === undefined) return
if (!focus) return
const [valueNew, posNew, action] = mutate(value, pos, raw(), multiline)
switch (action) {
case 'cancel':
onCancel()
break
case 'submit':
onSubmit(valueNew)
setValue('')
setPos(0)
break
default:
setValue(valueNew)
setPos(posNew)
}
},
[focus, value, pos, onCancel, onSubmit]
)
useEffect(() => {
onChange(value)
}, [value])
if (type === 'hidden') return null
const text = useMemo(() => {
if (type === 'password') return '*'.repeat(value.length)
return value
}, [value, type])
const { y: yo, x: xo } = useMemo(() => {
if (typeof width !== 'number') return offset.current
let posLine = pos
let valueLine = value
if (multiline && typeof height === 'number') {
const line = value.substring(0, pos).split('\n').length - 1
if (offset.current.y < line - height + 1) offset.current.y = line - height + 1
if (offset.current.y > line) offset.current.y = line
const currentLine = value.substring(0, pos).lastIndexOf('\n')
posLine = pos - (currentLine !== -1 ? currentLine + 1 : 0)
const nextLine = value.substring(pos).indexOf('\n')
valueLine = value.substring(currentLine + 1, nextLine !== -1 ? pos + nextLine : value.length)
}
if (!multiline && offset.current.x + valueLine.length + 1 > width)
offset.current.x = Math.max(0, valueLine.length - width + 1)
if (offset.current.x < posLine - width + 1) offset.current.x = posLine - width + 1
if (offset.current.x > posLine) offset.current.x = posLine
return offset.current
}, [value, pos, width])
return (
{text.substring(0, pos)}
{focus && (
<>
{(text[pos] !== '\n' && text[pos]) || ' '}
{text[pos] === '\n' && '\n'}
>
)}
{text.length > pos && text.substring(pos + (focus ? 1 : 0))}
)
}
================================================
FILE: components/List.tsx
================================================
import useInput from '../hooks/useInput'
import useSize from '../hooks/useSize'
import { Color } from '../screen'
import Scrollbar from './Scrollbar'
import Text from './Text'
import { useEffect, useMemo, useState } from 'react'
export const getYO = (offset: number, limit: number, y: number) => {
if (offset <= y - limit) return y - limit + 1
if (offset > y) return y
return offset
}
export const inputHandler =
(
vi: boolean,
pos: ListPos,
setPos: (_: any) => void,
height: number,
dataLength: number,
onChange: (_: any) => void
) =>
(input: string) => {
let y: undefined | number
let yo: undefined | number
if (((vi && input === 'k') || input === '\x1b\x5b\x41') /* up */ && pos.y > 0) y = pos.y - 1
if (((vi && input === 'j') || input === '\x1b\x5b\x42') /* down */ && pos.y < dataLength - 1) y = pos.y + 1
if (((vi && input === '\x02') /* C-b */ || input === '\x1b\x5b\x35\x7e') /* pageup */ && pos.y > 0)
y = Math.max(0, pos.y - height)
if (((vi && input === '\x06') /* C-f */ || input === '\x1b\x5b\x36\x7e') /* pagedown */ && pos.y < dataLength - 1)
y = Math.min(dataLength - 1, pos.y + height)
if (vi && input === '\x15' /* C-u */ && pos.y > 0) y = Math.max(0, pos.y - Math.floor(height / 2))
if (vi && input === '\x04' /* C-d */ && pos.y < dataLength - 1)
y = Math.min(dataLength - 1, pos.y + Math.floor(height / 2))
if (((vi && input === 'g') || input === '\x1b\x5b\x31\x7e') /* home */ && pos.y > 0) y = 0
if (((vi && input === 'G') || input === '\x1b\x5b\x34\x7e') /* end */ && pos.y < dataLength - 1) y = dataLength - 1
if (y !== undefined) yo = getYO(pos.yo, height, y)
if (vi && input === 'H') y = pos.yo
if (vi && input === 'M') y = pos.yo + Math.floor(height / 2)
if (vi && input === 'L') y = pos.yo + height - 1
if (y !== undefined) {
let newPos = { ...pos, y }
if (yo !== undefined) newPos = { ...newPos, yo }
setPos(newPos)
onChange(newPos)
}
}
export interface ListPos {
y: number
x: number
yo: number
xo: number
x1: number
x2: number
xm?: number
}
export interface ListBase {
focus?: boolean
initialPos?: ListPos
height?: number
width?: number
renderItem?: (_: any) => any
scrollbar?: boolean
scrollbarBackground?: Color
scrollbarColor?: Color
vi?: boolean
pass?: any
onChange?: (pos: ListPos) => void
onSubmit?: (pos: ListPos) => void
}
interface ListProps extends ListBase {
data?: any[]
}
export default function List({
focus = true,
initialPos = { y: 0, x: 0, yo: 0, xo: 0, x1: 0, x2: 0 },
data = [''],
renderItem = (_: any) => ,
height: _height = undefined,
width: _width = undefined,
scrollbar = undefined,
scrollbarBackground = undefined,
scrollbarColor = undefined,
vi = true,
pass = undefined,
onChange = (_: ListPos) => {},
onSubmit = (_: ListPos) => {}
}: ListProps) {
const size = _height === undefined || _width === undefined ? useSize() : undefined
const height = _height ?? size!.height
const width = _width ?? size!.width
const [pos, setPos] = useState({ ...{ y: 0, x: 0, yo: 0, xo: 0, x1: 0, x2: 0 }, ...initialPos })
const isScrollbarRequired = useMemo(() => {
return scrollbar === undefined ? data.length > height : scrollbar
}, [scrollbar, data.length, height])
useEffect(() => {
let newPos: undefined | ListPos
let { y } = initialPos
if (y > 0 && y >= data.length) {
y = data.length - 1
onChange({ ...pos, y })
}
if (y !== pos.y) {
y = Math.max(0, y)
newPos = { ...(newPos || pos), y, yo: getYO(pos.yo, height - 1, y) }
}
if (newPos) {
setPos(newPos)
onChange(newPos)
}
}, [initialPos.y])
useEffect(() => {
if (pos.y > 0 && pos.y > data.length - 1) {
const y = Math.max(0, data.length - 1)
const newPos = { ...pos, y, yo: getYO(pos.yo, data.length, y) }
setPos(newPos)
onChange(newPos)
}
}, [data])
useInput(
(input: string) => {
if (!focus) return
inputHandler(vi, pos, setPos, height, data.length, onChange)(input)
if (input === '\x0d' /* cr */) onSubmit(pos)
},
[focus, vi, pos, setPos, height, data, onChange, onSubmit]
)
return (
{data
.filter((_: any, index: number) => index >= pos.yo && index < height + pos.yo)
.map((row: any, index: number) => (
{renderItem({
focus,
item: row,
selected: index + pos.yo === pos.y,
pass
})}
))}
{isScrollbarRequired && (
)}
)
}
================================================
FILE: components/ListTable.tsx
================================================
import useInput from '../hooks/useInput'
import useSize from '../hooks/useSize'
import { getYO, inputHandler, ListBase, ListPos } from './List'
import Scrollbar from './Scrollbar'
import Text from './Text'
import { useEffect, useMemo, useState } from 'react'
const getX = (index: number, widths: number[]) => {
const [x1, x2] = widths.reduce((acc, i, k) => [acc[0] + (k < index ? i : 0), acc[1] + (k <= index ? i : 0)], [0, 0])
return { x1, x2 }
}
const getXO = (offsetX: number, limit: number, x1: number, x2: number) => {
if (x1 <= offsetX) return x1
if (x2 >= offsetX + limit) return x2 - limit + 1
return offsetX
}
interface ListTableProps extends ListBase {
mode?: 'cell' | 'row'
head?: any[]
renderHead?: (_: any) => any
data?: any[][]
}
export default function List({
mode = 'cell',
focus = true,
initialPos = { y: 0, x: 0, yo: 0, xo: 0, x1: 0, x2: 0 },
height: _height = undefined,
width: _width = undefined,
head = [''],
renderHead = (_: any) => ,
data = [['']],
renderItem = (_: any) => ,
scrollbar = undefined,
scrollbarBackground = undefined,
scrollbarColor = undefined,
vi = true,
pass = undefined,
onChange = (_pos: ListPos) => {},
onSubmit = (_pos: ListPos) => {}
}: ListTableProps) {
const size = _height === undefined || _width === undefined ? useSize() : undefined
const height = _height ?? size!.height
const width = _width ?? size!.width
const [pos, setPos] = useState({ ...{ y: 0, x: 0, yo: 0, xo: 0, x1: 0, x2: 0 }, ...initialPos })
const isScrollbarRequired = useMemo(() => {
return scrollbar === undefined ? data.length > height - 1 : scrollbar
}, [scrollbar, data.length, height])
const widths = useMemo(() => {
const widths = data
.reduce(
(acc: number[], row: string[]) => {
row.forEach((i, k) => (acc[k] = Math.max(acc[k], (i?.toString() || 'null').length)))
return acc
},
head.map((i: any) => i.toString().length)
)
.map((i, index) => i + (index <= head.length - 2 ? 2 : 0))
const sum = widths.reduce((acc, i) => acc + i, 0)
if (sum >= width - 1) return widths.map(i => Math.min(32, i))
// const left = width - sum - 2
// if (sum > 0) widths[widths.length - 1] += left
return widths
}, [data, head, width])
const isCropped = useMemo(() => {
const sum = widths.reduce((acc, i) => acc + i, 0)
return sum - pos.xo >= width + (isScrollbarRequired ? -1 : 0)
}, [widths, pos.xo, width, isScrollbarRequired])
const dataFiltered = useMemo(() => {
return data.filter((_: any, index: number) => index >= pos.yo && index < height + pos.yo)
}, [data, pos.yo, height])
useEffect(() => {
let newPos: undefined | ListPos
let { y, x } = initialPos
if (y > 0 && y >= data.length) {
y = data.length - 1
onChange({ ...pos, y })
}
if (y !== pos.y) {
y = Math.max(0, y)
newPos = { ...(newPos || pos), y, yo: getYO(pos.yo, height - 1, y) }
}
if (initialPos.xm) {
let acc = 0
x = widths.map(i => (acc += i)).findIndex(i => i >= Math.min(acc, (initialPos.xm ?? 0) + pos.xo))
}
if (x > 0 && x >= head.length) {
x = head.length - 1
onChange({ ...pos, x })
}
if (x !== pos.x) {
const { x1, x2 } = getX(x, widths)
newPos = { ...(newPos || pos), x, xo: getXO(pos.xo, width + (isScrollbarRequired ? -1 : 0), x1, x2) }
}
if (newPos) {
setPos(newPos)
onChange(newPos)
}
}, [initialPos.y, initialPos.x, initialPos.xm])
useEffect(() => {
if (pos.y > 0 && head.length > 0 && pos.y > data.length - 1) {
const y = Math.max(0, data.length - 1)
const newPos = { ...pos, y, yo: getYO(pos.yo, data.length, y) }
setPos(newPos)
onChange(newPos)
}
if (pos.x > 0 && head.length > 0 && pos.x > head.length - 1) {
const newPos = { ...pos, x: head.length - 1 }
setPos(newPos)
onChange(newPos)
}
}, [head, data])
useInput(
(input: string) => {
if (!focus) return
inputHandler(vi, pos, setPos, height - 1, data.length, onChange)(input)
let x: undefined | number
switch (mode) {
case 'cell':
if (((vi && input === 'h') || input === '\x1b\x5b\x44') /* left */ && pos.x > 0) x = pos.x - 1
if (((vi && input === 'l') || input === '\x1b\x5b\x43') /* right */ && pos.x < head.length - 1) x = pos.x + 1
if (vi && input === '^' && pos.x > 0) x = 0
if (vi && input === '$' && pos.x < head.length - 1) x = head.length - 1
if (x !== undefined) {
const { x1, x2 } = getX(x, widths)
const xo = getXO(pos.xo, width + (isScrollbarRequired ? -1 : 0), x1, x2)
const newPos = { ...pos, x, xo, x1, x2 }
setPos(newPos)
onChange(newPos)
}
break
case 'row':
if (((vi && input === 'h') || input === '\x1b\x5b\x44') /* left */ && pos.x > 0) x = pos.x - 1
if (((vi && input === 'l') || input === '\x1b\x5b\x43') /* right */ && pos.x < head.length - 1) x = pos.x + 1
if (x !== undefined) {
const { x1, x2 } = getX(x, widths)
const newPos = { ...pos, x, xo: x1, x1, x2 }
setPos(newPos)
onChange(newPos)
}
break
}
if (input === '\x0d' /* cr */) onSubmit({ ...pos, ...getX(pos.x, widths) })
},
[focus, vi, pos, width, height, head, data, widths, isScrollbarRequired, setPos, onChange, onSubmit]
)
return (
{renderHead({
focus,
item: head,
widths,
pass
})}
{dataFiltered.map((item: any, index: number) => (
{renderItem({
mode,
focus,
item,
y: pos.y,
x: pos.x,
widths,
index: index + pos.yo,
pass
})}
))}
{isCropped && (
~
)}
{isScrollbarRequired && (
)}
)
}
================================================
FILE: components/Scrollbar.tsx
================================================
import { Color } from '../screen'
import Bar from './Bar'
import Text from './Text'
export interface ScrollbarProps {
type?: 'vertical' | 'horizontal'
offset: number
limit: number
length: number
background?: Color
color?: Color
}
export default function Scrollbar({ type = 'vertical', offset, limit, length, background, color }: ScrollbarProps) {
length ||= limit
offset = (limit / length) * offset
let size = limit / (length / limit)
if (size < 1) {
offset *= (length - limit / size) / (length - limit)
size = 1
}
return (
)
}
================================================
FILE: components/Separator.tsx
================================================
import useSize from '../hooks/useSize'
import Text, { TextProps } from './Text'
export interface SeparatorProps extends TextProps {
type?: 'vertical' | 'horizontal'
height?: number
width?: number
}
export default function Separator({ type = 'vertical', height: _height, width: _width, ...props }: SeparatorProps) {
const size = _height === undefined || _width === undefined ? useSize() : undefined
const height = _height ?? size!.height
const width = _width ?? size!.width
if (type === 'vertical' && height < 1) return null
if (type === 'horizontal' && width < 1) return null
return (
{type === 'vertical' &&
[...Array(height)].map((_, key) => (
│
))}
{type === 'horizontal' && '─'.repeat(width)}
)
}
================================================
FILE: components/Spinner.tsx
================================================
import useAnimation from '../hooks/useAnimation'
import Text, { TextProps } from './Text'
export interface SpinnerProps extends TextProps {
children?: string
}
export default function Spinner({ children, ...props }: SpinnerProps) {
const frames = children ? children.split('') : ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const { ms, interpolate } = useAnimation(Infinity)
const frame = Math.floor(interpolate(0, frames.length, 0, 500, ms % 500))
const color = 255 - Math.abs(Math.floor(interpolate(-16, 16, 0, 1500, ms % 1500)))
return (
{frames[frame]}
)
}
================================================
FILE: components/Text.tsx
================================================
import { Modifier } from '../screen'
export interface TextProps extends Modifier {
readonly absolute?: boolean
readonly x?: number | string
readonly y?: number | string
readonly width?: number | string
readonly height?: number | string
readonly block?: boolean
readonly children?: React.ReactNode
}
export default function Text({ children, ...props }: TextProps) {
// @ts-ignore
return {children}
}
================================================
FILE: components/View.tsx
================================================
import useChildrenSize from '../hooks/useChildrenSize'
import useInput from '../hooks/useInput'
import useSize from '../hooks/useSize'
import Scrollbar from './Scrollbar'
import Text, { TextProps } from './Text'
import { useState } from 'react'
export interface ViewProps extends TextProps {
focus?: boolean
height?: number
scrollbar?: boolean
vi?: boolean
}
export default function View({ focus = true, height: _height, scrollbar, vi = true, children, ...props }: ViewProps) {
const height = _height ?? useSize().height
const [yo, setYo] = useState(0)
const { height: length } = useChildrenSize(children)
useInput(
(input: string) => {
if (!focus) return
if (((vi && input === 'k') || input === '\x1b\x5b\x41') /* up */ && yo > 0) setYo(yo - 1)
if (((vi && input === 'j') || input === '\x1b\x5b\x42') /* down */ && yo < length - height) setYo(yo + 1)
if (((vi && input === '\x02') /* C-b */ || input === '\x1b\x5b\x35\x7e') /* pageup */ && yo > 0)
setYo(Math.max(0, yo - height))
if (((vi && input === '\x06') /* C-f */ || input === '\x1b\x5b\x36\x7e') /* pagedown */ && yo < length - height)
setYo(Math.min(length - height, yo + height))
if (vi && input === '\x15' /* C-u */ && yo > 0) setYo(Math.max(0, yo - Math.floor(height / 2)))
if (vi && input === '\x04' /* C-d */ && yo < length - height)
setYo(Math.min(length - height, yo + Math.floor(height / 2)))
if (((vi && input === 'g') || input === '\x1b\x5b\x31\x7e') /* home */ && yo > 0) setYo(0)
if (((vi && input === 'G') || input === '\x1b\x5b\x34\x7e') /* end */ && yo < length - height)
setYo(length - height)
},
[focus, yo, length, height]
)
const isScrollbarRequired = scrollbar === undefined ? length > height : scrollbar
return (
{children}
{isScrollbarRequired && (
)}
)
}
================================================
FILE: create/Create.tsx
================================================
import ReactCurse, { Input, Spinner, Text, useAnimation, useInput } from '..'
import { Logo } from '../mediacreators/logo'
import { exec } from 'child_process'
import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'fs'
import { join } from 'path'
import { cwd } from 'process'
import { useState } from 'react'
const pwd = cwd()
const install = (value: string) => {
value ??= ''
// console.log({ value })
// if (!value) return
const dest = join(pwd, value)
if (!existsSync(dest)) mkdirSync(dest)
if (existsSync(join(dest, 'package.json'))) return
const src = join(__dirname, 'template')
const files = readdirSync(src)
for (const file of files) {
copyFileSync(join(src, file), join(dest, file))
}
const cp = exec(`cd ${dest} && npm i`)
return new Promise(resolve => {
cp.on('exit', () => {
resolve(true)
})
})
}
// const Logo = ({ text }: { text: string }) => {
// const { interpolate } = useAnimation(1000)
// const w = Math.floor(interpolate(0, 22))
//
// return (
//
//
// {text.replace(/[^\s]/g, '#')}
//
//
// {text.substring(0, w)}
//
//
// )
// }
const App = () => {
const [focus, setFocus] = useState(1)
const [value, setValue] = useState(null)
const onSubmit = async (value: string) => {
setFocus(2)
const res = await install(value)
setFocus(res ? 3 : -1)
setTimeout(ReactCurse.exit, 1000 / 60)
}
useInput()
return (
<>
{/* */}
{focus === 1 && ? }
{focus !== 1 && {'✔ '} }
Where would you like to create your app
{pwd}/
{focus === 1 && }
{focus !== 1 && {value} }
{focus >= 2 && (
{focus === 2 && (
<>
Installing...
>
)}
{focus > 2 && (
<>
{'✔ '}
Done
>
)}
)}
{focus === 3 && (
<>
{value && (
{'# '} cd {value}
)}
{'# '} npm start
Enjoy!
>
)}
{focus === -1 && Canceled }
>
)
}
ReactCurse.inline( )
================================================
FILE: create/readme.md
================================================
# create-react-curse
Generate a [react-curse](https://www.npmjs.com/package/react-curse) app
================================================
FILE: create/template/App.tsx
================================================
import { useState } from 'react'
import ReactCurse, { Text, useInput } from 'react-curse'
const App = () => {
const [counter, setCounter] = useState(0)
useInput(
input => {
if (input === 'q') ReactCurse.exit()
else setCounter(counter + 1)
},
[counter]
)
return (
Counter: {counter.toString()}
Press q to exit or any key to increment the counter
Edit App.tsx
)
}
ReactCurse.render( )
================================================
FILE: create/template/package.json
================================================
{
"name": "react-curse-app",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"type": "module",
"scripts": {
"start": "npx esbuild App.tsx --outfile=.dist/index.js --bundle --platform=node --format=esm --external:'./node_modules/*' --sourcemap && node --enable-source-maps .dist",
"dist": "npx esbuild App.tsx --outfile=.dist/index.cjs --bundle --platform=node --define:'process.env.NODE_ENV=\"production\"' --minify --tree-shaking=true"
},
"dependencies": {
"react-curse": "^1.0.0"
},
"devDependencies": {
"@types/node": "^18.11.18",
"@types/react": "^18.0.27"
}
}
================================================
FILE: create/template/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"allowJs": false,
"checkJs": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"jsx": "react-jsx",
"noEmit": true
},
"exclude": ["node_modules"]
}
================================================
FILE: eslint.config.js
================================================
import js from '@eslint/js'
import pluginReact from 'eslint-plugin-react'
import { defineConfig } from 'eslint/config'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default defineConfig([
{
files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
plugins: { js },
extends: ['js/recommended'],
languageOptions: { globals: globals.browser }
},
tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
{
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'react/react-in-jsx-scope': 'off'
}
}
])
================================================
FILE: examples/Banner.tsx
================================================
import ReactCurse, { Text, useInput } from '..'
import Banner from '../components/Banner'
import { useEffect, useState } from 'react'
const lorem = [...Array(3)]
.map((_, offset) => [...Array(32)].map((_, index) => String.fromCharCode((offset + 1) * 32 + index)).join(''))
.join('\n')
const getTime = () => new Date().toTimeString().substring(0, 8)
const Clock = (props: any) => {
const [time, setTime] = useState(getTime())
useEffect(() => {
const interval = setInterval(() => setTime(getTime()), 1000)
return () => clearInterval(interval)
}, [])
useInput((input: string) => {
if (input === 'q') ReactCurse.exit()
})
return {time}
}
ReactCurse.render(
<>
0x20{'\n'.repeat(3)}0x40{'\n'.repeat(3)}0x60
{lorem}
>
)
================================================
FILE: examples/Canvas.tsx
================================================
import ReactCurse, { Text, useInput, useSize } from '..'
import Canvas, { Line } from '../components/Canvas'
import { useEffect, useMemo, useState } from 'react'
const CELL = 4
const COLORS = ['Red', 'Green', 'Blue']
const DATA = [...Array(COLORS.length)].map(() => {
let prev = 0
return [...Array(1024)].map(() => {
prev += Math.round(Math.random() * 2 - 1)
prev = Math.max(0, Math.min(4, prev))
return prev
})
})
const Graph = ({ mode, play }: any) => {
const { width } = useSize()
const h = useMemo(() => CELL * mode.h, [mode])
const w = useMemo(() => CELL * mode.w, [mode])
const c = useMemo(() => h * 4, [h])
const [lines, setLines] = useState([])
const [offset, setOffset] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
if (!play) return
setOffset(offset + 1)
}, 1000 / 60)
return () => clearInterval(interval)
}, [offset, play])
useEffect(() => {
setLines(
DATA.flatMap((line, colorIndex) => {
return line
.map((i, index) => {
if (index - (offset * mode.w) / w > (width * mode.w) / w) return
return {
x: index * w - offset * mode.w,
y: c - (line[index - 1] || 0) * h,
dx: index * w + w - offset * mode.w,
dy: c - i * h,
color: COLORS[colorIndex]
}
})
.filter(i => i)
})
)
}, [mode, offset])
return (
{lines.map((props, key) => (
))}
)
}
const App = () => {
const [mode, setMode] = useState({ w: 1, h: 2 })
const [play, setPlay] = useState(true)
useInput((input: string) => {
if (input === '\x10\x0d') ReactCurse.exit()
if (input === 'q') ReactCurse.exit()
if (input === '1') setMode({ w: 1, h: 1 })
if (input === '2') setMode({ w: 1, h: 2 })
if (input === '3') setMode({ w: 2, h: 2 })
if (input === '4') setMode({ w: 2, h: 4 })
if (input === ' ') setPlay(play => !play)
})
return (
<>
{COLORS.map((color, key) => (
Line {key + 1}{' '}
))}
{[...Array(5)].map((_, key) => (
{key.toString()}
))}
>
)
}
ReactCurse.render( )
================================================
FILE: examples/Chat.tsx
================================================
import ReactCurse, { Banner, Input, Separator, Text, Trail, useAnimation, useWordWrap } from '..'
import { useState } from 'react'
const Message = ({ color: toColor, children }: { color: string; children: string }) => {
const { interpolateColor } = useAnimation(1000)
const color = interpolateColor('#888888', toColor)
return {useWordWrap(children)}
}
const App = () => {
const [messages, setMessages] = useState<{ role: 'system' | 'user'; message: string }[]>([
{ role: 'system', message: 'Hello' },
{ role: 'system', message: 'Type anything' },
{ role: 'system', message: 'And press enter' }
])
const submitHandler = (message: string) => {
if (message) setMessages([...messages, { role: 'user', message }])
}
return (
<>
CHAT
{messages.map((i, key) => (
{`${i.role}:`.padEnd(7, ' ')} {' '}
{i.message}
))}
#
>
)
}
ReactCurse.render( )
================================================
FILE: examples/Example.tsx
================================================
import ReactCurse, { Text, useInput } from '..'
import Frame from '../components/Frame'
import { useState } from 'react'
const App = () => {
const [counter, setCounter] = useState(0)
useInput(
input => {
if (input === 'q') ReactCurse.exit()
if (input === 'k') setCounter(counter + 1)
if (input === 'j') setCounter(counter - 1)
},
[counter]
)
return (
<>
Counter: {' '}
{counter}
j,k - change counter, q - quit
>
)
}
ReactCurse.render( )
================================================
FILE: examples/Inline.tsx
================================================
import ReactCurse, { Text } from '..'
const App = () => {
return (
<>
Line 1
Line 2
Line 3
>
)
}
ReactCurse.inline( )
================================================
FILE: examples/Pong.tsx
================================================
import ReactCurse, { Banner, Canvas, Point, Line, useSize, useInput } from '..'
import { useEffect, useState } from 'react'
const Game = () => {
const { width, height } = useSize()
const [scores, setScores] = useState([0, 0])
const [y, setY] = useState(height - 4)
const [ball, setBall] = useState({ x: Math.floor(width / 2), y: height, dx: 1, dy: 1 })
useEffect(() => {
const interval = setInterval(() => {
setBall(({ x, y, dx, dy }) => {
x += dx
y += dy
if (x <= 1 || x >= width - 2) {
dx = -dx
scores[0]++
setScores(scores)
}
if (y <= 0 || y >= height * 2 - 1) {
dy = -dy
scores[1]++
setScores(scores)
}
return { x, y, dx, dy }
})
}, 1000 / 60)
return () => clearInterval(interval)
}, [scores])
useInput((input: string) => {
if (input === '\x10\x0d') ReactCurse.exit()
if (input === 'q') ReactCurse.exit()
if (input === 'k') setY(y => y - 1)
if (input === 'j') setY(y => y + 1)
})
return (
<>
{scores.join(' ')}
>
)
}
ReactCurse.render( )
================================================
FILE: examples/Prompt.tsx
================================================
import ReactCurse, { Block, Frame, Input, Text, useAnimation, useInput } from '..'
import { useState } from 'react'
const InputText = ({ text, type, color }: { text: string; type: any; color: any }) => {
const [focus, setFocus] = useState(true)
const [value, setValue] = useState('')
const onSubmit = (input: string) => {
setFocus(false)
setValue(input)
ReactCurse.exit(input)
}
return (
<>
{text}
:
{focus && }
{!focus && {value} }
>
)
}
const InputList = ({ items, color }: { items: string[]; color: any }) => {
const [, setFocus] = useState(true)
const [selected, setSelected] = useState(0)
useInput(
(input: string) => {
if (input === 'q') ReactCurse.exit()
if (input === 'k') setSelected(i => Math.max(0, i - 1))
if (input === 'j') setSelected(i => Math.min(items.length - 1, i + 1))
if (input === '\x0d') {
ReactCurse.exit(items[selected])
setFocus(false)
}
},
[selected]
)
return (
<>
Please select an option :
{items.map((i, key) => (
{selected === key ? '>' : ' '} {i}
))}
>
)
}
const Spinner = ({ text }: { text: string }) => {
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] // ['|', '/', '-', '\\']
const { ms, interpolate } = useAnimation(Infinity)
const frame = Math.floor(interpolate(0, frames.length, 0, 500, ms % 500))
const color = 255 - Math.abs(Math.floor(interpolate(-16, 16, 0, 1500, ms % 1500)))
return (
<>
{frames[frame]} {text}
>
)
}
;(async () => {
await ReactCurse.frame(
hello world
)
const res1 = await ReactCurse.prompt( )
console.log(`Answer 1: ${res1}`)
const res2 = await ReactCurse.prompt( )
console.log(`Answer 2: ${res2}`)
const res3 = await ReactCurse.prompt( )
console.log(`Answer 3: ${res3}`)
const res4 = await ReactCurse.prompt( )
console.log(`Answer 3: ${res4}`)
ReactCurse.inline( )
await new Promise(resolve => setTimeout(resolve, 500))
process.exit()
})()
================================================
FILE: examples/Speed.tsx
================================================
import ReactCurse, { Text, useInput } from '..'
import { useEffect, useState } from 'react'
const TEXT = ''
const width = process.stdout.columns
const height = process.stdout.rows
const rand = () => {
return [...Array(128)].map(() => [
// 512
width / 2,
0,
Math.floor(Math.random() * 256),
Math.random() * 2 - 1, // 3 - 1.5
Math.random() * 1 // 1.5
])
}
const App = () => {
const [texts, setTexts] = useState(rand())
useEffect(() => {
const interval = setInterval(() => {
setTexts(texts =>
texts
.map(([x, y, color, dx, dy]) => {
x += dx
y += dy
if (x >= width - 1 - TEXT.length || x <= 0) dx = -dx
if (y >= height - 1 || y <= 0) {
dy = -dy
if (dy < 0) dy *= 0.9
}
return [x, y, color, dx, dy]
})
.filter(i => Math.round(i[4] * 10))
)
}, 1000 / 60)
return () => clearInterval(interval)
}, [])
useInput((input: string) => {
if (input === 'q') ReactCurse.exit()
})
return (
<>
{texts.map(([x, y, color], key) => (
{TEXT}
))}
>
)
}
ReactCurse.render( )
================================================
FILE: examples/Todo.tsx
================================================
import ReactCurse, { Block, Input, List, Text, useInput, useSize } from '..'
import useAnimation, { useTrail } from '../hooks/useAnimation'
import { useCallback, useState } from 'react'
const Task = ({ title, completed, selected }: { title: string; completed: boolean; selected: boolean }) => {
const { interpolateColor } = useAnimation(250)
return (
{completed ? '' : ''} {title}
)
}
const Tasks = ({ focus, setFocus }: { focus: boolean; setFocus: (focus: boolean) => void }) => {
const { height, width } = useSize()
const [pos, setPos] = useState<{ y: number }>({ y: 0 })
const [tasks, setTasks] = useState(() =>
[...Array(32)].map((_, i) => ({ id: i + 1, title: `Task ${i + 1}`, completed: Math.random() >= 0.5 }))
)
useInput(
input => {
if (focus) return
if (input === 'D') setTasks(tasks.filter((_, index) => index !== pos.y))
if (input === ' ') setTasks(tasks.map((i, index) => (index === pos.y ? { ...i, completed: !i.completed } : i)))
if (input === '\x0d') setFocus(true)
},
[pos, tasks]
)
const onSubmit = useCallback(
(title: string) => {
setFocus(false)
setTasks(tasks => [...tasks, { id: tasks.length + 1, title, completed: false }])
},
[tasks]
)
return (
<>
{
return
}}
onChange={setPos}
/>
setFocus(false)} />
>
)
}
const Fade = ({ children }: { children: React.ReactNode }) => {
const { interpolateColor } = useAnimation(1000)
const color = interpolateColor('#3c3836', '#ebdbb2', 500)
if (color === '#3c3836') return null
return (
{children}
)
}
const App = () => {
const { width } = useSize()
const [show, setShow] = useState(true)
const [focus, setFocus] = useState(false)
const { interpolate, interpolateColor } = useAnimation(500)
const x = Math.round(interpolate(0, width))
const background = interpolateColor('#282828', '#3c3836')
useInput(
input => {
if (input === '\x10\x0d') ReactCurse.exit()
if (focus) return
if (input === 'q') ReactCurse.exit()
if (input === 't') setShow(i => !i)
},
[focus]
)
return (
hello
{show && }
world
)
}
ReactCurse.render( )
================================================
FILE: examples/Visualizer.tsx
================================================
import ReactCurse, { Bar, Text, Trail, useAnimation, useSize } from '..'
import { useMemo } from 'react'
// const App = () => {
// const { height, width } = useSize()
//
// const { ms, interpolate } = useAnimation(2000)
//
// const lines = useMemo(() => {
// return [...Array(Math.floor(width / 2))].map(_ => ({
// h: Math.floor(Math.random() * height),
// t: Math.floor(Math.random() * 250) + 250,
// c: 255 - Math.floor(Math.random() * 16)
// }))
// }, [])
//
// return (
// <>
// {lines.map(({ h, t, c }, key) => {
// h = ms < 1000 ? interpolate(0, h, 0, t, ms % t) : interpolate(h, 0, 1000, 2000)
// return (
//
//
//
// )
// })}
// >
// )
// }
const Line = ({ h, c }: { h: number; c: number }) => {
const { height } = useSize()
const { interpolate } = useAnimation(500)
h = interpolate(h, 0)
return
}
const App2 = () => {
const { height, width } = useSize()
const lines = useMemo(() => {
return [...Array(Math.floor(width / 2))].map(_ => ({
h: Math.floor(Math.random() * height),
c: 255 - Math.floor(Math.random() * 16)
}))
}, [])
return (
{lines.map((props, key) => (
))}
)
}
ReactCurse.render( )
================================================
FILE: hooks/useAnimation.ts
================================================
import Renderer from '../renderer'
import { useState, useEffect, useRef } from 'react'
const interpolate = (toLow: number, toHigh: number, fromLow: number, fromHigh: number, value: number) => {
const res = toLow + ((((toHigh - toLow) / 100) * 100) / (fromHigh - fromLow)) * (value - fromLow)
return Math.max(Math.min(toLow, toHigh), Math.min(Math.max(toLow, toHigh), res))
}
const interpolateColor = (toLow: string, toHigh: string, fromLow: number, fromHigh: number, value: number) => {
if (!toLow.startsWith('#')) return toHigh
if (!toHigh.startsWith('#')) return toHigh
const toLowColor = Renderer.term.parseHexColor(toLow)
return (
'#' +
Buffer.from(
Renderer.term.parseHexColor(toHigh).map((i: number, index: number) => {
return Math.round(interpolate(toLowColor[index], i, fromLow, fromHigh, value))
})
).toString('hex')
)
}
export const Trail = ({ delay, children }: { delay: number; children: any }): React.JSX.Element => {
return useTrail(delay, children)
}
export const useTrail = (delay: number, children: any[], key: string = 'key'): any => {
const ms = useRef(0)
const timeout = useRef(undefined)
const [keys, setKeys] = useState([])
useEffect(() => {
const keysNew = children.map((i: any) => i[key])
const keyOld = keys.find(key => !keysNew.includes(key))
if (keyOld) {
setKeys(keys.filter(i => i !== keyOld))
return
}
const keyNew = keysNew.find((i: any) => !keys.includes(i))
if (!keyNew) return
const at = Date.now()
const nextAt = Math.max(0, delay - (at - ms.current))
clearTimeout(timeout.current)
timeout.current = setTimeout(() => {
ms.current = Date.now()
setKeys([...keys, keyNew])
}, nextAt)
}, [children.map((i: any) => i[key]).join('\n'), keys])
return children.filter((i: any) => keys.includes(i[key]))
}
interface useAnimation {
ms: number
interpolate: (toLow: number, toHigh: number, fromLow?: number, fromHigh?: number, value?: number) => number
interpolateColor: (toLow: string, toHigh: string, fromLow?: number, fromHigh?: number, value?: number) => string
}
export default (time = Infinity, fps = 60): useAnimation => {
if (time <= 0 || fps <= 0) return { ms: 0, interpolate: i => i, interpolateColor: i => i }
const at = useRef(Date.now())
const interval = useRef(undefined)
const [ms, setMs] = useState(0)
useEffect(() => {
const frameMs = 1000 / Math.min(60, fps)
interval.current = setInterval(() => {
const msNew = Date.now() - at.current
if (msNew >= time) clearInterval(interval.current)
setMs(Math.min(msNew, time))
}, frameMs)
return () => {
clearInterval(interval.current)
}
}, [])
return {
ms,
interpolate: (toLow, toHigh, fromLow = 0, fromHigh = time, value = ms) => {
return interpolate(toLow, toHigh, fromLow, fromHigh, value)
},
interpolateColor: (toLow, toHigh, fromLow = 0, fromHigh = time, value = ms) => {
return interpolateColor(toLow, toHigh, fromLow, fromHigh, value)
}
}
}
================================================
FILE: hooks/useBell.ts
================================================
/**
* @deprecated
*/
export default () => {
process.stdout.write('\x07')
}
================================================
FILE: hooks/useChildrenSize.ts
================================================
import { type ReactElement, useEffect, useState } from 'react'
const render = (element: ReactElement | ReactElement[] | any): any => {
if (Array.isArray(element)) return element.map(i => render(i)).join('')
const { children } = ((element as ReactElement).props as any) ?? { children: element }
if (Array.isArray(children) || children?.props) return render(children)
return children.toString()
}
const getSize = (children: ReactElement | ReactElement[] | any) => {
const string = render(children).split('\n')
const width = string.reduce((acc: number, i: string) => Math.max(acc, i.length), 0)
const height = string.length
return { width, height }
}
export default (children: ReactElement | ReactElement[] | any) => {
const [size, setSize] = useState(getSize(children))
useEffect(() => {
setSize(getSize(children))
}, [children])
return size
}
================================================
FILE: hooks/useClipboard.ts
================================================
import { spawnSync } from 'child_process'
export default (): [() => string, (input: string) => string] => {
const getClipboard = () => {
switch (process.platform) {
case 'darwin':
return spawnSync('pbpaste', [], { encoding: 'utf8' }).stdout
}
return ''
}
const setClipboard = (input: any) => {
if (typeof input !== 'string') input = input.toString()
switch (process.platform) {
case 'darwin':
spawnSync('pbcopy', [], { input })
break
default:
input = ''
}
return input
}
return [getClipboard, setClipboard]
}
================================================
FILE: hooks/useExit.ts
================================================
import Renderer from '../renderer'
import process from 'process'
/**
* @deprecated
*/
export default (code: number | any = 0) => {
if (typeof code === 'number') process.exit(code)
Renderer.term.setResult(code)
}
================================================
FILE: hooks/useInput.ts
================================================
import Renderer from '../renderer'
import { useEffect } from 'react'
export default (callback: (input: string, raw: () => string) => void = () => {}, deps: React.DependencyList = []) => {
useEffect(() => {
if (!process.stdin.isRaw) process.stdin.setRawMode?.(true)
}, [])
useEffect(() => {
const handler = (input: string, raw: () => string) => {
if (input === '\x03') process.exit()
if (input.startsWith('\x1b\x5b\x4d')) return
callback(input, raw)
}
Renderer.input.on(handler)
return () => {
Renderer.input.off(handler)
}
}, deps)
}
================================================
FILE: hooks/useMouse.ts
================================================
import Renderer from '../renderer'
import { type DependencyList, useEffect } from 'react'
interface Event {
type: 'mousedown' | 'mouseup' | 'wheeldown' | 'wheelup'
x: number
y: number
}
export default (callback: (event: Event) => void, deps: DependencyList = []) => {
useEffect(() => {
if (!process.stdin.isRaw) process.stdin.setRawMode?.(true)
Renderer.term.enableMouse()
}, [])
useEffect(() => {
const handler = (input: string) => {
if (!input.startsWith('\x1b\x5b\x4d')) return
const b = input.charCodeAt(3)
const type = (1 << 6) & b ? (1 & b ? 'wheelup' : 'wheeldown') : (3 & b) === 3 ? 'mouseup' : 'mousedown'
const x = input.charCodeAt(4) - 0o41
const y = input.charCodeAt(5) - 0o41
callback({ type, x, y })
}
Renderer.input.on(handler)
return () => {
Renderer.input.off(handler)
}
}, deps)
}
================================================
FILE: hooks/useSize.ts
================================================
import { useEffect, useState } from 'react'
const subscribers = new Set<(size: { width: number; height: number }) => void>()
const getSize = () => {
const { columns: width, rows: height } = process.stdout
return { width, height }
}
process.stdout.on('resize', () => {
const size = getSize()
subscribers.forEach((_, fn) => fn(size))
})
export default () => {
const [size, setSize] = useState(getSize())
useEffect(() => {
subscribers.add(setSize)
return () => {
subscribers.delete(setSize)
}
}, [])
return size
}
================================================
FILE: hooks/useWordWrap.ts
================================================
import useSize from './useSize'
export default (text: string, _width: number | undefined = undefined) => {
const width = _width ?? useSize().width
return text
.split('\n')
.map((line: string) => {
if (line.length <= width) return line
return line
.split(' ')
.reduce(
(acc, i) => {
if (acc[acc.length - 1].length + i.length > width) acc.push('')
acc[acc.length - 1] += `${i} `
return acc
},
['']
)
.map(i => i.trimEnd())
.join('\n')
})
.join('\n')
}
================================================
FILE: index.ts
================================================
export { default } from './renderer'
export { default as Banner } from './components/Banner'
export { default as Bar } from './components/Bar'
export { default as Block } from './components/Block'
export { default as Canvas, Point, Line } from './components/Canvas'
export { default as Frame } from './components/Frame'
export { default as Input } from './components/Input'
export { default as List, type ListPos } from './components/List'
export { default as ListTable } from './components/ListTable'
export { default as Scrollbar } from './components/Scrollbar'
export { default as Separator } from './components/Separator'
export { default as Spinner } from './components/Spinner'
export { default as Text } from './components/Text'
export { default as View } from './components/View'
export { default as useAnimation, useTrail, Trail } from './hooks/useAnimation'
export { default as useBell } from './hooks/useBell'
export { default as useChildrenSize } from './hooks/useChildrenSize'
export { default as useClipboard } from './hooks/useClipboard'
export { default as useExit } from './hooks/useExit'
export { default as useInput } from './hooks/useInput'
export { default as useMouse } from './hooks/useMouse'
export { default as useSize } from './hooks/useSize'
export { default as useWordWrap } from './hooks/useWordWrap'
export { default as log } from './utils/log'
================================================
FILE: input.ts
================================================
import EventEmitter from 'events'
export default class Input {
ee: EventEmitter
queue: string[] = []
constructor() {
this.ee = new EventEmitter()
}
terminate() {
this.ee.removeAllListeners()
}
private onData = (key: Buffer) => {
const raw = key.toString()
const chunks = this.parse(raw)
if (chunks.length > 1) this.queue = chunks.slice(1)
this.ee.emit('data', chunks[0], () => {
this.queue = []
return raw
})
}
parse(input: string) {
const chars = input.split('')
let res: any
const chunks: string[] = []
while ((res = chars.shift())) {
if (['\x10', '\x1b'].includes(res)) {
// length >= 2, example: M-a (1b 61)
res += chars.shift() || ''
if (res.endsWith('\x5b')) {
// length >= 3, example: arrowup (1b 5b 41)
res += chars.shift() || ''
if (res.endsWith('\x31') || res.endsWith('\x34') || res.endsWith('\x35') || res.endsWith('\x36')) {
// length >= 4, example: pageup (1b 5b 35 7e, 1b 5b 36 7e)
res += chars.shift() || ''
} else if (res.endsWith('\x4d')) {
// length >= 4, example: mousedown (1b 5b 4d 20 21 21, 1b 5b 4d 20 c3 80 21)
res += chars.shift() || ''
res += chars.shift() || ''
res += chars.shift() || ''
}
}
}
chunks.push(res)
}
return chunks
}
on(callback: (input: string, raw: () => string) => void) {
if (this.ee.listenerCount('data') === 0) process.stdin.on('data', this.onData)
this.ee.on('data', callback)
}
off(callback: (input: string, raw: () => string) => void) {
this.ee.off('data', callback)
if (this.ee.listenerCount('data') === 0) process.stdin.off('data', this.onData)
}
render() {
const chunk = this.queue.shift()
if (chunk) setTimeout(() => this.ee.emit('data', chunk), 0)
}
}
================================================
FILE: mediacreators/Banner.tsx
================================================
/* @vhs 80x3@1 */
import ReactCurse, { Banner, useInput } from '..'
const App = () => {
useInput()
return {'12:34:56' /* new Date().toTimeString().substring(0, 8) */}
}
ReactCurse.render( )
================================================
FILE: mediacreators/Bar-1.tsx
================================================
/* @vhs 80x3@1 */
import ReactCurse, { Bar, useInput } from '..'
const App = () => {
useInput()
return (
<>
{[...Array(24)].map((_, index) => (
))}
>
)
}
ReactCurse.render( )
================================================
FILE: mediacreators/Bar-2.tsx
================================================
/* @vhs 80x3
Hide
Type@0 npm_start
Enter
Sleep 40ms
Show
Sleep 2s */
import ReactCurse, { Bar, Text, useAnimation, useInput } from '..'
const App = () => {
useInput()
const { interpolate } = useAnimation(2000)
return (
<>
{''}
{''}{' '}
{' '.repeat(22)}
>
)
}
ReactCurse.render( )
================================================
FILE: mediacreators/Block.tsx
================================================
/* @vhs 80x3@1 */
import ReactCurse, { Block } from '..'
const App = () => {
setTimeout(() => {}, 1000)
return (
<>
left
center
right
>
)
}
process.stdout.write('\x1bc')
ReactCurse.inline( )
================================================
FILE: mediacreators/Canvas-1.tsx
================================================
/* @vhs 80x3@1 */
import ReactCurse, { Canvas, Point, Line, useInput } from '..'
const App = () => {
useInput()
return (
)
}
ReactCurse.render( )
================================================
FILE: mediacreators/Canvas-2.tsx
================================================
/* @vhs 80x3@1
Set FontFamily "Apple Symbols"
Set FontSize 21
Hide
Type@0 npm_start
Enter
Sleep 250ms
Show
Sleep 250ms */
import ReactCurse, { Canvas, Point, Line, useInput } from '..'
const App = () => {
useInput()
return (
)
}
ReactCurse.render( )
================================================
FILE: mediacreators/Frame.tsx
================================================
/* @vhs 80x3@1 */
import ReactCurse, { Frame, useInput } from '..'
const App = () => {
useInput()
return (
<>
single border type
double border type
rounded border type
>
)
}
ReactCurse.render( )
================================================
FILE: mediacreators/Input-1.tsx
================================================
/* @vhs 80x3
Hide
Type@0 npm_start
Enter
Sleep 250ms
Show
Sleep 250ms
Type hello world
Left 11
Sleep 1s */
import ReactCurse, { Input } from '..'
const App = () => {
return
}
ReactCurse.render( )
================================================
FILE: mediacreators/Input-2.tsx
================================================
/* @vhs 80x3
Hide
Type@0 npm_start
Enter
Sleep 250ms
Show
Sleep 250ms
Type hello
Enter
Type world
Enter
Enter
Up@200ms 4
Down@200ms 4
Sleep 1s */
import ReactCurse, { Input } from '..'
const App = () => {
return
}
ReactCurse.render( )
================================================
FILE: mediacreators/List.tsx
================================================
/* @vhs 80x8
Hide
Type@0 npm_start
Enter
Sleep 250ms
Show
Sleep 500ms
Type@100ms jjj
Sleep 500ms
Type@100ms kkk
Sleep 1000ms */
import ReactCurse, { List, Text } from '..'
const App = () => {
const items = [...Array(8)].map((_, index) => ({ id: index + 1, title: `Task ${index + 1}` }))
return (
{item.title} }
/>
)
}
ReactCurse.render( )
================================================
FILE: mediacreators/ListTable.tsx
================================================
/* @vhs 80x8
Hide
Type@0 npm_start
Enter
Sleep 250ms
Show
Sleep 500ms
Type@200ms jjl
Sleep 500ms
Type@200ms kkh
Sleep 1000ms */
import ReactCurse, { ListTable, Text } from '..'
const App = () => {
const head = ['id', 'title']
const items = [...Array(8)].map((_, index) => [index + 1, `Task ${index + 1}`])
return (
item.map((i: string, key: string) => (
{i}
))
}
data={items}
renderItem={({ item, x, y, index }) =>
item.map((text: string, key: string) => (
{text}
))
}
/>
)
}
ReactCurse.render( )
================================================
FILE: mediacreators/Scrollbar.tsx
================================================
/* @vhs 80x3@1 */
import ReactCurse, { Scrollbar, useInput } from '..'
const App = () => {
useInput()
return
}
ReactCurse.render( )
================================================
FILE: mediacreators/Separator.tsx
================================================
/* @vhs 80x3@1 */
import ReactCurse, { Separator, useInput } from '..'
const App = () => {
useInput()
return (
<>
>
)
}
ReactCurse.render( )
================================================
FILE: mediacreators/Spinner.tsx
================================================
/* @vhs 80x3
Hide
Type@0 npm_start
Enter
Sleep 40ms
Show
Sleep 1.5s */
import ReactCurse, { Spinner, useInput } from '..'
const App = () => {
useInput()
return (
<>
-\|/
>
)
}
ReactCurse.render( )
================================================
FILE: mediacreators/Text.tsx
================================================
/* @vhs 80x3@1 */
import ReactCurse, { Text, useInput } from '..'
const App = () => {
useInput()
return (
<>
hello world
hello world
hello world
hello world
hello world
hello world
>
)
}
ReactCurse.render( )
================================================
FILE: mediacreators/Trail.tsx
================================================
/* @vhs 80x8
Hide
Type@0 npm_start
Enter
Sleep 40ms
Show
Sleep 2s */
import ReactCurse, { Text, Trail, useInput } from '..'
const App = () => {
useInput()
const items = [...Array(8)].map((_, index) => ({ id: index + 1, title: `Task ${index + 1}` }))
return (
{items.map(({ id, title }) => (
{title}
))}
)
}
ReactCurse.render( )
================================================
FILE: mediacreators/View.tsx
================================================
/* @vhs 80x8
Hide
Type@0 npm_start
Enter
Sleep 250ms
Show
Sleep 750ms
Type@20ms jjjjjjjjjj
Sleep 250ms
Type@20ms kkkkkkkkkk */
import ReactCurse, { View } from '..'
import json from '../package.json'
const App = () => {
return {JSON.stringify(json, null, 2)}
}
ReactCurse.render( )
================================================
FILE: mediacreators/demo.tsx
================================================
/* @vhs 80x16
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" }
Hide
Type@0 'npm start --src=examples/Todo.tsx'
Enter
Sleep 400ms
Show
Sleep 2s
Hide
Ctrl+c
Type@0 'clear; npm start --src=examples/Visualizer.tsx'
Enter
Sleep 400ms
Show
Sleep 1s
Hide
Ctrl+c
Type@0 'clear; npm start --src=examples/Speed.tsx'
Enter
Sleep 400ms
Show
Sleep 2s
Hide
Ctrl+c
Type@0 'clear; npm start --src=examples/Pong.tsx'
Enter
Sleep 400ms
Show
Sleep 250ms
Type kkk
Sleep 500ms
Type@500ms jj */
================================================
FILE: mediacreators/exampleAnimate.tsx
================================================
/* @vhs 80x3
Hide
Type@0 npm_start
Enter
Sleep 40ms
Show
Sleep 2s */
import ReactCurse, { Text, useAnimation, useInput } from '..'
const App = () => {
useInput()
const { interpolate, interpolateColor } = useAnimation(1000)
return
}
ReactCurse.render( )
================================================
FILE: mediacreators/exampleHello.tsx
================================================
/* @vhs 80x3@1 */
import ReactCurse, { Text, useInput } from '..'
const App = ({ text }: { text: string }) => {
useInput()
return {text}
}
ReactCurse.render( )
================================================
FILE: mediacreators/exampleInput.tsx
================================================
/* @vhs 80x3x10
Hide
Type@0 npm_start
Enter
Sleep 250ms
Show
Sleep 250ms
Type@250ms kkjkkkjjjj
Sleep 250ms */
import ReactCurse, { Text, useInput } from '..'
import { useState } from 'react'
const App = () => {
const [counter, setCounter] = useState(0)
useInput(
input => {
if (input === 'k') setCounter(counter + 1)
if (input === 'j') setCounter(counter - 1)
if (input === 'q') ReactCurse.exit()
},
[counter]
)
return (
counter: {counter.toString()}
)
}
ReactCurse.render( )
================================================
FILE: mediacreators/logo.tsx
================================================
/* @vhs 48x7
Set Theme { "green": "#98971a", "background": "#ffffff", "foreground": "#ecdbb2" }
Hide
Type@0 npm_start
Enter
Sleep 40ms
Show
Sleep 10s */
import ReactCurse, { Bar, Text, useAnimation, useInput } from '..'
const splitLine = (line: string) => {
const chunks: Record = {}
let chunksAt = 0
line.split('').forEach((value, x: number) => {
if (value !== ' ') {
chunksAt = x + 1
return
}
if (chunks[chunksAt] === undefined) chunks[chunksAt] = ['']
chunks[chunksAt] += value
})
return Object.entries(chunks)
}
const mask = `
### ### ### ### ### ### # # ### ### ###
# # # # # # # # # # # # # #
## ## ### # # ### # # # ## ### ##
# # # # # # # # # # # # # #
# # ### # # ### # ### ### # # ### ###
`.trim()
const w = Math.max(...mask.split('\n').map(i => i.length))
// prettier-ignore
const lines = [
[[w + 32, w], [w * 3 + 32, w * 2]],
[[w + 8, w], [w * 3 + 8, w * 2]],
[[w + 16, w], [w * 3 + 16, w * 2]],
[[w, w], [w * 3, w * 2]],
[[w + 24, w], [w * 3 + 24, w * 2]],
]
export const Logo = ({ ...props }) => {
useInput(input => input === '\x10\x0d' && ReactCurse.exit())
const l = 10000
const { ms, interpolate } = useAnimation(l)
return (
{mask}
{[
[0, l - 1250],
[l - 1200, l - 600],
[l - 550, l - 500]
].find(([from, to]) => ms >= from && ms < to) && (
{lines.map((line, key) => (
{line.map(([x, width], key) => (
))}
))}
)}
{mask.split('\n').map((line, key) => {
return (
{splitLine(line).map(([x, str], key) => (
{str as string}
))}
{' '.repeat(w - line.length)}
)
})}
)
}
// ReactCurse.render( )
================================================
FILE: mediacreators/useAnimation.tsx
================================================
/* @vhs 80x3
Hide
Type@0 npm_start
Enter
Sleep 40ms
Show
Sleep 2s */
import ReactCurse, { Text, useAnimation, useInput } from '..'
const App = () => {
useInput()
const { ms, interpolate, interpolateColor } = useAnimation(1000, 4)
const rounded = Math.floor(ms / 250) * 250
const color = interpolateColor('#000', '#0f8', 0, 1000, rounded)
return (
<>
ms: {Math.floor(ms / 250) * 250}
interpolate: {Math.round(interpolate(0, 80, 0, 1000, rounded))}
interpolateColor: {color}
>
)
}
ReactCurse.render( )
================================================
FILE: package.json
================================================
{
"name": "react-curse",
"version": "1.0.23",
"description": "Fastest terminal UI for react (TUI, CLI, curses-like)",
"keywords": [
"ansi",
"ascii",
"blessed",
"cli",
"console",
"cursed",
"curses",
"ncurses",
"gui",
"ncurses",
"ranger",
"react",
"renderer",
"term",
"terminal",
"tmux",
"tui",
"unicode",
"vim",
"xterm"
],
"author": {
"name": "Oleksandr Vasyliev",
"email": "infely@gmail.com",
"url": "https://github.com/infely"
},
"repository": "infely/react-curse",
"homepage": "https://github.com/infely/react-curse",
"main": "index.ts",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "bun run --watch examples/Example.tsx",
"build": "NODE_ENV=production bun build --minify --target=node ${npm_config_src:=examples/Example.tsx} > .dist/index.js",
"lint": "tsc --noEmit",
"esbuild: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",
"npm": "npx esbuild index.ts --outdir=.npm --bundle --platform=node --format=esm --packages=external",
"postnpm": "tsc --emitDeclarationOnly --declaration --jsx react-jsx --target esnext --esModuleInterop --moduleResolution node index.ts --outdir .npm && bin/postnpm.js",
"create": "npx esbuild create/Create.tsx --outfile=.create/index.js --bundle --platform=node --define:'process.env.NODE_ENV=\"production\"' --minify --tree-shaking=true",
"postcreate": "bin/postcreate.js",
"esbuild: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",
"logger": "bin/logger.js"
},
"dependencies": {
"react": "^19.1.1",
"react-reconciler": "^0.32.0"
},
"devDependencies": {
"esbuild": "^0.25.9",
"@eslint/js": "^9.34.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/node": "^24.3.0",
"@types/react": "^19.1.11",
"@types/react-reconciler": "^0.32.0",
"eslint": "^9.34.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.3.0",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.40.0"
}
}
================================================
FILE: prettier.config.js
================================================
import sortImports from '@trivago/prettier-plugin-sort-imports'
export default {
arrowParens: 'avoid',
printWidth: 120,
semi: false,
singleQuote: true,
trailingComma: 'none',
plugins: [sortImports]
}
================================================
FILE: readme.md
================================================
# react-curse
Fastest terminal UI for react (TUI, CLI, curses-like)
- It is fast, intuitive and easy to use
- It draws only changed characters
- It uses a small amount of SSH traffic
See it in action:

Still here? Let's go deeper:
- It has fancy components that are ready to use or can be tree-shaked from your final bundle
- It supports keyboard and mouse
- It works in fullscreen and inline modes
- It has cool hooks like animation with trail
- It is solely dependent on react
- It can generate an all-in-one bundle around 100 kb
You can easily build full-scale terminal UI applications like:
## Apps that use it
- [mngr](https://github.com/infely/mngr) - Database manager supports mongodb, mysql/mariadb, postgresql, sqlite and json-server
- [nfi](https://github.com/infely/nfi) - Simple nerd fonts icons cheat sheet that allows you to quickly find and copy glyph to clipboard
- [cosmo](https://github.com/turutupa/cosmo) - A tool for visualizing graphs on terminal. And it allows you to pan around and search by id/value nodes, and in the near future edit/remove/add nodes too
## Installation
Just run `npm init react-curse` answer a few questions and you are ready to go
## Examples
#### Hello world
```jsx
import ReactCurse, { Text } from 'react-curse'
const App = ({ text }) => {
return {text}
}
ReactCurse.render( )
```

#### How to handle input
```jsx
import { useState } from 'react'
import ReactCurse, { Text, useInput, exit } from 'react-curse'
const App = () => {
const [counter, setCounter] = useState(0)
useInput(
input => {
if (input === 'k') setCounter(counter + 1)
if (input === 'j') setCounter(counter - 1)
if (input === 'q') exit()
},
[counter]
)
return (
counter: {counter}
)
}
ReactCurse.render( )
```

#### How to animate
```jsx
import ReactCurse, { useAnimation } from 'react-curse'
const App = () => {
const { interpolate, interpolateColor } = useAnimation(1000)
return
}
ReactCurse.render( )
```

## Contents
- [Components](#components)
- [``](#text)
- [` `](#input)
- [``](#banner)
- [``](#bar)
- [``](#block)
- [``](#canvas), [``](#point), [``](#line)
- [` `](#frame)
- [``](#list)
- [``](#listtable)
- [``](#Scrollbar)
- [``](#separator)
- [``](#spinner)
- [``](#view)
- [Hooks](#hooks)
- [`useAnimation`](#useanimation), [`useTrail`](#usetrail), [``](#trail)
- [`useChildrenSize`](#usechildrensize)
- [`useClipboard`](#useclipboard)
- [`useInput`](#useinput)
- [`useMouse`](#usemouse)
- [`useSize`](#usesize)
- [`useWordWrap`](#useWordWrap)
- [API](#api)
- [`render`](#render)
- [`inline`](#inline)
- [`bell`](#bell)
- [`exit`](#exit)
## Components
### ``
Base component\
The only component required to do anything\
Every other component uses this one to draw
##### y?, x?: `number` | `string`
Position from top left corner relative to parent\
Content will be cropped by parent\
See `absolute` to avoid this behavior\
Example: `32, '100%', '100%-8'`
##### height?, width?: `number` | `string`
Size of block, will be cropped by parent\
See `absolute` to avoid this behavior
##### absolute?: `boolean`
Makes position and size ignoring parent container
##### background?, color?: `number` | `string`
Background and foreground color\
Example: `31, 'Red', '#f04020', '#f42'`
##### clear?: `boolean`
Clears block before drawing content\
`height` and `width`
##### block?: `boolean`
Moves cursor to a new line after its content relative to parent
##### bold?, dim?, italic?, underline?, blinking?, inverse?, strikethrough?: `boolean`
Text modifiers
#### Examples
```jsx
hello world
hello world
hello world
hello world
hello world
hello world
```

### ` `
Text input component with cursor movement and text scroll support\
If its height is more than 1, then it switches to multiline, like textarea\
Most terminal shortcuts are supported
##### focus?: `boolean` = `true`
Makes it active
##### type?: `'text'` | `'password'` | `'hidden'` = `‘text'`
##### initialValue?: `string`
##### cursorBackground?: `number` | `string`
##### onCancel?: `() => void`
##### onChange?: `(string) => void`
##### onSubmit?: `(string) => void`
#### Examples
```jsx
```


### ``
Displays big text
##### y?, x?: `number` | `string`
##### background?, color?: `number` | `string`
##### children: `string`
#### Examples
```jsx
{new Date().toTimeString().substring(0, 8)}
```

### ``
Displays vertical or horizontal bar with 1/8 character resolution
##### type: `'vertical'` | `'horizontal'`
##### y & height, x & width: `number`
#### Examples
```jsx
<>
{[...Array(24)].map((_, index) => (
))}
>
```

Compare to ``

### ``
Aligns content
##### width?: `number`
##### align?: `'left'` | `'center'` | `'right'` = `'left'`
#### Examples
```jsx
left
center
right
```

### ``
Create a canvas for drawing with one these modes
##### mode: `{ h: 1, w: 1 }` | `{ h: 2, w: 1 }` | `{ h: 2, w: 2 }` | `{ h: 4, w: 2 }`
Pixels per character
##### height, width: `number`
Size in pixels
##### children: (`Point` | `Line`)`[]`
#### ``
Draws a point at the coordinates
##### y, x: `number`
##### color?: `number` | `string`
#### ``
Draws a line using coordinates
##### y, x, dy, dx: `number`
##### color?: `number` | `string`
#### Examples
```jsx
```

Braille's font demo (`{ h: 4, w: 2 }`)

### ` `
Draws frame around its content
##### children: `string`
##### type?: `'single'` | `'double'` | `'rounded'` = `'single'`
##### height?, width?: `number`
#### Examples
```jsx
single border type
double border type
rounded border type
```

### ``
Creates a list with navigation support\
Vim shortcuts are supported
##### focus?: `boolean`
##### initialPos?: { y: `number` }
##### data?: `any[]`
##### renderItem?: `(object) => JSX.Element`
##### height?, width?: `number`
##### scrollbar?: `boolean`
##### scrollbarBackground?: `boolean`
##### scrollbarColor?: `boolean`
##### vi?: `boolean` = `true`
##### pass?: `any`
##### onChange?: `(object) => void`
##### onSubmit?: `(object) => void`
#### Examples
```jsx
const items = [...Array(8)].map((_, index) => ({ id: index + 1, title: `Task ${index + 1}` }))
return (
{item.title} }
/>
)
```

### ``: ``
Creates a table with navigation support\
Vim shortcuts are supported
##### mode?: `'cell'` | `'row'` = `'cell'`
##### head?: `any[]`
##### renderHead?: `(object) => JSX.Element`
##### data?: `any[][]`
#### Examples
```jsx
const head = ['id', 'title']
const items = [...Array(8)].map((_, index) => [index + 1, `Task ${index + 1}`])
return (
item.map((i, key) => (
{i}
))
}
data={items}
renderItem={({ item, x, y, index }) =>
item.map((text, key) => (
{text}
))
}
/>
)
```

### ``
Draws a scrollbar with 1/8 character resolution
##### type?: `'vertical'` | `'horizontal'` = `'vertical'`
##### offset: `number`
##### limit: `number`
##### length: `number`
##### background?, color?: `number` | `string`
#### Examples
```jsx
```

### ``
Draws a vertical or horizontal line
##### type: `'vertical'` | `'horizontal'`
##### height, width: `number`
#### Examples
```jsx
```

### ``
Draws an animated spinner
##### children?: `string`
#### Examples
```jsx
-\|/
```

### ``
Creates a scrollable viewport\
Vim shortcuts are supported
##### focus?: `boolean`
##### height?: `number`
##### scrollbar?: `boolean`
##### vi?: `boolean` = `true`
##### children: `any`
#### Examples
```jsx
{JSON.stringify(json, null, 2)}
```

## hooks
### `useAnimation`
##### (time: `number`, fps?: `'number'` = `60`) => `object`
Creates a timer for a specified duration\
That gives you time and interpolation functions each frame of animation
#### return
##### ms: `number`
##### interpolate: (from: `number`, to: `number`, delay?: `number`)
##### interpolateColor: (from: `string`, to: `string`: delay?: `number`)
#### Examples
```jsx
const { ms } = useAnimation(1000, 4)
return ms // 0, 250, 500, 750, 1000
```
```jsx
const { interpolate } = useAnimation(1000, 4)
return interpolate(0, 80) // 0, 20, 40, 60, 80
```
```jsx
const { interpolateColor } = useAnimation(1000, 4)
return interpolateColor('#000', '#0f8') // #000, #042, #084, #0c6, #0f8
```

#### ``
Mutate array of items to show one by one with latency
##### delay: `number`
##### children: `JSX.Element[]`
#### Examples
```jsx
const items = [...Array(8)].map((_, index) => ({ id: index + 1, title: `Task ${index + 1}` }))
return (
{items.map(({ id, title }) => (
{title}
))}
)
```

#### `useTrail`
##### (delay: `number`, items: `JSX.Element[]`, key?: `string` = `'key'`) => `JSX.Element[]`
Same as `` but hook\
You can pass it to `data` property of `` component for example
#### Examples
```jsx
```
### `useChildrenSize`
##### (value: `string`) => `object`
Gives you content size
#### return
##### height, width: `number`
#### Examples
```jsx
useChildrenSize('1\n22\n333') // { height: 3, width: 3 }
```
### `useClipboard`
#### () => `array`
Allows you to work with the system clipboard
#### return
##### getClipboard: `() => string`
##### setClipboard: `(value: string) => void`
#### Examples
```jsx
const { getClipboard, setClipboard } = useClipboard()
const string = getClipboard()
setClipboard(string.toUpperCase()) // copied
```
### `useInput`
##### (callback: `(string) => void`, dependencies: `any[]`) => `void`
Allows you to handle keyboard input
#### Examples
```jsx
set[(counter, setCounter)] = useState(0)
useInput(
input => {
if (input === 'k') setCounter(counter + 1)
if (input === 'j') setCounter(counter - 1)
},
[counter]
)
```
### `useMouse`
##### (callback: `(object) => void`, dependencies: `any[]`)
Allows you to handle mouse input
#### Examples
```jsx
set[(counter, setCounter)] = useState(0)
useMouse(
event => {
if (event.type === 'wheelup') setCounter(counter + 1)
if (event.type === 'wheeldown') setCounter(counter - 1)
},
[counter]
)
```
### `useSize`
##### () => `object`
Gives you terminal size\
Updates when size is changing
#### return
##### height, width: `number`
#### Examples
```jsx
useSize() // { height: 24, width: 80 }
```
### `useWordWrap`
##### (text: `string`, width?: `number`) => `object`
Gives your text a word wrap
#### return
##### height, width: `number`
#### Examples
```jsx
useWordWrap('hello world', 5) // hello\nworld
```
## API
### `render` (children: `JSX.Element`) => `void`
Renders your fullscreen application to `stdout`
### `inline` (children: `JSX.Element`) => `void`
Renders your inline application to `stdout`
### `bell`
#### () => `void`
Makes a terminal bell
```jsx
bell() // ding
```
### `exit`
##### (code: `number` = `0`) => `void`
Allows you to exit from an application that waits for user input or has timers
#### Examples
```jsx
useInput(input => {
if (input === 'q') exit()
})
```
================================================
FILE: reconciler.ts
================================================
import { type TextProps } from './components/Text'
import Reconciler from 'react-reconciler'
let currentUpdatePriority = 0
export class TextElement {
props: TextProps
parent: TextElement | null
children: TextElement[]
constructor(props: object = {}) {
this.props = props
this.parent = null
this.children = []
}
terminate() {
this.children = []
}
appendChild(child: any) {
this.children = [...this.children, child]
}
commitUpdate(nextProps: any) {
this.props = nextProps
}
insertBefore(child: any, beforeChild: any) {
const index = this.children.indexOf(beforeChild)
if (index !== -1) this.children.splice(index, 0, child)
}
removeChild(child: any) {
const index = this.children.indexOf(child)
if (index !== -1) this.children.splice(index, 1)
}
}
export class TextInstance {
value: string
constructor(value: string) {
this.value = value
}
commitTextUpdate(value: string) {
this.value = value
}
toString() {
return this.value
}
}
export default (resetAfterCommit: () => void) => {
// prettier-ignore
const reconciler = Reconciler({
supportsMutation: true,
appendChild(parentInstance: any, child: any) { parentInstance.appendChild(child) },
appendChildToContainer(container: any, child: any) { container.appendChild(child) },
appendInitialChild(parentInstance: any, child: any) { parentInstance.appendChild(child) },
clearContainer() {},
commitTextUpdate(textInstance: any, _oldText: any, newText: any) { textInstance.commitTextUpdate(newText) },
commitUpdate(instance: any, _type: any, _prevProps: any, nextProps: any) { instance.commitUpdate(nextProps) },
createInstance(type: any, props: any) { if (type === 'text') { return new TextElement(props) } else { throw new Error('must be ') } },
createTextInstance(text: string) { return new TextInstance(text) },
detachDeletedInstance() {},
finalizeInitialChildren() { return false },
getChildHostContext() { return {} },
getPublicInstance(instance: any) { return instance },
getRootHostContext(rootContainer: any) { return rootContainer },
insertBefore(parentInstance: any, child: any, beforeChild: any) { parentInstance.insertBefore(child, beforeChild) },
insertInContainerBefore(container: any, child: any, beforeChild: any) { container.insertBefore(child, beforeChild) },
prepareForCommit() { return null },
// @ts-expect-error any
prepareUpdate() { return true },
removeChild(parentInstance: any, child: any) { parentInstance.removeChild(child) },
removeChildFromContainer(container: any, child: any) { container.removeChild(child) },
resetAfterCommit() { resetAfterCommit() },
shouldSetTextContent() { return false },
setCurrentUpdatePriority(newPriority: number) { currentUpdatePriority = newPriority },
getCurrentUpdatePriority() { return currentUpdatePriority },
resolveUpdatePriority() { return currentUpdatePriority !== 0 ? currentUpdatePriority : 16 },
maySuspendCommit() { return false },
})
return reconciler
}
================================================
FILE: renderer.ts
================================================
import Input from './input'
import Reconciler, { TextElement } from './reconciler'
import Screen from './screen'
import Term from './term'
import { spawnSync, type SpawnSyncOptions, type SpawnSyncReturns } from 'child_process'
import { type ReactElement } from 'react'
class Renderer {
container: TextElement
screen: Screen
input: Input
term: Term
reconciler: ReturnType
callback?: (value: any) => void
throttleAt = 0
throttleTimeout?: NodeJS.Timeout
constructor() {
this.container = new TextElement()
this.screen = new Screen()
this.input = new Input()
this.reconciler = Reconciler(this.throttle)
this.term = new Term() // TODO:
}
render(reactElement: ReactElement, options = { fullscreen: true, print: false }) {
this.term = new Term()
this.term.init(options.fullscreen, options.print).then(() => {
this.reconciler.updateContainer(
reactElement,
this.reconciler.createContainer(this.container, 0, null, false, null, '', () => {}, null)
)
})
}
inline(reactElement: ReactElement, options = { fullscreen: false, print: false }) {
this.render(reactElement, options)
}
prompt(reactElement: ReactElement, options = { fullscreen: false, print: false }): Promise {
this.render(reactElement, options)
return new Promise(resolve => {
this.callback = resolve
})
}
print(reactElement: ReactElement, options = { fullscreen: false, print: true }) {
this.render(reactElement, options)
return new Promise(resolve => {
this.callback = resolve
})
}
frame(reactElement: ReactElement, options = { fullscreen: false, print: true }) {
this.render(reactElement, options)
return new Promise(resolve => {
this.callback = (value: any) => {
process.stdout.write(value)
resolve(value)
}
})
}
terminate(value: any) {
this.container.terminate()
this.input.terminate()
if (this.term) {
this.term.terminate()
// process.stdout.write(this.term.terminate())
}
this.callback?.(value)
}
spawnSync(
command: string,
args: ReadonlyArray,
options: SpawnSyncOptions
): SpawnSyncReturns {
const res = spawnSync(command, args, options)
this.term?.reinit()
this.term?.render(this.screen.buffer)
return res
}
bell() {
process.stdout.write('\x07')
}
exit(code: number | any = 0) {
if (typeof code === 'number') process.exit(code)
this.term?.setResult(code)
}
private throttle = () => {
const at = Date.now()
const nextAt = Math.max(0, 1000 / 60 - (at - this.throttleAt))
clearTimeout(this.throttleTimeout)
this.throttleTimeout = setTimeout(() => {
this.throttleAt = at
this.screen.render(this.container.children)
this.term?.render(this.screen.buffer)
this.input.render()
}, nextAt)
}
}
export default new Renderer()
================================================
FILE: screen.ts
================================================
import { type TextProps } from './components/Text'
import { type TextElement } from './reconciler'
import { type ReactElement } from 'react'
export type Color =
| number
| string
| 'Black'
| 'Red'
| 'Green'
| 'Yellow'
| 'Blue'
| 'Magenta'
| 'Cyan'
| 'White'
| 'BrightBlack'
| 'BrightRed'
| 'BrightGreen'
| 'BrightYellow'
| 'BrightBlue'
| 'BrightMagenta'
| 'BrightCyan'
| 'BrightWhite'
export interface Modifier {
background?: Color
color?: Color
clear?: boolean
bold?: boolean
dim?: boolean
italic?: boolean
underline?: boolean
blinking?: boolean
inverse?: boolean
strikethrough?: boolean
}
export type Char = [string, Modifier]
interface Bounds {
x: number
y: number
x1: number
y1: number
x2: number
y2: number
}
class Screen {
buffer: Char[][]
cursor = { x: 0, y: 0 }
size = { x1: 0, y1: 0, x2: 0, y2: 0 }
constructor() {
this.buffer = this.generateBuffer()
}
generateBuffer() {
this.size = { x1: 0, y1: 0, x2: process.stdout.columns, y2: process.stdout.rows }
return [...Array(this.size.y2)].map(() => [...Array(this.size.x2)].map(() => [' ', {}] as Char))
}
clearBuffer() {
this.buffer = this.generateBuffer()
this.cursor = { x: 0, y: 0 }
}
render(elements: TextElement[]) {
this.clearBuffer()
this.renderElement(elements, { ...this.cursor, ...this.size })
}
stringAt(value: string, limit: number) {
const percent = parseFloat(value)
let diff = ''
const index = value.search(/%[+-]\d+$/)
if (index !== -1) diff = value.substring(index + 1)
if (!value.endsWith('%' + diff) || isNaN(percent)) throw new Error('must be percent')
return Math.round((limit / 100) * percent) + parseInt(diff || '0')
}
renderElement(element: ReactElement | ReactElement[] | any, prevBounds: Bounds, prevProps: TextProps = {}) {
if (Array.isArray(element)) return element.forEach(i => this.renderElement(i, prevBounds, prevProps))
const { children, ...props } = element.props ?? { children: element }
if (typeof props.x === 'string')
props.x = this.stringAt(props.x, props.absolute ? this.buffer[0].length : prevBounds.x2 - prevBounds.x)
if (typeof props.y === 'string')
props.y = this.stringAt(props.y, props.absolute ? this.buffer.length : prevBounds.y2 - prevBounds.y)
if (typeof props.width === 'string')
props.width = this.stringAt(props.width, props.absolute ? this.buffer[0].length : prevBounds.x2 - prevBounds.x)
if (typeof props.height === 'string')
props.height = this.stringAt(props.height, props.absolute ? this.buffer.length : prevBounds.y2 - prevBounds.y)
if (props.width !== undefined && isNaN(props.width)) props.width = 0
if (props.height !== undefined && isNaN(props.height)) props.height = 0
const x = props.x !== undefined ? (props.absolute ? 0 : prevBounds.x) + props.x : this.cursor.x
const y = props.y !== undefined ? (props.absolute ? 0 : prevBounds.y) + props.y : this.cursor.y
const x1 =
props.x !== undefined
? props.absolute
? props.x
: Math.max(prevBounds.x, prevBounds.x + props.x)
: prevBounds.x1
const y1 =
props.y !== undefined
? props.absolute
? props.y
: Math.max(prevBounds.y, prevBounds.y + props.y)
: prevBounds.y1
const x2 =
props.width !== undefined
? Math.min(props.absolute ? this.buffer[0].length : prevBounds.x2, props.width + x)
: props.absolute
? this.buffer[0].length
: prevBounds.x2
const y2 =
props.height !== undefined
? Math.min(props.absolute ? this.buffer.length : prevBounds.y2, props.height + y)
: props.absolute
? this.buffer.length
: prevBounds.y2
const bounds = { x, y, x1, y1, x2, y2 }
this.cursor.x = bounds.x
this.cursor.y = bounds.y
const modifiers = Object.fromEntries(
['color', 'background', 'bold', 'dim', 'italic', 'underline', 'blinking', 'inverse', 'strikethrough']
.map(i => [i, props[i] ?? (prevProps as any)[i]])
.filter(i => i[1])
)
if ((props.background || props.clear) && (props.width || props.height))
this.fill(bounds, props.absolute ? bounds : prevBounds, modifiers)
if (Array.isArray(children) || children?.props) {
this.renderElement(element.children, bounds, modifiers)
} else if (typeof children === 'number' || children) {
const text = children.toString()
if (text.includes('\n')) {
const lines = children.toString().split('\n')
lines.forEach((line: string, index: number) => {
this.renderElement(line, bounds, modifiers)
if (index < lines.length - 1) this.carret(prevBounds)
})
} else {
this.cursor.x = this.put(text, bounds, modifiers)
}
}
if (props.block) this.carret(prevBounds)
if (props.width || props.height) {
this.cursor.x = props.block ? prevBounds.x : bounds.x2
this.cursor.y = props.block ? bounds.y2 : prevBounds.y
}
}
fill(bounds: Bounds, prevBounds: Bounds, modifiers: TextProps) {
for (let y = bounds.y; y < bounds.y2; y++) {
if (y < Math.max(0, prevBounds.y1) || y >= Math.min(prevBounds.y2, this.buffer.length)) continue
for (let x = bounds.x; x < bounds.x2; x++) {
if (x < Math.max(0, prevBounds.x1) || x >= Math.min(prevBounds.x2, this.buffer[y].length)) continue
this.buffer[y][x] = [' ', modifiers]
}
}
}
put(text: string, bounds: Bounds, modifiers: TextProps) {
const { x, y } = bounds
let i: number
for (i = 0; i < text.length; i++) {
if (y < Math.max(0, bounds.y1) || y >= Math.min(this.buffer.length, bounds.y2)) break
if (x + i < Math.max(0, bounds.x1) || x + i >= Math.min(this.buffer[y].length, bounds.x2)) continue
this.buffer[y][x + i] = [text[i], modifiers]
}
return x + i
}
carret(bounds: Bounds) {
this.cursor.x = bounds.x ?? 0
this.cursor.y++
}
}
export default Screen
================================================
FILE: term.ts
================================================
import Renderer from './renderer'
import { type Char, type Color, type Modifier } from './screen'
const ESC = '\x1B'
class Term {
fullscreen = true
print = false
isResized = false
isMouseEnabled = false
prevBuffer: Char[][] | undefined
prevModifier: Modifier = {}
nextWritePrefix = ''
size = { width: process.stdout.columns, height: process.stdout.rows }
offset = { x: 0, y: 0 }
cursor = { x: 0, y: 0 }
maxCursor = { x: 0, y: 0 }
result: any
async init(fullscreen: boolean, print: boolean) {
this.fullscreen = fullscreen
this.print = print
process.stdout.on('resize', () => {
this.isResized = true
// this.offset.y += process.stdout.rows - this.size.height
this.size = { width: process.stdout.columns, height: process.stdout.rows }
})
process.on('exit', this.onExit)
if (fullscreen) {
this.append(`${ESC}[?1049h`) // enables the alternative buffer
this.append(`${ESC}c`) // clear screen
} else {
const cursor = await this.termGetCursor()
this.offset = cursor
}
this.append(`${ESC}[?25l`) // make cursor invisible
}
reinit() {
this.prevModifier = {}
this.prevBuffer = undefined
this.append(`${ESC}[?1049h${ESC}c${ESC}[?25l`) // enables the alternative buffer, clear screen, make cursor invisible
}
onExit = (code: number) => {
if (code !== 0) return
process.stdout.write(this.terminate())
process.exit(0)
}
terminate() {
process.off('exit', this.onExit)
const sequence: string[] = []
if (this.fullscreen) {
sequence.push(`${ESC}[?1049l`) // disables the alternative buffer
} else {
const y = this.maxCursor.y - this.cursor.y
if (y > 0) sequence.push(`${ESC}[${y}B`) // moves cursor down
const x = this.maxCursor.x - this.cursor.x + 1
if (x > 0) sequence.push(`${ESC}[${x}C`) // moves cursor right
sequence.push(`\n`)
}
sequence.push(`${ESC}[?25h`) // make cursor visible
if (this.isMouseEnabled) sequence.push(`${ESC}[?1000l`) // disable mouse
return sequence.join('')
}
append(value: string) {
this.nextWritePrefix += value
}
setResult(result: any) {
this.result = result
}
enableMouse() {
this.append(`${ESC}[?1000h${ESC}[?1005h`) // enable mouse
this.isMouseEnabled = true
}
async termGetCursor(): Promise<{ x: number; y: number }> {
process.stdin.setRawMode(true)
process.stdout.write('\x1b[6n')
return await new Promise(resolve => {
process.stdin.once('data', data => {
const [x, y] = data
.toString()
.slice(2, -1)
.split(';')
.reverse()
.map(i => parseInt(i) - 1)
resolve({ x, y })
// process.stdin.unref()
// process.stdin.setRawMode(false)
})
})
}
parseHexColor(color: string) {
if (!color.match(/^([\da-f]{6})|([\da-f]{3})$/i)) return
return (
color.length === 4
? color
.substring(1, 4)
.split('')
.map(i => i + i)
: (color.substring(1, 7).match(/.{2}/g) as any)
).map((i: string) => parseInt(i, 16))
}
parseColor(color: Color | string | number, offset = 0) {
if (typeof color === 'number') {
if (color < 0 || color > 255) throw new Error('color not found')
return `${38 + offset};5;${color}`
}
if (color.startsWith('#')) {
const [r, g, b] = this.parseHexColor(color)
return `${38 + offset};2;${r};${g};${b}`
}
const names = {
black: 30,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
brightblack: 90,
brightred: 91,
brightgreen: 92,
brightyellow: 93,
brightblue: 94,
brightmagenta: 95,
brightcyan: 96,
brightwhite: 97
}
const colorFromName = (names as any)[color.toLowerCase()]
if (colorFromName === undefined) throw new Error('color not found')
return colorFromName + offset
}
createModifierSequence(modifier: Modifier) {
if (JSON.stringify(modifier) === '{}') return '0'
const { prevModifier } = this
const sequence: (number | string)[] = []
if (modifier.color !== prevModifier.color) sequence.push(modifier.color ? this.parseColor(modifier.color) : 39)
if (modifier.background !== prevModifier.background)
sequence.push(modifier.background ? this.parseColor(modifier.background, 10) : 49)
if (modifier.bold !== prevModifier.bold) sequence.push(modifier.bold ? 1 : modifier.dim ? '22;2' : 22)
if (modifier.dim !== prevModifier.dim) sequence.push(modifier.dim ? 2 : modifier.bold ? '22;1' : 22)
if (modifier.italic !== prevModifier.italic) sequence.push(modifier.italic ? 3 : 23)
if (modifier.underline !== prevModifier.underline) sequence.push(modifier.underline ? 4 : 24)
if (modifier.blinking !== prevModifier.blinking) sequence.push(modifier.blinking ? 5 : 25)
if (modifier.inverse !== prevModifier.inverse) sequence.push(modifier.inverse ? 7 : 27)
if (modifier.strikethrough !== prevModifier.strikethrough) sequence.push(modifier.strikethrough ? 9 : 29)
return sequence.join(';')
}
isIcon(char: string) {
const code = char.charCodeAt(0)
return (code >= 9211 && code <= 9214) || [9829, 9889, 11096].includes(code) || (code >= 57344 && code <= 64838)
}
render(buffer: Char[][]) {
let full = false
let result = ''
if (this.isResized) {
if (this.fullscreen) {
result += `${ESC}[H` // moves cursor to home position
this.cursor = { x: 0, y: 0 }
full = true
}
this.isResized = false
}
for (let y = 0; y < buffer.length; y++) {
const line = buffer[y]
const prevLine = this.prevBuffer?.[y]
let includesEmoji = false
let includesIcon = false
const diffLine = full
? line
: line
.map((i: Char, x: number) => {
const [prevChar, prevModifier] = prevLine && prevLine[x] ? prevLine[x] : [' ', {}]
const [char, modifier] = i
return prevChar !== char || JSON.stringify(prevModifier) !== JSON.stringify(modifier) ? i : null
})
.filter(i => i !== undefined)
const chunks: Record = {}
let chunksAt = 0
diffLine.forEach((value, x: number) => {
if (value === null) {
chunksAt = x + 1
return
}
const [char, modifier] = value
if (chunks[chunksAt] === undefined) chunks[chunksAt] = ['', '']
if (JSON.stringify(modifier) !== JSON.stringify(this.prevModifier)) {
chunks[chunksAt][1] += `\x1b[${this.createModifierSequence(modifier)}m`
this.prevModifier = modifier
}
chunks[chunksAt][0] += char
chunks[chunksAt][1] += char
})
Object.entries(chunks).map(([index, value]) => {
const [str, strWithModifiers] = value as [string, string]
const x = parseInt(index)
if (/\p{Emoji}/u.test(str)) includesEmoji = true
if (!includesIcon && str.split('').find((i: string) => this.isIcon(i))) includesIcon = true
if (x === 0 && y === this.cursor.y + 1) {
if (!this.fullscreen && y > this.maxCursor.y) {
this.offset.y -= 1
}
result += '\n'
} else {
if (!this.fullscreen && y > this.cursor.y && y > this.maxCursor.y) {
const diff = y - this.maxCursor.y
result += '\n'.repeat(diff)
this.cursor = { y: this.cursor.y + diff, x: 0 }
const rows = this.offset.y + y - (process.stdout.rows - 1)
if (rows > 0) this.offset.y -= rows
}
if (y !== this.cursor.y && x !== this.cursor.x) {
result += `${ESC}[${y + 1 + this.offset.y};${x + 1}H` // move cursor to position
} else if (y > this.cursor.y) {
const diff = y - this.cursor.y
result += `${ESC}[${diff > 1 ? diff : ''}B` // move cursor down
} else if (y < this.cursor.y) {
const diff = this.cursor.y - y
result += `${ESC}[${diff > 1 ? diff : ''}A` // move cursor up
} else if (x > this.cursor.x) {
if (includesEmoji || includesIcon) {
result += `${ESC}[G${ESC}[${x > 1 ? x : ''}C` // move cursor to column, move cursor right
} else {
const diff = x - this.cursor.x
result += `${ESC}[${diff > 1 ? diff : ''}C` // move cursor right
}
} else if (x < this.cursor.x) {
if (includesEmoji) {
result += `${ESC}[G${ESC}[${x > 1 ? x : ''}C` // move cursor to start, move cursor right
} else {
const diff = this.cursor.x - x
result += `${ESC}[${diff > 1 ? diff : ''}D` // move cursor left
}
}
}
result += strWithModifiers
this.cursor = { x: x + str.length, y }
})
// if (this.cursor.x > buffer[y].length - 1) {
// this.cursor = { x: 0, y: 0 }
// result += `${ESC}[H` // moves cursor to home position
// }
if (this.cursor.x > this.maxCursor.x) this.maxCursor.x = this.cursor.x
if (this.cursor.y > this.maxCursor.y) this.maxCursor.y = this.cursor.y
}
this.prevBuffer = buffer
if (this.nextWritePrefix) {
result = this.nextWritePrefix + result
this.nextWritePrefix = ''
}
if (this.result !== undefined || this.print) {
result += this.terminate()
}
if (result) {
if (this.print) return Renderer.terminate(result)
process.stdout.write(result)
// log(/* Date.now(), */ result)
}
if (this.result !== undefined) {
Renderer.terminate(this.result)
}
}
}
export default Term
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"allowJs": false,
"checkJs": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"jsx": "react-jsx",
"noEmit": true
},
"exclude": ["node_modules", ".create", ".dist", ".npm", "create/template"]
}
================================================
FILE: utils/chunk.ts
================================================
export default function chunk(arr: any, size: number, cache: any[] = []) {
const tmp = [...arr]
while (tmp.length) cache.push(tmp.splice(0, size))
return cache
}
================================================
FILE: utils/log.ts
================================================
import { createConnection } from 'net'
import { tmpdir } from 'os'
import { join } from 'path'
import { inspect } from 'util'
let socket: any
let once = false
const connect = () => {
return new Promise((resolve, reject) => {
socket = createConnection(join(tmpdir(), 'node-log.sock'))
.on('connect', function () {
socket.write('\0')
resolve(socket)
})
.on('error', () => {
reject(undefined)
})
})
}
const toString = (data: any[]) => data.map(i => inspect(i, undefined, null, true)).join(' ')
export default async function log(...rest: any) {
if (once && socket === undefined) return
try {
once = true
if (socket === undefined) socket = await connect()
socket.write(toString(rest) + '\n')
} catch {
socket = undefined
// console.log(...rest)
}
}