# easy-tui **Repository Path**: lipingruan/easy-tui ## Basic Information - **Project Name**: easy-tui - **Description**: NodeJS Terminal UI Framework - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-06-25 - **Last Updated**: 2026-07-20 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # easy-tui A declarative, component-based Terminal UI framework for Node.js. Build interactive terminal applications with a familiar React-like component model, flexbox layout, and rich input components. ## Features - **Declarative Components** -- class-based components with `render()`, lifecycle hooks, and reconciliation - **Flexbox Layout** -- row/column directions, flex weights, percentage/fixed/auto sizing, borders & dividers - **Rich Input** -- single/multiline text input with prompt, placeholder, scrolling, line numbers; single/multiple select - **Conditional & List Rendering** -- `IF` / `ELSEIF` / `ELSE` / `ITER` / `LAMBDA` for dynamic UIs - **Theme & Style** -- cascading themes, inline styles (fg/bg/bold/dim/italic/underline/inverse/strikethrough), ANSI passthrough - **Focus Management** -- Tab/Shift+Tab cycling, programmatic focus, key event bubbling - **Full Unicode** -- CJK wide characters, emoji, ZWJ sequences, grapheme-aware cursor movement - **stdout Interception** -- `console.log` works normally above the TUI panel - **TypeScript** -- written in strict TypeScript with full type definitions ## Installation ```bash npm install easy-tui ``` ## Quick Start ```typescript import { Box, CustomComponent, Input, Select, SelectItem, Text, TUI } from 'easy-tui' const ui = new TUI({ stdin: process.stdin, stdout: process.stdout, }) class App extends CustomComponent { name = '' render() { return Box( { showBorder: true, flexDirection: 'col' }, Text({ style: { bold: true } }, 'Welcome!'), Text(''), Input({ prompt: 'Your name:', placeholder: 'Type here...', }).setOnSubmit((value) => { this.name = value this.markDirty() }), Text(''), this.name ? Text({ style: { fg: 'green' } }, `Hello, ${this.name}!`) : Text({ style: { dim: true } }, 'Enter your name above'), Text(''), Text({ style: { dim: true } }, 'Ctrl+C to exit'), ) } } ui.show(new App()) ``` ## Core Concepts ### Components All components extend `BaseComponent`. Use `CustomComponent` to build your own: ```typescript class MyComponent extends CustomComponent { // State count = 0 // Render the UI tree render() { return Text(`Count: ${this.count}`) } // Handle keyboard events onKey(key: KeyEvent): boolean { if (key.name === 'space') { this.count++ this.markDirty() // Trigger re-render return true } return false } // Lifecycle hooks onMount() { /* called when component appears */ } onUnmount() { /* called when component disappears */ } onFocus() { /* called when focused */ } onBlur() { /* called when losing focus */ } // Make focusable (optional) canFocus() { return true } } ``` ### Layout Use `Box` to build layouts. It supports `flexDirection: 'col'` (default) and `'row'`: ```typescript Box( { flexDirection: 'col', showBorder: true, showDivider: true }, // Fixed height Text({ height: 1 }, 'Header'), // Flex children fill remaining space Box({ flex: 1 }, Text('Main content')), // Auto height Text('Footer'), ) ``` **Sizing properties:** | Property | Type | Description | |----------|------|-------------| | `width` | `number \| \`${number}%\` \| 'auto'` | Fixed columns, percentage, or `'auto'` (default, fills parent) | | `height` | `number \| 'auto'` | Fixed rows or `'auto'` (default, fits content) | | `flex` | `number` | Flex weight for distributing remaining space among siblings | ### Conditional Rendering ```typescript import { IF, ELSEIF, ELSE } from 'easy-tui' Box( IF(condition)( Text('Shown when true') ), ELSEIF(otherCondition)( Text('Shown when other is true') ), ELSE( Text('Fallback') ), ) ``` Conditions can be values or functions (evaluated each render): ```typescript IF(() => this.items.length > 0)(...) ``` ### List Rendering ```typescript import { ITER } from 'easy-tui' Box( ITER(this.items, (index, item) => Text(`${index + 1}. ${item.name}`) ), ) ``` `ITER` accepts arrays, Maps, Sets, objects, numbers (`0..n`), and strings. ### Styling ```typescript Text( { style: { fg: 'red', bg: '#1a1a1a', bold: true, underline: true } }, 'Styled text' ) ``` **Style properties:** `fg`, `bg`, `bold`, `dim`, `italic`, `underline`, `inverse`, `strikethrough` **Color formats:** named colors (`red`, `cyan`, `brightGreen`), hex (`#ff0000`), ANSI 256 (`ansi256:196`) ### Theming Themes cascade from parent to children: ```typescript Box( { theme: { border: { fg: 'cyan' }, text: { fg: 'white' } } }, Text('This text is white'), Box( { theme: { text: { fg: 'green' } } }, Text('This text is green'), ), ) ``` **Theme slots:** `prompt`, `border`, `divider`, `highlight`, `selection`, `lineNo`, `focused`, `text`, `placeholder` ### Triggering Re-renders Call `this.markDirty()` after state changes to schedule a re-render: ```typescript onKey(key: KeyEvent): boolean { if (key.name === 'space') { this.count++ this.markDirty() return true } return false } ``` Built-in methods `show()` and `hide()` also trigger re-renders automatically. ### Component Refs Use `ref` and `refs` to access child component instances from a parent: ```typescript class App extends CustomComponent { render() { return Box( Input({ ref: this.refTo('nameInput'), // Bind child to refs['nameInput'] prompt: 'Name:', }).setOnSubmit(() => { // Access the child component via refs const input = this.refs['nameInput'] as InputComponent console.log('Value:', input.value) input.value = [] // Clear the input this.markDirty() }), ) } } ``` - `this.refTo(key)` -- returns a ref binding function; pass it as the `ref` option on a child component - `this.refs[key]` -- returns the child component instance bound to that key - Refs are re-bound on every render, so they always point to the current instance ## Components ### Box Container component for layout. ```typescript Box(options?, ...children) ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `flexDirection` | `'col' \| 'row'` | `'col'` | Layout direction | | `showBorder` | `boolean` | `false` | Draw border around the box | | `showDivider` | `boolean` | `false` | Draw dividers between children | | `width` | `number \| \`${number}%\` \| 'auto'` | `'auto'` | Box width | | `height` | `number \| 'auto'` | `'auto'` | Box height | | `flex` | `number` | - | Flex weight | | `style` | `Style` | - | Box style (e.g., background) | | `theme` | `Theme` | - | Theme overrides for children | ### Text Static text display. ```typescript Text(options?, ...lines) ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `prompt` | `string` | - | Prompt text (displayed in theme prompt color) | | `style` | `Style` | - | Text style | | `width` | `number \| \`${number}%\` \| 'auto'` | `'auto'` | Text width | | `height` | `number \| 'auto'` | `'auto'` | Fixed height (rows) | ### Input Text input component. **Focusable.** ```typescript Input(options?) .setOnSubmit((value: string) => void) .setOnCancel((value: string) => void) .setOnChange((value: string) => void) ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `prompt` | `string` | - | Prompt text | | `value` | `string[]` | `[]` | Initial value (array of lines) | | `placeholder` | `string` | - | Placeholder text | | `multiline` | `boolean` | `false` | Enable multiline input | | `maxHeight` | `number` | - | Max visible rows (scrollable) | | `maxLength` | `number` | - | Max character limit | | `lineNo` | `boolean` | `false` | Show line numbers (multiline) | | `newline` | `boolean` | `false` | Prompt on its own line | | `cancelable` | `boolean` | `false` | Allow Escape to cancel | **Keyboard shortcuts:** | Key | Action | |-----|--------| | `Enter` | Submit (single-line) / Newline (multiline) | | `Escape` | Cancel (if `cancelable`) | | `Left` / `Right` | Move cursor (grapheme-aware) | | `Up` / `Down` | Move between lines | | `Home` / `End` | Jump to line start / end | | `Backspace` / `Delete` | Delete character | ### Select List selector component. **Focusable.** ```typescript Select( options?, SelectItem({ title: 'Option 1', value: 'opt1' }), SelectItem({ title: 'Option 2', value: 'opt2' }), ) .setOnSubmit((items: SelectItemData[]) => void) .setOnChange((items: SelectItemData[]) => void) .setOnCancel((items: SelectItemData[]) => void) ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `prompt` | `string` | - | Prompt text | | `multiple` | `boolean` | `false` | Enable multi-select | | `cancelable` | `boolean` | `false` | Allow Escape to cancel | **SelectItem options:** | Option | Type | Description | |--------|------|-------------| | `title` | `string` | Display text | | `value` | `any` | Associated value | | `disabled` | `boolean` | Disable selection | | `selected` | `boolean` | Initial selection state | **Keyboard shortcuts:** | Key | Single Select | Multi Select | |-----|---------------|--------------| | `Up` / `Down` | Move highlight | Move highlight | | `Space` | Select & submit | Toggle selection | | `Enter` | Submit highlighted | Submit selected | | `Escape` | Cancel | Cancel | ## Logic Components Logic components control rendering flow but don't produce visual output themselves. ### IF / ELSEIF / ELSE Conditional rendering: ```typescript Box( IF(this.showHeader)( Text('Header') ), ELSEIF(this.showSubheader)( Text('Subheader') ), ELSE( Text('Default') ), ) ``` ### ITER List rendering: ```typescript Box( ITER(this.items, (key, item) => Text(`${key}: ${item}`) ), ) ``` ### LAMBDA Dynamic computed children: ```typescript Box( LAMBDA(() => { const result = computeExpensiveResult() return Text(result) }), ) ``` ## API Reference ### TUI ```typescript const ui = new TUI({ stdin: process.stdin, stdout: process.stdout, fps: 30, // Render frame rate (default: 30) exitOnCtrlC: true, // Exit on Ctrl+C (default: true) }) ``` | Method | Description | |--------|-------------| | `ui.show(component?)` | Activate TUI, start render loop | | `ui.hide()` | Deactivate TUI, restore terminal | | `ui.setComponent(comp)` | Set/replace root component | | `ui.requestRender()` | Schedule a re-render | | `ui.comps` | Component registry by `key` | ### BaseComponent All components inherit from `BaseComponent`: | Property / Method | Type | Description | |-------------------|------|-------------| | `key` | `string` | Component identifier, registered in `ui.comps` | | `hidden` | `boolean` | Hide component (not rendered) | | `style` | `Style` | Component style | | `theme` | `Theme` | Theme overrides | | `parent` | `BaseComponent` | Parent component | | `children` | `BaseComponent[]` | Child components | | `focused` | `boolean` | Whether component is focused | | `markDirty()` | `void` | Schedule re-render | | `show()` | `void` | Show component (sets `hidden = false`) | | `hide()` | `void` | Hide component (sets `hidden = true`) | | `render()` | `LayoutNode \| BaseComponent` | Return UI tree | | `onKey(key)` | `boolean` | Handle key event, return true if handled | | `canFocus()` | `boolean` | Whether component can receive focus | | `onMount()` | `void` | Called when component appears | | `onUnmount()` | `void` | Called when component disappears | | `onFocus()` | `void` | Called when focused | | `onBlur()` | `void` | Called when losing focus | | `ref` | `(comp) => void` | Ref callback for external access | | `refs` | `Record` | Access child components by ref key | | `refTo(key)` | `(comp) => void` | Create ref binding for child | ## Examples Run the included examples: ```bash # Todo app with filtering npx ts-node examples/todo.ts # Multi-step form with validation npx ts-node examples/form.ts # Live dashboard with real-time updates npx ts-node examples/dashboard.ts # Interactive menu with submenus npx ts-node examples/menu.ts # Log viewer with filtering npx ts-node examples/log-viewer.ts ``` See the [examples/](./examples) directory for more. ## Key Handling Key events are dispatched to the focused component, then bubble up through parents. ```typescript onKey(key: KeyEvent): boolean { if (key.name === 'enter' && key.ctrl) { // Handle Ctrl+Enter return true } if (key.name === 's' && key.meta) { // Handle Alt/Meta+S return true } return false } ``` **KeyEvent properties:** | Property | Type | Description | |----------|------|-------------| | `name` | `string` | Key name (`'enter'`, `'escape'`, `'tab'`, `'up'`, `'a'`, `'f1'`, etc.) | | `ctrl` | `boolean` | Ctrl modifier | | `shift` | `boolean` | Shift modifier | | `meta` | `boolean` | Alt/Meta modifier | Set `bubblingKeyEvent: true` on a component to allow events to continue bubbling after handling. ## License MIT