Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions examples/input/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Input Examples Progression

Seven files that build on each other, each solving a specific problem or expanding features from the previous. Start at [`1-raw.ts`](./src/1-raw.ts) and work down.

| File | Problem | Solution | Deps |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------- |
| [`1-raw.ts`](./src/1-raw.ts) | `data.toString()` can't decode escape sequences — arrow keys produce mojibake | `createInput()` decodes raw bytes into structured events | `@bomb.sh/tty` |
| [`2-parser.ts`](./src/2-parser.ts) | Duplicated event handling in data handler and flush timeout | Extract `processEvent()` as a pure function | — |
| [`3-signals.ts`](./src/3-signals.ts) | Manual `render()` calls become difficult manage with scale. Use a library which can subscribe to changes and trigger a render after every state change. | `signal()` + `effect()` auto-renders on state change | `alien-signals` (choice optional, user can choose) |
| [`4-modes.ts`](./src/4-modes.ts) | Change `seeings()` which requires a revert on before process exits. | `alternateBuffer()` + `settings()` — quit becomes a state flag | `@bomb.sh/tty` |
| [`5-stream.ts`](./src/5-stream.ts) | Refactor overloaded data handler with an async iterator and flush all events in one callback. | Async iterator moves flush logic into its own function | `@bomb.sh/tty` |
| [`6-events.ts`](./src/6-events.ts) | One all event types including mouse movements and element transitions | Same iterator yields all types; reactive signals drive color transitions | `@bomb.sh/tty` |
| [`7-focus.ts`](./src/7-focus.ts) | Double our inputs and handle focus changes (Tab) between them while still supporting transitions | Two panels, Tab/Shift+Tab focus, full reactive treatment on focused panel | `@clack/ui` |
13 changes: 11 additions & 2 deletions examples/input/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@
"private": true,
"type": "module",
"exports": {
"./ui/raw": "./ui/raw.ts",
"./1-raw": "./src/1-raw.ts",
"./2-parser": "./src/2-parser.ts",
"./3-signals": "./src/3-signals.ts",
"./4-modes": "./src/4-modes.ts",
"./5-stream": "./src/5-stream.ts",
"./6-events": "./src/6-events.ts",
"./7-focus": "./src/7-focus.ts",
"./package.json": "./package.json"
},
"dependencies": {
"@clack/ui": "catalog:"
"@bomb.sh/tty": "catalog:",
"@clack/ui": "catalog:",
"@clack/vendored.ui": "workspace:",
"alien-signals": "catalog:"
}
}
27 changes: 10 additions & 17 deletions examples/input/ui/raw.ts → examples/input/src/1-raw.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import { box, text, createRoot, rgba } from '@clack/ui';

const cyan = rgba(0, 205, 205);
const white = rgba(229, 229, 229);
const gray = rgba(127, 127, 127);
const blue = rgba(0, 0, 238);
const yellow = rgba(255, 255, 0);
const red = rgba(255, 80, 80);
import { box, text, createRoot } from '@clack/ui';
import { cyan, white, gray, blue, yellow, red } from './helper.ts';

const root = await createRoot();

// a raw variable which we cannot subscribe to
// so we have to re-render on every input event
let inputBuffer = '';
let lastKey = '';

Expand All @@ -21,7 +13,7 @@ function render(): void {
layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } },
border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 },
},
text({ color: cyan }, 'Level 1 — Raw String Input'),
text({ color: cyan }, 'Raw String Input'),
text({ color: gray }, 'Uses data.toString() — arrow keys produce garbage'),
box(
{ layout: { direction: 'ltr', gap: 0 } },
Expand All @@ -38,27 +30,27 @@ function render(): void {
);
}

// Initial render before we start reading input.
render();

