├── demo ├── dom │ ├── public │ │ ├── ansi │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── preview.jpg │ │ ├── ansi-examples.zip │ │ ├── manifest.json │ │ └── index.html │ ├── .gitignore │ ├── src │ │ ├── reportWebVitals.js │ │ ├── index.js │ │ ├── css │ │ │ └── App.css │ │ └── App.js │ └── package.json ├── ansi │ ├── JET.ANS │ ├── ABYSS1.ANS │ ├── AN-D2.ANS │ ├── ED-NS.ANS │ ├── GLOBE.ANS │ ├── LM-OKC.ICE │ ├── PACMAN.ANS │ ├── THEQ.ANS │ ├── US-UWU.ANS │ ├── XMAS1.ANS │ ├── BCACID7.ANS │ ├── BK-KING.ANS │ ├── BOGACID1.ANS │ ├── CC-ICE1.ICE │ ├── CHRIST1.ANS │ ├── COMICS14.ANS │ ├── DONATELO.ANS │ ├── DT-GHETO.ANS │ ├── DW-FACES.ANS │ ├── FROSTBBS.ANS │ ├── GAVEL30.ANS │ ├── JD-BUTT.ANS │ ├── SC-ACID5.ANS │ ├── SMRFBONK.ANS │ ├── SUBACID.ANS │ ├── UTOPIA20.ANS │ ├── UTOPIA86.ANS │ ├── WWANS54.ANS │ ├── CT-DIE_HARD.ANS │ ├── CT-PIXELS.ANS │ ├── US-CANDLES.ANS │ ├── LDA-GARFIELD.ANS │ └── DW-HAPPY_HOLIDAYS.ANS └── ink │ ├── img │ ├── screenshot-1.jpg │ └── screenshot-2.jpg │ ├── .gitignore │ ├── BlinkingSelect.jsx │ ├── ScrollingSelect.jsx │ ├── TransparencySelect.jsx │ ├── README.md │ ├── ModemSpeedSelect.jsx │ ├── AnsiDisplay.jsx │ ├── AnsiSlideShow.jsx │ ├── SelectBox.jsx │ ├── package.json │ ├── main.jsx │ ├── bin │ ├── BlinkingSelect.mjs │ ├── ScrollingSelect.mjs │ ├── TransparencySelect.mjs │ ├── ModemSpeedSelect.mjs │ ├── AnsiDisplay.mjs │ ├── AnsiSlideShow.mjs │ ├── SelectBox.mjs │ ├── main.mjs │ ├── cli.mjs │ ├── FileList.mjs │ └── MulticolumnSelectInput.mjs │ ├── transpile.mjs │ ├── cli.jsx │ └── FileList.jsx ├── .gitignore ├── test ├── ansi │ ├── LM-OKC.ICE │ ├── US-CANDLES.ANS │ └── LDA-GARFIELD.ANS ├── setup.js ├── test-renderer.js ├── step.js ├── ink-components.test.js └── dom-components.test.js ├── doc ├── img │ ├── screenshot-1.jpg │ └── screenshot-2.jpg ├── AnsiCanvas.md ├── useAnsi.md └── AnsiText.md ├── ink.js ├── index.js ├── .nycrc ├── LICENSE ├── src ├── ink-components.js ├── dos-environment.js ├── dom-components.js └── hooks.js ├── .github └── workflows │ ├── demo.deploy.yml │ └── node.js.yml ├── package.json └── README.md /demo/dom/public/ansi: -------------------------------------------------------------------------------- 1 | ../../ansi -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | webpack.debug.js 3 | coverage 4 | -------------------------------------------------------------------------------- /demo/ansi/JET.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/JET.ANS -------------------------------------------------------------------------------- /demo/ansi/ABYSS1.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/ABYSS1.ANS -------------------------------------------------------------------------------- /demo/ansi/AN-D2.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/AN-D2.ANS -------------------------------------------------------------------------------- /demo/ansi/ED-NS.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/ED-NS.ANS -------------------------------------------------------------------------------- /demo/ansi/GLOBE.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/GLOBE.ANS -------------------------------------------------------------------------------- /demo/ansi/LM-OKC.ICE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/LM-OKC.ICE -------------------------------------------------------------------------------- /demo/ansi/PACMAN.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/PACMAN.ANS -------------------------------------------------------------------------------- /demo/ansi/THEQ.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/THEQ.ANS -------------------------------------------------------------------------------- /demo/ansi/US-UWU.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/US-UWU.ANS -------------------------------------------------------------------------------- /demo/ansi/XMAS1.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/XMAS1.ANS -------------------------------------------------------------------------------- /demo/dom/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /test/ansi/LM-OKC.ICE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/test/ansi/LM-OKC.ICE -------------------------------------------------------------------------------- /demo/ansi/BCACID7.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/BCACID7.ANS -------------------------------------------------------------------------------- /demo/ansi/BK-KING.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/BK-KING.ANS -------------------------------------------------------------------------------- /demo/ansi/BOGACID1.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/BOGACID1.ANS -------------------------------------------------------------------------------- /demo/ansi/CC-ICE1.ICE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/CC-ICE1.ICE -------------------------------------------------------------------------------- /demo/ansi/CHRIST1.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/CHRIST1.ANS -------------------------------------------------------------------------------- /demo/ansi/COMICS14.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/COMICS14.ANS -------------------------------------------------------------------------------- /demo/ansi/DONATELO.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/DONATELO.ANS -------------------------------------------------------------------------------- /demo/ansi/DT-GHETO.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/DT-GHETO.ANS -------------------------------------------------------------------------------- /demo/ansi/DW-FACES.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/DW-FACES.ANS -------------------------------------------------------------------------------- /demo/ansi/FROSTBBS.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/FROSTBBS.ANS -------------------------------------------------------------------------------- /demo/ansi/GAVEL30.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/GAVEL30.ANS -------------------------------------------------------------------------------- /demo/ansi/JD-BUTT.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/JD-BUTT.ANS -------------------------------------------------------------------------------- /demo/ansi/SC-ACID5.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/SC-ACID5.ANS -------------------------------------------------------------------------------- /demo/ansi/SMRFBONK.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/SMRFBONK.ANS -------------------------------------------------------------------------------- /demo/ansi/SUBACID.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/SUBACID.ANS -------------------------------------------------------------------------------- /demo/ansi/UTOPIA20.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/UTOPIA20.ANS -------------------------------------------------------------------------------- /demo/ansi/UTOPIA86.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/UTOPIA86.ANS -------------------------------------------------------------------------------- /demo/ansi/WWANS54.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/WWANS54.ANS -------------------------------------------------------------------------------- /demo/ansi/CT-DIE_HARD.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/CT-DIE_HARD.ANS -------------------------------------------------------------------------------- /demo/ansi/CT-PIXELS.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/CT-PIXELS.ANS -------------------------------------------------------------------------------- /demo/ansi/US-CANDLES.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/US-CANDLES.ANS -------------------------------------------------------------------------------- /doc/img/screenshot-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/doc/img/screenshot-1.jpg -------------------------------------------------------------------------------- /doc/img/screenshot-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/doc/img/screenshot-2.jpg -------------------------------------------------------------------------------- /test/ansi/US-CANDLES.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/test/ansi/US-CANDLES.ANS -------------------------------------------------------------------------------- /demo/ansi/LDA-GARFIELD.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/LDA-GARFIELD.ANS -------------------------------------------------------------------------------- /demo/dom/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/dom/public/favicon.ico -------------------------------------------------------------------------------- /demo/dom/public/preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/dom/public/preview.jpg -------------------------------------------------------------------------------- /test/ansi/LDA-GARFIELD.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/test/ansi/LDA-GARFIELD.ANS -------------------------------------------------------------------------------- /demo/ink/img/screenshot-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ink/img/screenshot-1.jpg -------------------------------------------------------------------------------- /demo/ink/img/screenshot-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ink/img/screenshot-2.jpg -------------------------------------------------------------------------------- /demo/ansi/DW-HAPPY_HOLIDAYS.ANS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/ansi/DW-HAPPY_HOLIDAYS.ANS -------------------------------------------------------------------------------- /ink.js: -------------------------------------------------------------------------------- 1 | export { 2 | useAnsi, 3 | } from './src/hooks.js'; 4 | export { 5 | AnsiText, 6 | } from './src/ink-components.js'; 7 | -------------------------------------------------------------------------------- /demo/dom/public/ansi-examples.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chung-leong/react-ansi-animation/HEAD/demo/dom/public/ansi-examples.zip -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export { 2 | useAnsi, 3 | } from './src/hooks.js'; 4 | export { 5 | AnsiText, 6 | AnsiCanvas, 7 | } from './src/dom-components.js'; 8 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "checkCoverage": true, 3 | "reporter": [ 4 | "text" 5 | ], 6 | "lines": 100, 7 | "branches": 100, 8 | "statements": 100 9 | } 10 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import AbortController from 'abort-controller'; 2 | 3 | if (!(AbortController in global)) { 4 | global.AbortController = AbortController; 5 | } 6 | 7 | global.resolve = (path) => { 8 | return (new URL(path, import.meta.url)).pathname; 9 | }; -------------------------------------------------------------------------------- /demo/dom/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "ANSI Animation", 3 | "name": "React ANSI Animation Demo", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /demo/dom/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /demo/dom/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /demo/ink/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # exclude package-lock since it's an example 26 | package-lock.json -------------------------------------------------------------------------------- /test/test-renderer.js: -------------------------------------------------------------------------------- 1 | export async function withTestRenderer(cb) { 2 | const { create, act } = await import('react-test-renderer'); 3 | let renderer; 4 | try { 5 | await cb({ 6 | render: (el) => act(() => renderer = create(el)), 7 | update: (el) => act(() => renderer.update(el)), 8 | unmount: () => act(() => renderer.unmount()), 9 | toJSON: () => renderer.toJSON(), 10 | act, 11 | }); 12 | } finally { 13 | if (renderer) { 14 | renderer.unmount(); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /demo/ink/BlinkingSelect.jsx: -------------------------------------------------------------------------------- 1 | import { useRoute } from 'array-router'; 2 | import SelectBox from "./SelectBox.jsx"; 3 | 4 | export default function BlinkingSelect() { 5 | const [ parts, options ] = useRoute(); 6 | const id = 'blinking'; 7 | const label = '&Blinking'; 8 | const items = [ 9 | { label: 'on', value: true }, 10 | { label: 'off', value: false }, 11 | ]; 12 | const value = options.blinking; 13 | const home = 'main'; 14 | const onSelect = ({ value }) => options.blinking = value; 15 | return ; 16 | } -------------------------------------------------------------------------------- /demo/ink/ScrollingSelect.jsx: -------------------------------------------------------------------------------- 1 | import { useRoute } from 'array-router'; 2 | import SelectBox from "./SelectBox.jsx"; 3 | 4 | export default function ScrollingSelect() { 5 | const [ parts, options ] = useRoute(); 6 | const id = 'scrolling'; 7 | const label = '&Scrolling'; 8 | const items = [ 9 | { label: 'on', value: true }, 10 | { label: 'off', value: false }, 11 | ]; 12 | const value = options.scrolling; 13 | const home = 'main'; 14 | const onSelect = ({ value }) => options.scrolling = value; 15 | return ; 16 | } -------------------------------------------------------------------------------- /demo/ink/TransparencySelect.jsx: -------------------------------------------------------------------------------- 1 | import { useRoute } from 'array-router'; 2 | import SelectBox from "./SelectBox.jsx"; 3 | 4 | export default function TransparencySelect() { 5 | const [ parts, options ] = useRoute(); 6 | const id = 'transparency'; 7 | const label = '&Transparency'; 8 | const items = [ 9 | { label: 'on', value: true }, 10 | { label: 'off', value: false }, 11 | ]; 12 | const value = options.transparency; 13 | const home = 'main'; 14 | const onSelect = ({ value }) => options.transparency = value; 15 | return ; 16 | } -------------------------------------------------------------------------------- /doc/AnsiCanvas.md: -------------------------------------------------------------------------------- 1 | # <AnsiCanvas> 2 | 3 | React component that renders an ANSI animation into a ``. It's useful in situations where 4 | arbituary scaling is desired. 5 | 6 | ## Syntax 7 | 8 | ```js 9 | return ( 10 |
11 | 12 |
13 | ); 14 | ``` 15 | 16 | ## Props 17 | 18 | `` has the same props as [``](./AnsiText.md), except the default value for `className` is `"AnsiCanvas"`. 19 | 20 | ## Notes 21 | 22 | You can change the appearance of text in the canvas by defining a CSS rule for the class "AnsiCanvas". The component will update itself to reflect subsequent style change when there is a 23 | change in its size. 24 | -------------------------------------------------------------------------------- /demo/dom/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import reportWebVitals from './reportWebVitals'; 5 | 6 | // ensure URL ends with a trailing slash 7 | if (!window.location.pathname.endsWith('/')) { 8 | window.history.replaceState(null, undefined, window.location.href + '/'); 9 | } 10 | 11 | const root = ReactDOM.createRoot(document.getElementById('root')); 12 | root.render( 13 | 14 | 15 | 16 | ); 17 | 18 | // If you want to start measuring performance in your app, pass a function 19 | // to log results (for example: reportWebVitals(console.log)) 20 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 21 | reportWebVitals(); 22 | -------------------------------------------------------------------------------- /demo/ink/README.md: -------------------------------------------------------------------------------- 1 | # ink-ansi-animation 2 | 3 | Ink-based console program for playing ANSI animations. It makes use of the 4 | [react-ansi-animation](https://www.npmjs.com/package/react-ansi-animation) library. 5 | 6 | ## Installation 7 | 8 | ```sh 9 | npm install -g ink-ansi-animation 10 | ``` 11 | 12 | ## Usage 13 | 14 | ```sh 15 | $: ink-ansi-anmation 16 | ``` 17 | 18 | ink-ansi-anmation will look for ANSI files in the current working directory and display them in a list. 19 | Press `ENTER` to play a file. Press `ENTER`, `ESC`, or `SPACE` to return to the list. Press `M`, `B`, 20 | `S` or `T` to change settings. Press `Q` to exit the program. 21 | 22 | Run `ink-ansi-animation --help` to see available command-line options. 23 | 24 | ## Screenshots 25 | 26 | ![Screenshot 1](https://github.com/chung-leong/react-ansi-animation/raw/main/demo/ink/img/screenshot-1.jpg) 27 | 28 | ![Screenshot 2](https://github.com/chung-leong/react-ansi-animation/raw/main/demo/ink/img/screenshot-2.jpg) 29 | -------------------------------------------------------------------------------- /test/step.js: -------------------------------------------------------------------------------- 1 | export function createSteps(invoke = null) { 2 | if (!invoke) { 3 | invoke = (cb) => setTimeout(cb, 0); 4 | } 5 | return new Proxy([], { 6 | get(arr, name) { 7 | const num = parseInt(name); 8 | if (isNaN(num)) { 9 | return arr[name]; 10 | } else { 11 | let promise = arr[num]; 12 | if (!promise) { 13 | let resolve, reject; 14 | promise = arr[num] = new Promise((r1, r2) => { resolve = r1; reject = r2; }); 15 | promise.done = (value) => invoke(() => resolve(value)); 16 | promise.fail = (err) => invoke(() => reject(err)); 17 | promise.throw = (err, value) => { 18 | invoke(() => resolve(value)); 19 | throw err; 20 | }; 21 | } 22 | return promise; 23 | } 24 | } 25 | }); 26 | } 27 | 28 | export async function loopThrough(steps, delay, fn) { 29 | let i = 0, interval = setInterval(() => steps[i++].done(), delay); 30 | try { 31 | await fn(); 32 | } finally { 33 | clearInterval(interval); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /demo/ink/ModemSpeedSelect.jsx: -------------------------------------------------------------------------------- 1 | import { useRoute } from 'array-router'; 2 | import SelectBox from './SelectBox.jsx'; 3 | 4 | export default function ModemSpeedSelect() { 5 | const [ parts, options ] = useRoute(); 6 | const id = 'modemSpeed'; 7 | const label = '&Modem speed'; 8 | const items = [ 9 | { label: '2400', value: 2400 }, 10 | { label: '9600', value: 9600 }, 11 | { label: '14400', value: 14400 }, 12 | { label: '19200', value: 19200 }, 13 | { label: '28800', value: 28800 }, 14 | { label: '33600', value: 33600 }, 15 | { label: '56000', value: 56000 }, 16 | { label: '115200', value: 115200 }, 17 | { label: '230400', value: 230400 }, 18 | { label: '460800', value: 460800 }, 19 | { label: '576000', value: 576000 }, 20 | { label: '921600', value: 921600 }, 21 | { label: 'Infinity', value: Infinity }, 22 | ]; 23 | const value = options.modemSpeed; 24 | const home = 'main'; 25 | const onSelect = ({ value }) => options.modemSpeed = value; 26 | return ; 27 | } 28 | -------------------------------------------------------------------------------- /demo/ink/AnsiDisplay.jsx: -------------------------------------------------------------------------------- 1 | import { useRoute } from 'array-router'; 2 | import { useFocus, useInput, Box } from 'ink'; 3 | import { AnsiText } from 'react-ansi-animation/ink'; 4 | 5 | export default function AnsiDisplay({ src, onExit, onEnd }) { 6 | const { isFocused } = useFocus({ id: 'main', autoFocus: true }); 7 | const [ parts, options ] = useRoute(); 8 | const { modemSpeed, blinking, scrolling, transparency } = options; 9 | useInput((input, key) => { 10 | if (key.escape || key.return || input === ' ') { 11 | onExit?.(key); 12 | } 13 | }, { isActive: isFocused }) 14 | const borderStyle = 'round'; 15 | const borderColor = (isFocused) ? 'blue' : undefined; 16 | const flexDirection = 'column'; 17 | const maxHeight = (scrolling) ? 25 : 1024; 18 | const onStatus = (status) => { 19 | if (status.position === 1) { 20 | // played to 100% 21 | onEnd?.(status); 22 | } 23 | }; 24 | return ( 25 | 26 | 27 | 28 | ); 29 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Chung Leong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/ink-components.js: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises'; 2 | import { useMemo, createElement, Fragment } from 'react'; 3 | import { useAnsi } from './hooks.js'; 4 | import { Text } from 'ink'; 5 | import { cgaPalette } from './dos-environment.js'; 6 | 7 | export function AnsiText({ src, srcObject, palette = cgaPalette, ...options }) { 8 | // retrieve data if necessary 9 | const data = useMemo(() => srcObject ?? fetchBuffer(src), [ src, srcObject ]); 10 | // process data through hook 11 | const { lines, blinked } = useAnsi(data, options); 12 | // convert lines to Ink Text elements 13 | const children = lines.map((segments) => { 14 | const spans = segments.map(({ text, fgColor, bgColor, blink }) => { 15 | const props = { 16 | backgroundColor: palette[bgColor], 17 | color: palette[(blink && blinked) ? bgColor : fgColor], 18 | }; 19 | return createElement(Text, props, text); 20 | }); 21 | return createElement(Text, {}, ...spans); 22 | }); 23 | return createElement(Fragment, {}, ...children); 24 | } 25 | 26 | async function fetchBuffer(src) { 27 | if (src) { 28 | return readFile(src); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /demo/dom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ansi-animation-demo", 3 | "homepage": "https://chung-leong.github.io/react-ansi-animation", 4 | "version": "0.5.0", 5 | "private": true, 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^5.16.5", 8 | "@testing-library/react": "^13.4.0", 9 | "@testing-library/user-event": "^13.5.0", 10 | "react": "^18.2.0", 11 | "react-ansi-animation": "^0.5.0", 12 | "react-dom": "^18.2.0", 13 | "react-scripts": "5.0.1", 14 | "react-seq": "^0.9.0", 15 | "web-vitals": "^2.1.4" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": [ 25 | "react-app", 26 | "react-app/jest", 27 | "react-seq" 28 | ] 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/demo.deploy.yml: -------------------------------------------------------------------------------- 1 | # Workflow for deploying demo to GitHub Pages 2 | name: Demo deployment 3 | 4 | on: 5 | push: 6 | branches: [ "main" ] 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "pages" 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | - name: Use Node.js 18.x 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: 18.x 30 | - name: Install dependencies 31 | run: cd ./demo/dom; npm ci 32 | - name: Create production build 33 | run: cd ./demo/dom; npm run build 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v3 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v1 38 | with: 39 | path: './demo/dom/build' 40 | - name: Deploy to GitHub Pages 41 | id: deployment 42 | uses: actions/deploy-pages@v1 43 | -------------------------------------------------------------------------------- /demo/ink/AnsiSlideShow.jsx: -------------------------------------------------------------------------------- 1 | import { useSequential } from 'react-seq'; 2 | import AnsiDisplay from './AnsiDisplay.jsx'; 3 | import { isAnsiFile, LoadingScreen } from './FileList.jsx'; 4 | 5 | export default function AnsiSlideShow({ srcList, onExit }) { 6 | return useSequential(async function*({ fallback, manageEvents }) { 7 | fallback(); 8 | const [ on, eventual ] = manageEvents(); 9 | let index = 0, played = 0, lastError = null; 10 | for (;;) { 11 | const path = srcList[index++]; 12 | try { 13 | if (await isAnsiFile(path)) { 14 | yield ; 15 | let { exitKey, animationEnd } = await eventual.animationEnd.or.exitKey; 16 | if (animationEnd) { 17 | // wait a little bit 18 | ({ exitKey } = await eventual.exitKey.for(10).seconds); 19 | } 20 | if (exitKey?.escape) { 21 | // escape ends the program 22 | onExit(exitKey); 23 | break; 24 | } 25 | played = true; 26 | } 27 | } catch (err) { 28 | lastError = err; 29 | } 30 | if (index >= srcList.length) { 31 | // end of the loop; if nothing got played then something is wrong 32 | if (played === 0) { 33 | if (!lastError) { 34 | lastError = new Error('No ANSI files found'); 35 | } 36 | throw lastError; 37 | } 38 | index = 0; 39 | } 40 | } 41 | }, [ srcList, onExit ]); 42 | } 43 | -------------------------------------------------------------------------------- /demo/ink/SelectBox.jsx: -------------------------------------------------------------------------------- 1 | import { useFocus, useInput, Text, Box } from 'ink'; 2 | import InkSelectInput from 'ink-select-input'; const { default: SelectInput } = InkSelectInput; 3 | 4 | export default function SelectBox({ id, items, label: labelWithAmp, value, home, onSelect: onSelectCaller }) { 5 | const { isFocused, focus } = useFocus({ id }); 6 | const initialIndex = items.findIndex(i => i.value === value); 7 | const [ label, hotkey ] = extractHotkey(labelWithAmp); 8 | useInput((input) => { 9 | if (input.toUpperCase() === hotkey) { 10 | focus(id); 11 | } 12 | }, { isActive: !isFocused }); 13 | if (isFocused) { 14 | const onSelect = (item) => { 15 | onSelectCaller?.(item); 16 | if (home) { 17 | // refocus main content (after this component has updated) 18 | setTimeout(() => focus(home), 0); 19 | } 20 | }; 21 | return ( 22 | 23 | {label} 24 | 25 | 26 | ); 27 | } else { 28 | const minWidth = items.reduce((w, i) => Math.max(w, i.label.length + 2), 2); 29 | const item = items[initialIndex]; 30 | return ( 31 | 32 | {label} 33 | : {item?.label} 34 | 35 | ); 36 | } 37 | } 38 | 39 | function extractHotkey(labelWithAmp) { 40 | const m = /(.*)&(\w)(.*)/.exec(labelWithAmp); 41 | if (!m) { 42 | return [ labelWithAmp ]; 43 | } 44 | return [ 45 | {m[1]}{m[2]}{m[3]}, 46 | m[2].toUpperCase() 47 | ]; 48 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ansi-animation", 3 | "version": "0.5.1", 4 | "description": "React component for displaying ANSI animation", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "./node_modules/mocha/bin/mocha.js --require test/setup.js -- test/*.test.js", 9 | "test:debug": "./node_modules/mocha/bin/mocha.js --require test/setup.js --no-timeouts --inspect-brk -- test/*.test.js", 10 | "test:watch": "./node_modules/mocha/bin/mocha.js --require test/setup.js --parallel --watch -- test/*.test.js", 11 | "coverage": "./node_modules/c8/bin/c8.js ./node_modules/mocha/bin/mocha.js --require test/setup.js --parallel -- test/*.test.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/chung-leong/react-ansi-animation.git" 16 | }, 17 | "keywords": [ 18 | "React", 19 | "ANSI" 20 | ], 21 | "exports": { 22 | ".": "./index.js", 23 | "./ink": "./ink.js" 24 | }, 25 | "files": [ 26 | "src/", 27 | "index.js", 28 | "ink.js" 29 | ], 30 | "author": "Chung Leong", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/chung-leong/react-ansi-animation/issues" 34 | }, 35 | "homepage": "https://github.com/chung-leong/react-ansi-animation#readme", 36 | "peerDependencies": { 37 | "react-seq": ">= 0.9.0" 38 | }, 39 | "devDependencies": { 40 | "abort-controller": "^3.0.0", 41 | "c8": "^7.12.0", 42 | "chai": "^4.3.7", 43 | "ink": "^3.2.0", 44 | "ink-testing-library": "^2.1.0", 45 | "mocha": "^10.2.0", 46 | "react": "^18.2.0", 47 | "react-seq": "^0.9.0", 48 | "react-test-renderer": "^18.2.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [14.x, 16.x, 18.x] 18 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - name: Install dependencies 29 | run: npm ci 30 | - name: Run tests 31 | run: FORCE_COLOR=2 npm test 32 | coverage: 33 | runs-on: ubuntu-latest 34 | strategy: 35 | matrix: 36 | node-version: [14.x, 16.x, 18.x] 37 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 38 | 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v3 42 | - name: Use Node.js ${{ matrix.node-version }} 43 | uses: actions/setup-node@v3 44 | with: 45 | node-version: ${{ matrix.node-version }} 46 | cache: 'npm' 47 | - name: Install dependencies 48 | run: npm ci 49 | - name: Run coverage test 50 | run: FORCE_COLOR=2 npm run coverage 51 | -------------------------------------------------------------------------------- /demo/ink/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ink-ansi-animation", 3 | "description": "Ink-based console program for playing ANSI animations", 4 | "version": "0.5.0", 5 | "bin": "./bin/cli.mjs", 6 | "engines": { 7 | "node": ">=10" 8 | }, 9 | "scripts": { 10 | "start": "./transpile.mjs && cd ../ansi && node ../ink/bin/cli.mjs --scrolling", 11 | "debug": "./transpile.mjs && cd ../ansi && node --inspect-brk ../ink/bin/cli.mjs", 12 | "build": "./transpile.mjs", 13 | "test": "xo && ava" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/chung-leong/react-ansi-animation.git" 18 | }, 19 | "keywords": [ 20 | "React", 21 | "ink", 22 | "ANSI" 23 | ], 24 | "files": [ 25 | "./bin" 26 | ], 27 | "author": "Chung Leong", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/chung-leong/react-ansi-animation/issues" 31 | }, 32 | "homepage": "https://github.com/chung-leong/react-ansi-animation#readme", 33 | "dependencies": { 34 | "array-router": "^0.9.2", 35 | "cli-meow-help": "^3.1.0", 36 | "ink": "^3.2.0", 37 | "ink-multicolumn-select-input": "^0.5.2", 38 | "ink-select-input": "^4.2.1", 39 | "ink-spinner": "^4.0.3", 40 | "meow-reverse": "^0.2.1", 41 | "react": "^18.0.0", 42 | "react-ansi-animation": "^0.5.0", 43 | "react-seq": "^0.9.0", 44 | "shell-quote": "^1.8.0" 45 | }, 46 | "devDependencies": { 47 | "@ava/babel": "^2.0.0", 48 | "@babel/core": "^7.20.12", 49 | "@babel/plugin-transform-react-jsx": "^7.20.7", 50 | "@babel/preset-env": "^7.20.2", 51 | "@babel/preset-react": "^7.18.6", 52 | "@babel/register": "^7.18.9", 53 | "ava": "^5.1.0", 54 | "chalk": "^4.1.2", 55 | "eslint-config-xo-react": "^0.27.0", 56 | "eslint-plugin-react": "^7.32.0", 57 | "eslint-plugin-react-hooks": "^4.6.0", 58 | "globby": "^13.1.3", 59 | "ink-testing-library": "^2.1.0", 60 | "xo": "^0.39.1" 61 | }, 62 | "ava": { 63 | "babel": true, 64 | "require": [ 65 | "@babel/register" 66 | ] 67 | }, 68 | "babel": { 69 | "presets": [ 70 | "@babel/preset-env", 71 | "@babel/preset-react" 72 | ] 73 | }, 74 | "xo": { 75 | "extends": "xo-react", 76 | "rules": { 77 | "react/prop-types": "off" 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /demo/ink/main.jsx: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path'; 2 | import { Box, Text } from 'ink'; 3 | import ModemSpeedSelect from './ModemSpeedSelect.jsx'; 4 | import BlinkingSelect from './BlinkingSelect.jsx'; 5 | import ScrollingSelect from './ScrollingSelect.jsx'; 6 | import TransparencySelect from './TransparencySelect.jsx'; 7 | import AnsiDisplay from './AnsiDisplay.jsx'; 8 | import AnsiSlideShow from './AnsiSlideShow.jsx'; 9 | import FileList from './FileList.jsx'; 10 | 11 | export default async function* main(methods) { 12 | const { wrap, manageRoute, manageEvents, replacing } = methods; 13 | const [ parts ] = manageRoute(); 14 | const [ on, eventual ] = manageEvents(); 15 | wrap((children) => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {children} 26 | 27 | 28 | ); 29 | }); 30 | for (;;) { 31 | try { 32 | if (parts[0] === undefined) { 33 | replacing(() => parts[0] = 'list') 34 | } else if (parts[0] === 'list') { 35 | yield ; 36 | const { file, folder } = await eventual.file.or.folder; 37 | parts.splice(0); 38 | if (folder) { 39 | parts.push('list', folder); 40 | } else if (file) { 41 | parts.push('show', file); 42 | } 43 | } else if (parts[0] === 'show') { 44 | const path = parts[1]; 45 | yield ; 46 | await eventual.exit; 47 | parts.splice(0); 48 | parts.push('list', dirname(path), path); 49 | } else if (parts[0] === 'loop') { 50 | const paths = parts.slice(1); 51 | yield ; 52 | await eventual.exit; 53 | process.exit(0); 54 | } else { 55 | throw new Error(`Unrecognized command: ${parts[0]}`); 56 | } 57 | } catch (err) { 58 | // not sure why Ink complains when nothing is yielded prior to program exit 59 | yield null; 60 | console.error(err.message); 61 | process.exit(1); 62 | } 63 | } 64 | } 65 | 66 | 67 | -------------------------------------------------------------------------------- /demo/ink/bin/BlinkingSelect.mjs: -------------------------------------------------------------------------------- 1 | import { useRoute } from 'array-router'; 2 | import SelectBox from "./SelectBox.mjs"; 3 | import { jsx as _jsx } from "react/jsx-runtime"; 4 | export default function BlinkingSelect() { 5 | const [parts, options] = useRoute(); 6 | const id = 'blinking'; 7 | const label = '&Blinking'; 8 | const items = [{ 9 | label: 'on', 10 | value: true 11 | }, { 12 | label: 'off', 13 | value: false 14 | }]; 15 | const value = options.blinking; 16 | const home = 'main'; 17 | const onSelect = ({ 18 | value 19 | }) => options.blinking = value; 20 | return /*#__PURE__*/_jsx(SelectBox, { 21 | id, 22 | label, 23 | items, 24 | value, 25 | home, 26 | onSelect 27 | }); 28 | } 29 | //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VSb3V0ZSIsIlNlbGVjdEJveCIsIkJsaW5raW5nU2VsZWN0IiwicGFydHMiLCJvcHRpb25zIiwiaWQiLCJsYWJlbCIsIml0ZW1zIiwidmFsdWUiLCJibGlua2luZyIsImhvbWUiLCJvblNlbGVjdCJdLCJzb3VyY2VzIjpbIkJsaW5raW5nU2VsZWN0LmpzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyB1c2VSb3V0ZSB9IGZyb20gJ2FycmF5LXJvdXRlcic7XG5pbXBvcnQgU2VsZWN0Qm94IGZyb20gXCIuL1NlbGVjdEJveC5qc3hcIjtcblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gQmxpbmtpbmdTZWxlY3QoKSB7XG4gIGNvbnN0IFsgcGFydHMsIG9wdGlvbnMgXSA9IHVzZVJvdXRlKCk7XG4gIGNvbnN0IGlkID0gJ2JsaW5raW5nJztcbiAgY29uc3QgbGFiZWwgPSAnJkJsaW5raW5nJztcbiAgY29uc3QgaXRlbXMgPSBbXG4gICAgeyBsYWJlbDogJ29uJywgdmFsdWU6IHRydWUgfSxcbiAgICB7IGxhYmVsOiAnb2ZmJywgdmFsdWU6IGZhbHNlIH0sXG4gIF07XG4gIGNvbnN0IHZhbHVlID0gb3B0aW9ucy5ibGlua2luZztcbiAgY29uc3QgaG9tZSA9ICdtYWluJztcbiAgY29uc3Qgb25TZWxlY3QgPSAoeyB2YWx1ZSB9KSA9PiBvcHRpb25zLmJsaW5raW5nID0gdmFsdWU7XG4gIHJldHVybiA8U2VsZWN0Qm94IHsuLi57IGlkLCBsYWJlbCwgaXRlbXMsIHZhbHVlLCBob21lLCBvblNlbGVjdCB9fSAvPjtcbn0iXSwibWFwcGluZ3MiOiJBQUFBLFNBQVNBLFFBQVEsUUFBUSxjQUFjO0FBQ3ZDLE9BQU9DLFNBQVM7QUFBd0I7QUFFeEMsZUFBZSxTQUFTQyxjQUFjLEdBQUc7RUFDdkMsTUFBTSxDQUFFQyxLQUFLLEVBQUVDLE9BQU8sQ0FBRSxHQUFHSixRQUFRLEVBQUU7RUFDckMsTUFBTUssRUFBRSxHQUFHLFVBQVU7RUFDckIsTUFBTUMsS0FBSyxHQUFHLFdBQVc7RUFDekIsTUFBTUMsS0FBSyxHQUFHLENBQ1o7SUFBRUQsS0FBSyxFQUFFLElBQUk7SUFBRUUsS0FBSyxFQUFFO0VBQUssQ0FBQyxFQUM1QjtJQUFFRixLQUFLLEVBQUUsS0FBSztJQUFFRSxLQUFLLEVBQUU7RUFBTSxDQUFDLENBQy9CO0VBQ0QsTUFBTUEsS0FBSyxHQUFHSixPQUFPLENBQUNLLFFBQVE7RUFDOUIsTUFBTUMsSUFBSSxHQUFHLE1BQU07RUFDbkIsTUFBTUMsUUFBUSxHQUFHLENBQUM7SUFBRUg7RUFBTSxDQUFDLEtBQUtKLE9BQU8sQ0FBQ0ssUUFBUSxHQUFHRCxLQUFLO0VBQ3hELG9CQUFPLEtBQUMsU0FBUztJQUFPSCxFQUFFO0lBQUVDLEtBQUs7SUFBRUMsS0FBSztJQUFFQyxLQUFLO0lBQUVFLElBQUk7SUFBRUM7RUFBUSxFQUFNO0FBQ3ZFIn0= -------------------------------------------------------------------------------- /demo/ink/bin/ScrollingSelect.mjs: -------------------------------------------------------------------------------- 1 | import { useRoute } from 'array-router'; 2 | import SelectBox from "./SelectBox.mjs"; 3 | import { jsx as _jsx } from "react/jsx-runtime"; 4 | export default function ScrollingSelect() { 5 | const [parts, options] = useRoute(); 6 | const id = 'scrolling'; 7 | const label = '&Scrolling'; 8 | const items = [{ 9 | label: 'on', 10 | value: true 11 | }, { 12 | label: 'off', 13 | value: false 14 | }]; 15 | const value = options.scrolling; 16 | const home = 'main'; 17 | const onSelect = ({ 18 | value 19 | }) => options.scrolling = value; 20 | return /*#__PURE__*/_jsx(SelectBox, { 21 | id, 22 | label, 23 | items, 24 | value, 25 | home, 26 | onSelect 27 | }); 28 | } 29 | //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VSb3V0ZSIsIlNlbGVjdEJveCIsIlNjcm9sbGluZ1NlbGVjdCIsInBhcnRzIiwib3B0aW9ucyIsImlkIiwibGFiZWwiLCJpdGVtcyIsInZhbHVlIiwic2Nyb2xsaW5nIiwiaG9tZSIsIm9uU2VsZWN0Il0sInNvdXJjZXMiOlsiU2Nyb2xsaW5nU2VsZWN0LmpzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyB1c2VSb3V0ZSB9IGZyb20gJ2FycmF5LXJvdXRlcic7XG5pbXBvcnQgU2VsZWN0Qm94IGZyb20gXCIuL1NlbGVjdEJveC5qc3hcIjtcblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gU2Nyb2xsaW5nU2VsZWN0KCkge1xuICBjb25zdCBbIHBhcnRzLCBvcHRpb25zIF0gPSB1c2VSb3V0ZSgpO1xuICBjb25zdCBpZCA9ICdzY3JvbGxpbmcnO1xuICBjb25zdCBsYWJlbCA9ICcmU2Nyb2xsaW5nJztcbiAgY29uc3QgaXRlbXMgPSBbXG4gICAgeyBsYWJlbDogJ29uJywgdmFsdWU6IHRydWUgfSxcbiAgICB7IGxhYmVsOiAnb2ZmJywgdmFsdWU6IGZhbHNlIH0sXG4gIF07XG4gIGNvbnN0IHZhbHVlID0gb3B0aW9ucy5zY3JvbGxpbmc7XG4gIGNvbnN0IGhvbWUgPSAnbWFpbic7XG4gIGNvbnN0IG9uU2VsZWN0ID0gKHsgdmFsdWUgfSkgPT4gb3B0aW9ucy5zY3JvbGxpbmcgPSB2YWx1ZTtcbiAgcmV0dXJuIDxTZWxlY3RCb3ggey4uLnsgaWQsIGxhYmVsLCBpdGVtcywgdmFsdWUsIGhvbWUsIG9uU2VsZWN0IH19IC8+O1xufSJdLCJtYXBwaW5ncyI6IkFBQUEsU0FBU0EsUUFBUSxRQUFRLGNBQWM7QUFDdkMsT0FBT0MsU0FBUztBQUF3QjtBQUV4QyxlQUFlLFNBQVNDLGVBQWUsR0FBRztFQUN4QyxNQUFNLENBQUVDLEtBQUssRUFBRUMsT0FBTyxDQUFFLEdBQUdKLFFBQVEsRUFBRTtFQUNyQyxNQUFNSyxFQUFFLEdBQUcsV0FBVztFQUN0QixNQUFNQyxLQUFLLEdBQUcsWUFBWTtFQUMxQixNQUFNQyxLQUFLLEdBQUcsQ0FDWjtJQUFFRCxLQUFLLEVBQUUsSUFBSTtJQUFFRSxLQUFLLEVBQUU7RUFBSyxDQUFDLEVBQzVCO0lBQUVGLEtBQUssRUFBRSxLQUFLO0lBQUVFLEtBQUssRUFBRTtFQUFNLENBQUMsQ0FDL0I7RUFDRCxNQUFNQSxLQUFLLEdBQUdKLE9BQU8sQ0FBQ0ssU0FBUztFQUMvQixNQUFNQyxJQUFJLEdBQUcsTUFBTTtFQUNuQixNQUFNQyxRQUFRLEdBQUcsQ0FBQztJQUFFSDtFQUFNLENBQUMsS0FBS0osT0FBTyxDQUFDSyxTQUFTLEdBQUdELEtBQUs7RUFDekQsb0JBQU8sS0FBQyxTQUFTO0lBQU9ILEVBQUU7SUFBRUMsS0FBSztJQUFFQyxLQUFLO0lBQUVDLEtBQUs7SUFBRUUsSUFBSTtJQUFFQztFQUFRLEVBQU07QUFDdkUifQ== -------------------------------------------------------------------------------- /doc/useAnsi.md: -------------------------------------------------------------------------------- 1 | # useAnsi(dataSource, [options]) 2 | 3 | Hook for decoding text with ANSI escape sequences. 4 | 5 | ## Syntax 6 | 7 | ```js 8 | const { lines, blinking, blinked } = useAnsi(data, options); 9 | ``` 10 | 11 | ## Parameters 12 | 13 | * `dataSource` - `` or `` or `` Buffer containing data from an ANSI file 14 | * `options` - `` 15 | * `return` `` 16 | 17 | ## Options 18 | 19 | * [`modemSpeed`](./AnsiText.md#modemspeed) 20 | * [`frameDuration`](./AnsiText.md#frameduration) 21 | * [`blinkDuration`](./AnsiText.md#blinkduration) 22 | * [`blinking`](./AnsiText.md#blinking) 23 | * [`transparency`](./AnsiText.md#transparency) 24 | * [`minWidth`](./AnsiText.md#minwidth) 25 | * [`minHeight`](./AnsiText.md#minheight) 26 | * [`maxWidth`](./AnsiText.md#maxwidth) 27 | * [`maxHeight`](./AnsiText.md#maxheight) 28 | * [`initialStatus`](./AnsiText.md#initialstatus) 29 | * [`onStatus`](./AnsiText.md#onstatus) 30 | * [`onError`](./AnsiText.md#onerror) 31 | * [`onMetadata`](./AnsiText.md#onmetadata) 32 | * [`beep`](./AnsiText.md#beep) 33 | 34 | ## Return value properties 35 | 36 | * `width` - `` The number of columns 37 | * `height` - `` The number of rows 38 | * `lines` - `` Objects describing text segments on each row 39 | * `blinking` - `` or `` Value given in `options` 40 | * `blinked` - `` Whether blinking text should be invisible at the current moment 41 | * `willBlink` - `` Whether there are any blinking text segments 42 | * `status` - `{ position: , playing: }` Current status of animation 43 | * `metadata` - `` Metadata stored at the end of file 44 | * `error` - `` Data retrieval error 45 | 46 | ## Text segment properties 47 | 48 | * `text` - `` Text contained in the segment 49 | * `fgColor` - `` A number between 0 and 15 representing one of the CGA colors 50 | * `bgColor` - `` A number between 0 and 15 (or 7 if `blinking` is true) representing one of 51 | the CGA colors 52 | * `blink` - `` Whether the text should blink 53 | 54 | ## Notes 55 | 56 | If `dataSource` is a promise, the hook will await it, returning in the meantime a blank 57 | screen. The result will be cached in a WeakMap. When the hook encounters the same 58 | promise later, it'll obtain the result immediately. 59 | 60 | If `dataSource` is a string, it'll be converted into CP-437 (DOS) encoding. Characters 61 | outside the codepage will appear as ?. 62 | 63 | -------------------------------------------------------------------------------- /demo/dom/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 31 | React ANSI Animation 32 | 33 | 34 | 35 |
36 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /demo/ink/bin/TransparencySelect.mjs: -------------------------------------------------------------------------------- 1 | import { useRoute } from 'array-router'; 2 | import SelectBox from "./SelectBox.mjs"; 3 | import { jsx as _jsx } from "react/jsx-runtime"; 4 | export default function TransparencySelect() { 5 | const [parts, options] = useRoute(); 6 | const id = 'transparency'; 7 | const label = '&Transparency'; 8 | const items = [{ 9 | label: 'on', 10 | value: true 11 | }, { 12 | label: 'off', 13 | value: false 14 | }]; 15 | const value = options.transparency; 16 | const home = 'main'; 17 | const onSelect = ({ 18 | value 19 | }) => options.transparency = value; 20 | return /*#__PURE__*/_jsx(SelectBox, { 21 | id, 22 | label, 23 | items, 24 | value, 25 | home, 26 | onSelect 27 | }); 28 | } 29 | //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VSb3V0ZSIsIlNlbGVjdEJveCIsIlRyYW5zcGFyZW5jeVNlbGVjdCIsInBhcnRzIiwib3B0aW9ucyIsImlkIiwibGFiZWwiLCJpdGVtcyIsInZhbHVlIiwidHJhbnNwYXJlbmN5IiwiaG9tZSIsIm9uU2VsZWN0Il0sInNvdXJjZXMiOlsiVHJhbnNwYXJlbmN5U2VsZWN0LmpzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyB1c2VSb3V0ZSB9IGZyb20gJ2FycmF5LXJvdXRlcic7XG5pbXBvcnQgU2VsZWN0Qm94IGZyb20gXCIuL1NlbGVjdEJveC5qc3hcIjtcblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gVHJhbnNwYXJlbmN5U2VsZWN0KCkge1xuICBjb25zdCBbIHBhcnRzLCBvcHRpb25zIF0gPSB1c2VSb3V0ZSgpO1xuICBjb25zdCBpZCA9ICd0cmFuc3BhcmVuY3knO1xuICBjb25zdCBsYWJlbCA9ICcmVHJhbnNwYXJlbmN5JztcbiAgY29uc3QgaXRlbXMgPSBbXG4gICAgeyBsYWJlbDogJ29uJywgdmFsdWU6IHRydWUgfSxcbiAgICB7IGxhYmVsOiAnb2ZmJywgdmFsdWU6IGZhbHNlIH0sXG4gIF07XG4gIGNvbnN0IHZhbHVlID0gb3B0aW9ucy50cmFuc3BhcmVuY3k7XG4gIGNvbnN0IGhvbWUgPSAnbWFpbic7XG4gIGNvbnN0IG9uU2VsZWN0ID0gKHsgdmFsdWUgfSkgPT4gb3B0aW9ucy50cmFuc3BhcmVuY3kgPSB2YWx1ZTtcbiAgcmV0dXJuIDxTZWxlY3RCb3ggey4uLnsgaWQsIGxhYmVsLCBpdGVtcywgdmFsdWUsIGhvbWUsIG9uU2VsZWN0IH19IC8+O1xufSJdLCJtYXBwaW5ncyI6IkFBQUEsU0FBU0EsUUFBUSxRQUFRLGNBQWM7QUFDdkMsT0FBT0MsU0FBUztBQUF3QjtBQUV4QyxlQUFlLFNBQVNDLGtCQUFrQixHQUFHO0VBQzNDLE1BQU0sQ0FBRUMsS0FBSyxFQUFFQyxPQUFPLENBQUUsR0FBR0osUUFBUSxFQUFFO0VBQ3JDLE1BQU1LLEVBQUUsR0FBRyxjQUFjO0VBQ3pCLE1BQU1DLEtBQUssR0FBRyxlQUFlO0VBQzdCLE1BQU1DLEtBQUssR0FBRyxDQUNaO0lBQUVELEtBQUssRUFBRSxJQUFJO0lBQUVFLEtBQUssRUFBRTtFQUFLLENBQUMsRUFDNUI7SUFBRUYsS0FBSyxFQUFFLEtBQUs7SUFBRUUsS0FBSyxFQUFFO0VBQU0sQ0FBQyxDQUMvQjtFQUNELE1BQU1BLEtBQUssR0FBR0osT0FBTyxDQUFDSyxZQUFZO0VBQ2xDLE1BQU1DLElBQUksR0FBRyxNQUFNO0VBQ25CLE1BQU1DLFFBQVEsR0FBRyxDQUFDO0lBQUVIO0VBQU0sQ0FBQyxLQUFLSixPQUFPLENBQUNLLFlBQVksR0FBR0QsS0FBSztFQUM1RCxvQkFBTyxLQUFDLFNBQVM7SUFBT0gsRUFBRTtJQUFFQyxLQUFLO0lBQUVDLEtBQUs7SUFBRUMsS0FBSztJQUFFRSxJQUFJO0lBQUVDO0VBQVEsRUFBTTtBQUN2RSJ9 -------------------------------------------------------------------------------- /demo/ink/transpile.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { statSync, readFileSync, writeFileSync } from 'fs'; 3 | import { globbySync } from 'globby'; 4 | import jsxTransform from '@babel/plugin-transform-react-jsx'; 5 | import { transformSync } from '@babel/core'; 6 | 7 | const ext = /\.jsx$/; 8 | const force = process.argv.includes('-f'); 9 | const src = new URL('.', import.meta.url).pathname; 10 | const dest = src + 'bin/'; 11 | 12 | const scriptTime = mtime(import.meta.url.pathname); 13 | for (const jsxPath of globbySync(src + '**/*.jsx', { gitignore: true })) { 14 | const jsPath = dest + jsxPath.substr(src.length).replace(ext, '.mjs'); 15 | const jsTime = mtime(jsPath); 16 | const jsxTime = mtime(jsxPath); 17 | if (force || jsxTime > jsTime || scriptTime > jsTime) { 18 | transpile(jsxPath, jsPath); 19 | } 20 | } 21 | 22 | function transpile(jsxPath, jsPath) { 23 | const rawSource = readFileSync(jsxPath, 'utf-8'); 24 | const { code } = transformSync(rawSource, { 25 | plugins: [ 26 | [ renameJSX ], 27 | [ jsxTransform, { runtime: 'automatic' } ], 28 | ], 29 | filename: jsxPath, 30 | sourceMaps: 'inline', 31 | babelrc: false, 32 | configFile: false 33 | }); 34 | writeFileSync(jsPath, code, 'utf-8'); 35 | } 36 | 37 | function renameJSX({ types:t }) { 38 | const mjs = (source) => t.stringLiteral(source.value.replace(ext, '.mjs')); 39 | return { 40 | visitor: { 41 | Program: (path) => { 42 | const visitor = { 43 | ImportDeclaration: (path) => { 44 | const { node } = path; 45 | const { source } = node; 46 | if (ext.test(source.value)) { 47 | node.source = mjs(source); 48 | } 49 | }, 50 | CallExpression: (path) => { 51 | const { node } = path; 52 | if (t.isImport(node.callee)) { 53 | const [ source ] = node.arguments; 54 | if (t.isStringLiteral(source)) { 55 | if (ext.test(source.value)) { 56 | node.arguments = [ mjs(source) ]; 57 | } 58 | } else { 59 | // insert replacement operation for non-static path 60 | // import(path) ==> import((path + "").replace(/\.jsx/, "")) 61 | const toString = t.binaryExpression('+', source, t.stringLiteral('')); 62 | const replace = t.memberExpression(toString, t.identifier('replace')); 63 | const regExp = t.regExpLiteral(ext.source, ext.flags); 64 | const call = t.callExpression(replace, [ regExp, t.stringLiteral('.mjs') ]); 65 | node.arguments = [ call ]; 66 | } 67 | } 68 | } 69 | }; 70 | path.traverse(visitor); 71 | } 72 | } 73 | }; 74 | } 75 | 76 | function mtime(path) { 77 | try { 78 | return statSync(path).mtime; 79 | } catch (err) { 80 | return -1; 81 | } 82 | } 83 | 84 | -------------------------------------------------------------------------------- /demo/ink/cli.jsx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { render, useInput } from 'ink'; 3 | import meow from 'meow'; 4 | import meowhelp from 'cli-meow-help'; 5 | import meowrev, { meowparse } from 'meow-reverse'; 6 | import parse from 'shell-quote/parse.js'; 7 | import quote from 'shell-quote/quote.js'; 8 | import { createContext } from 'react'; 9 | import { useSequential } from 'react-seq'; 10 | import { useSequentialRouter } from 'array-router'; 11 | import main from './main.jsx'; 12 | 13 | const name = `ink-ansi-animation`; 14 | const commands = { 15 | 'show [FILE]': { desc: `Show an ANSI animation` }, 16 | 'loop [FILE]...': { desc: `Show files in a loop` }, 17 | 'list': { desc: `List ANSI files in current directory` }, 18 | }; 19 | const flags = { 20 | modemSpeed: { 21 | desc: `Emulate modem of specific baudrate`, 22 | alias: 'm', 23 | type: 'number', 24 | default: 56000 25 | }, 26 | blinking: { 27 | desc: `Enable blinking text`, 28 | alias: 'b', 29 | type: 'boolean', 30 | }, 31 | scrolling: { 32 | desc: `Enable scrolling`, 33 | alias: 's', 34 | type: 'boolean', 35 | }, 36 | transparency: { 37 | desc: `Enable transparency`, 38 | alias: 't', 39 | type: 'boolean', 40 | }, 41 | }; 42 | 43 | const helpText = meowhelp({ name, flags, commands }); 44 | const options = { importMeta: import.meta, flags }; 45 | const { input: parts, flags: query } = meow(helpText, options); 46 | 47 | function parseURL(_, { pathname }) { 48 | const argv = parse(pathname); 49 | const { input: parts, flags: query } = meowparse(argv, options); 50 | return { parts, query }; 51 | } 52 | 53 | function createURL(_, { parts: input, query: flags }) { 54 | const argv = meowrev({ input, flags }, options); 55 | const pathname = quote(argv); 56 | return new URL(`argv:${pathname}`); 57 | } 58 | 59 | function applyURL(currentURL) { 60 | globalThis.location.href = currentURL.href; 61 | } 62 | 63 | globalThis.location = createURL(null, { parts, query }); 64 | 65 | const SpecialContext = createContext(); 66 | 67 | function App() { 68 | useInput((input) => { 69 | if (input.toLowerCase() === 'q') { 70 | process.exit(0); 71 | } 72 | }) 73 | // use command-line URL 74 | const override = { createURL, parseURL, applyURL }; 75 | const [ parts, query, rMethods, { createContext, createBoundary } ] = useSequentialRouter(override); 76 | return createContext(useSequential((sMethods) => { 77 | const methods = { ...rMethods, ...sMethods }; 78 | const { fallback, wrap, trap, reject } = methods; 79 | // default fallback (issue #142 in React-seq 0.9.0) 80 | fallback(null); 81 | // create error boundary 82 | wrap(children => createBoundary(children)); 83 | // redirect error from boundary to generator function 84 | trap('error', err => reject(err)); 85 | // method for managing route 86 | methods.manageRoute = () => [ parts, query ]; 87 | return main(methods); 88 | }, [ parts, query, rMethods, createBoundary ])); 89 | } 90 | 91 | render(); 92 | -------------------------------------------------------------------------------- /src/dos-environment.js: -------------------------------------------------------------------------------- 1 | export function toCP437(msg) { 2 | const array = new Uint8Array(msg.length); 3 | const oob = cp437Chars.indexOf('?'); 4 | for (let i = 0; i < msg.length; i++) { 5 | const cp = cp437Chars.indexOf(msg.charAt(i)); 6 | array[i] = (cp !== -1) ? cp : oob; 7 | } 8 | return array.buffer; 9 | } 10 | 11 | export const cp437Chars = [ 12 | '\u0000', '\u263a', '\u263b', '\u2665', '\u2666', '\u2663', '\u2660', '\u0007', '\u0008', '\u0009', '\u000a', '\u2642', '\u000c', '\u000d', '\u266b', '\u263c', 13 | '\u25ba', '\u25c4', '\u2195', '\u203c', '\u00b6', '\u00a7', '\u25ac', '\u21a8', '\u2191', '\u2193', '\u2192', '\u001b', '\u221f', '\u2194', '\u25b2', '\u25bc', 14 | '\u0020', '\u0021', '\u0022', '\u0023', '\u0024', '\u0025', '\u0026', '\u0027', '\u0028', '\u0029', '\u002a', '\u002b', '\u002c', '\u002d', '\u002e', '\u002f', 15 | '\u0030', '\u0031', '\u0032', '\u0033', '\u0034', '\u0035', '\u0036', '\u0037', '\u0038', '\u0039', '\u003a', '\u003b', '\u003c', '\u003d', '\u003e', '\u003f', 16 | '\u0040', '\u0041', '\u0042', '\u0043', '\u0044', '\u0045', '\u0046', '\u0047', '\u0048', '\u0049', '\u004a', '\u004b', '\u004c', '\u004d', '\u004e', '\u004f', 17 | '\u0050', '\u0051', '\u0052', '\u0053', '\u0054', '\u0055', '\u0056', '\u0057', '\u0058', '\u0059', '\u005a', '\u005b', '\u005c', '\u005d', '\u005e', '\u005f', 18 | '\u0060', '\u0061', '\u0062', '\u0063', '\u0064', '\u0065', '\u0066', '\u0067', '\u0068', '\u0069', '\u006a', '\u006b', '\u006c', '\u006d', '\u006e', '\u006f', 19 | '\u0070', '\u0071', '\u0072', '\u0073', '\u0074', '\u0075', '\u0076', '\u0077', '\u0078', '\u0079', '\u007a', '\u007b', '\u007c', '\u007d', '\u007e', '\u007f', 20 | '\u00c7', '\u00fc', '\u00e9', '\u00e2', '\u00e4', '\u00e0', '\u00e5', '\u00e7', '\u00ea', '\u00eb', '\u00e8', '\u00ef', '\u00ee', '\u00ec', '\u00c4', '\u00c5', 21 | '\u00c9', '\u00e6', '\u00c6', '\u00f4', '\u00f6', '\u00f2', '\u00fb', '\u00f9', '\u00ff', '\u00d6', '\u00dc', '\u00a2', '\u00a3', '\u00a5', '\u20a7', '\u0192', 22 | '\u00e1', '\u00ed', '\u00f3', '\u00fa', '\u00f1', '\u00d1', '\u00aa', '\u00ba', '\u00bf', '\u2310', '\u00ac', '\u00bd', '\u00bc', '\u00a1', '\u00ab', '\u00bb', 23 | '\u2591', '\u2592', '\u2593', '\u2502', '\u2524', '\u2561', '\u2562', '\u2556', '\u2555', '\u2563', '\u2551', '\u2557', '\u255d', '\u255c', '\u255b', '\u2510', 24 | '\u2514', '\u2534', '\u252c', '\u251c', '\u2500', '\u253c', '\u255e', '\u255f', '\u255a', '\u2554', '\u2569', '\u2566', '\u2560', '\u2550', '\u256c', '\u2567', 25 | '\u2568', '\u2564', '\u2565', '\u2559', '\u2558', '\u2552', '\u2553', '\u256b', '\u256a', '\u2518', '\u250c', '\u2588', '\u2584', '\u258c', '\u2590', '\u2580', 26 | '\u03b1', '\u00df', '\u0393', '\u03c0', '\u03a3', '\u03c3', '\u00b5', '\u03c4', '\u03a6', '\u0398', '\u03a9', '\u03b4', '\u221e', '\u03c6', '\u03b5', '\u2229', 27 | '\u2261', '\u00b1', '\u2265', '\u2264', '\u2320', '\u2321', '\u00f7', '\u2248', '\u00b0', '\u2219', '\u00b7', '\u221a', '\u207f', '\u00b2', '\u25a0', '\u00a0', 28 | ]; 29 | 30 | export const cgaPalette = [ 31 | '#000000', '#aa0000', '#00aa00', '#aa5500', '#0000aa', '#aa00aa', '#00aaaa', '#aaaaaa', 32 | '#555555', '#ff5555', '#55ff55', '#ffff55', '#5555ff', '#ff55ff', '#55ffff', '#ffffff', 33 | ]; -------------------------------------------------------------------------------- /demo/dom/src/css/App.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | display: flex; 7 | flex-direction: row; 8 | justify-content: center; 9 | background-color: #202020; 10 | color: #ffffff; 11 | height: 100vh; 12 | width: 98vw; 13 | margin: 0; 14 | } 15 | 16 | .App { 17 | font-family: monospace; 18 | display: flex; 19 | position: relative; 20 | flex-direction: row; 21 | width: fit-content; 22 | min-height: 100%; 23 | padding: 0.5em 0.5em 0.5em 0.5em; 24 | } 25 | 26 | .App .controls, .App .file-list { 27 | overflow: hidden; 28 | margin: 1em 1em 1em 1em; 29 | } 30 | 31 | .App .controls label { 32 | display: block; 33 | margin-bottom: 1em; 34 | } 35 | 36 | .App .controls select { 37 | display: block; 38 | margin-top: 0.3em; 39 | margin-left: 0.3em; 40 | } 41 | 42 | .App .controls input[type="checkbox"] { 43 | margin-right: 0.5em; 44 | width: 1.2em; 45 | height: 1.2em; 46 | vertical-align: middle; 47 | accent-color: #666666; 48 | } 49 | 50 | .App .file-list h4:first-child { 51 | margin-top: 0; 52 | } 53 | 54 | .App .file-list ul { 55 | padding-left: 2em; 56 | } 57 | 58 | .App .file-list li:hover { 59 | background-color: #eeeeee; 60 | color: #000000; 61 | width: fit-content; 62 | cursor: pointer; 63 | } 64 | 65 | .App .file-list li:hover::marker { 66 | color: #33ff33; 67 | } 68 | 69 | .App .link { 70 | position: absolute; 71 | bottom: 0.5em; 72 | } 73 | 74 | .App .contents { 75 | display: flex; 76 | flex-direction: column; 77 | align-items: center; 78 | min-width: 50em; 79 | text-align: center; 80 | } 81 | 82 | .App .playback-controls { 83 | display: flex; 84 | flex-direction: column; 85 | align-items: center; 86 | width: 100%; 87 | } 88 | 89 | .App .playback-controls input[type="range"] { 90 | display: block; 91 | width: 96%; 92 | margin-top: 1em; 93 | margin-bottom: 1em; 94 | appearance: none; 95 | height: 14px; 96 | background: #666666; 97 | outline: none; 98 | transition: opacity .2s; 99 | border-radius: 2px; 100 | } 101 | 102 | .slider:hover { 103 | opacity: 1; /* Fully shown on mouse-over */ 104 | } 105 | 106 | .App .playback-controls input[type="range"]::-webkit-slider-thumb { 107 | appearance: none; 108 | width: 14px; 109 | height: 24px; 110 | background: #EFEFEF; 111 | border-radius: 2px; 112 | cursor: pointer; 113 | } 114 | 115 | .App .playback-controls button { 116 | font-size: 1.5em; 117 | padding: 0.2em 0.4em 0.2em 0.5em; 118 | 119 | } 120 | 121 | .App .playback-controls.playing button { 122 | visibility: hidden; 123 | } 124 | 125 | .App .link a:link, .App .link a:visited { 126 | color: #00ccff; 127 | text-decoration: none; 128 | } 129 | 130 | @media screen and (max-width: 1024px) { 131 | body { 132 | display: block; 133 | } 134 | 135 | .App { 136 | flex-direction: column; 137 | } 138 | 139 | .App .controls, .App .file-list { 140 | margin-left: 0; 141 | } 142 | 143 | .App .controls label { 144 | margin-top: 0.2em; 145 | margin-bottom: 0; 146 | margin-right: 1em; 147 | float: left; 148 | } 149 | 150 | .App .contents { 151 | min-width: inherit; 152 | min-height: 18em; 153 | } 154 | 155 | .App .playback-controls button { 156 | font-size: 1.2em; 157 | } 158 | 159 | .App .link { 160 | position: inherit; 161 | } 162 | } 163 | 164 | @media screen and (max-width: 640px) { 165 | .App .AnsiText { 166 | font-size: 2vw; 167 | } 168 | .App .AnsiCanvas { 169 | font-size: 8pt; 170 | width: 100%; 171 | } 172 | } 173 | 174 | @media print { 175 | body { 176 | background-color: transparent; 177 | } 178 | 179 | .App .controls, .App .file-list { 180 | display: none; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /doc/AnsiText.md: -------------------------------------------------------------------------------- 1 | # <AnsiText> 2 | 3 | React component that renders an ANSI animation as HTML text elements. 4 | 5 | ## Syntax 6 | 7 | ```js 8 | return ( 9 |
10 | 11 |
12 | ); 13 | ``` 14 | 15 | ## HTML structure 16 | 17 | ```html 18 | 19 |
20 | [ text ] 21 | 22 | ⋮ 23 | 24 |
25 |
26 | ⋮ 27 |
28 | 29 | ``` 30 | 31 | 32 | ## Props 33 | 34 | ### `src` 35 | 36 | URL to ANSI file (file path for Ink version of component). 37 | 38 | Type: `` or `undefined` 39 | 40 | ### `srcObject` 41 | 42 | Buffer holding the content of an ANSI file. 43 | 44 | Type: `` or `` or `` or `undefined` 45 | 46 | ### `palette ` 47 | 48 | Color palette used for drawing text. Specify 49 | 50 | Type: `` or `"css"` 51 | Default: [CGA Palette](../src/dos-environment.js#L30) 52 | 53 | ### `className` 54 | 55 | The DOM element's class name. Absent from Ink version of component. 56 | 57 | Type: `` 58 | Default: `"AnsiText"` 59 | 60 | ### `modemSpeed` 61 | 62 | Modem baudrate to emulate. Use `Infinity` to show the final picture immediately. 63 | 64 | Type: `` 65 | Default: `56000` 66 | 67 | ### `frameDuration` 68 | 69 | Duration of an animation frame in millisecond. A shorter duration means a smoother animation. 70 | 71 | Type: `` 72 | Default: `50` 73 | 74 | ### `blinkDuration` 75 | 76 | Duration of a blink. Should a multiple of `frameDuration`. 77 | 78 | Type: `` 79 | Default: `500` 80 | 81 | ### `blinking` 82 | 83 | Whether the blink bit causes text to blink or the selection of bright background colors. Specific 84 | `"css"` if you wish to perform blinking through CSS instead. `` elements that are suppose 85 | to blink will receive a "blinking" class name. 86 | 87 | Type: `` or `"css"` 88 | Default: `false` 89 | 90 | ### `transparency` 91 | 92 | When `true`, the default text attributes become [no background]/[no foreground color] instead of 93 | [black]/[gray]. Only works for specially crafted ANSI files. ANSI files created in the BBS era 94 | all assume a default black background. 95 | 96 | Type: `` 97 | Default: `false` 98 | 99 | ### `minWidth` 100 | 101 | Type: `` 102 | Default: `79` 103 | 104 | ### `minHeight` 105 | 106 | Type: `` 107 | Default: 22 108 | 109 | ### `maxWidth` 110 | 111 | Type: `` 112 | Default: 80 113 | 114 | ### `maxHeight` 115 | 116 | Type: `` 117 | Default: 25 118 | 119 | ### `initialStatus` 120 | 121 | Type: `` 122 | Default: `{ position: 0, playing: true }` 123 | 124 | ### `beep` 125 | 126 | Function to call when the beep control code is encountered. 127 | 128 | Type: `` or `undefined` 129 | Default: `undefined` 130 | 131 | ### `onStatus` 132 | 133 | Function that receives the current status, an object containing the property `position` and `playing`. `position` is a `number` between 0 and 1, representing the percentage of the file that has been 134 | processed. 135 | 136 | Type: `` or `undefined` 137 | Default: `undefined` 138 | 139 | ### `onError` 140 | 141 | Function that receive any error encountered during data retrieval. 142 | 143 | Type: `` or `undefined` 144 | Default: `undefined` 145 | 146 | ### `onMetadata` 147 | 148 | Function that receives any text strings coming after the end-of-file control code. 149 | 150 | Type: `` or `undefined` 151 | Default: `undefined` 152 | -------------------------------------------------------------------------------- /demo/ink/FileList.jsx: -------------------------------------------------------------------------------- 1 | import { readdir, open } from 'fs/promises'; 2 | import { basename, normalize } from 'path'; 3 | import { useProgressive } from 'react-seq'; 4 | import { useFocus, Box, Text } from 'ink'; 5 | import MulticolumnSelectInput from 'ink-multicolumn-select-input'; 6 | import InkSpinner from 'ink-spinner'; const { default: Spinner } = InkSpinner; 7 | 8 | export default function FileList({ folder = '.', selected = '', onFileSelect, onFolderSelect }) { 9 | return useProgressive(async ({ fallback, type, usable, defer }) => { 10 | fallback(); 11 | type(FileListUI); 12 | usable(5); 13 | defer(100); 14 | return { 15 | folders: findSubfolder(folder), 16 | files: findAnsiFiles(folder), 17 | selected, 18 | onFileSelect, 19 | onFolderSelect, 20 | }; 21 | }, [ folder ]); 22 | } 23 | 24 | // typical ANSI files are 79x24 25 | const width = 81; 26 | const height = 26; 27 | const borderStyle = 'round'; 28 | 29 | function FileListUI({ folders = [], files = [], selected, onFileSelect, onFolderSelect }) { 30 | const { isFocused } = useFocus({ id: 'main', autoFocus: true }); 31 | const items = []; 32 | for (const folder of folders) { 33 | items.push({ label: '\u{1F5C0} ' + basename(folder), value: folder, type: 'folder' }) 34 | } 35 | for (const file of files) { 36 | items.push({ label: '\u{1F5CF} ' + basename(file), value: file, type: 'file' }) 37 | } 38 | // ensure that columns are wide enough for the longest filename 39 | const maxWidth = items.reduce((m, i) => m = Math.max(m, i.label.length), 0); 40 | const columnCount = Math.max(1, Math.floor(80 / (maxWidth + 2))); 41 | const limit = height - 2; 42 | const initialIndex = Math.max(0, items.findIndex(i => i.value === selected)); 43 | const borderColor = (isFocused) ? 'blue' : undefined; 44 | const onSelect = ({ value, type }) => { 45 | if (type === 'folder') { 46 | onFolderSelect?.(value); 47 | } else if (type === 'file') { 48 | onFileSelect?.(value); 49 | } 50 | }; 51 | return ( 52 | 53 | 54 | 55 | ); 56 | } 57 | 58 | export function LoadingScreen() { 59 | const alignItems = 'center'; 60 | const justifyContent = 'center'; 61 | return ( 62 | 63 | Loading 64 | 65 | ); 66 | } 67 | 68 | async function* findSubfolder(folder) { 69 | const list = await readdir(folder, { withFileTypes: true }); 70 | yield normalize(`${folder}/..`); 71 | for (const entry of list) { 72 | if (entry.isDirectory() && !entry.name.startsWith('.')) { 73 | yield `${folder}/${entry.name}`; 74 | } 75 | } 76 | } 77 | 78 | async function* findAnsiFiles(folder) { 79 | const list = await readdir(folder, { withFileTypes: true }); 80 | for (const entry of list) { 81 | if (entry.isFile() && !entry.name.startsWith('.')) { 82 | const path = `${folder}/${entry.name}`; 83 | if (await isAnsiFile(path)) { 84 | yield path; 85 | } 86 | } 87 | } 88 | } 89 | 90 | export async function isAnsiFile(path) { 91 | let file; 92 | try { 93 | file = await open(path); 94 | const buffer = Buffer.alloc(1024); 95 | const { bytesRead } = await file.read(buffer, 0, buffer.length); 96 | const array = new Uint8Array(buffer, 0, bytesRead); 97 | for (let i = 0; i < array.length - 1; i++) { 98 | if (array[i] === 0x1B && array[i + 1] === 0x5B) { 99 | return true; 100 | } 101 | } 102 | } catch (err) { 103 | } finally { 104 | await file?.close(); 105 | } 106 | return false; 107 | } -------------------------------------------------------------------------------- /demo/ink/bin/ModemSpeedSelect.mjs: -------------------------------------------------------------------------------- 1 | import { useRoute } from 'array-router'; 2 | import SelectBox from "./SelectBox.mjs"; 3 | import { jsx as _jsx } from "react/jsx-runtime"; 4 | export default function ModemSpeedSelect() { 5 | const [parts, options] = useRoute(); 6 | const id = 'modemSpeed'; 7 | const label = '&Modem speed'; 8 | const items = [{ 9 | label: '2400', 10 | value: 2400 11 | }, { 12 | label: '9600', 13 | value: 9600 14 | }, { 15 | label: '14400', 16 | value: 14400 17 | }, { 18 | label: '19200', 19 | value: 19200 20 | }, { 21 | label: '28800', 22 | value: 28800 23 | }, { 24 | label: '33600', 25 | value: 33600 26 | }, { 27 | label: '56000', 28 | value: 56000 29 | }, { 30 | label: '115200', 31 | value: 115200 32 | }, { 33 | label: '230400', 34 | value: 230400 35 | }, { 36 | label: '460800', 37 | value: 460800 38 | }, { 39 | label: '576000', 40 | value: 576000 41 | }, { 42 | label: '921600', 43 | value: 921600 44 | }, { 45 | label: 'Infinity', 46 | value: Infinity 47 | }]; 48 | const value = options.modemSpeed; 49 | const home = 'main'; 50 | const onSelect = ({ 51 | value 52 | }) => options.modemSpeed = value; 53 | return /*#__PURE__*/_jsx(SelectBox, { 54 | id, 55 | label, 56 | items, 57 | value, 58 | home, 59 | onSelect 60 | }); 61 | } 62 | //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VSb3V0ZSIsIlNlbGVjdEJveCIsIk1vZGVtU3BlZWRTZWxlY3QiLCJwYXJ0cyIsIm9wdGlvbnMiLCJpZCIsImxhYmVsIiwiaXRlbXMiLCJ2YWx1ZSIsIkluZmluaXR5IiwibW9kZW1TcGVlZCIsImhvbWUiLCJvblNlbGVjdCJdLCJzb3VyY2VzIjpbIk1vZGVtU3BlZWRTZWxlY3QuanN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IHVzZVJvdXRlIH0gZnJvbSAnYXJyYXktcm91dGVyJztcbmltcG9ydCBTZWxlY3RCb3ggZnJvbSAnLi9TZWxlY3RCb3guanN4JztcblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTW9kZW1TcGVlZFNlbGVjdCgpIHtcbiAgY29uc3QgWyBwYXJ0cywgb3B0aW9ucyBdID0gdXNlUm91dGUoKTtcbiAgY29uc3QgaWQgPSAnbW9kZW1TcGVlZCc7XG4gIGNvbnN0IGxhYmVsID0gJyZNb2RlbSBzcGVlZCc7XG4gIGNvbnN0IGl0ZW1zID0gW1xuICAgICAgeyBsYWJlbDogJzI0MDAnLCB2YWx1ZTogMjQwMCB9LFxuICAgICAgeyBsYWJlbDogJzk2MDAnLCB2YWx1ZTogOTYwMCB9LFxuICAgICAgeyBsYWJlbDogJzE0NDAwJywgdmFsdWU6IDE0NDAwIH0sXG4gICAgICB7IGxhYmVsOiAnMTkyMDAnLCB2YWx1ZTogMTkyMDAgfSxcbiAgICAgIHsgbGFiZWw6ICcyODgwMCcsIHZhbHVlOiAyODgwMCB9LFxuICAgICAgeyBsYWJlbDogJzMzNjAwJywgdmFsdWU6IDMzNjAwIH0sXG4gICAgICB7IGxhYmVsOiAnNTYwMDAnLCB2YWx1ZTogNTYwMDAgfSxcbiAgICAgIHsgbGFiZWw6ICcxMTUyMDAnLCB2YWx1ZTogMTE1MjAwIH0sXG4gICAgICB7IGxhYmVsOiAnMjMwNDAwJywgdmFsdWU6IDIzMDQwMCB9LFxuICAgICAgeyBsYWJlbDogJzQ2MDgwMCcsIHZhbHVlOiA0NjA4MDAgfSxcbiAgICAgIHsgbGFiZWw6ICc1NzYwMDAnLCB2YWx1ZTogNTc2MDAwIH0sXG4gICAgICB7IGxhYmVsOiAnOTIxNjAwJywgdmFsdWU6IDkyMTYwMCB9LFxuICAgICAgeyBsYWJlbDogJ0luZmluaXR5JywgdmFsdWU6IEluZmluaXR5IH0sXG4gICAgXTtcbiAgY29uc3QgdmFsdWUgPSBvcHRpb25zLm1vZGVtU3BlZWQ7XG4gIGNvbnN0IGhvbWUgPSAnbWFpbic7XG4gIGNvbnN0IG9uU2VsZWN0ID0gKHsgdmFsdWUgfSkgPT4gb3B0aW9ucy5tb2RlbVNwZWVkID0gdmFsdWU7XG4gIHJldHVybiA8U2VsZWN0Qm94ICB7Li4ueyBpZCwgbGFiZWwsIGl0ZW1zLCB2YWx1ZSwgaG9tZSwgb25TZWxlY3QgfX0gLz47XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLFNBQVNBLFFBQVEsUUFBUSxjQUFjO0FBQ3ZDLE9BQU9DLFNBQVM7QUFBd0I7QUFFeEMsZUFBZSxTQUFTQyxnQkFBZ0IsR0FBRztFQUN6QyxNQUFNLENBQUVDLEtBQUssRUFBRUMsT0FBTyxDQUFFLEdBQUdKLFFBQVEsRUFBRTtFQUNyQyxNQUFNSyxFQUFFLEdBQUcsWUFBWTtFQUN2QixNQUFNQyxLQUFLLEdBQUcsY0FBYztFQUM1QixNQUFNQyxLQUFLLEdBQUcsQ0FDVjtJQUFFRCxLQUFLLEVBQUUsTUFBTTtJQUFFRSxLQUFLLEVBQUU7RUFBSyxDQUFDLEVBQzlCO0lBQUVGLEtBQUssRUFBRSxNQUFNO0lBQUVFLEtBQUssRUFBRTtFQUFLLENBQUMsRUFDOUI7SUFBRUYsS0FBSyxFQUFFLE9BQU87SUFBRUUsS0FBSyxFQUFFO0VBQU0sQ0FBQyxFQUNoQztJQUFFRixLQUFLLEVBQUUsT0FBTztJQUFFRSxLQUFLLEVBQUU7RUFBTSxDQUFDLEVBQ2hDO0lBQUVGLEtBQUssRUFBRSxPQUFPO0lBQUVFLEtBQUssRUFBRTtFQUFNLENBQUMsRUFDaEM7SUFBRUYsS0FBSyxFQUFFLE9BQU87SUFBRUUsS0FBSyxFQUFFO0VBQU0sQ0FBQyxFQUNoQztJQUFFRixLQUFLLEVBQUUsT0FBTztJQUFFRSxLQUFLLEVBQUU7RUFBTSxDQUFDLEVBQ2hDO0lBQUVGLEtBQUssRUFBRSxRQUFRO0lBQUVFLEtBQUssRUFBRTtFQUFPLENBQUMsRUFDbEM7SUFBRUYsS0FBSyxFQUFFLFFBQVE7SUFBRUUsS0FBSyxFQUFFO0VBQU8sQ0FBQyxFQUNsQztJQUFFRixLQUFLLEVBQUUsUUFBUTtJQUFFRSxLQUFLLEVBQUU7RUFBTyxDQUFDLEVBQ2xDO0lBQUVGLEtBQUssRUFBRSxRQUFRO0lBQUVFLEtBQUssRUFBRTtFQUFPLENBQUMsRUFDbEM7SUFBRUYsS0FBSyxFQUFFLFFBQVE7SUFBRUUsS0FBSyxFQUFFO0VBQU8sQ0FBQyxFQUNsQztJQUFFRixLQUFLLEVBQUUsVUFBVTtJQUFFRSxLQUFLLEVBQUVDO0VBQVMsQ0FBQyxDQUN2QztFQUNILE1BQU1ELEtBQUssR0FBR0osT0FBTyxDQUFDTSxVQUFVO0VBQ2hDLE1BQU1DLElBQUksR0FBRyxNQUFNO0VBQ25CLE1BQU1DLFFBQVEsR0FBRyxDQUFDO0lBQUVKO0VBQU0sQ0FBQyxLQUFLSixPQUFPLENBQUNNLFVBQVUsR0FBR0YsS0FBSztFQUMxRCxvQkFBTyxLQUFDLFNBQVM7SUFBUUgsRUFBRTtJQUFFQyxLQUFLO0lBQUVDLEtBQUs7SUFBRUMsS0FBSztJQUFFRyxJQUFJO0lBQUVDO0VBQVEsRUFBTTtBQUN4RSJ9 -------------------------------------------------------------------------------- /demo/ink/bin/AnsiDisplay.mjs: -------------------------------------------------------------------------------- 1 | import { useRoute } from 'array-router'; 2 | import { useFocus, useInput, Box } from 'ink'; 3 | import { AnsiText } from 'react-ansi-animation/ink'; 4 | import { jsx as _jsx } from "react/jsx-runtime"; 5 | export default function AnsiDisplay({ 6 | src, 7 | onExit, 8 | onEnd 9 | }) { 10 | const { 11 | isFocused 12 | } = useFocus({ 13 | id: 'main', 14 | autoFocus: true 15 | }); 16 | const [parts, options] = useRoute(); 17 | const { 18 | modemSpeed, 19 | blinking, 20 | scrolling, 21 | transparency 22 | } = options; 23 | useInput((input, key) => { 24 | if (key.escape || key.return || input === ' ') { 25 | onExit?.(key); 26 | } 27 | }, { 28 | isActive: isFocused 29 | }); 30 | const borderStyle = 'round'; 31 | const borderColor = isFocused ? 'blue' : undefined; 32 | const flexDirection = 'column'; 33 | const maxHeight = scrolling ? 25 : 1024; 34 | const onStatus = status => { 35 | if (status.position === 1) { 36 | // played to 100% 37 | onEnd?.(status); 38 | } 39 | }; 40 | return /*#__PURE__*/_jsx(Box, { 41 | borderStyle, 42 | borderColor, 43 | flexDirection, 44 | children: /*#__PURE__*/_jsx(AnsiText, { 45 | src, 46 | modemSpeed, 47 | blinking, 48 | maxHeight, 49 | transparency, 50 | onStatus 51 | }) 52 | }); 53 | } 54 | //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VSb3V0ZSIsInVzZUZvY3VzIiwidXNlSW5wdXQiLCJCb3giLCJBbnNpVGV4dCIsIkFuc2lEaXNwbGF5Iiwic3JjIiwib25FeGl0Iiwib25FbmQiLCJpc0ZvY3VzZWQiLCJpZCIsImF1dG9Gb2N1cyIsInBhcnRzIiwib3B0aW9ucyIsIm1vZGVtU3BlZWQiLCJibGlua2luZyIsInNjcm9sbGluZyIsInRyYW5zcGFyZW5jeSIsImlucHV0Iiwia2V5IiwiZXNjYXBlIiwicmV0dXJuIiwiaXNBY3RpdmUiLCJib3JkZXJTdHlsZSIsImJvcmRlckNvbG9yIiwidW5kZWZpbmVkIiwiZmxleERpcmVjdGlvbiIsIm1heEhlaWdodCIsIm9uU3RhdHVzIiwic3RhdHVzIiwicG9zaXRpb24iXSwic291cmNlcyI6WyJBbnNpRGlzcGxheS5qc3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgdXNlUm91dGUgfSBmcm9tICdhcnJheS1yb3V0ZXInO1xuaW1wb3J0IHsgdXNlRm9jdXMsIHVzZUlucHV0LCBCb3ggfSBmcm9tICdpbmsnO1xuaW1wb3J0IHsgQW5zaVRleHQgfSBmcm9tICdyZWFjdC1hbnNpLWFuaW1hdGlvbi9pbmsnO1xuXG5leHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBBbnNpRGlzcGxheSh7IHNyYywgb25FeGl0LCBvbkVuZCB9KSB7XG4gIGNvbnN0IHsgaXNGb2N1c2VkIH0gPSB1c2VGb2N1cyh7IGlkOiAnbWFpbicsIGF1dG9Gb2N1czogdHJ1ZSB9KTtcbiAgY29uc3QgWyBwYXJ0cywgb3B0aW9ucyBdID0gdXNlUm91dGUoKTtcbiAgY29uc3QgeyBtb2RlbVNwZWVkLCBibGlua2luZywgc2Nyb2xsaW5nLCB0cmFuc3BhcmVuY3kgfSA9IG9wdGlvbnM7XG4gIHVzZUlucHV0KChpbnB1dCwga2V5KSA9PiB7XG4gICAgaWYgKGtleS5lc2NhcGUgfHwga2V5LnJldHVybiB8fCBpbnB1dCA9PT0gJyAnKSB7XG4gICAgICBvbkV4aXQ/LihrZXkpO1xuICAgIH1cbiAgfSwgeyBpc0FjdGl2ZTogaXNGb2N1c2VkIH0pXG4gIGNvbnN0IGJvcmRlclN0eWxlID0gJ3JvdW5kJztcbiAgY29uc3QgYm9yZGVyQ29sb3IgPSAoaXNGb2N1c2VkKSA/ICdibHVlJyA6IHVuZGVmaW5lZDtcbiAgY29uc3QgZmxleERpcmVjdGlvbiA9ICdjb2x1bW4nO1xuICBjb25zdCBtYXhIZWlnaHQgPSAoc2Nyb2xsaW5nKSA/IDI1IDogMTAyNDtcbiAgY29uc3Qgb25TdGF0dXMgPSAoc3RhdHVzKSA9PiB7XG4gICAgaWYgKHN0YXR1cy5wb3NpdGlvbiA9PT0gMSkge1xuICAgICAgLy8gcGxheWVkIHRvIDEwMCVcbiAgICAgIG9uRW5kPy4oc3RhdHVzKTtcbiAgICB9XG4gIH07XG4gIHJldHVybiAoXG4gICAgPEJveCB7Li4ueyBib3JkZXJTdHlsZSwgYm9yZGVyQ29sb3IsIGZsZXhEaXJlY3Rpb24gfX0+XG4gICAgICA8QW5zaVRleHQgey4uLnsgc3JjLCBtb2RlbVNwZWVkLCBibGlua2luZywgbWF4SGVpZ2h0LCB0cmFuc3BhcmVuY3ksIG9uU3RhdHVzIH19IC8+XG4gICAgPC9Cb3g+XG4gICk7XG59Il0sIm1hcHBpbmdzIjoiQUFBQSxTQUFTQSxRQUFRLFFBQVEsY0FBYztBQUN2QyxTQUFTQyxRQUFRLEVBQUVDLFFBQVEsRUFBRUMsR0FBRyxRQUFRLEtBQUs7QUFDN0MsU0FBU0MsUUFBUSxRQUFRLDBCQUEwQjtBQUFDO0FBRXBELGVBQWUsU0FBU0MsV0FBVyxDQUFDO0VBQUVDLEdBQUc7RUFBRUMsTUFBTTtFQUFFQztBQUFNLENBQUMsRUFBRTtFQUMxRCxNQUFNO0lBQUVDO0VBQVUsQ0FBQyxHQUFHUixRQUFRLENBQUM7SUFBRVMsRUFBRSxFQUFFLE1BQU07SUFBRUMsU0FBUyxFQUFFO0VBQUssQ0FBQyxDQUFDO0VBQy9ELE1BQU0sQ0FBRUMsS0FBSyxFQUFFQyxPQUFPLENBQUUsR0FBR2IsUUFBUSxFQUFFO0VBQ3JDLE1BQU07SUFBRWMsVUFBVTtJQUFFQyxRQUFRO0lBQUVDLFNBQVM7SUFBRUM7RUFBYSxDQUFDLEdBQUdKLE9BQU87RUFDakVYLFFBQVEsQ0FBQyxDQUFDZ0IsS0FBSyxFQUFFQyxHQUFHLEtBQUs7SUFDdkIsSUFBSUEsR0FBRyxDQUFDQyxNQUFNLElBQUlELEdBQUcsQ0FBQ0UsTUFBTSxJQUFJSCxLQUFLLEtBQUssR0FBRyxFQUFFO01BQzdDWCxNQUFNLEdBQUdZLEdBQUcsQ0FBQztJQUNmO0VBQ0YsQ0FBQyxFQUFFO0lBQUVHLFFBQVEsRUFBRWI7RUFBVSxDQUFDLENBQUM7RUFDM0IsTUFBTWMsV0FBVyxHQUFHLE9BQU87RUFDM0IsTUFBTUMsV0FBVyxHQUFJZixTQUFTLEdBQUksTUFBTSxHQUFHZ0IsU0FBUztFQUNwRCxNQUFNQyxhQUFhLEdBQUcsUUFBUTtFQUM5QixNQUFNQyxTQUFTLEdBQUlYLFNBQVMsR0FBSSxFQUFFLEdBQUcsSUFBSTtFQUN6QyxNQUFNWSxRQUFRLEdBQUlDLE1BQU0sSUFBSztJQUMzQixJQUFJQSxNQUFNLENBQUNDLFFBQVEsS0FBSyxDQUFDLEVBQUU7TUFDekI7TUFDQXRCLEtBQUssR0FBR3FCLE1BQU0sQ0FBQztJQUNqQjtFQUNGLENBQUM7RUFDRCxvQkFDRSxLQUFDLEdBQUc7SUFBT04sV0FBVztJQUFFQyxXQUFXO0lBQUVFLGFBQWE7SUFBQSx1QkFDaEQsS0FBQyxRQUFRO01BQU9wQixHQUFHO01BQUVRLFVBQVU7TUFBRUMsUUFBUTtNQUFFWSxTQUFTO01BQUVWLFlBQVk7TUFBRVc7SUFBUTtFQUFNLEVBQzlFO0FBRVYifQ== -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React-ansi-animation ![ci](https://img.shields.io/github/actions/workflow/status/chung-leong/react-ansi-animation/node.js.yml?branch=main&label=Node.js%20CI&logo=github) ![nycrc config on GitHub](https://img.shields.io/nycrc/chung-leong/react-ansi-animation) 2 | 3 | React-ansi-animation provides a set of components for displaying ANSI art. It can render 4 | either HTML text elements or into a canvas. It can also output text for 5 | [Ink](https://github.com/vadimdemedes/ink)-based CLI applications. 6 | 7 | The library was built with the help of 8 | [React-seq](https://github.com/chung-leong/react-seq#readme). 9 | It is designed for React 18 and above. 10 | 11 | ## Installation 12 | 13 | ```sh 14 | npm install --save-dev react-ansi-animation 15 | ``` 16 | 17 | ## Basic usage 18 | 19 | ```js 20 | import { AnsiText } from 'react-ansi-animation'; 21 | 22 | export default function Widget() { 23 | return ; 24 | } 25 | ``` 26 | 27 | ## Features 28 | 29 | * [Embedded font support](#customizing-text-appearance) 30 | * [Modem speed emulation](#modem-speed-emulation) 31 | * [Blinking text](#blinking-text) 32 | * [Animation playback control](#animation-playback-control) 33 | 34 | ## Live demo 35 | 36 | You can see the both `` and `` in action at https://chung-leong.github.io/react-ansi-animation/: 37 | 38 | ![Screenshot](./doc/img/screenshot-1.jpg) 39 | 40 | The website is optimized for viewing on mobile devices as well. 41 | 42 | To see the Ink version of `` in action, install 43 | [ink-ansi-animation](https://www.npmjs.com/package/ink-ansi-animation) then run it from a directory containing ANSI files: 44 | 45 | ![Screenshot](./doc/img/screenshot-2.jpg) 46 | 47 | You can download the ANSI files used by the demo website [here](https://chung-leong.github.io/react-ansi-animation/). Check out the ANSI art archive [Sixteen Colors](https://16colo.rs/) if you 48 | wish to see more glorious creations from years bygone. 49 | 50 | ## Components 51 | 52 | * [``](./doc/AnsiText.md) 53 | * [``](./doc/AnsiCanvas.md) 54 | 55 | ## Hooks 56 | 57 | * [`useAnsi`](./doc/useAnsi.md) 58 | 59 | ## Customizing text appearance 60 | 61 | [``](./doc/AnsiText.md) creates a `` HTML element, which employs the "monotype" font 62 | by default. It will have the `className` "AnsiText". To change the font size, weight, and other 63 | attributes, simply add a rule to your CSS file: 64 | 65 | ```css 66 | .AnsiText { 67 | font-family: 'Courier New', monotype; 68 | font-size: 24px; 69 | font-weight: bold; 70 | } 71 | ``` 72 | 73 | You can change the font used by [``](./doc/AnsiCanvas.md) in the same manner: 74 | 75 | ```css 76 | .AnsiCanvas { 77 | font-family: 'Courier New', monotype; 78 | font-size: 24px; 79 | font-weight: bold; 80 | } 81 | ``` 82 | 83 | Use a `@font-face` declaration if you wish to use a custom font: 84 | 85 | ```css 86 | @font-face { 87 | font-family: 'Flexi IBM VGA'; 88 | font-style: normal; 89 | font-weight: normal; 90 | src: url('/fonts/flexi-ibm-vga-true-437.woff2') format('woff2'), 91 | url('/fonts/flexi-ibm-vga-true-437.woff') format('woff'); 92 | } 93 | ``` 94 | 95 | You can change the color palette by providing the [`palette`](./doc/AnsiText.md#palette) prop. To 96 | define colors through CSS instead, set `palette` to `"css"`. 97 | 98 | ## Modem speed emulation 99 | 100 | By default this library emulates a 56K modem. Set the [`modemSpeed`](./doc/AnsiText.md#modemspeed) 101 | prop to use a different speed. Use `Infinity` if you want the final picture to appear immediately. 102 | 103 | ## Blinking text 104 | 105 | The CGA 80x25 text mode uses 4 bits to store the text color (16 possible values), 3 bites to store 106 | the background color (8 possible values), and 1 bit for blinking. ANSI arts created in the BBS 107 | era sometimes depends on blinking for certain effects. ANSI arts created in later times tend to 108 | use the final bit for high-intensity background colors. They would look odd when viewed in CGA 109 | text mode. 110 | 111 | Blinking is disabled by default. Set the [`blinking`](./doc/AnsiText.md#blinking) prop to enable it. 112 | 113 | ## Animation playback control 114 | 115 | By providing an [`onStatus`](./doc/AnsiText.md#onstatus) handler and altering the 116 | [`initialStatus`](./doc/AnsiText.md#initialstatus) prop, you can pause the animation or jump to 117 | different points in time. [Code for the demo](./demo/dom/src/App.js#L1) serves as a good working example. 118 | 119 | ## Acknowledgement 120 | 121 | Special thanks to the maintainers of [ansi-bbs.org](http://www.ansi-bbs.org/) for providing a 122 | detailed [ANSI specification](http://www.ansi-bbs.org/), and the maintainers of 123 | [Sixteen Colors](https://16colo.rs/), the source of most of the ANSI arts used in the demo. 124 | 125 | -------------------------------------------------------------------------------- /test/ink-components.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { readFile, stat } from 'fs/promises'; 3 | import { createElement } from 'react'; 4 | import { delay } from 'react-seq'; 5 | import { render } from 'ink-testing-library'; 6 | 7 | import { 8 | AnsiText 9 | } from '../ink.js'; 10 | 11 | describe('Ink components', function() { 12 | describe('#AnsiText', function() { 13 | it('should yield empty string when both src and srcObject are absent', async function() { 14 | const el = createElement(AnsiText); 15 | const { lastFrame } = render(el); 16 | const s = lastFrame(); 17 | const m = s.match(/\n/g); 18 | expect(m).to.have.lengthOf(21); 19 | }) 20 | it('should render ANSI text', async function() { 21 | const srcObject = await readFile(resolve('./ansi/LDA-GARFIELD.ANS')); 22 | const el = createElement(AnsiText, { 23 | srcObject, 24 | modemSpeed: Infinity, 25 | maxHeight: 1024 26 | }); 27 | const { lastFrame } = render(el); 28 | const s = lastFrame(); 29 | const m = s.match(/\n/g); 30 | expect(m).to.have.lengthOf(39); 31 | expect(s).to.contain('lda'); 32 | }) 33 | it('should accept promise to data as well', async function() { 34 | const srcObject = readFile(resolve('./ansi/LDA-GARFIELD.ANS')); 35 | const el = createElement(AnsiText, { 36 | srcObject, 37 | modemSpeed: Infinity, 38 | maxHeight: 1024 39 | }); 40 | const { lastFrame } = render(el); 41 | await delay(50); 42 | const s = lastFrame(); 43 | const m = s.match(/\n/g); 44 | expect(m).to.have.lengthOf(39); 45 | expect(s).to.contain('lda'); 46 | }) 47 | it('should return metadata contained in file', async function() { 48 | const srcObject = await readFile(resolve('./ansi/LDA-GARFIELD.ANS')); 49 | let metadata = null; 50 | const el = createElement(AnsiText, { 51 | srcObject, 52 | modemSpeed: Infinity, 53 | maxHeight: 1024, 54 | onMetadata: m => metadata = m, 55 | }); 56 | const { lastFrame } = render(el); 57 | await delay(10); 58 | expect(metadata).to.be.an('array'); 59 | }) 60 | it('should load ANSI text from file', async function() { 61 | const src = resolve('./ansi/LDA-GARFIELD.ANS'); 62 | const el = createElement(AnsiText, { 63 | src, 64 | modemSpeed: Infinity, 65 | maxHeight: 1024 66 | }); 67 | const { lastFrame, stdout } = render(el); 68 | await delay(50); 69 | expect(stdout.frames).to.have.lengthOf(2); 70 | const s = lastFrame(); 71 | const m = s.match(/\n/g); 72 | expect(m).to.have.lengthOf(39); 73 | expect(s).to.contain('lda'); 74 | }) 75 | it('should render error message when file is missing', async function() { 76 | const src = resolve('./ansi/BOGUS.ANS'); 77 | let error; 78 | const el = createElement(AnsiText, { 79 | src, 80 | modemSpeed: Infinity, 81 | maxHeight: 1024, 82 | onError: err => error = err, 83 | }); 84 | const { lastFrame, stdout } = render(el); 85 | await delay(10); 86 | expect(stdout.frames).to.have.lengthOf(2); 87 | const s = lastFrame(); 88 | expect(s).to.contain('no such file or directory'); 89 | expect(error).to.be.an('error'); 90 | }) 91 | it('should handle ANSI text with blinking text', async function() { 92 | const srcObject = await readFile(resolve('./ansi/US-CANDLES.ANS')); 93 | const el = createElement(AnsiText, { 94 | srcObject, 95 | modemSpeed: Infinity, 96 | frameDuration: 10, 97 | blinkDuration: 50, 98 | blinking: true, 99 | }); 100 | const { lastFrame, stdout, unmount } = render(el); 101 | try { 102 | const s1 = lastFrame(); 103 | const m = s1.match(/\n/g); 104 | expect(m).to.have.lengthOf(24); 105 | await delay(70); 106 | expect(stdout.frames).to.have.lengthOf(2); 107 | const s2 = lastFrame(); 108 | expect(s2).to.not.equal(s1); 109 | } finally { 110 | unmount(); 111 | 112 | } 113 | }) 114 | it('should be able to render an animation', async function() { 115 | const path = resolve('./ansi/LM-OKC.ICE'); 116 | const statuses = []; 117 | const el = createElement(AnsiText, { 118 | src: path, 119 | modemSpeed: 10000000, 120 | frameDuration: 10, 121 | onStatus: s => statuses.push(s), 122 | }); 123 | let previousCount = 0; 124 | const { stdout, unmount } = render(el); 125 | try { 126 | while (previousCount !== stdout.frames.length) { 127 | previousCount = stdout.frames.length; 128 | await delay(20); 129 | } 130 | expect(previousCount).to.be.at.least(10); 131 | expect(statuses.length).to.be.at.least(10); 132 | } finally { 133 | unmount(); 134 | } 135 | }) 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /demo/ink/bin/AnsiSlideShow.mjs: -------------------------------------------------------------------------------- 1 | import { useSequential } from 'react-seq'; 2 | import AnsiDisplay from "./AnsiDisplay.mjs"; 3 | import { isAnsiFile, LoadingScreen } from "./FileList.mjs"; 4 | import { jsx as _jsx } from "react/jsx-runtime"; 5 | export default function AnsiSlideShow({ 6 | srcList, 7 | onExit 8 | }) { 9 | return useSequential(async function* ({ 10 | fallback, 11 | manageEvents 12 | }) { 13 | fallback( /*#__PURE__*/_jsx(LoadingScreen, {})); 14 | const [on, eventual] = manageEvents(); 15 | let index = 0, 16 | played = 0, 17 | lastError = null; 18 | for (;;) { 19 | const path = srcList[index++]; 20 | try { 21 | if (await isAnsiFile(path)) { 22 | yield /*#__PURE__*/_jsx(AnsiDisplay, { 23 | src: path, 24 | onExit: on.exitKey, 25 | onEnd: on.animationEnd 26 | }); 27 | let { 28 | exitKey, 29 | animationEnd 30 | } = await eventual.animationEnd.or.exitKey; 31 | if (animationEnd) { 32 | // wait a little bit 33 | ({ 34 | exitKey 35 | } = await eventual.exitKey.for(10).seconds); 36 | } 37 | if (exitKey?.escape) { 38 | // escape ends the program 39 | onExit(exitKey); 40 | break; 41 | } 42 | played = true; 43 | } 44 | } catch (err) { 45 | lastError = err; 46 | } 47 | if (index >= srcList.length) { 48 | // end of the loop; if nothing got played then something is wrong 49 | if (played === 0) { 50 | if (!lastError) { 51 | lastError = new Error('No ANSI files found'); 52 | } 53 | throw lastError; 54 | } 55 | index = 0; 56 | } 57 | } 58 | }, [srcList, onExit]); 59 | } 60 | //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VTZXF1ZW50aWFsIiwiQW5zaURpc3BsYXkiLCJpc0Fuc2lGaWxlIiwiTG9hZGluZ1NjcmVlbiIsIkFuc2lTbGlkZVNob3ciLCJzcmNMaXN0Iiwib25FeGl0IiwiZmFsbGJhY2siLCJtYW5hZ2VFdmVudHMiLCJvbiIsImV2ZW50dWFsIiwiaW5kZXgiLCJwbGF5ZWQiLCJsYXN0RXJyb3IiLCJwYXRoIiwiZXhpdEtleSIsImFuaW1hdGlvbkVuZCIsIm9yIiwiZm9yIiwic2Vjb25kcyIsImVzY2FwZSIsImVyciIsImxlbmd0aCIsIkVycm9yIl0sInNvdXJjZXMiOlsiQW5zaVNsaWRlU2hvdy5qc3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgdXNlU2VxdWVudGlhbCB9IGZyb20gJ3JlYWN0LXNlcSc7XG5pbXBvcnQgQW5zaURpc3BsYXkgZnJvbSAnLi9BbnNpRGlzcGxheS5qc3gnO1xuaW1wb3J0IHsgaXNBbnNpRmlsZSwgTG9hZGluZ1NjcmVlbiB9IGZyb20gJy4vRmlsZUxpc3QuanN4JztcblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gQW5zaVNsaWRlU2hvdyh7IHNyY0xpc3QsIG9uRXhpdCB9KSB7XG4gIHJldHVybiB1c2VTZXF1ZW50aWFsKGFzeW5jIGZ1bmN0aW9uKih7IGZhbGxiYWNrLCBtYW5hZ2VFdmVudHMgfSkge1xuICAgIGZhbGxiYWNrKDxMb2FkaW5nU2NyZWVuIC8+KTtcbiAgICBjb25zdCBbIG9uLCBldmVudHVhbCBdID0gbWFuYWdlRXZlbnRzKCk7XG4gICAgbGV0IGluZGV4ID0gMCwgcGxheWVkID0gMCwgbGFzdEVycm9yID0gbnVsbDtcbiAgICBmb3IgKDs7KSB7XG4gICAgICBjb25zdCBwYXRoID0gc3JjTGlzdFtpbmRleCsrXTtcbiAgICAgIHRyeSB7XG4gICAgICAgIGlmIChhd2FpdCBpc0Fuc2lGaWxlKHBhdGgpKSB7XG4gICAgICAgICAgeWllbGQgPEFuc2lEaXNwbGF5IHNyYz17cGF0aH0gb25FeGl0PXtvbi5leGl0S2V5fSBvbkVuZD17b24uYW5pbWF0aW9uRW5kfSAvPjtcbiAgICAgICAgICBsZXQgeyBleGl0S2V5LCBhbmltYXRpb25FbmQgfSA9IGF3YWl0IGV2ZW50dWFsLmFuaW1hdGlvbkVuZC5vci5leGl0S2V5O1xuICAgICAgICAgIGlmIChhbmltYXRpb25FbmQpIHtcbiAgICAgICAgICAgIC8vIHdhaXQgYSBsaXR0bGUgYml0XG4gICAgICAgICAgICAoeyBleGl0S2V5IH0gPSBhd2FpdCBldmVudHVhbC5leGl0S2V5LmZvcigxMCkuc2Vjb25kcyk7XG4gICAgICAgICAgfVxuICAgICAgICAgIGlmIChleGl0S2V5Py5lc2NhcGUpIHtcbiAgICAgICAgICAgIC8vIGVzY2FwZSBlbmRzIHRoZSBwcm9ncmFtXG4gICAgICAgICAgICBvbkV4aXQoZXhpdEtleSk7XG4gICAgICAgICAgICBicmVhaztcbiAgICAgICAgICB9XG4gICAgICAgICAgcGxheWVkID0gdHJ1ZTtcbiAgICAgICAgfVxuICAgICAgfSBjYXRjaCAoZXJyKSB7XG4gICAgICAgIGxhc3RFcnJvciA9IGVycjtcbiAgICAgIH1cbiAgICAgIGlmIChpbmRleCA+PSBzcmNMaXN0Lmxlbmd0aCkge1xuICAgICAgICAvLyBlbmQgb2YgdGhlIGxvb3A7IGlmIG5vdGhpbmcgZ290IHBsYXllZCB0aGVuIHNvbWV0aGluZyBpcyB3cm9uZ1xuICAgICAgICBpZiAocGxheWVkID09PSAwKSB7XG4gICAgICAgICAgaWYgKCFsYXN0RXJyb3IpIHtcbiAgICAgICAgICAgIGxhc3RFcnJvciA9IG5ldyBFcnJvcignTm8gQU5TSSBmaWxlcyBmb3VuZCcpO1xuICAgICAgICAgIH1cbiAgICAgICAgICB0aHJvdyBsYXN0RXJyb3I7XG4gICAgICAgIH1cbiAgICAgICAgaW5kZXggPSAwO1xuICAgICAgfVxuICAgIH1cbiAgfSwgWyBzcmNMaXN0LCBvbkV4aXQgXSk7XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLFNBQVNBLGFBQWEsUUFBUSxXQUFXO0FBQ3pDLE9BQU9DLFdBQVc7QUFDbEIsU0FBU0MsVUFBVSxFQUFFQyxhQUFhO0FBQXlCO0FBRTNELGVBQWUsU0FBU0MsYUFBYSxDQUFDO0VBQUVDLE9BQU87RUFBRUM7QUFBTyxDQUFDLEVBQUU7RUFDekQsT0FBT04sYUFBYSxDQUFDLGlCQUFnQjtJQUFFTyxRQUFRO0lBQUVDO0VBQWEsQ0FBQyxFQUFFO0lBQy9ERCxRQUFRLGVBQUMsS0FBQyxhQUFhLEtBQUcsQ0FBQztJQUMzQixNQUFNLENBQUVFLEVBQUUsRUFBRUMsUUFBUSxDQUFFLEdBQUdGLFlBQVksRUFBRTtJQUN2QyxJQUFJRyxLQUFLLEdBQUcsQ0FBQztNQUFFQyxNQUFNLEdBQUcsQ0FBQztNQUFFQyxTQUFTLEdBQUcsSUFBSTtJQUMzQyxTQUFTO01BQ1AsTUFBTUMsSUFBSSxHQUFHVCxPQUFPLENBQUNNLEtBQUssRUFBRSxDQUFDO01BQzdCLElBQUk7UUFDRixJQUFJLE1BQU1ULFVBQVUsQ0FBQ1ksSUFBSSxDQUFDLEVBQUU7VUFDMUIsbUJBQU0sS0FBQyxXQUFXO1lBQUMsR0FBRyxFQUFFQSxJQUFLO1lBQUMsTUFBTSxFQUFFTCxFQUFFLENBQUNNLE9BQVE7WUFBQyxLQUFLLEVBQUVOLEVBQUUsQ0FBQ087VUFBYSxFQUFHO1VBQzVFLElBQUk7WUFBRUQsT0FBTztZQUFFQztVQUFhLENBQUMsR0FBRyxNQUFNTixRQUFRLENBQUNNLFlBQVksQ0FBQ0MsRUFBRSxDQUFDRixPQUFPO1VBQ3RFLElBQUlDLFlBQVksRUFBRTtZQUNoQjtZQUNBLENBQUM7Y0FBRUQ7WUFBUSxDQUFDLEdBQUcsTUFBTUwsUUFBUSxDQUFDSyxPQUFPLENBQUNHLEdBQUcsQ0FBQyxFQUFFLENBQUMsQ0FBQ0MsT0FBTztVQUN2RDtVQUNBLElBQUlKLE9BQU8sRUFBRUssTUFBTSxFQUFFO1lBQ25CO1lBQ0FkLE1BQU0sQ0FBQ1MsT0FBTyxDQUFDO1lBQ2Y7VUFDRjtVQUNBSCxNQUFNLEdBQUcsSUFBSTtRQUNmO01BQ0YsQ0FBQyxDQUFDLE9BQU9TLEdBQUcsRUFBRTtRQUNaUixTQUFTLEdBQUdRLEdBQUc7TUFDakI7TUFDQSxJQUFJVixLQUFLLElBQUlOLE9BQU8sQ0FBQ2lCLE1BQU0sRUFBRTtRQUMzQjtRQUNBLElBQUlWLE1BQU0sS0FBSyxDQUFDLEVBQUU7VUFDaEIsSUFBSSxDQUFDQyxTQUFTLEVBQUU7WUFDZEEsU0FBUyxHQUFHLElBQUlVLEtBQUssQ0FBQyxxQkFBcUIsQ0FBQztVQUM5QztVQUNBLE1BQU1WLFNBQVM7UUFDakI7UUFDQUYsS0FBSyxHQUFHLENBQUM7TUFDWDtJQUNGO0VBQ0YsQ0FBQyxFQUFFLENBQUVOLE9BQU8sRUFBRUMsTUFBTSxDQUFFLENBQUM7QUFDekIifQ== -------------------------------------------------------------------------------- /demo/dom/src/App.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, startTransition } from 'react'; 2 | import { AnsiText, AnsiCanvas } from 'react-ansi-animation'; 3 | import './css/App.css'; 4 | 5 | export default function App() { 6 | const [ modemSpeed, setModemSpeed ] = useState(56000); 7 | const [ blinking, setBlinking ] = useState(false); 8 | const [ scrolling, setScrolling ] = useState(true); 9 | const [ canvas, setCanvas ] = useState(false); 10 | const [ transparency, setTransparency ] = useState(false); 11 | const [ filename, setFilename ] = useState('ABYSS1.ANS'); 12 | const [ initialStatus, setInitialStatus ] = useState({ position: 0, playing: true }); 13 | const [ currentStatus, setCurrentStatus ] = useState(initialStatus); 14 | const Ansi = (canvas) ? AnsiCanvas : AnsiText; 15 | const maxHeight = (scrolling) ? 25 : 1024; 16 | 17 | const onSpeedChange = ({ target }) => { 18 | setModemSpeed(parseFloat(target.value)); 19 | setInitialStatus(currentStatus); 20 | }; 21 | const onBlinkChange = ({ target }) => { 22 | setBlinking(target.checked); 23 | setInitialStatus(currentStatus); 24 | }; 25 | const onScrollChange = ({ target }) => { 26 | setScrolling(target.checked); 27 | setInitialStatus(currentStatus); 28 | }; 29 | const onCanvasChange = ({ target }) => { 30 | setCanvas(target.checked); 31 | setInitialStatus(currentStatus); 32 | }; 33 | const onTransparencyChange = ({ target }) => { 34 | setTransparency(target.checked); 35 | setInitialStatus(currentStatus); 36 | }; 37 | const onFileClick = ({ target }) => { 38 | if (target.tagName === 'LI') { 39 | setFilename(target.firstChild.nodeValue); 40 | setInitialStatus({ position: 0, playing: true }); 41 | document.body.parentElement.scrollTop = 0; 42 | } 43 | }; 44 | const onPositionChange = ({ target }) => { 45 | const position = parseFloat(target.value); 46 | const status = { position, playing: false }; 47 | setCurrentStatus(status); 48 | startTransition(() => setInitialStatus(status)); 49 | }; 50 | const onStatus = (status) => { 51 | setCurrentStatus(status); 52 | }; 53 | const onPlayClick = () => { 54 | let { position } = currentStatus; 55 | if (position === 1) { 56 | position = 0; 57 | } 58 | setInitialStatus({ position, playing: true }); 59 | }; 60 | 61 | useEffect(() => { 62 | if (transparency) { 63 | document.body.style.backgroundColor = '#005500'; 64 | return () => { 65 | document.body.style.backgroundColor = ''; 66 | }; 67 | } 68 | }, [ transparency ]); 69 | 70 | return ( 71 |
72 |
73 | 91 | 94 | 97 | 100 | 103 |
104 |
105 | 106 |
107 | 108 | 109 |
110 |
111 |
112 |

Static:

113 |
    114 |
  • ABYSS1.ANS
  • 115 |
  • AN-D2.ANS
  • 116 |
  • BK-KING.ANS
  • 117 |
  • COMICS14.ANS
  • 118 |
  • CT-DIE_HARD.ANS
  • 119 |
  • CT-PIXELS.ANS
  • 120 |
  • DONATELO.ANS
  • 121 |
  • DW-FACES.ANS
  • 122 |
  • DW-HAPPY_HOLIDAYS.ANS
  • 123 |
  • ED-NS.ANS
  • 124 |
  • JET.ANS
  • 125 |
  • FROSTBBS.ANS
  • 126 |
  • GAVEL30.ANS
  • 127 |
  • GLOBE.ANS
  • 128 |
  • LDA-GARFIELD.ANS
  • 129 |
  • THEQ.ANS
  • 130 |
  • US-UWU.ANS
  • 131 |
  • WWANS54.ANS
  • 132 |
133 |

Blinking:

134 |
    135 |
  • CHRIST1.ANS
  • 136 |
  • SMRFBONK.ANS
  • 137 |
  • US-CANDLES.ANS
  • 138 |
  • UTOPIA20.ANS
  • 139 |
  • XMAS1.ANS
  • 140 |
141 |

Animations:

142 |
    143 |
  • BCACID7.ANS
  • 144 |
  • BOGACID1.ANS
  • 145 |
  • CC-ICE1.ICE
  • 146 |
  • DT-GHETO.ANS
  • 147 |
  • JD-BUTT.ANS
  • 148 |
  • LM-OKC.ICE
  • 149 |
  • SC-ACID5.ANS
  • 150 |
  • SUBACID.ANS
  • 151 |
  • UTOPIA86.ANS
  • 152 |
153 |
154 |
155 | GitHub repo 156 |
157 |
158 | ); 159 | } 160 | -------------------------------------------------------------------------------- /src/dom-components.js: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef, useEffect, createElement } from 'react'; 2 | import { useAnsi } from './hooks.js'; 3 | import { cgaPalette } from './dos-environment.js'; 4 | 5 | export function AnsiText({ src, srcObject, palette = cgaPalette, className = 'AnsiText', ...options }) { 6 | // retrieve data if necessary 7 | const data = useMemo(() => srcObject ?? fetchBuffer(src), [ src, srcObject ]); 8 | // process data through hook 9 | const { lines, blinking, blinked } = useAnsi(data, options); 10 | // convert lines to spans 11 | const children = lines.map((segments) => { 12 | const spans = segments.map(({ text, fgColor, bgColor, blink }) => { 13 | const props = {}; 14 | if (Array.isArray(palette)) { 15 | props.style = { 16 | backgroundColor: palette[bgColor], 17 | color: palette[(blink && blinked) ? bgColor : fgColor], 18 | }; 19 | } else { 20 | const names = []; 21 | if (fgColor !== undefined) { 22 | names.push(`fgColor${fgColor}`) 23 | } 24 | if (bgColor !== undefined) { 25 | names.push(`bgColor${bgColor}`); 26 | } 27 | if (blink) { 28 | if (blinking === true) { 29 | // manual blinking 30 | if (blink && blinked) { 31 | names.push('blink'); 32 | } 33 | } else { 34 | // blinking through css 35 | names.push('blinking'); 36 | } 37 | } 38 | props.className = names.join(' '); 39 | } 40 | return createElement('span', props, text); 41 | }); 42 | return createElement('div', {}, ...spans); 43 | }); 44 | const style = { 45 | display: 'inline-block', 46 | whiteSpace: 'pre', 47 | width: 'fit-content', 48 | }; 49 | return createElement('code', { className, style }, ...children); 50 | } 51 | 52 | /* c8 ignore start */ 53 | export function AnsiCanvas({ src, srcObject, palette = cgaPalette, className = 'AnsiCanvas', ...options }) { 54 | const canvasRef = useRef(); 55 | // retrieve data if necessary 56 | const data = useMemo(() => srcObject ?? fetchBuffer(src), [ src, srcObject ]); 57 | // process data through hook 58 | const { width, height, lines, blinked } = useAnsi(data, options); 59 | // draw into canvas in useEffect hook 60 | useEffect(() => { 61 | const canvas = canvasRef.current; 62 | if (!canvas) { 63 | return; 64 | } 65 | // get font applicable to canvas 66 | let { color, font } = getCanvasStyle(canvas); 67 | if (document.fonts.check(font)) { 68 | draw(); 69 | // observe resizing of element so any font changes get applied 70 | const observer = new ResizeObserver(() => { 71 | const { font: newFont, color: newColor } = getCanvasStyle(canvas); 72 | if (newFont !== font || newColor !== color) { 73 | font = newFont; 74 | color = newColor; 75 | draw(); 76 | } 77 | }); 78 | observer.observe(canvas); 79 | return () => observer.disconnect(); 80 | } else { 81 | // draw when the font has been loaded 82 | let cancelled = false; 83 | document.fonts.load(font).then(() => { 84 | if (!cancelled) { 85 | draw(); 86 | } 87 | }); 88 | return () => cancelled = true; 89 | } 90 | 91 | function draw() { 92 | const { charWidth, charHeight, ascent } = getFontMetrics(font); 93 | canvas.width = width * charWidth; 94 | canvas.height = height * charHeight; 95 | const cxt = canvas.getContext('2d'); 96 | cxt.clearRect(0, 0, canvas.width, canvas.height); 97 | cxt.font = font; 98 | let x = 0, y = ascent; 99 | for (const line of lines) { 100 | for (const { text, bgColor, fgColor, blink } of line) { 101 | for (let i = 0; i < text.length; i++) { 102 | if (bgColor !== undefined) { 103 | // fill background with block character for more consistent appearance 104 | // if the full-block character doesn't quote fill the cell, then the gaps between 105 | // cells should appear everywhere 106 | cxt.fillStyle = palette[bgColor]; 107 | cxt.fillText('\u2588', x, y); 108 | } 109 | if (!blink || !blinked) { 110 | // use black if foreground color isn't set 111 | cxt.fillStyle = (fgColor !== undefined) ? palette[fgColor] : color; 112 | cxt.fillText(text.charAt(i), x, y); 113 | } 114 | x += charWidth; 115 | } 116 | } 117 | y += charHeight; 118 | x = 0; 119 | } 120 | } 121 | }, [ width, height, lines, blinked, palette ]); 122 | return createElement('canvas', { ref: canvasRef, className }); 123 | } 124 | 125 | function getCanvasStyle(node) { 126 | const { fontStyle, fontWeight, fontSize, fontFamily, color } = getComputedStyle(node); 127 | const font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`; 128 | return { color, font }; 129 | } 130 | 131 | const fontMetrics = {}; 132 | 133 | function getFontMetrics(specifier) { 134 | let metrics = fontMetrics[specifier]; 135 | if (!metrics) { 136 | const canvas = document.createElement('CANVAS'); 137 | const cxt = canvas.getContext('2d'); 138 | cxt.font = specifier; 139 | const m = cxt.measureText('\u2588'); 140 | // support for fontBoundingBoxAscent and fontBoundingBoxDescent is spotty 141 | // (https://developer.mozilla.org/en-US/docs/Web/API/TextMetrics#browser_compatibility) 142 | // 143 | // actualBoundingBoxAscent and actualBoundingBoxDescent wouldn't yield the exactly result 144 | // since the bounding box of the full-block character (U+2588) will likely stick out just a little 145 | const ascent = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent; 146 | const descent = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent; 147 | const charWidth = m.width; 148 | const charHeight = ascent + descent; 149 | metrics = fontMetrics[specifier] = { ascent, descent, charWidth, charHeight }; 150 | } 151 | return metrics; 152 | } 153 | /* c8 ignore stop */ 154 | 155 | async function fetchBuffer(src, options) { 156 | if (src) { 157 | const res = await fetch(src, options); 158 | if (res.status !== 200) { 159 | throw new Error(`HTTP ${res.status} - ${res.statusText}`); 160 | } 161 | return await res.arrayBuffer(); 162 | } 163 | } 164 | 165 | -------------------------------------------------------------------------------- /demo/ink/bin/SelectBox.mjs: -------------------------------------------------------------------------------- 1 | import { useFocus, useInput, Text, Box } from 'ink'; 2 | import InkSelectInput from 'ink-select-input'; 3 | import { jsx as _jsx } from "react/jsx-runtime"; 4 | import { jsxs as _jsxs } from "react/jsx-runtime"; 5 | const { 6 | default: SelectInput 7 | } = InkSelectInput; 8 | export default function SelectBox({ 9 | id, 10 | items, 11 | label: labelWithAmp, 12 | value, 13 | home, 14 | onSelect: onSelectCaller 15 | }) { 16 | const { 17 | isFocused, 18 | focus 19 | } = useFocus({ 20 | id 21 | }); 22 | const initialIndex = items.findIndex(i => i.value === value); 23 | const [label, hotkey] = extractHotkey(labelWithAmp); 24 | useInput(input => { 25 | if (input.toUpperCase() === hotkey) { 26 | focus(id); 27 | } 28 | }, { 29 | isActive: !isFocused 30 | }); 31 | if (isFocused) { 32 | const onSelect = item => { 33 | onSelectCaller?.(item); 34 | if (home) { 35 | // refocus main content (after this component has updated) 36 | setTimeout(() => focus(home), 0); 37 | } 38 | }; 39 | return /*#__PURE__*/_jsxs(Box, { 40 | borderStyle: "round", 41 | borderColor: "blue", 42 | children: [/*#__PURE__*/_jsx(Text, { 43 | children: label 44 | }), /*#__PURE__*/_jsx(SelectInput, { 45 | items, 46 | initialIndex, 47 | onSelect 48 | })] 49 | }); 50 | } else { 51 | const minWidth = items.reduce((w, i) => Math.max(w, i.label.length + 2), 2); 52 | const item = items[initialIndex]; 53 | return /*#__PURE__*/_jsxs(Box, { 54 | borderStyle: "round", 55 | children: [/*#__PURE__*/_jsx(Text, { 56 | children: label 57 | }), /*#__PURE__*/_jsx(Box, { 58 | minWidth: minWidth, 59 | children: /*#__PURE__*/_jsxs(Text, { 60 | children: [": ", item?.label] 61 | }) 62 | })] 63 | }); 64 | } 65 | } 66 | function extractHotkey(labelWithAmp) { 67 | const m = /(.*)&(\w)(.*)/.exec(labelWithAmp); 68 | if (!m) { 69 | return [labelWithAmp]; 70 | } 71 | return [/*#__PURE__*/_jsxs(Text, { 72 | children: [m[1], /*#__PURE__*/_jsx(Text, { 73 | underline: true, 74 | children: m[2] 75 | }), m[3]] 76 | }), m[2].toUpperCase()]; 77 | } 78 | //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VGb2N1cyIsInVzZUlucHV0IiwiVGV4dCIsIkJveCIsIklua1NlbGVjdElucHV0IiwiZGVmYXVsdCIsIlNlbGVjdElucHV0IiwiU2VsZWN0Qm94IiwiaWQiLCJpdGVtcyIsImxhYmVsIiwibGFiZWxXaXRoQW1wIiwidmFsdWUiLCJob21lIiwib25TZWxlY3QiLCJvblNlbGVjdENhbGxlciIsImlzRm9jdXNlZCIsImZvY3VzIiwiaW5pdGlhbEluZGV4IiwiZmluZEluZGV4IiwiaSIsImhvdGtleSIsImV4dHJhY3RIb3RrZXkiLCJpbnB1dCIsInRvVXBwZXJDYXNlIiwiaXNBY3RpdmUiLCJpdGVtIiwic2V0VGltZW91dCIsIm1pbldpZHRoIiwicmVkdWNlIiwidyIsIk1hdGgiLCJtYXgiLCJsZW5ndGgiLCJtIiwiZXhlYyJdLCJzb3VyY2VzIjpbIlNlbGVjdEJveC5qc3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgdXNlRm9jdXMsIHVzZUlucHV0LCBUZXh0LCBCb3ggfSBmcm9tICdpbmsnO1xuaW1wb3J0IElua1NlbGVjdElucHV0IGZyb20gJ2luay1zZWxlY3QtaW5wdXQnOyBjb25zdCB7IGRlZmF1bHQ6IFNlbGVjdElucHV0IH0gPSBJbmtTZWxlY3RJbnB1dDtcblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gU2VsZWN0Qm94KHsgaWQsIGl0ZW1zLCBsYWJlbDogbGFiZWxXaXRoQW1wLCB2YWx1ZSwgaG9tZSwgb25TZWxlY3Q6IG9uU2VsZWN0Q2FsbGVyIH0pIHtcbiAgY29uc3QgeyBpc0ZvY3VzZWQsIGZvY3VzIH0gPSB1c2VGb2N1cyh7IGlkIH0pO1xuICBjb25zdCBpbml0aWFsSW5kZXggPSBpdGVtcy5maW5kSW5kZXgoaSA9PiBpLnZhbHVlID09PSB2YWx1ZSk7XG4gIGNvbnN0IFsgbGFiZWwsIGhvdGtleSBdID0gZXh0cmFjdEhvdGtleShsYWJlbFdpdGhBbXApO1xuICB1c2VJbnB1dCgoaW5wdXQpID0+IHtcbiAgICBpZiAoaW5wdXQudG9VcHBlckNhc2UoKSA9PT0gaG90a2V5KSB7XG4gICAgICBmb2N1cyhpZCk7XG4gICAgfVxuICB9LCB7IGlzQWN0aXZlOiAhaXNGb2N1c2VkIH0pO1xuICBpZiAoaXNGb2N1c2VkKSB7XG4gICAgY29uc3Qgb25TZWxlY3QgPSAoaXRlbSkgPT4ge1xuICAgICAgb25TZWxlY3RDYWxsZXI/LihpdGVtKTtcbiAgICAgIGlmIChob21lKSB7XG4gICAgICAgIC8vIHJlZm9jdXMgbWFpbiBjb250ZW50IChhZnRlciB0aGlzIGNvbXBvbmVudCBoYXMgdXBkYXRlZClcbiAgICAgICAgc2V0VGltZW91dCgoKSA9PiBmb2N1cyhob21lKSwgMCk7XG4gICAgICB9XG4gICAgfTtcbiAgICByZXR1cm4gKFxuICAgICAgPEJveCBib3JkZXJTdHlsZT1cInJvdW5kXCIgYm9yZGVyQ29sb3I9XCJibHVlXCI+XG4gICAgICAgIDxUZXh0PntsYWJlbH08L1RleHQ+XG4gICAgICAgIDxTZWxlY3RJbnB1dCB7Li4ueyBpdGVtcywgaW5pdGlhbEluZGV4LCBvblNlbGVjdCB9fSAvPlxuICAgICAgPC9Cb3g+XG4gICAgKTtcbiAgfSBlbHNlIHtcbiAgICBjb25zdCBtaW5XaWR0aCA9IGl0ZW1zLnJlZHVjZSgodywgaSkgPT4gTWF0aC5tYXgodywgaS5sYWJlbC5sZW5ndGggKyAyKSwgMik7XG4gICAgY29uc3QgaXRlbSA9IGl0ZW1zW2luaXRpYWxJbmRleF07XG4gICAgcmV0dXJuIChcbiAgICAgIDxCb3ggYm9yZGVyU3R5bGU9XCJyb3VuZFwiPlxuICAgICAgICA8VGV4dD57bGFiZWx9PC9UZXh0PlxuICAgICAgICA8Qm94IG1pbldpZHRoPXttaW5XaWR0aH0+PFRleHQ+OiB7aXRlbT8ubGFiZWx9PC9UZXh0PjwvQm94PlxuICAgICAgPC9Cb3g+XG4gICAgKTtcbiAgfVxufVxuXG5mdW5jdGlvbiBleHRyYWN0SG90a2V5KGxhYmVsV2l0aEFtcCkge1xuICBjb25zdCBtID0gLyguKikmKFxcdykoLiopLy5leGVjKGxhYmVsV2l0aEFtcCk7XG4gIGlmICghbSkge1xuICAgIHJldHVybiBbIGxhYmVsV2l0aEFtcCBdO1xuICB9XG4gIHJldHVybiBbXG4gICAgPFRleHQ+e21bMV19PFRleHQgdW5kZXJsaW5lPnttWzJdfTwvVGV4dD57bVszXX08L1RleHQ+LFxuICAgIG1bMl0udG9VcHBlckNhc2UoKVxuICBdO1xufSJdLCJtYXBwaW5ncyI6IkFBQUEsU0FBU0EsUUFBUSxFQUFFQyxRQUFRLEVBQUVDLElBQUksRUFBRUMsR0FBRyxRQUFRLEtBQUs7QUFDbkQsT0FBT0MsY0FBYyxNQUFNLGtCQUFrQjtBQUFDO0FBQUE7QUFBQyxNQUFNO0VBQUVDLE9BQU8sRUFBRUM7QUFBWSxDQUFDLEdBQUdGLGNBQWM7QUFFOUYsZUFBZSxTQUFTRyxTQUFTLENBQUM7RUFBRUMsRUFBRTtFQUFFQyxLQUFLO0VBQUVDLEtBQUssRUFBRUMsWUFBWTtFQUFFQyxLQUFLO0VBQUVDLElBQUk7RUFBRUMsUUFBUSxFQUFFQztBQUFlLENBQUMsRUFBRTtFQUMzRyxNQUFNO0lBQUVDLFNBQVM7SUFBRUM7RUFBTSxDQUFDLEdBQUdqQixRQUFRLENBQUM7SUFBRVE7RUFBRyxDQUFDLENBQUM7RUFDN0MsTUFBTVUsWUFBWSxHQUFHVCxLQUFLLENBQUNVLFNBQVMsQ0FBQ0MsQ0FBQyxJQUFJQSxDQUFDLENBQUNSLEtBQUssS0FBS0EsS0FBSyxDQUFDO0VBQzVELE1BQU0sQ0FBRUYsS0FBSyxFQUFFVyxNQUFNLENBQUUsR0FBR0MsYUFBYSxDQUFDWCxZQUFZLENBQUM7RUFDckRWLFFBQVEsQ0FBRXNCLEtBQUssSUFBSztJQUNsQixJQUFJQSxLQUFLLENBQUNDLFdBQVcsRUFBRSxLQUFLSCxNQUFNLEVBQUU7TUFDbENKLEtBQUssQ0FBQ1QsRUFBRSxDQUFDO0lBQ1g7RUFDRixDQUFDLEVBQUU7SUFBRWlCLFFBQVEsRUFBRSxDQUFDVDtFQUFVLENBQUMsQ0FBQztFQUM1QixJQUFJQSxTQUFTLEVBQUU7SUFDYixNQUFNRixRQUFRLEdBQUlZLElBQUksSUFBSztNQUN6QlgsY0FBYyxHQUFHVyxJQUFJLENBQUM7TUFDdEIsSUFBSWIsSUFBSSxFQUFFO1FBQ1I7UUFDQWMsVUFBVSxDQUFDLE1BQU1WLEtBQUssQ0FBQ0osSUFBSSxDQUFDLEVBQUUsQ0FBQyxDQUFDO01BQ2xDO0lBQ0YsQ0FBQztJQUNELG9CQUNFLE1BQUMsR0FBRztNQUFDLFdBQVcsRUFBQyxPQUFPO01BQUMsV0FBVyxFQUFDLE1BQU07TUFBQSx3QkFDekMsS0FBQyxJQUFJO1FBQUEsVUFBRUg7TUFBSyxFQUFRLGVBQ3BCLEtBQUMsV0FBVztRQUFPRCxLQUFLO1FBQUVTLFlBQVk7UUFBRUo7TUFBUSxFQUFNO0lBQUEsRUFDbEQ7RUFFVixDQUFDLE1BQU07SUFDTCxNQUFNYyxRQUFRLEdBQUduQixLQUFLLENBQUNvQixNQUFNLENBQUMsQ0FBQ0MsQ0FBQyxFQUFFVixDQUFDLEtBQUtXLElBQUksQ0FBQ0MsR0FBRyxDQUFDRixDQUFDLEVBQUVWLENBQUMsQ0FBQ1YsS0FBSyxDQUFDdUIsTUFBTSxHQUFHLENBQUMsQ0FBQyxFQUFFLENBQUMsQ0FBQztJQUMzRSxNQUFNUCxJQUFJLEdBQUdqQixLQUFLLENBQUNTLFlBQVksQ0FBQztJQUNoQyxvQkFDRSxNQUFDLEdBQUc7TUFBQyxXQUFXLEVBQUMsT0FBTztNQUFBLHdCQUN0QixLQUFDLElBQUk7UUFBQSxVQUFFUjtNQUFLLEVBQVEsZUFDcEIsS0FBQyxHQUFHO1FBQUMsUUFBUSxFQUFFa0IsUUFBUztRQUFBLHVCQUFDLE1BQUMsSUFBSTtVQUFBLFdBQUMsSUFBRSxFQUFDRixJQUFJLEVBQUVoQixLQUFLO1FBQUE7TUFBUSxFQUFNO0lBQUEsRUFDdkQ7RUFFVjtBQUNGO0FBRUEsU0FBU1ksYUFBYSxDQUFDWCxZQUFZLEVBQUU7RUFDbkMsTUFBTXVCLENBQUMsR0FBRyxlQUFlLENBQUNDLElBQUksQ0FBQ3hCLFlBQVksQ0FBQztFQUM1QyxJQUFJLENBQUN1QixDQUFDLEVBQUU7SUFDTixPQUFPLENBQUV2QixZQUFZLENBQUU7RUFDekI7RUFDQSxPQUFPLGNBQ0wsTUFBQyxJQUFJO0lBQUEsV0FBRXVCLENBQUMsQ0FBQyxDQUFDLENBQUMsZUFBQyxLQUFDLElBQUk7TUFBQyxTQUFTO01BQUEsVUFBRUEsQ0FBQyxDQUFDLENBQUM7SUFBQyxFQUFRLEVBQUNBLENBQUMsQ0FBQyxDQUFDLENBQUM7RUFBQSxFQUFRLEVBQ3REQSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUNWLFdBQVcsRUFBRSxDQUNuQjtBQUNIIn0= -------------------------------------------------------------------------------- /demo/ink/bin/main.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path'; 2 | import { Box, Text } from 'ink'; 3 | import ModemSpeedSelect from "./ModemSpeedSelect.mjs"; 4 | import BlinkingSelect from "./BlinkingSelect.mjs"; 5 | import ScrollingSelect from "./ScrollingSelect.mjs"; 6 | import TransparencySelect from "./TransparencySelect.mjs"; 7 | import AnsiDisplay from "./AnsiDisplay.mjs"; 8 | import AnsiSlideShow from "./AnsiSlideShow.mjs"; 9 | import FileList from "./FileList.mjs"; 10 | import { jsx as _jsx } from "react/jsx-runtime"; 11 | import { jsxs as _jsxs } from "react/jsx-runtime"; 12 | export default async function* main(methods) { 13 | const { 14 | wrap, 15 | manageRoute, 16 | manageEvents, 17 | replacing 18 | } = methods; 19 | const [parts] = manageRoute(); 20 | const [on, eventual] = manageEvents(); 21 | wrap(children => { 22 | return /*#__PURE__*/_jsxs(Box, { 23 | children: [/*#__PURE__*/_jsxs(Box, { 24 | flexDirection: "column", 25 | children: [/*#__PURE__*/_jsx(ModemSpeedSelect, {}), /*#__PURE__*/_jsx(BlinkingSelect, {}), /*#__PURE__*/_jsx(ScrollingSelect, {}), /*#__PURE__*/_jsx(TransparencySelect, {})] 26 | }), /*#__PURE__*/_jsx(Box, { 27 | children: children 28 | })] 29 | }); 30 | }); 31 | for (;;) { 32 | try { 33 | if (parts[0] === undefined) { 34 | replacing(() => parts[0] = 'list'); 35 | } else if (parts[0] === 'list') { 36 | yield /*#__PURE__*/_jsx(FileList, { 37 | folder: parts[1], 38 | selected: parts[2], 39 | onFileSelect: on.file, 40 | onFolderSelect: on.folder 41 | }); 42 | const { 43 | file, 44 | folder 45 | } = await eventual.file.or.folder; 46 | parts.splice(0); 47 | if (folder) { 48 | parts.push('list', folder); 49 | } else if (file) { 50 | parts.push('show', file); 51 | } 52 | } else if (parts[0] === 'show') { 53 | const path = parts[1]; 54 | yield /*#__PURE__*/_jsx(AnsiDisplay, { 55 | src: parts[1], 56 | onExit: on.exit 57 | }); 58 | await eventual.exit; 59 | parts.splice(0); 60 | parts.push('list', dirname(path), path); 61 | } else if (parts[0] === 'loop') { 62 | const paths = parts.slice(1); 63 | yield /*#__PURE__*/_jsx(AnsiSlideShow, { 64 | srcList: paths, 65 | onExit: on.exit 66 | }); 67 | await eventual.exit; 68 | process.exit(0); 69 | } else { 70 | throw new Error(`Unrecognized command: ${parts[0]}`); 71 | } 72 | } catch (err) { 73 | // not sure why Ink complains when nothing is yielded prior to program exit 74 | yield null; 75 | console.error(err.message); 76 | process.exit(1); 77 | } 78 | } 79 | } 80 | //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJkaXJuYW1lIiwiQm94IiwiVGV4dCIsIk1vZGVtU3BlZWRTZWxlY3QiLCJCbGlua2luZ1NlbGVjdCIsIlNjcm9sbGluZ1NlbGVjdCIsIlRyYW5zcGFyZW5jeVNlbGVjdCIsIkFuc2lEaXNwbGF5IiwiQW5zaVNsaWRlU2hvdyIsIkZpbGVMaXN0IiwibWFpbiIsIm1ldGhvZHMiLCJ3cmFwIiwibWFuYWdlUm91dGUiLCJtYW5hZ2VFdmVudHMiLCJyZXBsYWNpbmciLCJwYXJ0cyIsIm9uIiwiZXZlbnR1YWwiLCJjaGlsZHJlbiIsInVuZGVmaW5lZCIsImZpbGUiLCJmb2xkZXIiLCJvciIsInNwbGljZSIsInB1c2giLCJwYXRoIiwiZXhpdCIsInBhdGhzIiwic2xpY2UiLCJwcm9jZXNzIiwiRXJyb3IiLCJlcnIiLCJjb25zb2xlIiwiZXJyb3IiLCJtZXNzYWdlIl0sInNvdXJjZXMiOlsibWFpbi5qc3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgZGlybmFtZSB9IGZyb20gJ3BhdGgnO1xuaW1wb3J0IHsgQm94LCBUZXh0IH0gZnJvbSAnaW5rJztcbmltcG9ydCBNb2RlbVNwZWVkU2VsZWN0IGZyb20gJy4vTW9kZW1TcGVlZFNlbGVjdC5qc3gnO1xuaW1wb3J0IEJsaW5raW5nU2VsZWN0IGZyb20gJy4vQmxpbmtpbmdTZWxlY3QuanN4JztcbmltcG9ydCBTY3JvbGxpbmdTZWxlY3QgZnJvbSAnLi9TY3JvbGxpbmdTZWxlY3QuanN4JztcbmltcG9ydCBUcmFuc3BhcmVuY3lTZWxlY3QgZnJvbSAnLi9UcmFuc3BhcmVuY3lTZWxlY3QuanN4JztcbmltcG9ydCBBbnNpRGlzcGxheSBmcm9tICcuL0Fuc2lEaXNwbGF5LmpzeCc7XG5pbXBvcnQgQW5zaVNsaWRlU2hvdyBmcm9tICcuL0Fuc2lTbGlkZVNob3cuanN4JztcbmltcG9ydCBGaWxlTGlzdCBmcm9tICcuL0ZpbGVMaXN0LmpzeCc7XG5cbmV4cG9ydCBkZWZhdWx0IGFzeW5jIGZ1bmN0aW9uKiBtYWluKG1ldGhvZHMpIHtcbiAgY29uc3QgeyB3cmFwLCBtYW5hZ2VSb3V0ZSwgbWFuYWdlRXZlbnRzLCByZXBsYWNpbmcgfSA9IG1ldGhvZHM7XG4gIGNvbnN0IFsgcGFydHMgXSA9IG1hbmFnZVJvdXRlKCk7XG4gIGNvbnN0IFsgb24sIGV2ZW50dWFsIF0gPSBtYW5hZ2VFdmVudHMoKTtcbiAgd3JhcCgoY2hpbGRyZW4pID0+IHtcbiAgICByZXR1cm4gKFxuICAgICAgPEJveD5cbiAgICAgICAgPEJveCBmbGV4RGlyZWN0aW9uPVwiY29sdW1uXCI+XG4gICAgICAgICAgPE1vZGVtU3BlZWRTZWxlY3QgLz5cbiAgICAgICAgICA8QmxpbmtpbmdTZWxlY3QgLz5cbiAgICAgICAgICA8U2Nyb2xsaW5nU2VsZWN0IC8+XG4gICAgICAgICAgPFRyYW5zcGFyZW5jeVNlbGVjdCAvPlxuICAgICAgICA8L0JveD5cbiAgICAgICAgPEJveD5cbiAgICAgICAgICB7Y2hpbGRyZW59XG4gICAgICAgIDwvQm94PlxuICAgICAgPC9Cb3g+XG4gICAgKTtcbiAgfSk7XG4gIGZvciAoOzspIHtcbiAgICB0cnkge1xuICAgICAgaWYgKHBhcnRzWzBdID09PSB1bmRlZmluZWQpIHtcbiAgICAgICAgcmVwbGFjaW5nKCgpID0+IHBhcnRzWzBdID0gJ2xpc3QnKVxuICAgICAgfSBlbHNlIGlmIChwYXJ0c1swXSA9PT0gJ2xpc3QnKSB7XG4gICAgICAgIHlpZWxkIDxGaWxlTGlzdCBmb2xkZXI9e3BhcnRzWzFdfSBzZWxlY3RlZD17cGFydHNbMl19IG9uRmlsZVNlbGVjdD17b24uZmlsZX0gb25Gb2xkZXJTZWxlY3Q9e29uLmZvbGRlcn0gLz47XG4gICAgICAgIGNvbnN0IHsgZmlsZSwgZm9sZGVyIH0gPSBhd2FpdCBldmVudHVhbC5maWxlLm9yLmZvbGRlcjtcbiAgICAgICAgcGFydHMuc3BsaWNlKDApO1xuICAgICAgICBpZiAoZm9sZGVyKSB7XG4gICAgICAgICAgcGFydHMucHVzaCgnbGlzdCcsIGZvbGRlcik7XG4gICAgICAgIH0gZWxzZSBpZiAoZmlsZSkge1xuICAgICAgICAgIHBhcnRzLnB1c2goJ3Nob3cnLCBmaWxlKTtcbiAgICAgICAgfVxuICAgICAgfSBlbHNlIGlmIChwYXJ0c1swXSA9PT0gJ3Nob3cnKSB7XG4gICAgICAgIGNvbnN0IHBhdGggPSBwYXJ0c1sxXTtcbiAgICAgICAgeWllbGQgPEFuc2lEaXNwbGF5IHNyYz17cGFydHNbMV19IG9uRXhpdD17b24uZXhpdH0gLz47XG4gICAgICAgIGF3YWl0IGV2ZW50dWFsLmV4aXQ7XG4gICAgICAgIHBhcnRzLnNwbGljZSgwKTtcbiAgICAgICAgcGFydHMucHVzaCgnbGlzdCcsIGRpcm5hbWUocGF0aCksIHBhdGgpO1xuICAgICAgfSBlbHNlIGlmIChwYXJ0c1swXSA9PT0gJ2xvb3AnKSB7XG4gICAgICAgIGNvbnN0IHBhdGhzID0gcGFydHMuc2xpY2UoMSk7XG4gICAgICAgIHlpZWxkIDxBbnNpU2xpZGVTaG93IHNyY0xpc3Q9e3BhdGhzfSBvbkV4aXQ9e29uLmV4aXR9IC8+O1xuICAgICAgICBhd2FpdCBldmVudHVhbC5leGl0O1xuICAgICAgICBwcm9jZXNzLmV4aXQoMCk7XG4gICAgICB9IGVsc2Uge1xuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoYFVucmVjb2duaXplZCBjb21tYW5kOiAke3BhcnRzWzBdfWApO1xuICAgICAgfVxuICAgIH0gY2F0Y2ggKGVycikge1xuICAgICAgLy8gbm90IHN1cmUgd2h5IEluayBjb21wbGFpbnMgd2hlbiBub3RoaW5nIGlzIHlpZWxkZWQgcHJpb3IgdG8gcHJvZ3JhbSBleGl0XG4gICAgICB5aWVsZCBudWxsO1xuICAgICAgY29uc29sZS5lcnJvcihlcnIubWVzc2FnZSk7ICAgICAgXG4gICAgICBwcm9jZXNzLmV4aXQoMSk7XG4gICAgfVxuICB9XG59XG5cblxuIl0sIm1hcHBpbmdzIjoiQUFBQSxTQUFTQSxPQUFPLFFBQVEsTUFBTTtBQUM5QixTQUFTQyxHQUFHLEVBQUVDLElBQUksUUFBUSxLQUFLO0FBQy9CLE9BQU9DLGdC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 -------------------------------------------------------------------------------- /demo/ink/bin/cli.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { render, useInput } from 'ink'; 3 | import meow from 'meow'; 4 | import meowhelp from 'cli-meow-help'; 5 | import meowrev, { meowparse } from 'meow-reverse'; 6 | import parse from 'shell-quote/parse.js'; 7 | import quote from 'shell-quote/quote.js'; 8 | import { createContext } from 'react'; 9 | import { useSequential } from 'react-seq'; 10 | import { useSequentialRouter } from 'array-router'; 11 | import main from "./main.mjs"; 12 | import { jsx as _jsx } from "react/jsx-runtime"; 13 | const name = `ink-ansi-animation`; 14 | const commands = { 15 | 'show [FILE]': { 16 | desc: `Show an ANSI animation` 17 | }, 18 | 'loop [FILE]...': { 19 | desc: `Show files in a loop` 20 | }, 21 | 'list': { 22 | desc: `List ANSI files in current directory` 23 | } 24 | }; 25 | const flags = { 26 | modemSpeed: { 27 | desc: `Emulate modem of specific baudrate`, 28 | alias: 'm', 29 | type: 'number', 30 | default: 56000 31 | }, 32 | blinking: { 33 | desc: `Enable blinking text`, 34 | alias: 'b', 35 | type: 'boolean' 36 | }, 37 | scrolling: { 38 | desc: `Enable scrolling`, 39 | alias: 's', 40 | type: 'boolean' 41 | }, 42 | transparency: { 43 | desc: `Enable transparency`, 44 | alias: 't', 45 | type: 'boolean' 46 | } 47 | }; 48 | const helpText = meowhelp({ 49 | name, 50 | flags, 51 | commands 52 | }); 53 | const options = { 54 | importMeta: import.meta, 55 | flags 56 | }; 57 | const { 58 | input: parts, 59 | flags: query 60 | } = meow(helpText, options); 61 | function parseURL(_, { 62 | pathname 63 | }) { 64 | const argv = parse(pathname); 65 | const { 66 | input: parts, 67 | flags: query 68 | } = meowparse(argv, options); 69 | return { 70 | parts, 71 | query 72 | }; 73 | } 74 | function createURL(_, { 75 | parts: input, 76 | query: flags 77 | }) { 78 | const argv = meowrev({ 79 | input, 80 | flags 81 | }, options); 82 | const pathname = quote(argv); 83 | return new URL(`argv:${pathname}`); 84 | } 85 | function applyURL(currentURL) { 86 | globalThis.location.href = currentURL.href; 87 | } 88 | globalThis.location = createURL(null, { 89 | parts, 90 | query 91 | }); 92 | const SpecialContext = createContext(); 93 | function App() { 94 | useInput(input => { 95 | if (input.toLowerCase() === 'q') { 96 | process.exit(0); 97 | } 98 | }); 99 | // use command-line URL 100 | const override = { 101 | createURL, 102 | parseURL, 103 | applyURL 104 | }; 105 | const [parts, query, rMethods, { 106 | createContext, 107 | createBoundary 108 | }] = useSequentialRouter(override); 109 | return createContext(useSequential(sMethods => { 110 | const methods = { 111 | ...rMethods, 112 | ...sMethods 113 | }; 114 | const { 115 | fallback, 116 | wrap, 117 | trap, 118 | reject 119 | } = methods; 120 | // default fallback (issue #142 in React-seq 0.9.0) 121 | fallback(null); 122 | // create error boundary 123 | wrap(children => createBoundary(children)); 124 | // redirect error from boundary to generator function 125 | trap('error', err => reject(err)); 126 | // method for managing route 127 | methods.manageRoute = () => [parts, query]; 128 | return main(methods); 129 | }, [parts, query, rMethods, createBoundary])); 130 | } 131 | render( /*#__PURE__*/_jsx(App, {})); 132 | //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJyZW5kZXIiLCJ1c2VJbnB1dCIsIm1lb3ciLCJtZW93aGVscCIsIm1lb3dyZXYiLCJtZW93cGFyc2UiLCJwYXJzZSIsInF1b3RlIiwiY3JlYXRlQ29udGV4dCIsInVzZVNlcXVlbnRpYWwiLCJ1c2VTZXF1ZW50aWFsUm91dGVyIiwibWFpbiIsIm5hbWUiLCJjb21tYW5kcyIsImRlc2MiLCJmbGFncyIsIm1vZGVtU3BlZWQiLCJhbGlhcyIsInR5cGUiLCJkZWZhdWx0IiwiYmxpbmtpbmciLCJzY3JvbGxpbmciLCJ0cmFuc3BhcmVuY3kiLCJoZWxwVGV4dCIsIm9wdGlvbnMiLCJpbXBvcnRNZXRhIiwiaW1wb3J0IiwibWV0YSIsImlucHV0IiwicGFydHMiLCJxdWVyeSIsInBhcnNlVVJMIiwiXyIsInBhdGhuYW1lIiwiYXJndiIsImNyZWF0ZVVSTCIsIlVSTCIsImFwcGx5VVJMIiwiY3VycmVudFVSTCIsImdsb2JhbFRoaXMiLCJsb2NhdGlvbiIsImhyZWYiLCJTcGVjaWFsQ29udGV4dCIsIkFwcCIsInRvTG93ZXJDYXNlIiwicHJvY2VzcyIsImV4aXQiLCJvdmVycmlkZSIsInJNZXRob2RzIiwiY3JlYXRlQm91bmRhcnkiLCJzTWV0aG9kcyIsIm1ldGhvZHMiLCJmYWxsYmFjayIsIndyYXAiLCJ0cmFwIiwicmVqZWN0IiwiY2hpbGRyZW4iLCJlcnIiLCJtYW5hZ2VSb3V0ZSJdLCJzb3VyY2VzIjpbImNsaS5qc3giXSwic291cmNlc0NvbnRlbnQiOlsiIyEvdXNyL2Jpbi9lbnYgbm9kZVxuaW1wb3J0IHsgcmVuZGVyLCB1c2VJbnB1dCB9IGZyb20gJ2luayc7XG5pbXBvcnQgbWVvdyBmcm9tICdtZW93JztcbmltcG9ydCBtZW93aGVscCBmcm9tICdjbGktbWVvdy1oZWxwJztcbmltcG9ydCBtZW93cmV2LCB7IG1lb3dwYXJzZSB9IGZyb20gJ21lb3ctcmV2ZXJzZSc7XG5pbXBvcnQgcGFyc2UgZnJvbSAnc2hlbGwtcXVvdGUvcGFyc2UuanMnO1xuaW1wb3J0IHF1b3RlIGZyb20gJ3NoZWxsLXF1b3RlL3F1b3RlLmpzJztcbmltcG9ydCB7IGNyZWF0ZUNvbnRleHQgfSBmcm9tICdyZWFjdCc7XG5pbXBvcnQgeyB1c2VTZXF1ZW50aWFsIH0gZnJvbSAncmVhY3Qtc2VxJztcbmltcG9ydCB7IHVzZVNlcXVlbnRpYWxSb3V0ZXIgfSBmcm9tICdhcnJheS1yb3V0ZXInO1xuaW1wb3J0IG1haW4gZnJvbSAnLi9tYWluLmpzeCc7XG5cbmNvbnN0IG5hbWUgPSBgaW5rLWFuc2ktYW5pbWF0aW9uYDtcbmNvbnN0IGNvbW1hbmRzID0ge1xuICAnc2hvdyBbRklMRV0nOiB7IGRlc2M6IGBTaG93IGFuIEFOU0kgYW5pbWF0aW9uYCB9LFxuICAnbG9vcCBbRklMRV0uLi4nOiB7IGRlc2M6IGBTaG93IGZpbGVzIGluIGEgbG9vcGAgfSxcblx0J2xpc3QnOiB7IGRlc2M6IGBMaXN0IEFOU0kgZmlsZXMgaW4gY3VycmVudCBkaXJlY3RvcnlgIH0sXG59O1xuY29uc3QgZmxhZ3MgPSB7XG4gIG1vZGVtU3BlZWQ6IHtcbiAgICBkZXNjOiBgRW11bGF0ZSBtb2RlbSBvZiBzcGVjaWZpYyBiYXVkcmF0ZWAsXG4gICAgYWxpYXM6ICdtJyxcbiAgICB0eXBlOiAnbnVtYmVyJyxcbiAgICBkZWZhdWx0OiA1NjAwMFxuICB9LFxuICBibGlua2luZzoge1xuICAgIGRlc2M6IGBFbmFibGUgYmxpbmtpbmcgdGV4dGAsXG4gICAgYWxpYXM6ICdiJyxcbiAgICB0eXBlOiAnYm9vbGVhbicsXG4gIH0sXG4gIHNjcm9sbGluZzoge1xuICAgIGRlc2M6IGBFbmFibGUgc2Nyb2xsaW5nYCxcbiAgICBhbGlhczogJ3MnLFxuICAgIHR5cGU6ICdib29sZWFuJyxcbiAgfSxcbiAgdHJhbnNwYXJlbmN5OiB7XG4gICAgZGVzYzogYEVuYWJsZSB0cmFuc3BhcmVuY3lgLFxuICAgIGFsaWFzOiAndCcsXG4gICAgdHlwZTogJ2Jvb2xlYW4nLFxuICB9LFxufTtcblxuY29uc3QgaGVscFRleHQgPSBtZW93aGVscCh7XHRuYW1lLFx0ZmxhZ3MsIGNvbW1hbmRzIH0pO1xuY29uc3Qgb3B0aW9ucyA9IHsgaW1wb3J0TWV0YTogaW1wb3J0Lm1ldGEsIGZsYWdzIH07XG5jb25zdCB7IGlucHV0OiBwYXJ0cywgZmxhZ3M6IHF1ZXJ5IH0gPSBtZW93KGhlbHBUZXh0LCBvcHRpb25zKTtcblxuZnVuY3Rpb24gcGFyc2VVUkwoXywgeyBwYXRobmFtZSB9KSB7XG4gIGNvbnN0IGFyZ3YgPSBwYXJzZShwYXRobmFtZSk7XG4gIGNvbnN0IHsgaW5wdXQ6IHBhcnRzLCBmbGFnczogcXVlcnkgfSA9IG1lb3dwYXJzZShhcmd2LCBvcHRpb25zKTtcbiAgcmV0dXJuIHsgcGFydHMsIHF1ZXJ5IH07XG59XG5cbmZ1bmN0aW9uIGNyZWF0ZVVSTChfLCB7IHBhcnRzOiBpbnB1dCwgcXVlcnk6IGZsYWdzIH0pIHtcbiAgY29uc3QgYXJndiA9IG1lb3dyZXYoeyBpbnB1dCwgZmxhZ3MgfSwgb3B0aW9ucyk7XG4gIGNvbnN0IHBhdGhuYW1lID0gcXVvdGUoYXJndik7XG4gIHJldHVybiBuZXcgVVJMKGBhcmd2OiR7cGF0aG5hbWV9YCk7XG59XG5cbmZ1bmN0aW9uIGFwcGx5VVJMKGN1cnJlbnRVUkwpIHtcbiAgZ2xvYmFsVGhpcy5sb2NhdGlvbi5ocmVmID0gY3VycmVudFVSTC5ocmVmO1xufVxuXG5nbG9iYWxUaGlzLmxvY2F0aW9uID0gY3JlYXRlVVJMKG51bGwsIHsgcGFydHMsIHF1ZXJ5IH0pO1xuXG5jb25zdCBTcGVjaWFsQ29udGV4dCA9IGNyZWF0ZUNvbnRleHQoKTtcblxuZnVuY3Rpb24gQXBwKCkge1xuICB1c2VJbnB1dCgoaW5wdXQpID0+IHtcbiAgICBpZiAoaW5wdXQudG9Mb3dlckNhc2UoKSA9PT0gJ3EnKSB7XG4gICAgICBwcm9jZXNzLmV4aXQoMCk7XG4gICAgfVxuICB9KVxuICAvLyB1c2UgY29tbWFuZC1saW5lIFVSTFxuICBjb25zdCBvdmVycmlkZSA9IHsgY3JlYXRlVVJMLCBwYXJzZVVSTCwgYXBwbHlVUkwgfTtcbiAgY29uc3QgWyBwYXJ0cywgcXVlcnksIHJNZXRob2RzLCB7IGNyZWF0ZUNvbnRleHQsIGNyZWF0ZUJvdW5kYXJ5IH0gXSA9IHVzZVNlcXVlbnRpYWxSb3V0ZXIob3ZlcnJpZGUpO1xuICByZXR1cm4gY3JlYXRlQ29udGV4dCh1c2VTZXF1ZW50aWFsKChzTWV0aG9kcykgPT4ge1xuICAgIGNvbnN0IG1ldGhvZHMgPSB7IC4uLnJNZXRob2RzLCAuLi5zTWV0aG9kcyB9O1xuICAgIGNvbnN0IHsgZmFsbGJhY2ssIHdyYXAsIHRyYXAsIHJlamVjdCB9ID0gbWV0aG9kcztcbiAgICAvLyBkZWZhdWx0IGZhbGxiYWNrIChpc3N1ZSAjMTQyIGluIFJlYWN0LXNlcSAwLjkuMClcbiAgICBmYWxsYmFjayhudWxsKTtcbiAgICAvLyBjcmVhdGUgZXJyb3IgYm91bmRhcnlcbiAgICB3cmFwKGNoaWxkcmVuID0+IGNyZWF0ZUJvdW5kYXJ5KGNoaWxkcmVuKSk7XG4gICAgLy8gcmVkaXJlY3QgZXJyb3IgZnJvbSBib3VuZGFyeSB0byBnZW5lcmF0b3IgZnVuY3Rpb25cbiAgICB0cmFwKCdlcnJvcicsIGVyciA9PiByZWplY3QoZXJyKSk7XG4gICAgLy8gbWV0aG9kIGZvciBtYW5hZ2luZyByb3V0ZVxuICAgIG1ldGhvZHMubWFuYWdlUm91dGUgPSAoKSA9PiBbIHBhcnRzLCBxdWVyeSBdO1xuICAgIHJldHVybiBtYWluKG1ldGhvZHMpO1xuICB9LCBbIHBhcnRzLCBxdWVyeSwgck1ldGhvZHMsIGNyZWF0ZUJvdW5kYXJ5IF0pKTtcbn1cblxucmVuZGVyKDxBcHAgLz4pO1xuIl0sIm1hcHBpbmdzIjoiQUFBQTtBQUNBLFNBQVNBLE1BQU0sRUFBRUMsUUFBUSxRQUFRLEtBQUs7QUFDdEMsT0FBT0MsSUFBSSxNQUFNLE1BQU07QUFDdkIsT0FBT0MsUUFBUSxNQUFNLGVBQWU7QUFDcEMsT0FBT0MsT0FBTyxJQUFJQyxTQUFTLFFBQVEsY0FBYztBQUNqRCxPQUFPQyxLQUFLLE1BQU0sc0JBQXNCO0FBQ3hDLE9BQU9DLEtBQUss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 -------------------------------------------------------------------------------- /test/dom-components.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { readFile, stat } from 'fs/promises'; 3 | import { createElement } from 'react'; 4 | import { delay } from 'react-seq'; 5 | import { withTestRenderer } from './test-renderer.js'; 6 | 7 | import { 8 | AnsiText 9 | } from '../index.js'; 10 | 11 | describe('DOM components', function() { 12 | describe('#AnsiText', function() { 13 | it('should produce empty block of text at min width and height when neither src or srcObject is provided', async function() { 14 | await withTestRenderer(async ({ render, toJSON }) => { 15 | const el = createElement(AnsiText, { minHeight: 4 }); 16 | await render(el); 17 | const node = toJSON(); 18 | expect(node).to.have.property('type', 'code'); 19 | expect(node.props).to.have.property('className', 'AnsiText'); 20 | expect(node.children).to.have.lengthOf(4); 21 | for (const { children: line } of node.children) { 22 | const segment = line[0]; 23 | expect(segment).to.have.property('type', 'span'); 24 | expect(segment.props.style).to.have.property('color', undefined); 25 | expect(segment.props.style).to.have.property('backgroundColor', undefined); 26 | const text = segment.children[0]; 27 | expect(text).to.have.lengthOf(79); 28 | expect(text).to.match(/^\s+$/); 29 | } 30 | }); 31 | }) 32 | it('should output error message when fetch throws', async function() { 33 | await withTestRenderer(async ({ render, toJSON }) => { 34 | try { 35 | global.fetch = () => { 36 | // checking handling of non-ASCII characters 37 | throw new Error('Stało się coś strasznego'); 38 | }; 39 | const src = 'http://whatever'; 40 | let error; 41 | const el = createElement(AnsiText, { src, minHeight: 4, onError: (err) => error = err }); 42 | await render(el); 43 | const node = toJSON(); 44 | expect(node).to.have.property('type', 'code'); 45 | expect(node.props).to.have.property('className', 'AnsiText'); 46 | expect(node.children).to.have.lengthOf(4); 47 | const segment = node.children[0].children[0]; 48 | expect(segment).to.have.property('type', 'span'); 49 | expect(segment.props.style).to.have.property('color', '#aaaaaa'); 50 | expect(segment.props.style).to.have.property('backgroundColor', '#000000'); 51 | const text = segment.children[0]; 52 | expect(text).to.match(/^Sta\?o si\? co\? strasznego\s+/); 53 | expect(error).to.be.an('error'); 54 | } finally { 55 | delete global.fetch; 56 | } 57 | }); 58 | }) 59 | it('should accept a buffer as srcObject', async function() { 60 | await withTestRenderer(async ({ render, toJSON }) => { 61 | const srcObject = await readFile(resolve('./ansi/LDA-GARFIELD.ANS')); 62 | const el = createElement(AnsiText, { srcObject, maxHeight: 1024 }); 63 | await render(el); 64 | const node = toJSON(); 65 | expect(node).to.have.property('type', 'code'); 66 | expect(node.props).to.have.property('className', 'AnsiText'); 67 | expect(node.children).to.have.lengthOf(40); 68 | }); 69 | }) 70 | it('should call onMetadata when file contains metadata', async function() { 71 | await withTestRenderer(async ({ render, toJSON }) => { 72 | const srcObject = await readFile(resolve('./ansi/LDA-GARFIELD.ANS')); 73 | let metadata = null; 74 | const el = createElement(AnsiText, { srcObject, modemSpeed: Infinity, onMetadata: (m) => metadata = m }); 75 | await render(el); 76 | expect(metadata).to.be.an('array'); 77 | }); 78 | }) 79 | it('should accept a promise as srcObject', async function() { 80 | await withTestRenderer(async ({ render, toJSON }) => { 81 | const srcObject = readFile(resolve('./ansi/LDA-GARFIELD.ANS')); 82 | const el = createElement(AnsiText, { srcObject, maxHeight: 1024 }); 83 | await render(el); 84 | await delay(50); 85 | const node = toJSON(); 86 | expect(node).to.have.property('type', 'code'); 87 | expect(node.props).to.have.property('className', 'AnsiText'); 88 | expect(node.children).to.have.lengthOf(40); 89 | }); 90 | }) 91 | it('should return status through onStatus', async function() { 92 | await withTestRenderer(async ({ render, toJSON }) => { 93 | const srcObject = await readFile(resolve('./ansi/LDA-GARFIELD.ANS')); 94 | let status = null; 95 | const el = createElement(AnsiText, { 96 | srcObject, 97 | maxHeight: 1024, 98 | onStatus: s => status = s, 99 | }); 100 | await render(el); 101 | await delay(50); 102 | const node = toJSON(); 103 | expect(node).to.have.property('type', 'code'); 104 | expect(node.props).to.have.property('className', 'AnsiText'); 105 | expect(node.children).to.have.lengthOf(40); 106 | expect(status.position).to.be.at.least(0).and.at.most(0.5); 107 | }); 108 | }) 109 | it('should display blinking text', async function() { 110 | await withTestRenderer(async ({ render, toJSON }) => { 111 | const srcObject = await readFile(resolve('./ansi/US-CANDLES.ANS')); 112 | const el = createElement(AnsiText, { srcObject, blinking: true, modemSpeed: Infinity, blinkDuration: 100 }); 113 | await render(el); 114 | const node1 = toJSON(); 115 | await delay(120); 116 | const node2 = toJSON(); 117 | expect(node2).to.not.eql(node1); 118 | await delay(120); 119 | const node3 = toJSON(); 120 | expect(node3).to.not.eql(node2); 121 | expect(node3).to.eql(node1); 122 | }); 123 | }) 124 | it('should leave out background color from undrawn area when transparency is on', async function() { 125 | await withTestRenderer(async ({ render, toJSON }) => { 126 | const srcObject = await readFile(resolve('./ansi/LDA-GARFIELD.ANS')); 127 | const el = createElement(AnsiText, { srcObject, transparency: true }); 128 | await render(el); 129 | const node = toJSON(); 130 | let transparentSegment; 131 | for (const line of node.children) { 132 | for (const segment of line.children) { 133 | if (!segment.props.style.backgroundColor) { 134 | transparentSegment = segment; 135 | } 136 | } 137 | } 138 | expect(transparentSegment).to.not.be.null; 139 | }); 140 | }) 141 | it('should load data through fetch when src is given', async function() { 142 | await withTestRenderer(async ({ render, toJSON }) => { 143 | let called = false; 144 | global.fetch = async function(path) { 145 | called = true; 146 | const data = await readFile(resolve(path)); 147 | return { 148 | status: 200, 149 | statusText: 'OK', 150 | arrayBuffer: async () => data, 151 | }; 152 | }; 153 | try { 154 | const el = createElement(AnsiText, { src: './ansi/LDA-GARFIELD.ANS', maxHeight: 1024 }); 155 | await render(el); 156 | await delay(10); 157 | expect(called).to.be.true; 158 | const node = toJSON(); 159 | expect(node).to.have.property('type', 'code'); 160 | expect(node.props).to.have.property('className', 'AnsiText'); 161 | expect(node.children).to.have.lengthOf(40); 162 | } finally { 163 | delete global.fetch; 164 | } 165 | }); 166 | }) 167 | it('should display error when fetch does not return 200 OK', async function() { 168 | await withTestRenderer(async ({ render, toJSON }) => { 169 | let called = false; 170 | global.fetch = async function(path) { 171 | called = true; 172 | await delay(10); 173 | return { 174 | status: 404, 175 | statusText: 'Not Found', 176 | }; 177 | }; 178 | try { 179 | const el = createElement(AnsiText, { src: './ansi/LDA-GARFIELD.ANS' }); 180 | await render(el); 181 | await delay(30); 182 | expect(called).to.be.true; 183 | const node = toJSON(); 184 | expect(node).to.have.property('type', 'code'); 185 | expect(node.props).to.have.property('className', 'AnsiText'); 186 | expect(node.children).to.have.lengthOf(22); 187 | const segment = node.children[0].children[0]; 188 | expect(segment).to.have.property('type', 'span'); 189 | expect(segment.props.style).to.have.property('color', '#aaaaaa'); 190 | expect(segment.props.style).to.have.property('backgroundColor', '#000000'); 191 | const text = segment.children[0]; 192 | expect(text).to.match(/^HTTP 404 \- Not Found\s+$/); 193 | } finally { 194 | delete global.fetch; 195 | } 196 | }); 197 | }) 198 | it('should blink text using CSS when palette is set to css', async function() { 199 | await withTestRenderer(async ({ render, toJSON }) => { 200 | const srcObject = await readFile(resolve('./ansi/US-CANDLES.ANS')); 201 | const el = createElement(AnsiText, { 202 | srcObject, 203 | blinking: true, 204 | modemSpeed: Infinity, 205 | blinkDuration: 100 , 206 | palette: 'css', 207 | }); 208 | await render(el); 209 | const node1 = toJSON(); 210 | const segment = node1.children[0].children[0]; 211 | expect(segment.props).to.not.have.property('style'); 212 | expect(segment.props).to.have.property('className').that.matches(/fgColor\d+ bgColor\d+/); 213 | await delay(120); 214 | let blinkingSegment = null; 215 | for (const line of node1.children) { 216 | for (const segment of line.children) { 217 | if (/\bblink\b/.test(segment.props.className)) { 218 | blinkingSegment = segment; 219 | } 220 | } 221 | } 222 | expect(blinkingSegment).to.be.null; 223 | const node2 = toJSON(); 224 | expect(node2).to.not.eql(node1); 225 | for (const line of node2.children) { 226 | for (const segment of line.children) { 227 | if (/\bblink\b/.test(segment.props.className)) { 228 | blinkingSegment = segment; 229 | } 230 | } 231 | } 232 | expect(blinkingSegment).to.not.be.null; 233 | expect(blinkingSegment.props.className).to.match(/fgColor\d+ bgColor\d+ blink\b/); 234 | }); 235 | }) 236 | it('should use CSS for blinking when blinking is also set to css', async function() { 237 | await withTestRenderer(async ({ render, toJSON }) => { 238 | const srcObject = await readFile(resolve('./ansi/US-CANDLES.ANS')); 239 | const el = createElement(AnsiText, { 240 | srcObject, 241 | blinking: 'css', 242 | modemSpeed: Infinity, 243 | blinkDuration: 100 , 244 | palette: 'css', 245 | }); 246 | await render(el); 247 | const node1 = toJSON(); 248 | const segment = node1.children[0].children[0]; 249 | expect(segment.props).to.not.have.property('style'); 250 | expect(segment.props).to.have.property('className').that.matches(/fgColor\d+ bgColor\d+/); 251 | let blinkingSegment = null; 252 | for (const line of node1.children) { 253 | for (const segment of line.children) { 254 | if (/\bblinking\b/.test(segment.props.className)) { 255 | blinkingSegment = segment; 256 | } 257 | } 258 | } 259 | expect(blinkingSegment).to.not.be.null; 260 | expect(blinkingSegment.props.className).to.match(/fgColor\d+ bgColor\d+ blinking\b/); 261 | await delay(120); 262 | const node2 = toJSON(); 263 | expect(node2).to.eql(node1); 264 | }); 265 | }) 266 | }) 267 | }) 268 | -------------------------------------------------------------------------------- /demo/ink/bin/FileList.mjs: -------------------------------------------------------------------------------- 1 | import { readdir, open } from 'fs/promises'; 2 | import { basename, normalize } from 'path'; 3 | import { useProgressive } from 'react-seq'; 4 | import { useFocus, Box, Text } from 'ink'; 5 | import MulticolumnSelectInput from 'ink-multicolumn-select-input'; 6 | import InkSpinner from 'ink-spinner'; 7 | import { jsx as _jsx } from "react/jsx-runtime"; 8 | import { jsxs as _jsxs } from "react/jsx-runtime"; 9 | const { 10 | default: Spinner 11 | } = InkSpinner; 12 | export default function FileList({ 13 | folder = '.', 14 | selected = '', 15 | onFileSelect, 16 | onFolderSelect 17 | }) { 18 | return useProgressive(async ({ 19 | fallback, 20 | type, 21 | usable, 22 | defer 23 | }) => { 24 | fallback( /*#__PURE__*/_jsx(LoadingScreen, {})); 25 | type(FileListUI); 26 | usable(5); 27 | defer(100); 28 | return { 29 | folders: findSubfolder(folder), 30 | files: findAnsiFiles(folder), 31 | selected, 32 | onFileSelect, 33 | onFolderSelect 34 | }; 35 | }, [folder]); 36 | } 37 | 38 | // typical ANSI files are 79x24 39 | const width = 81; 40 | const height = 26; 41 | const borderStyle = 'round'; 42 | function FileListUI({ 43 | folders = [], 44 | files = [], 45 | selected, 46 | onFileSelect, 47 | onFolderSelect 48 | }) { 49 | const { 50 | isFocused 51 | } = useFocus({ 52 | id: 'main', 53 | autoFocus: true 54 | }); 55 | const items = []; 56 | for (const folder of folders) { 57 | items.push({ 58 | label: '\u{1F5C0} ' + basename(folder), 59 | value: folder, 60 | type: 'folder' 61 | }); 62 | } 63 | for (const file of files) { 64 | items.push({ 65 | label: '\u{1F5CF} ' + basename(file), 66 | value: file, 67 | type: 'file' 68 | }); 69 | } 70 | // ensure that columns are wide enough for the longest filename 71 | const maxWidth = items.reduce((m, i) => m = Math.max(m, i.label.length), 0); 72 | const columnCount = Math.max(1, Math.floor(80 / (maxWidth + 2))); 73 | const limit = height - 2; 74 | const initialIndex = Math.max(0, items.findIndex(i => i.value === selected)); 75 | const borderColor = isFocused ? 'blue' : undefined; 76 | const onSelect = ({ 77 | value, 78 | type 79 | }) => { 80 | if (type === 'folder') { 81 | onFolderSelect?.(value); 82 | } else if (type === 'file') { 83 | onFileSelect?.(value); 84 | } 85 | }; 86 | return /*#__PURE__*/_jsx(Box, { 87 | borderStyle, 88 | borderColor, 89 | width, 90 | children: /*#__PURE__*/_jsx(MulticolumnSelectInput, { 91 | items, 92 | limit, 93 | columnCount, 94 | isFocused, 95 | initialIndex, 96 | onSelect 97 | }) 98 | }); 99 | } 100 | export function LoadingScreen() { 101 | const alignItems = 'center'; 102 | const justifyContent = 'center'; 103 | return /*#__PURE__*/_jsx(Box, { 104 | borderStyle, 105 | width, 106 | height, 107 | alignItems, 108 | justifyContent, 109 | children: /*#__PURE__*/_jsxs(Text, { 110 | children: [" ", /*#__PURE__*/_jsx(Spinner, {}), " Loading"] 111 | }) 112 | }); 113 | } 114 | async function* findSubfolder(folder) { 115 | const list = await readdir(folder, { 116 | withFileTypes: true 117 | }); 118 | yield normalize(`${folder}/..`); 119 | for (const entry of list) { 120 | if (entry.isDirectory() && !entry.name.startsWith('.')) { 121 | yield `${folder}/${entry.name}`; 122 | } 123 | } 124 | } 125 | async function* findAnsiFiles(folder) { 126 | const list = await readdir(folder, { 127 | withFileTypes: true 128 | }); 129 | for (const entry of list) { 130 | if (entry.isFile() && !entry.name.startsWith('.')) { 131 | const path = `${folder}/${entry.name}`; 132 | if (await isAnsiFile(path)) { 133 | yield path; 134 | } 135 | } 136 | } 137 | } 138 | export async function isAnsiFile(path) { 139 | let file; 140 | try { 141 | file = await open(path); 142 | const buffer = Buffer.alloc(1024); 143 | const { 144 | bytesRead 145 | } = await file.read(buffer, 0, buffer.length); 146 | const array = new Uint8Array(buffer, 0, bytesRead); 147 | for (let i = 0; i < array.length - 1; i++) { 148 | if (array[i] === 0x1B && array[i + 1] === 0x5B) { 149 | return true; 150 | } 151 | } 152 | } catch (err) {} finally { 153 | await file?.close(); 154 | } 155 | return false; 156 | } 157 | //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJyZWFkZGlyIiwib3BlbiIsImJhc2VuYW1lIiwibm9ybWFsaXplIiwidXNlUHJvZ3Jlc3NpdmUiLCJ1c2VGb2N1cyIsIkJveCIsIlRleHQiLCJNdWx0aWNvbHVtblNlbGVjdElucHV0IiwiSW5rU3Bpbm5lciIsImRlZmF1bHQiLCJTcGlubmVyIiwiRmlsZUxpc3QiLCJmb2xkZXIiLCJzZWxlY3RlZCIsIm9uRmlsZVNlbGVjdCIsIm9uRm9sZGVyU2VsZWN0IiwiZmFsbGJhY2siLCJ0eXBlIiwidXNhYmxlIiwiZGVmZXIiLCJGaWxlTGlzdFVJIiwiZm9sZGVycyIsImZpbmRTdWJmb2xkZXIiLCJmaWxlcyIsImZpbmRBbnNpRmlsZXMiLCJ3aWR0aCIsImhlaWdodCIsImJvcmRlclN0eWxlIiwiaXNGb2N1c2VkIiwiaWQiLCJhdXRvRm9jdXMiLCJpdGVtcyIsInB1c2giLCJsYWJlbCIsInZhbHVlIiwiZmlsZSIsIm1heFdpZHRoIiwicmVkdWNlIiwibSIsImkiLCJNYXRoIiwibWF4IiwibGVuZ3RoIiwiY29sdW1uQ291bnQiLCJmbG9vciIsImxpbWl0IiwiaW5pdGlhbEluZGV4IiwiZmluZEluZGV4IiwiYm9yZGVyQ29sb3IiLCJ1bmRlZmluZWQiLCJvblNlbGVjdCIsIkxvYWRpbmdTY3JlZW4iLCJhbGlnbkl0ZW1zIiwianVzdGlmeUNvbnRlbnQiLCJsaXN0Iiwid2l0aEZpbGVUeXBlcyIsImVudHJ5IiwiaXNEaXJlY3RvcnkiLCJuYW1lIiwic3RhcnRzV2l0aCIsImlzRmlsZSIsInBhdGgiLCJpc0Fuc2lGaWxlIiwiYnVmZmVyIiwiQnVmZmVyIiwiYWxsb2MiLCJieXRlc1JlYWQiLCJyZWFkIiwiYXJyYXkiLCJVaW50OEFycmF5IiwiZXJyIiwiY2xvc2UiXSwic291cmNlcyI6WyJGaWxlTGlzdC5qc3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgcmVhZGRpciwgb3BlbiB9IGZyb20gJ2ZzL3Byb21pc2VzJztcbmltcG9ydCB7IGJhc2VuYW1lLCBub3JtYWxpemUgfSBmcm9tICdwYXRoJztcbmltcG9ydCB7IHVzZVByb2dyZXNzaXZlIH0gZnJvbSAncmVhY3Qtc2VxJztcbmltcG9ydCB7IHVzZUZvY3VzLCBCb3gsIFRleHQgfSBmcm9tICdpbmsnO1xuaW1wb3J0IE11bHRpY29sdW1uU2VsZWN0SW5wdXQgZnJvbSAnaW5rLW11bHRpY29sdW1uLXNlbGVjdC1pbnB1dCc7XG5pbXBvcnQgSW5rU3Bpbm5lciBmcm9tICdpbmstc3Bpbm5lcic7IGNvbnN0IHsgZGVmYXVsdDogU3Bpbm5lciB9ID0gSW5rU3Bpbm5lcjtcblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gRmlsZUxpc3QoeyBmb2xkZXIgPSAnLicsIHNlbGVjdGVkID0gJycsIG9uRmlsZVNlbGVjdCwgb25Gb2xkZXJTZWxlY3QgfSkge1xuICByZXR1cm4gdXNlUHJvZ3Jlc3NpdmUoYXN5bmMgKHsgZmFsbGJhY2ssIHR5cGUsIHVzYWJsZSwgZGVmZXIgfSkgPT4ge1xuICAgIGZhbGxiYWNrKDxMb2FkaW5nU2NyZWVuIC8+KTtcbiAgICB0eXBlKEZpbGVMaXN0VUkpO1xuICAgIHVzYWJsZSg1KTtcbiAgICBkZWZlcigxMDApO1xuICAgIHJldHVybiB7XG4gICAgICBmb2xkZXJzOiBmaW5kU3ViZm9sZGVyKGZvbGRlciksXG4gICAgICBmaWxlczogZmluZEFuc2lGaWxlcyhmb2xkZXIpLFxuICAgICAgc2VsZWN0ZWQsXG4gICAgICBvbkZpbGVTZWxlY3QsIFxuICAgICAgb25Gb2xkZXJTZWxlY3QsXG4gICAgfTtcbiAgfSwgWyBmb2xkZXIgXSk7XG59XG5cbi8vIHR5cGljYWwgQU5TSSBmaWxlcyBhcmUgNzl4MjRcbmNvbnN0IHdpZHRoID0gODE7XG5jb25zdCBoZWlnaHQgPSAyNjtcbmNvbnN0IGJvcmRlclN0eWxlID0gJ3JvdW5kJztcblxuZnVuY3Rpb24gRmlsZUxpc3RVSSh7IGZvbGRlcnMgPSBbXSwgZmlsZXMgPSBbXSwgc2VsZWN0ZWQsIG9uRmlsZVNlbGVjdCwgb25Gb2xkZXJTZWxlY3QgfSkge1xuICBjb25zdCB7IGlzRm9jdXNlZCB9ID0gdXNlRm9jdXMoeyBpZDogJ21haW4nLCBhdXRvRm9jdXM6IHRydWUgfSk7XG4gIGNvbnN0IGl0ZW1zID0gW107XG4gIGZvciAoY29uc3QgZm9sZGVyIG9mIGZvbGRlcnMpIHtcbiAgICBpdGVtcy5wdXNoKHsgbGFiZWw6ICdcXHV7MUY1QzB9ICcgKyBiYXNlbmFtZShmb2xkZXIpLCB2YWx1ZTogZm9sZGVyLCB0eXBlOiAnZm9sZGVyJyB9KVxuICB9XG4gIGZvciAoY29uc3QgZmlsZSBvZiBmaWxlcykge1xuICAgIGl0ZW1zLnB1c2goeyBsYWJlbDogJ1xcdXsxRjVDRn0gJyArIGJhc2VuYW1lKGZpbGUpLCB2YWx1ZTogZmlsZSwgdHlwZTogJ2ZpbGUnIH0pXG4gIH1cbiAgLy8gZW5zdXJlIHRoYXQgY29sdW1ucyBhcmUgd2lkZSBlbm91Z2ggZm9yIHRoZSBsb25nZXN0IGZpbGVuYW1lXG4gIGNvbnN0IG1heFdpZHRoID0gaXRlbXMucmVkdWNlKChtLCBpKSA9PiBtID0gTWF0aC5tYXgobSwgaS5sYWJlbC5sZW5ndGgpLCAwKTtcbiAgY29uc3QgY29sdW1uQ291bnQgPSBNYXRoLm1heCgxLCBNYXRoLmZsb29yKDgwIC8gKG1heFdpZHRoICsgMikpKTtcbiAgY29uc3QgbGltaXQgPSBoZWlnaHQgLSAyO1xuICBjb25zdCBpbml0aWFsSW5kZXggPSBNYXRoLm1heCgwLCBpdGVtcy5maW5kSW5kZXgoaSA9PiBpLnZhbHVlID09PSBzZWxlY3RlZCkpO1xuICBjb25zdCBib3JkZXJDb2xvciA9IChpc0ZvY3VzZWQpID8gJ2JsdWUnIDogdW5kZWZpbmVkO1xuICBjb25zdCBvblNlbGVjdCA9ICh7IHZhbHVlLCB0eXBlIH0pID0+IHtcbiAgICBpZiAodHlwZSA9PT0gJ2ZvbGRlcicpIHtcbiAgICAgIG9uRm9sZGVyU2VsZWN0Py4odmFsdWUpO1xuICAgIH0gZWxzZSBpZiAodHlwZSA9PT0gJ2ZpbGUnKSB7XG4gICAgICBvbkZpbGVTZWxlY3Q/Lih2YWx1ZSk7XG4gICAgfVxuICB9O1xuICByZXR1cm4gKFxuICAgIDxCb3ggey4uLnsgYm9yZGVyU3R5bGUsIGJvcmRlckNvbG9yLCB3aWR0aCB9fT5cbiAgICAgIDxNdWx0aWNvbHVtblNlbGVjdElucHV0IHsuLi57IGl0ZW1zLCBsaW1pdCwgY29sdW1uQ291bnQsIGlzRm9jdXNlZCwgaW5pdGlhbEluZGV4LCBvblNlbGVjdCB9fSAvPlxuICAgIDwvQm94PlxuICApO1xufVxuXG5leHBvcnQgZnVuY3Rpb24gTG9hZGluZ1NjcmVlbigpIHtcbiAgY29uc3QgYWxpZ25JdGVtcyA9ICdjZW50ZXInO1xuICBjb25zdCBqdXN0aWZ5Q29udGVudCA9ICdjZW50ZXInO1xuICByZXR1cm4gKFxuICAgIDxCb3ggey4uLnsgYm9yZGVyU3R5bGUsIHdpZHRoLCBoZWlnaHQsIGFsaWduSXRlbXMsIGp1c3RpZnlDb250ZW50IH19PlxuICAgICAgPFRleHQ+IDxTcGlubmVyIC8+IExvYWRpbmc8L1RleHQ+XG4gICAgPC9Cb3g+XG4gICk7XG59XG5cbmFzeW5jIGZ1bmN0aW9uKiBmaW5kU3ViZm9sZGVyKGZvbGRlcikge1xuICBjb25zdCBsaXN0ID0gYXdhaXQgcmVhZGRpcihmb2xkZXIsIHsgd2l0aEZpbGVUeXBlczogdHJ1ZSB9KTtcbiAgeWllbGQgbm9ybWFsaXplKGAke2ZvbGRlcn0vLi5gKTtcbiAgZm9yIChjb25zdCBlbnRyeSBvZiBsaXN0KSB7XG4gICAgaWYgKGVudHJ5LmlzRGlyZWN0b3J5KCkgJiYgIWVudHJ5Lm5hbWUuc3RhcnRzV2l0aCgnLicpKSB7XG4gICAgICB5aWVsZCBgJHtmb2xkZXJ9LyR7ZW50cnkubmFtZX1gO1xuICAgIH1cbiAgfVxufVxuXG5hc3luYyBmdW5jdGlvbiogZmluZEFuc2lGaWxlcyhmb2xkZXIpIHtcbiAgY29uc3QgbGlzdCA9IGF3YWl0IHJlYWRkaXIoZm9sZGVyLCB7IHdpdGhGaWxlVHlwZXM6IHRydWUgfSk7XG4gIGZvciAoY29uc3QgZW50cnkgb2YgbGlzdCkge1xuICAgIGlmIChlbnRyeS5pc0ZpbGUoKSAmJiAhZW50cnkubmFtZS5zdGFydHNXaXRoKCcuJykpIHtcbiAgICAgIGNvbnN0IHBhdGggPSBgJHtmb2xkZXJ9LyR7ZW50cnkubmFtZX1gO1xuICAgICAgaWYgKGF3YWl0IGlzQW5zaUZpbGUocGF0aCkpIHtcbiAgICAgICAgeWllbGQgcGF0aDtcbiAgICAgIH1cbiAgICB9XG4gIH1cbn1cblxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIGlzQW5zaUZpbGUocGF0aCkge1xuICBsZXQgZmlsZTtcbiAgdHJ5IHtcbiAgICBmaWxlID0gYXdhaXQgb3BlbihwYXRoKTtcbiAgICBjb25zdCBidWZmZXIgPSBCdWZmZXIuYWxsb2MoMTAyNCk7XG4gICAgY29uc3QgeyBieXRlc1JlYWQgfSA9IGF3YWl0IGZpbGUucmVhZChidWZmZXIsIDAsIGJ1ZmZlci5sZW5ndGgpO1xuICAgIGNvbnN0IGFycmF5ID0gbmV3IFVpbnQ4QXJyYXkoYnVmZmVyLCAwLCBieXRlc1JlYWQpO1xuICAgIGZvciAobGV0IGkgPSAwOyBpIDwgYXJyYXkubGVuZ3RoIC0gMTsgaSsrKSB7XG4gICAgICBpZiAoYXJyYXlbaV0gPT09IDB4MUIgJiYgYXJyYXlbaSArIDFdID09PSAweDVCKSB7XG4gICAgICAgIHJldHVybiB0cnVlO1xuICAgICAgfVxuICAgIH1cbiAgfSBjYXRjaCAoZXJyKSB7XG4gIH0gZmluYWxseSB7XG4gICAgYXdhaXQgZmlsZT8uY2xvc2UoKTtcbiAgfVxuICByZXR1cm4gZmFsc2U7XG59Il0sIm1hcHBpbmdzIjoiQUFBQSxTQUFTQSxPQUFPLEVBQUVDLElBQUksUUFBUSxhQUFhO0FBQzNDLFNBQVNDLFFBQVEsRUFBRUMsU0FBUyxRQUFRLE1BQU07QUFDMUMsU0FBU0MsY0FBYyxRQUFRLFdBQVc7QUFDMUMsU0FBU0MsUUFBUSxFQUFFQyxHQUFHLEVBQUVDLElBQUksUUFBUSxLQUFLO0FBQ3pDLE9BQU9DLHNCQUFzQixNQUFNLDhCQUE4QjtBQUNqRSxPQUFPQyxVQUFVLE1BQU0sYUFBYTtBQUFDO0FBQUE7QUFB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 -------------------------------------------------------------------------------- /demo/ink/bin/MulticolumnSelectInput.mjs: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect, createElement } from 'react'; 2 | import { Text, Box, useInput } from 'ink'; 3 | export default function MulticolumnSelectInput(props) { 4 | const { 5 | items = [], 6 | initialIndex = 0, 7 | isFocused = true, 8 | limit = 24, 9 | columnCount = 4, 10 | width = '100%', 11 | indicatorComponent = Indicator, 12 | itemComponent = Item, 13 | onSelect, 14 | onHighlight 15 | } = props; 16 | const [selectedIndex, setSelectedIndex] = useState(initialIndex); 17 | const [columnOffset, setColumnOffset] = useState(calculateColumnOffset(selectedIndex, columnCount, limit)); 18 | useInput((input, key) => { 19 | let newIndex = -1; 20 | if (input === 'k' || key.upArrow) { 21 | newIndex = selectedIndex - 1; 22 | } else if (input === 'j' || key.downArrow) { 23 | newIndex = selectedIndex + 1; 24 | } else if (input === 'h' || key.leftArrow) { 25 | newIndex = selectedIndex - limit; 26 | } else if (input === 'l' || key.rightArrow) { 27 | newIndex = selectedIndex + limit; 28 | if (newIndex >= items.length) { 29 | // jump to the last item only if we're in the second to last column 30 | const currentColumn = Math.floor((selectedIndex + 1) / limit); 31 | const lastColumn = Math.floor(items.length / limit); 32 | if (lastColumn > currentColumn) { 33 | newIndex = items.length - 1; 34 | } 35 | } 36 | } else if (input === '^' || input === '\u005BH') { 37 | newIndex = 0; 38 | } else if (input === '$' || input === '\u005BF') { 39 | newIndex = items.length - 1; 40 | } else if (key.return) { 41 | if (items[selectedIndex]) { 42 | onSelect?.(items[selectedIndex]); 43 | } 44 | } 45 | if (newIndex >= 0 && newIndex < items.length) { 46 | setSelectedIndex(newIndex); 47 | // adjust column offset so the selected item is in view 48 | setColumnOffset(calculateColumnOffset(initialIndex, columnCount, limit, columnOffset)); 49 | onHighlight?.(items[newIndex]); 50 | } 51 | }, { 52 | isActive: isFocused 53 | }); 54 | // reset selectIndex when items are different 55 | const previousItems = useRef(); 56 | useEffect(() => { 57 | const { 58 | current 59 | } = previousItems; 60 | if (current && items.some((item, i) => item.value !== current[i].value)) { 61 | setSelectedIndex(initialIndex); 62 | setColumnOffset(calculateColumnOffset(initialIndex, columnCount, limit)); 63 | } 64 | previousItems.current = items; 65 | }, [items, initialIndex, columnCount, limit]); 66 | const columns = []; 67 | for (let i = 0; i < columnCount; i++) { 68 | const rows = []; 69 | for (let j = 0, index = (columnOffset + i) * limit; j < limit && index < items.length; j++, index++) { 70 | const isSelected = index === selectedIndex; 71 | const indicator = createElement(indicatorComponent, { 72 | isSelected 73 | }); 74 | const item = createElement(itemComponent, { 75 | ...items[index], 76 | isSelected 77 | }); 78 | const row = createElement(Box, { 79 | key: index 80 | }, indicator, item); 81 | rows.push(row); 82 | } 83 | // use flex basis 0 so columns have the same width 84 | const column = createElement(Box, { 85 | key: i, 86 | flexDirection: 'column', 87 | flexGrow: 1, 88 | flexBasis: 0 89 | }, rows); 90 | columns.push(column); 91 | } 92 | return createElement(Box, { 93 | width 94 | }, columns); 95 | } 96 | function calculateColumnOffset(index, count, limit, offset = 0) { 97 | const columnIndex = Math.floor(Math.max(1, index + 1) / limit); 98 | let newOffset = offset; 99 | while (newOffset + count <= columnIndex) { 100 | newOffset++; 101 | } 102 | while (newOffset > columnIndex) { 103 | newOffset--; 104 | } 105 | return newOffset; 106 | } 107 | function Item({ 108 | isSelected = false, 109 | label 110 | }) { 111 | return createElement(Text, { 112 | inverse: isSelected 113 | }, ` ${label} `); 114 | } 115 | function Indicator({ 116 | isSelected 117 | }) { 118 | return null; 119 | } 120 | //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VTdGF0ZSIsInVzZVJlZiIsInVzZUVmZmVjdCIsImNyZWF0ZUVsZW1lbnQiLCJUZXh0IiwiQm94IiwidXNlSW5wdXQiLCJNdWx0aWNvbHVtblNlbGVjdElucHV0IiwicHJvcHMiLCJpdGVtcyIsImluaXRpYWxJbmRleCIsImlzRm9jdXNlZCIsImxpbWl0IiwiY29sdW1uQ291bnQiLCJ3aWR0aCIsImluZGljYXRvckNvbXBvbmVudCIsIkluZGljYXRvciIsIml0ZW1Db21wb25lbnQiLCJJdGVtIiwib25TZWxlY3QiLCJvbkhpZ2hsaWdodCIsInNlbGVjdGVkSW5kZXgiLCJzZXRTZWxlY3RlZEluZGV4IiwiY29sdW1uT2Zmc2V0Iiwic2V0Q29sdW1uT2Zmc2V0IiwiY2FsY3VsYXRlQ29sdW1uT2Zmc2V0IiwiaW5wdXQiLCJrZXkiLCJuZXdJbmRleCIsInVwQXJyb3ciLCJkb3duQXJyb3ciLCJsZWZ0QXJyb3ciLCJyaWdodEFycm93IiwibGVuZ3RoIiwiY3VycmVudENvbHVtbiIsIk1hdGgiLCJmbG9vciIsImxhc3RDb2x1bW4iLCJyZXR1cm4iLCJpc0FjdGl2ZSIsInByZXZpb3VzSXRlbXMiLCJjdXJyZW50Iiwic29tZSIsIml0ZW0iLCJpIiwidmFsdWUiLCJjb2x1bW5zIiwicm93cyIsImoiLCJpbmRleCIsImlzU2VsZWN0ZWQiLCJpbmRpY2F0b3IiLCJyb3ciLCJwdXNoIiwiY29sdW1uIiwiZmxleERpcmVjdGlvbiIsImZsZXhHcm93IiwiZmxleEJhc2lzIiwiY291bnQiLCJvZmZzZXQiLCJjb2x1bW5JbmRleCIsIm1heCIsIm5ld09mZnNldCIsImxhYmVsIiwiaW52ZXJzZSJdLCJzb3VyY2VzIjpbIk11bHRpY29sdW1uU2VsZWN0SW5wdXQuanN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IHVzZVN0YXRlLCB1c2VSZWYsIHVzZUVmZmVjdCwgY3JlYXRlRWxlbWVudCB9IGZyb20gJ3JlYWN0JztcbmltcG9ydCB7IFRleHQsIEJveCwgdXNlSW5wdXQgfSBmcm9tICdpbmsnO1xuXG5leHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBNdWx0aWNvbHVtblNlbGVjdElucHV0KHByb3BzKSB7XG4gIGNvbnN0IHsgXG4gICAgaXRlbXMgPSBbXSxcbiAgICBpbml0aWFsSW5kZXggPSAwLFxuICAgIGlzRm9jdXNlZCA9IHRydWUsXG4gICAgbGltaXQgPSAyNCxcbiAgICBjb2x1bW5Db3VudCA9IDQsXG4gICAgd2lkdGggPSAnMTAwJScsXG4gICAgaW5kaWNhdG9yQ29tcG9uZW50ID0gSW5kaWNhdG9yLFxuICAgIGl0ZW1Db21wb25lbnQgPSBJdGVtLCAgICBcbiAgICBvblNlbGVjdCxcbiAgICBvbkhpZ2hsaWdodCxcbiAgfSA9IHByb3BzO1xuICBjb25zdCBbIHNlbGVjdGVkSW5kZXgsIHNldFNlbGVjdGVkSW5kZXggXSA9IHVzZVN0YXRlKGluaXRpYWxJbmRleCk7XG4gIGNvbnN0IFsgY29sdW1uT2Zmc2V0LCBzZXRDb2x1bW5PZmZzZXQgXSA9IHVzZVN0YXRlKGNhbGN1bGF0ZUNvbHVtbk9mZnNldChzZWxlY3RlZEluZGV4LCBjb2x1bW5Db3VudCwgbGltaXQpKTtcbiAgdXNlSW5wdXQoKGlucHV0LCBrZXkpID0+IHtcbiAgICBsZXQgbmV3SW5kZXggPSAtMTtcbiAgICBpZiAoaW5wdXQgPT09ICdrJyB8fCBrZXkudXBBcnJvdykge1xuICAgICAgbmV3SW5kZXggPSBzZWxlY3RlZEluZGV4IC0gMTtcbiAgICB9IGVsc2UgaWYgKGlucHV0ID09PSAnaicgfHwga2V5LmRvd25BcnJvdykge1xuICAgICAgbmV3SW5kZXggPSBzZWxlY3RlZEluZGV4ICsgMTtcbiAgICB9IGVsc2UgaWYgKGlucHV0ID09PSAnaCcgfHwga2V5LmxlZnRBcnJvdykge1xuICAgICAgbmV3SW5kZXggPSBzZWxlY3RlZEluZGV4IC0gbGltaXQ7XG4gICAgfSBlbHNlIGlmIChpbnB1dCA9PT0gJ2wnIHx8IGtleS5yaWdodEFycm93KSB7XG4gICAgICBuZXdJbmRleCA9IHNlbGVjdGVkSW5kZXggKyBsaW1pdDtcbiAgICAgIGlmIChuZXdJbmRleCA+PSBpdGVtcy5sZW5ndGgpIHtcbiAgICAgICAgLy8ganVtcCB0byB0aGUgbGFzdCBpdGVtIG9ubHkgaWYgd2UncmUgaW4gdGhlIHNlY29uZCB0byBsYXN0IGNvbHVtblxuICAgICAgICBjb25zdCBjdXJyZW50Q29sdW1uID0gTWF0aC5mbG9vcigoc2VsZWN0ZWRJbmRleCArIDEpIC8gbGltaXQpO1xuICAgICAgICBjb25zdCBsYXN0Q29sdW1uID0gTWF0aC5mbG9vcihpdGVtcy5sZW5ndGggLyBsaW1pdCk7XG4gICAgICAgIGlmIChsYXN0Q29sdW1uID4gY3VycmVudENvbHVtbikge1xuICAgICAgICAgIG5ld0luZGV4ID0gaXRlbXMubGVuZ3RoIC0gMTtcbiAgICAgICAgfVxuICAgICAgfVxuICAgIH0gZWxzZSBpZiAoaW5wdXQgPT09ICdeJyB8fCBpbnB1dCA9PT0gJ1xcdTAwNUJIJykge1xuICAgICAgbmV3SW5kZXggPSAwO1xuICAgIH0gZWxzZSBpZiAoaW5wdXQgPT09ICckJyB8fCBpbnB1dCA9PT0gJ1xcdTAwNUJGJykge1xuICAgICAgbmV3SW5kZXggPSBpdGVtcy5sZW5ndGggLSAxO1xuICAgIH0gZWxzZSBpZiAoa2V5LnJldHVybikge1xuICAgICAgaWYgKGl0ZW1zW3NlbGVjdGVkSW5kZXhdKSB7XG4gICAgICAgIG9uU2VsZWN0Py4oaXRlbXNbc2VsZWN0ZWRJbmRleF0pO1xuICAgICAgfVxuICAgIH0gICBcbiAgICBpZiAobmV3SW5kZXggPj0gMCAmJiBuZXdJbmRleCA8IGl0ZW1zLmxlbmd0aCkge1xuICAgICAgc2V0U2VsZWN0ZWRJbmRleChuZXdJbmRleCk7XG4gICAgICAvLyBhZGp1c3QgY29sdW1uIG9mZnNldCBzbyB0aGUgc2VsZWN0ZWQgaXRlbSBpcyBpbiB2aWV3XG4gICAgICBzZXRDb2x1bW5PZmZzZXQoY2FsY3VsYXRlQ29sdW1uT2Zmc2V0KGluaXRpYWxJbmRleCwgY29sdW1uQ291bnQsIGxpbWl0LCBjb2x1bW5PZmZzZXQpKTtcbiAgICAgIG9uSGlnaGxpZ2h0Py4oaXRlbXNbbmV3SW5kZXhdKTtcbiAgICB9XG4gIH0sIHsgaXNBY3RpdmU6IGlzRm9jdXNlZCB9KTtcbiAgLy8gcmVzZXQgc2VsZWN0SW5kZXggd2hlbiBpdGVtcyBhcmUgZGlmZmVyZW50IFxuXHRjb25zdCBwcmV2aW91c0l0ZW1zID0gdXNlUmVmKCk7XG4gIHVzZUVmZmVjdCgoKSA9PiB7XG4gICAgY29uc3QgeyBjdXJyZW50IH0gPSBwcmV2aW91c0l0ZW1zO1xuICAgIGlmIChjdXJyZW50ICYmIGl0ZW1zLnNvbWUoKGl0ZW0sIGkpID0+IGl0ZW0udmFsdWUgIT09IGN1cnJlbnRbaV0udmFsdWUpKSB7XG4gICAgICBzZXRTZWxlY3RlZEluZGV4KGluaXRpYWxJbmRleCk7XG4gICAgICBzZXRDb2x1bW5PZmZzZXQoY2FsY3VsYXRlQ29sdW1uT2Zmc2V0KGluaXRpYWxJbmRleCwgY29sdW1uQ291bnQsIGxpbWl0KSk7XG4gICAgfVxuICAgIHByZXZpb3VzSXRlbXMuY3VycmVudCA9IGl0ZW1zO1xuICB9LCBbIGl0ZW1zLCBpbml0aWFsSW5kZXgsIGNvbHVtbkNvdW50LCBsaW1pdCBdKTtcbiAgY29uc3QgY29sdW1ucyA9IFtdO1xuICBmb3IgKGxldCBpID0gMDsgaSA8IGNvbHVtbkNvdW50OyBpKyspIHsgICAgXG4gICAgY29uc3Qgcm93cyA9IFtdO1xuICAgIGZvciAobGV0IGogPSAwLCBpbmRleCA9IChjb2x1bW5PZmZzZXQgKyBpKSAqIGxpbWl0OyBqIDwgbGltaXQgJiYgaW5kZXggPCBpdGVtcy5sZW5ndGg7IGorKywgaW5kZXgrKykge1xuICAgICAgY29uc3QgaXNTZWxlY3RlZCA9IChpbmRleCA9PT0gc2VsZWN0ZWRJbmRleCk7XG4gICAgICBjb25zdCBpbmRpY2F0b3IgPSBjcmVhdGVFbGVtZW50KGluZGljYXRvckNvbXBvbmVudCwgeyBpc1NlbGVjdGVkIH0pO1xuICAgICAgY29uc3QgaXRlbSA9IGNyZWF0ZUVsZW1lbnQoaXRlbUNvbXBvbmVudCwgeyAuLi5pdGVtc1tpbmRleF0sIGlzU2VsZWN0ZWQgfSk7XG4gICAgICBjb25zdCByb3cgPSBjcmVhdGVFbGVtZW50KEJveCwgeyBrZXk6IGluZGV4IH0sIGluZGljYXRvciwgaXRlbSk7XG4gICAgICByb3dzLnB1c2gocm93KTtcbiAgICB9XG4gICAgLy8gdXNlIGZsZXggYmFzaXMgMCBzbyBjb2x1bW5zIGhhdmUgdGhlIHNhbWUgd2lkdGhcbiAgICBjb25zdCBjb2x1bW4gPSBjcmVhdGVFbGVtZW50KEJveCwgeyBrZXk6IGksIGZsZXhEaXJlY3Rpb246ICdjb2x1bW4nLCBmbGV4R3JvdzogMSwgZmxleEJhc2lzOiAwIH0sIHJvd3MpO1xuICAgIGNvbHVtbnMucHVzaChjb2x1bW4pO1xuICB9XG4gIHJldHVybiBjcmVhdGVFbGVtZW50KEJveCwgeyB3aWR0aCB9LCBjb2x1bW5zKTtcbn1cblxuZnVuY3Rpb24gY2FsY3VsYXRlQ29sdW1uT2Zmc2V0KGluZGV4LCBjb3VudCwgbGltaXQsIG9mZnNldCA9IDApIHtcbiAgY29uc3QgY29sdW1uSW5kZXggPSBNYXRoLmZsb29yKE1hdGgubWF4KDEsIGluZGV4ICsgMSkgLyBsaW1pdCk7XG4gIGxldCBuZXdPZmZzZXQgPSBvZmZzZXQ7XG4gIHdoaWxlIChuZXdPZmZzZXQgKyBjb3VudCA8PSBjb2x1bW5JbmRleCkge1xuICAgIG5ld09mZnNldCsrO1xuICB9XG4gIHdoaWxlIChuZXdPZmZzZXQgPiBjb2x1bW5JbmRleCkge1xuICAgIG5ld09mZnNldC0tO1xuICB9XG4gIHJldHVybiBuZXdPZmZzZXQ7XG59XG5cblxuXG5mdW5jdGlvbiBJdGVtKHsgaXNTZWxlY3RlZCA9IGZhbHNlLCBsYWJlbCB9KSB7XG5cdHJldHVybiBjcmVhdGVFbGVtZW50KFRleHQsIHsgaW52ZXJzZTogaXNTZWxlY3RlZCB9LCBgICR7bGFiZWx9IGApO1xufVxuXG5mdW5jdGlvbiBJbmRpY2F0b3IoeyBpc1NlbGVjdGVkIH0pIHtcbiAgcmV0dXJuIG51bGw7XG59Il0sIm1h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 -------------------------------------------------------------------------------- /src/hooks.js: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | import { useSequentialState, delay } from 'react-seq'; 3 | import { toCP437, cp437Chars } from './dos-environment.js'; 4 | 5 | const defaultStatus = { position: 0, playing: true }; 6 | const promisedData = new WeakMap(); 7 | 8 | export function useAnsi(dataSource, options = {}) { 9 | const { 10 | modemSpeed = 56000, 11 | frameDuration = 50, 12 | blinkDuration = 500, 13 | blinking = false, 14 | transparency = false, 15 | minWidth = 79, 16 | minHeight = 22, 17 | maxWidth = 80, 18 | maxHeight = 25, 19 | initialStatus = defaultStatus, 20 | onStatus, 21 | onError, 22 | onMetadata, 23 | beep, 24 | } = options; 25 | const state = useSequentialState(async function*({ initial, mount, signal }) { 26 | // screen is at minimum dimensions and empty initially 27 | let state = { 28 | width: minWidth, 29 | height: minHeight, 30 | blinked: false, 31 | lines: Array(minHeight).fill([ { text: ' '.repeat(minWidth), fgColor: undefined, bgColor: undefined, blinking, blink: false } ]), 32 | willBlink: false, 33 | status: initialStatus, 34 | metadata: null, 35 | error: null, 36 | }; 37 | let data = null, initialized = false, error = null; 38 | if (typeof(dataSource?.then) === 'function') { 39 | data = promisedData.get(dataSource); 40 | if (!data) { 41 | try { 42 | // set initial state now, since we need to wait for data to show up 43 | initial(state); 44 | initialized = true; 45 | data = await dataSource; 46 | } catch (err) { 47 | data = err.message; 48 | error = err; 49 | } 50 | // remember the data 51 | promisedData.set(dataSource, data); 52 | } 53 | } else { 54 | data = dataSource; 55 | } 56 | if (typeof(data) === 'string') { 57 | data = toCP437(data); 58 | } 59 | let chars = new Uint8Array(data); 60 | let detectedWidth = 0, detectedHeight = 0; 61 | // process data in two passes: the first determines the maximum extent of the contents 62 | // while the second pass actually outputs them 63 | for (let pass = 1; pass <= 2; pass++) { 64 | // screen states 65 | let width = detectedWidth, height = detectedHeight; 66 | let cursorX = 0, cursorY = 0, savedCursorX = 0, savedCursorY = 0, maxCursorX = 0, maxCursorY = 0; 67 | let bgColor = 0, fgColor = 7, bgColorBase = 0, fgColorBase = 7, bgBright = false, fgBright = false; 68 | let transparencyFlags = 0, bgSet = false, fgSet = false; 69 | let buffer = null, blinked = false, willBlink = false; 70 | let escapeSeq = null, eof = false, metadata = null, metaString = ''; 71 | if (pass === 1) { 72 | // there's no need to do the first pass if the minimum dimensions match the maximum 73 | if (minWidth !== maxWidth || minHeight !== maxHeight) { 74 | chars.map(processCharacter); 75 | } 76 | detectedWidth = Math.max(minWidth, maxCursorX + 1); 77 | detectedHeight = Math.max(minHeight, maxCursorY + 1); 78 | } else if (pass === 2) { 79 | // calculate the number of frames during which blinking text stays visible or invisible 80 | const blinkFrameCount = Math.ceil(blinkDuration / frameDuration); 81 | let blinkFramesRemaining = blinkFrameCount; 82 | // create buffer, using 32-bit integers when handling transparency 83 | buffer = (transparency) ? new Uint32Array(width * height) : new Uint16Array(width * height); 84 | // fill buffer with default attributes 85 | buffer.fill(cell(0)); 86 | metadata = []; 87 | // process data in a multiple chunks 88 | const animationSpeed = modemSpeed / 10 / 1000; 89 | const chunks = []; 90 | let i = 0; 91 | if (initialStatus.position > 0) { 92 | // add initial chunk 93 | i = Math.floor(initialStatus.position * chars.length); 94 | chunks.push(chars.subarray(0, i)); 95 | } 96 | if (initialStatus.playing) { 97 | // add remaining chunks 98 | const chunkLength = Math.floor(animationSpeed * frameDuration); 99 | while (i < chars.length) { 100 | chunks.push(chars.subarray(i, i + chunkLength)); 101 | i += chunkLength; 102 | } 103 | } 104 | let processed = 0; 105 | for (const [ index, chunk ] of chunks.entries()) { 106 | chunk.map(processCharacter); 107 | // time to output what's held in the screen buffer to the hook consumer, 108 | // consolidating characters with identical attributes into segments 109 | const lines = scanBuffer(); 110 | // calculate status 111 | processed += chunk.length; 112 | const playing = (index !== chunks.length - 1); 113 | const position = processed / chars.length; 114 | const status = { position, playing }; 115 | state = { width, height, blinking, blinked, lines, willBlink, status, metadata, error }; 116 | if (!initialized) { 117 | // initialize with real contents 118 | initial(state); 119 | initialized = true; 120 | await mount(); 121 | } else { 122 | yield state; 123 | } 124 | if (playing) { 125 | // wait for frame to end 126 | await delay(frameDuration, { signal }); 127 | if (blinking) { 128 | // update blink states 129 | blinkFramesRemaining--; 130 | if (blinkFramesRemaining === 0) { 131 | blinked = !blinked; 132 | blinkFramesRemaining = blinkFrameCount; 133 | } 134 | } 135 | } 136 | } 137 | data = chars = buffer = null; 138 | 139 | // go into an endless loop if there's blinking text (unless blinking is just truthy and not true) 140 | if (state.willBlink && blinking === true) { 141 | // wait out the remaining blink period 142 | await delay(frameDuration * blinkFramesRemaining, { signal }); 143 | for (;;) { 144 | blinked = !blinked; 145 | yield { ...state, blinked }; 146 | await delay(blinkDuration, { signal }); 147 | } 148 | } 149 | } 150 | 151 | // --- helper functions below ---- 152 | 153 | function cell(c) { 154 | // pack text attributes and codepoint into 16-bit cell 155 | return (bgColor << 8) | (fgColor << 12) | c | transparencyFlags; 156 | } 157 | 158 | function setCharacter(c) { 159 | if (cursorY >= maxHeight) { 160 | if (buffer) { 161 | // scroll up 162 | processCommand('S', `${cursorY - maxHeight + 1}`); 163 | } 164 | cursorY = maxHeight - 1; 165 | } 166 | if (!buffer) { 167 | if (cursorX > maxCursorX) { 168 | maxCursorX = cursorX; 169 | } 170 | if (cursorY > maxCursorY) { 171 | maxCursorY = cursorY; 172 | } 173 | } else { 174 | buffer[cursorY * width + cursorX] = cell(c); 175 | } 176 | cursorX++; 177 | if (cursorX >= maxWidth) { 178 | cursorX = 0; 179 | cursorY++; 180 | } 181 | } 182 | 183 | function parseOne(text, def1) { 184 | return (text) ? parseInt(text) : def1; 185 | } 186 | 187 | function parseTwo(text, def1, def2) { 188 | const parts = text.split(';'); 189 | return [ parseOne(parts[0], def1), parseOne(parts[1], def2) ]; 190 | } 191 | 192 | function parseMultiple(text, def) { 193 | const parts = text.split(';'); 194 | return parts.map(p => parseOne(p, def)); 195 | } 196 | 197 | function processCharacter(c) { 198 | if (escapeSeq) { 199 | if (escapeSeq.length === 1) { 200 | escapeSeq.push(c); 201 | if (c !== 0x5b) { 202 | // invalid sequence 203 | for (const c of escapeSeq) { 204 | setCharacter(c); 205 | } 206 | escapeSeq = null; 207 | } 208 | } else { 209 | if (c >= 0x40 && c <= 0x7e) { 210 | // @ to ~ 211 | const cmd = cp437Chars[c]; 212 | const params = escapeSeq.slice(2).map(c => cp437Chars[c]).join(''); 213 | processCommand(cmd, params); 214 | escapeSeq = null; 215 | } else { 216 | escapeSeq.push(c); 217 | } 218 | } 219 | } else if (!eof) { 220 | if (c === 0x07) { 221 | beep?.(); 222 | } else if (c === 0x08) { 223 | // backspace 224 | cursorX--; 225 | if (cursorX < 0) { 226 | cursorX = 0; 227 | } 228 | } else if (c === 0x09) { 229 | // tabs 230 | cursorX = ((cursorX >> 3) << 3) + 8; 231 | } else if (c === 0x0a) { 232 | // linefeed 233 | cursorY++; 234 | } else if (c === 0x0c) { 235 | // clear screen 236 | processCommand('J', 2); 237 | } else if (c === 0x0d) { 238 | // carriage return 239 | cursorX = 0; 240 | } else if (c === 0x1a) { 241 | eof = true; 242 | } else if (c === 0x1b) { 243 | escapeSeq = [ c ]; 244 | } else { 245 | setCharacter(c); 246 | } 247 | } else { 248 | // metadata 249 | if (metadata) { 250 | if (c === 0 || c === 0x1a) { 251 | if (metaString) { 252 | metadata.push(metaString); 253 | metaString = ''; 254 | } 255 | } else { 256 | metaString += cp437Chars[c]; 257 | } 258 | } 259 | } 260 | } 261 | 262 | function processCommand(cmd, params = '') { 263 | if (cmd === 'A') { 264 | const count = parseOne(params, 1); 265 | cursorY -= count; 266 | if (cursorY < 0) { 267 | cursorY = 0; 268 | } 269 | } else if (cmd === 'B') { 270 | const count = parseOne(params, 1); 271 | cursorY += count; 272 | if (cursorY >= maxHeight) { 273 | cursorY = maxHeight - 1; 274 | } 275 | } else if (cmd === 'C') { 276 | const count = parseOne(params, 1); 277 | cursorX += count; 278 | if (cursorX >= maxWidth) { 279 | cursorX = maxWidth - 1; 280 | } 281 | } else if (cmd === 'D') { 282 | const count = parseOne(params, 1); 283 | cursorX -= count; 284 | if (cursorX < 0) { 285 | cursorX = 0; 286 | } 287 | } else if (cmd === 'H' || cmd === 'f') { 288 | const [ row, col ] = parseTwo(params, 1, 1); 289 | cursorX = Math.min(col, maxWidth) - 1; 290 | cursorY = Math.min(row, maxHeight)- 1; 291 | } else if (cmd === 'J') { 292 | // clear screen 293 | const mode = parseOne(params, 0); 294 | if (buffer) { 295 | let start, end; 296 | if (mode === 0) { 297 | start = cursorY * width + cursorX; 298 | end = width * height; 299 | } else if (mode === 1) { 300 | start = 0; 301 | end = cursorY * width + cursorX; 302 | } else if (mode === 2) { 303 | start = 0; 304 | end = width * height; 305 | } 306 | buffer.fill(cell(0), start, end);; 307 | } 308 | if (mode === 2) { 309 | cursorX = 0; 310 | cursorY = 0; 311 | } 312 | } else if (cmd === 'K') { 313 | // clear line to end 314 | const mode = parseOne(params, 0); 315 | if (buffer) { 316 | let start, end; 317 | if (mode === 0) { 318 | start = cursorY * width + cursorX; 319 | end = (cursorY + 1) * width; 320 | } else if (mode === 1) { 321 | start = cursorY * width; 322 | end = start + cursorX; 323 | } else if (mode === 2) { 324 | start = cursorY * width; 325 | end = start + width; 326 | } 327 | buffer.fill(cell(0), start, end); 328 | } 329 | } else if (cmd === 'L') { 330 | // insert line 331 | const count = parseOne(params, 1); 332 | if (buffer) { 333 | const target = (cursorY + count) * width; 334 | const source = cursorY * width; 335 | buffer.copyWithin(target, source); 336 | const start = source; 337 | const end = target; 338 | buffer.fill(cell(0), start, end); 339 | } 340 | if (cursorY <= maxCursorY) { 341 | maxCursorY += count; 342 | } 343 | } else if (cmd === 'M') { 344 | // delete line 345 | const count = parseOne(params, 1); 346 | if (buffer) { 347 | const target = cursorY * width; 348 | const source = (cursorY + count) * width; 349 | buffer.copyWithin(target, source); 350 | const start = source; 351 | const end = width * height; 352 | buffer.fill(cell(0), start, end); 353 | } 354 | } else if (cmd === 'P') { 355 | // delete characters 356 | const count = parseOne(params, 1); 357 | if (buffer) { 358 | const target = cursorY * width + cursorX; 359 | const source = target + count; 360 | const last = (cursorY + 1) * width; 361 | buffer.copyWithin(target, source, last); 362 | const start = last - count; 363 | const end = last; 364 | buffer.fill(cell(0), start, end); 365 | } 366 | } else if (cmd === 'S') { 367 | // scroll up 368 | const count = parseOne(params, 1); 369 | if (buffer) { 370 | const target = 0; 371 | const source = count * width; 372 | buffer.copyWithin(target, source); 373 | const start = width * (height - count); 374 | const end = width * height; 375 | buffer.fill(cell(0), start, end); 376 | } 377 | } else if (cmd === 'T') { 378 | // scroll down 379 | const count = parseOne(params, 1); 380 | if (buffer) { 381 | const target = count * width; 382 | const source = 0; 383 | buffer.copyWithin(target, source); 384 | const start = 0; 385 | const end = count * width; 386 | buffer.fill(cell(0), start, end); 387 | } 388 | } else if (cmd === 'X') { 389 | // clear characters 390 | const count = parseOne(params, 1); 391 | if (buffer) { 392 | const start = cursorY * width + cursorX; 393 | const end = Math.min(start + count, (cursorY + 1) * width); 394 | buffer.fill(cell(0), start, end); 395 | } 396 | } else if (cmd === 'm') { 397 | // modify text properties 398 | const modifiers = parseMultiple(params, 0); 399 | for (const m of modifiers) { 400 | if (m === 0) { 401 | fgBright = false; 402 | bgBright = false; 403 | fgColorBase = 7; 404 | bgColorBase = 0; 405 | fgSet = false; 406 | bgSet = false; 407 | } else if (m === 1) { 408 | fgBright = true; 409 | } else if (m === 2 || m === 22) { 410 | fgBright = false; 411 | } else if (m === 5 || m === 6) { 412 | bgBright = true; 413 | } else if (m === 7) { 414 | const fgColorBefore = fgColorBase; 415 | fgColorBase = bgColorBase; 416 | bgColorBase = fgColorBefore; 417 | fgSet = true; 418 | bgSet = true; 419 | } else if (m === 8) { 420 | fgColorBase = bgColorBase; 421 | fgSet = true; 422 | } else if (m === 25) { 423 | bgBright = false; 424 | } else if (m >= 30 && m <= 37) { 425 | fgColorBase = m - 30; 426 | fgSet = true; 427 | } else if (m >= 40 && m <= 47) { 428 | bgColorBase = m - 40; 429 | bgSet = true; 430 | } 431 | } 432 | fgColor = fgColorBase + (fgBright ? 8 : 0); 433 | bgColor = bgColorBase + (bgBright ? 8 : 0); 434 | if (transparency) { 435 | transparencyFlags = (fgSet ? 0x00010000 : 0) | (bgSet ? 0x00020000 : 0); 436 | } 437 | } else if (cmd === 's') { 438 | savedCursorX = cursorX; 439 | savedCursorY = cursorY; 440 | } else if (cmd === 'u') { 441 | cursorX = savedCursorX; 442 | cursorY = savedCursorY; 443 | } 444 | } 445 | 446 | function scanBuffer() { 447 | const lines = []; 448 | const blinkMask = (blinking) ? 0x0800 : 0x0000; 449 | const bgColorMask = (blinking) ? 0x0700 : 0x0F00; 450 | const fgColorMask = 0xF000; 451 | const fgMask = 0x00010000; 452 | const bgMask = 0x00020000; 453 | for (let row = 0; row < height; row++) { 454 | const segments = []; 455 | const first = row * width; 456 | const last = first + width; 457 | let attr = 0x00FF; // invalid attributes 458 | let text = ''; 459 | // find where there's a change in attributes 460 | for (let i = first; i < last; i++) { 461 | const cp = buffer[i] & 0x00FF; 462 | const newAttr = buffer[i] & 0x000FFF00; 463 | if (attr !== newAttr) { 464 | // add preceding text 465 | if (text.length > 0) { 466 | segments.push({ attr, text }); 467 | } 468 | attr = newAttr; 469 | text = ''; 470 | } 471 | // map codepoint 0 to space 472 | text += cp437Chars[cp || 0x20]; 473 | } 474 | // add leftover at end of line 475 | if (text.length > 0) { 476 | segments.push({ attr, text }); 477 | } 478 | const line = []; 479 | for (const { attr, text } of segments) { 480 | const blink = (attr & blinkMask) !== 0; 481 | const fgColor = (!transparency || (attr & fgMask)) ? (attr & fgColorMask) >> 12 : undefined; 482 | const bgColor = (!transparency || (attr & bgMask)) ? (attr & bgColorMask) >> 8 : undefined; 483 | line.push({ text, fgColor, bgColor, blink }); 484 | willBlink = willBlink || blink; 485 | } 486 | lines.push(line); 487 | } 488 | return lines; 489 | } 490 | } 491 | if (!initialized) { 492 | // the data source was empty--initialize with empty screen 493 | initial(state); 494 | } 495 | }, [ dataSource, modemSpeed, frameDuration, blinkDuration, blinking, minWidth, minHeight, maxWidth, maxHeight, transparency, initialStatus, beep ]); 496 | // saving handlers into a ref so we don't trigger useEffect when they're different 497 | const handlerRef = useRef(); 498 | handlerRef.current = { onStatus, onMetadata, onError }; 499 | // relay events to event handlers 500 | const { status, metadata, error } = state; 501 | useEffect(() => { 502 | handlerRef.current.onStatus?.(status); 503 | }, [ status ]); 504 | useEffect(() => { 505 | handlerRef.current.onMetadata?.(metadata); 506 | }, [ metadata ]); 507 | useEffect(() => { 508 | if (error) { 509 | handlerRef.current.onError?.(error); 510 | } 511 | }, [ error ]); 512 | return state; 513 | } 514 | 515 | --------------------------------------------------------------------------------