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: ![](media/demo.gif) 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() ``` ![](media/exampleHello.png) #### 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() ``` ![](media/exampleInput.gif) #### How to animate ```jsx import ReactCurse, { useAnimation } from 'react-curse' const App = () => { const { interpolate, interpolateColor } = useAnimation(1000) return } ReactCurse.render() ``` ![](media/exampleAnimate.gif) ## 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 ``` ![](media/Text.png) ### `` 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 ``` ![](media/Input-1.gif) ![](media/Input-2.gif) ### `` Displays big text ##### y?, x?: `number` | `string` ##### background?, color?: `number` | `string` ##### children: `string` #### Examples ```jsx {new Date().toTimeString().substring(0, 8)} ``` ![](media/Banner.png) ### `` Displays vertical or horizontal bar with 1/8 character resolution ##### type: `'vertical'` | `'horizontal'` ##### y & height, x & width: `number` #### Examples ```jsx <> {[...Array(24)].map((_, index) => ( ))} ``` ![](media/Bar-1.png) Compare to `` ![](media/Bar-2.gif) ### `` Aligns content ##### width?: `number` ##### align?: `'left'` | `'center'` | `'right'` = `'left'` #### Examples ```jsx left center right ``` ![](media/Block.png) ### `` 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 ``` ![](media/Canvas-1.png) Braille's font demo (`{ h: 4, w: 2 }`) ![](media/Canvas-2.png) ### `` 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 ``` ![](media/Frame.png) ### `` 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}} /> ) ``` ![](media/List.gif) ### ``: `` 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} )) } /> ) ``` ![](media/ListTable.gif) ### `` Draws a scrollbar with 1/8 character resolution ##### type?: `'vertical'` | `'horizontal'` = `'vertical'` ##### offset: `number` ##### limit: `number` ##### length: `number` ##### background?, color?: `number` | `string` #### Examples ```jsx ``` ![](media/Scrollbar.png) ### `` Draws a vertical or horizontal line ##### type: `'vertical'` | `'horizontal'` ##### height, width: `number` #### Examples ```jsx ``` ![](media/Separator.png) ### `` Draws an animated spinner ##### children?: `string` #### Examples ```jsx -\|/ ``` ![](media/Spinner.gif) ### `` 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)} ``` ![](media/View.gif) ## 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 ``` ![](media/useAnimation.gif) #### `` 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} ))} ) ``` ![](media/Trail.gif) #### `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) } }