// Raw mode pipes all bytes from stdin on 'data'
process.stdin.setRawMode(true);
process.stdin.resume();
// listen for input events and call render() on every event
process.stdin.on('data', (data: Buffer) => {
// data.toString() allows observation of all input,
// but it doesn't interpret the terminal protocol so
// we need to manually handle special keys. See
// ./2-parser.ts for a more robust solution.
const str = data.toString();
if (str === '\x03') {
// Ctrl+C
process.exit(0);
}
if (str === '\x7f' || str === '\b') {
// DEL or BS
inputBuffer = inputBuffer.slice(0, -1);
lastKey = 'Backspace';
} else if (str === '\r' || str === '\n') {
// CR or LF
inputBuffer = '';
lastKey = 'Enter';
} else if (str.startsWith('\x1b')) {
// ESC — start of ANSI escape sequence
lastKey = `Raw escape: ${str
.split('')
.map((c) => `0x${c.charCodeAt(0).toString(16)}`)
Expand All @@ -68,5 +60,6 @@ process.stdin.on('data', (data: Buffer) => {
inputBuffer += str;
lastKey = `Char: ${JSON.stringify(str)}`;
}
// Re-render after every chunk input
render();
});
89 changes: 89 additions & 0 deletions examples/input/src/2-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { box, text, createRoot } from '@clack/ui';
import { createInput, type InputEvent } from '@bomb.sh/tty';
import { cyan, white, gray, blue, yellow, green } from './helper.ts';

const root = await createRoot();
// createInput() returns a parser that decodes raw VT/ANSI bytes
// into structured InputEvents (keydown, mousemove, cursor, etc.).
const input = await createInput();

let inputBuffer = '';
let lastKey = '';

function render(): void {
root.render(
box(
{
layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } },
border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 },
},
text({ color: cyan }, 'Input Parser'),
text({ color: gray }, 'Uses createInput() — arrow keys parsed correctly'),
box(
{ layout: { direction: 'ltr', gap: 0 } },
text({ color: yellow }, '> '),
text({ color: white }, inputBuffer || '(type something)'),
),
text({ color: gray }, lastKey ? `Last: ${lastKey}` : ''),
text({ color: green }, 'Arrow keys, Escape, and special keys all work'),
text({ color: gray }, 'Press Ctrl+C to exit'),
),
);
}

// Paint the initial frame before we start reading input.
render();
// This function handles the parsed input and is a separate function as we need to call it from two places.
function processEvent(
event: InputEvent,
s: { inputBuffer: string; lastKey: string },
): { inputBuffer: string; lastKey: string } {
if (event.type !== 'keydown') return s;
if (event.ctrl && event.code === 'c') process.exit(0);
if (event.code === 'Backspace')
return { inputBuffer: s.inputBuffer.slice(0, -1), lastKey: 'Backspace' };
if (event.code === 'Enter') return { inputBuffer: '', lastKey: 'Enter' };
if (event.code === 'Escape') return { ...s, lastKey: 'Escape' };
if (event.code.startsWith('Arrow')) return { ...s, lastKey: event.code };
if (event.text)
return {
inputBuffer: s.inputBuffer + event.text,
lastKey: `Char: ${JSON.stringify(event.text)}`,
};
return s;
}

// Accumulate state from events so we can call render once
function processEvents(events: InputEvent[]): void {
for (const event of events) {
const next = processEvent(event, { inputBuffer, lastKey });
inputBuffer = next.inputBuffer;
lastKey = next.lastKey;
}
}

process.stdin.setRawMode(true);
let timer: ReturnType<typeof setTimeout>;

// Each data chunk is raw bytes from the terminal. `input.scan()` feeds them
// into the parser, which emits zero or more structured events plus a
// `pending` hint if an incomplete escape sequence was encountered.
process.stdin.on('data', (buf: Buffer) => {
clearTimeout(timer);
const { events, pending } = input.scan(new Uint8Array(buf));
processEvents(events);
if (pending) {
// A partial escape sequence was split across chunks (e.g. arrow key bytes
// arriving in two data events). Wait for the remainder to arrive, flush it
// through the parser, then render once with all events processed.
timer = setTimeout(() => {
processEvents(input.scan().events);
render();
}, pending.delay);
} else {
// This conditional render has becomes more complicated with scale however
// as we need to track state changes through multiple events and appropriately
// render once at the end. See ./3-signals.ts for a more robust solution.
render();
}
});
92 changes: 92 additions & 0 deletions examples/input/src/3-signals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { box, text, createRoot } from '@clack/ui';
import { createInput, type InputEvent } from '@bomb.sh/tty';
import { signal, effect } from 'alien-signals';
import { cyan, white, gray, blue, yellow, green } from './helper.ts';

const root = await createRoot();
const input = await createInput();

interface State {
inputBuffer: string;
lastKey: string;
}

const state = signal<State>({ inputBuffer: '', lastKey: '' });

// Same pure function as ./2-parser.ts
function processEvent(s: State, event: InputEvent): State {
if (event.type !== 'keydown') return s;
// calling process.exit() here is a bad idea because it doesn't give the caller
// a chance to clean up terminal modes. See ./4-modes.ts for a better approach.
if (event.ctrl && event.code === 'c') process.exit(0);
if (event.code === 'Backspace') {
return { ...s, inputBuffer: s.inputBuffer.slice(0, -1), lastKey: 'Backspace' };
}
if (event.code === 'Enter') {
return { ...s, inputBuffer: '', lastKey: 'Enter' };
}
if (event.code === 'Escape') {
return { ...s, lastKey: 'Escape' };
}
if (event.code.startsWith('Arrow')) {
return { ...s, lastKey: event.code };
}
if (event.text) {
return {
...s,
inputBuffer: s.inputBuffer + event.text,
lastKey: `Char: ${JSON.stringify(event.text)}`,
};
}
return s;
}

// Process all events from this batch and update the state signal. If the data changed,
// effect() will automatically re-run the render callback.
function processEvents(events: InputEvent[]): void {
let current = state();
for (const event of events) {
current = processEvent(current, event);
}
state(current);
}

// Here our state changes automatically trigger a re-render via effect().
effect(() => {
const s = state();
root.render(
box(
{
layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } },
border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 },
},
text({ color: cyan }, 'Reactive Signals'),
text({ color: gray }, 'Same processEvent, but effect() auto-renders on state change'),
box(
{ layout: { direction: 'ltr', gap: 0 } },
text({ color: yellow }, '> '),
text({ color: white }, s.inputBuffer || '(type something)'),
),
text({ color: gray }, s.lastKey ? `Last: ${s.lastKey}` : ''),
text({ color: green }, 'Arrow keys, Escape, and special keys all work'),
text({ color: gray }, 'Press Ctrl+C to exit'),
),
);
});

process.stdin.setRawMode(true);
let timer: ReturnType<typeof setTimeout>;

process.stdin.on('data', (buf: Buffer) => {
clearTimeout(timer);
const { events, pending } = input.scan(new Uint8Array(buf));
// We can more confidently process the events here since effect() will handle re-rendering.
// This is a more robust solution than ./2-parser.ts as we don't need to worry about
// batching state changes and rendering at the right time.
processEvents(events);
if (pending) {
timer = setTimeout(() => {
processEvents(input.scan().events);
}, pending.delay);
}
});
111 changes: 111 additions & 0 deletions examples/input/src/4-modes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { box, text, createRoot } from '@clack/ui';
import { createInput, alternateBuffer, settings, type InputEvent } from '@bomb.sh/tty';
import { signal, effect } from 'alien-signals';
import { cyan, white, gray, blue, yellow, green } from './helper.ts';

// ./3-signals.ts gave us signals + effect() for reactive rendering, but every event
// was a local state change where effect() was able to handle it all. Some events
// need to do more: exit the process, revert terminal modes, trigger I/O or other computation.
// For a concrete example: In ./3-signals.ts, we just called process.exit() inside processEvent,
// but that only works when there's nothing to clean up.

// This adds terminal settings which require the mode to be reverted
// before exiting. We can't call process.exit() inside processEvent() because it doesn't
// give the caller a chance to revert the terminal modes.

const root = await createRoot();
const input = await createInput();

interface State {
inputBuffer: string;
lastKey: string;
quit: boolean;
}

const state = signal<State>({ inputBuffer: '', lastKey: '', quit: false });

// Same processEvent as ./3-signals.ts, but quit becomes a flag instead of
// calling process.exit() directly.
function processEvent(s: State, event: InputEvent): State {
if (event.type !== 'keydown') return s;
if (event.ctrl && event.code === 'c') return { ...s, quit: true };
if (event.code === 'Escape') return { ...s, quit: true };
if (event.code === 'Backspace') {
return { ...s, inputBuffer: s.inputBuffer.slice(0, -1), lastKey: 'Backspace' };
}
if (event.code === 'Enter') {
return { ...s, inputBuffer: '', lastKey: 'Enter' };
}
if (event.code.startsWith('Arrow')) {
return { ...s, lastKey: event.code };
}
if (event.text) {
return {
...s,
inputBuffer: s.inputBuffer + event.text,
lastKey: `Char: ${JSON.stringify(event.text)}`,
};
}
return s;
}

function processEvents(events: InputEvent[]): State {
let current = state();
for (const event of events) {
current = processEvent(current, event);
}
state(current);
return current;
}

// Alternate buffer keeps the user's scrollback clean, but this
// needs to be reverted before exiting; see quit() below.
const mode = settings(alternateBuffer({ clear: true }));
process.stdout.write(mode.apply);

effect(() => {
const s = state();
root.render(
box(
{
layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } },
border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 },
},
text({ color: cyan }, 'Terminal Modes'),
text({ color: gray }, 'Same input as ./3-signals.ts, but in an alternate buffer'),
box(
{ layout: { direction: 'ltr', gap: 0 } },
text({ color: yellow }, '> '),
text({ color: white }, s.inputBuffer || '(type something)'),
),
text({ color: gray }, s.lastKey ? `Last: ${s.lastKey}` : ''),
text({ color: green }, 'Arrow keys, Escape, and special keys all work'),
text({ color: gray }, 'Press Escape to exit (reverts terminal modes)'),
),
);
});

function quit(): void {
process.stdout.write(mode.revert);
process.exit(0);
}

process.stdin.setRawMode(true);
let timer: ReturnType<typeof setTimeout>;

process.stdin.on('data', (buf: Buffer) => {
clearTimeout(timer);
const { events, pending } = input.scan(new Uint8Array(buf));
// We can process different outcomes from processEvents() here,
// but the data handler is getting overloaded. Imagine handling a quit
// event which raises a modal to request confirmation before quitting.
// See ./5-stream.ts for a more robust approach.
const after = processEvents(events);
if (after.quit) quit();
if (pending) {
timer = setTimeout(() => {
const afterFlush = processEvents(input.scan().events);
if (afterFlush.quit) quit();
}, pending.delay);
}
});
Loading