Skip to content

nvms/trendr

Repository files navigation

▗        ▌
▜▘▛▘█▌▛▌▛▌
▐▖▌ ▙▖▌▌▙▌

The only Node.js TUI framework with JSX components and per-cell diffing. benchmarks

Per-cell diffing has been the standard terminal rendering technique since curses in the 80s - track every character position, only write what changed. The popular Node.js TUI frameworks skipped it entirely and redraw the whole screen every frame.

trend brings per-cell diffing to JSX without React or any dependencies, with its own signals, hooks, and flexbox layout. 4-16x faster frame times and 580x less I/O per render (17 bytes vs ~10,000 on a single-cell update).

plasma.mp4

Usage

Requires esbuild (or similar) for JSX transformation.

{ "jsx": "automatic", "jsxImportSource": "trend" }
import { mount, createSignal, useInput } 'from ''trend'

function App() {
  const [count, setCount] = createSignal(0)

  useInput(({ key }) => {
    if (key === 'up') setCount(c => c + 1)
    if (key === 'down') setCount(c => c - 1)
  })

  return (
    <box style={{ flexDirection: 'column', padding: 1 }}>
      <text style={{ color: 'cyan', bold: true }}>Count: {count()}</text>
      <text style={{ color: 'gray' }}>up/down to change</text>
    </box>
  )
}

mount(App)

mount(Component, { stream?, stdin?, theme? }) enters alt screen, starts a 60fps render loop, and returns { unmount }. Components are plain functions that return JSX. State is managed with signals - call the getter to read, the setter to write. The framework re-renders automatically when signals change.

Theming

All built-in components use accent as the focus/highlight color (default: 'cyan'). Override it globally via mount:

mount(App, { theme: { accent: 'green' } })

Components read the accent with useTheme(). Use this in your own components to stay consistent:

import { useTheme } from '@trendr/core'

const { accent } = useTheme()

Individual components still accept explicit color props (e.g. <Spinner color="magenta" />) which override the theme.

Signals

import { createSignal, createEffect, createMemo, batch, untrack, onCleanup } from '@trendr/core'

const [value, setValue] = createSignal(0)
value()         // read (tracks dependency)
setValue(1)     // write
setValue(v => v + 1) // updater

createEffect(() => {
  console.log(value()) // re-runs when value changes
  return () => {}      // optional cleanup
})

const doubled = createMemo(() => value() * 2) // cached derived value

batch(() => {        // coalesce multiple updates into one render
  setValue(1)
  setValue(2)
})

untrack(() => value()) // read without tracking
onCleanup(() => {})    // runs when component unmounts or effect re-runs

Layout

Two element types: box (container) and text (leaf).

<box style={{
  flexDirection: 'column',  // 'column' (default) | 'row'
  flexGrow: 1,              // fill remaining space
  gap: 1,                   // space between children
  justifyContent: 'flex-start', // 'flex-start' | 'center' | 'flex-end'
  alignItems: 'stretch',    // 'stretch' | 'flex-start' | 'center' | 'flex-end'
  width: 20,                // fixed or '50%'
  height: 10,               // fixed or '25%'
  minWidth: 5, maxWidth: 30,
  minHeight: 2, maxHeight: 15,
  padding: 1,               // all sides
  paddingX: 1, paddingY: 1, // axis
  paddingTop: 1, paddingBottom: 1, paddingLeft: 1, paddingRight: 1,
  margin: 1,                // same variants as padding
  border: 'round',          // 'single' | 'double' | 'round' | 'bold'
  borderColor: 'cyan',
  borderEdges: { bottom: true, left: true }, // render only specific sides
  bg: 'blue',               // background color
  texture: 'dots',          // background texture (see below)
  textureColor: '#333',     // color for texture characters
  position: 'absolute',     // remove from flow, position with top/left/right/bottom
  top: 0, left: 0, right: 0, bottom: 0,
  overflow: 'scroll',       // scrollable container (see ScrollBox)
  scrollOffset: 0,          // scroll position (pixels from top)
}}>

<text style={{
  color: 'cyan',            // named, hex (#ff0000), or 256-color index
  bg: 'black',
  bold: true,
  dim: true,
  italic: true,
  underline: true,
  inverse: true,
  strikethrough: true,
  overflow: 'wrap',         // 'wrap' (default) | 'truncate' | 'nowrap'
}}>

Background Textures

Fill a box's background with a repeating character instead of blank space. Works with or without bg.

<box style={{ bg: '#1a1a2e', texture: 'dots', textureColor: '#2a2a4e' }}>

Presets: 'shade-light' (░), 'shade-medium' (▒), 'shade-heavy' (▓), 'dots' (·), 'cross' (╳), 'grid' (┼), 'dash' (╌). Or pass any single character: texture: '~'.

Texture characters show through spaces in text rendered on top (unless the text has an explicit bg, which claims the cell).

Absolute Positioning

Remove an element from flex flow and position it relative to the parent's content area.

<box style={{ border: 'round', height: 5, flexDirection: 'column' }}>
  <text>content here</text>
  <box style={{ position: 'absolute', top: 0, right: 1 }}>
    <text style={{ color: 'green', bold: true }}>ONLINE</text>
  </box>
</box>

Supports top, left, right, bottom. If both left and right are set, width is derived (same for top/bottom). Absolute elements paint after flow children.

Box, Text, and Spacer are convenience wrappers:

import { Box, Text, Spacer } from '@trendr/core'
<Box style={{ flexDirection: 'row' }}><Text>hello</Text><Spacer /><Text>right</Text></Box>

Hooks

useInput

Used in counter, dashboard, explorer, chat, modal-form, components, focus-demo

useInput((event) => {
  // event.key: 'a', 'return', 'escape', 'up', 'down', 'left', 'right',
  //            'tab', 'shift-tab', 'space', 'backspace', 'delete',
  //            'home', 'end', 'pageup', 'pagedown', 'f1'-'f12'
  // event.ctrl: boolean
  // event.meta: boolean (alt/option key)
  // event.raw: raw character string
  // event.stopPropagation(): prevent other handlers from receiving this event
})

Handlers fire in reverse registration order (innermost component first). Call stopPropagation() to consume the event.

useHotkey

Declarative key binding. Parses 'ctrl+s', 'alt+enter', etc.

import { useHotkey } from '@trendr/core'

useHotkey('ctrl+s', () => save())
useHotkey('alt+enter', () => submit(), { when: () => isFocused })

useLayout

Returns the component's computed layout rectangle.

const { x, y, width, height } = useLayout()

useResize

useResize(({ width, height }) => { /* terminal resized */ })

useInterval

Used in dashboard

useInterval(() => tick(), 1000) // auto-cleaned on unmount

useStdout

const stream = useStdout() // the output stream (process.stdout or custom)

useTheme

Returns the current theme object. See Theming.

const { accent } = useTheme()

useFocus

Used in explorer, chat, modal-form, components, focus-demo, layout

Manages focus across multiple interactive regions. You register named items in the order you want tab to cycle through them. The focus manager tracks which name is currently active - it doesn't know anything about your components or layout.

import { useFocus } from '@trendr/core'

const fm = useFocus({ initial: 'input' })

// declaration order = tab order
fm.item('input')     // tab stop 0
fm.item('list')      // tab stop 1
fm.item('sidebar')   // tab stop 2

Then wire each component's focused prop to fm.is(), which returns true when that name is the active one:

<TextInput focused={fm.is('input')} />
<List focused={fm.is('list')} />
<Select focused={fm.is('sidebar')} />

Tab and shift-tab cycle through the registered names. The focus manager handles the index - you just query it.

fm.is('input')   // boolean - is 'input' the active name?
fm.focus('list')  // jump to 'list' programmatically
fm.current()      // the currently active name

Groups let you nest multiple items under one tab stop. Tab moves between groups/items at the top level, and within a focused group you navigate between its items with keyboard:

fm.group('settings', { items: ['theme', 'autosave', 'format'] })
// fm.is('theme'), fm.is('autosave'), etc. work within the group

Options:

  • navigate - which keys move between group items: 'both' (default, j/k and up/down), 'jk', or 'updown'
  • wrap - wrap around at ends (default false)

Stack-based focus for modals and drills - push saves the current focus and switches, pop restores it:

fm.push('modal')  // save current focus, switch to 'modal'
fm.pop()          // restore previous focus

useToast

Used in chat, modal-form, components

import { useToast } from '@trendr/core'

const toast = useToast({
  duration: 2000,           // ms, default 2000
  position: 'bottom-right', // see positions below
  margin: 1,                // padding from screen edge, default 1
  render: (message) => (    // optional custom render
    <box style={{ bg: '#1E1E1E', paddingX: 1 }}>
      <text style={{ color: '#9A9EA3' }}>{message}</text>
    </box>
  ),
})

toast('saved')

// positions: 'top-left', 'top-center', 'top-right',
//            'center-left', 'center', 'center-right',
//            'bottom-left', 'bottom-center', 'bottom-right'

Components

All interactive components accept a focused prop that controls whether they respond to keyboard input. When multiple components are on screen, only the focused one should capture keys - otherwise a keypress meant for a text input would also scroll a list. In practice you wire this to a focus manager:

const fm = useFocus({ initial: 'search' })
fm.item('search')
fm.item('results')

<TextInput focused={fm.is('search')} />
<List focused={fm.is('results')} />

TextInput

Used in explorer, modal-form, focus-demo

Single-line text input with horizontal scrolling.

<TextInput
  focused={fm.is('search')}
  placeholder="search..."
  onChange={v => {}}   // every keystroke
  onSubmit={v => {}}   // Enter
  onCancel={() => {}}  // Escape (only stopPropagates if provided)
/>

Keys: left/right, home/end, ctrl-a/e, ctrl-u/k/w, backspace, delete.

TextArea

Used in chat

Multi-line text input. Auto-grows up to maxHeight, then scrolls.

<TextArea
  focused={fm.is('input')}
  placeholder="write something..."
  maxHeight={10}         // default 10
  onChange={v => {}}     // every edit
  onSubmit={v => {}}     // Alt+Enter
  onCancel={() => {}}    // Escape
/>

Keys: Enter inserts newline. Up/down with sticky goal column. Home/end operate on display rows. Ctrl-u/k/w operate on logical lines.

List

Used in explorer, chat, modal-form, components, focus-demo, layout

Scrollable list with keyboard navigation.

<List
  items={data}
  selected={selectedIndex}  // controlled, or omit for internal state
  onSelect={setIndex}
  focused={fm.is('list')}
  height={10}               // defaults to layout height
  header={<text>title</text>}
  renderItem={(item, { selected, index, focused }) => (
    <text style={{ bg: selected ? (focused ? accent : 'gray') : null }}>{item.name}</text>
  )}
/>

Multi-row items are supported via itemHeight. The layout engine sizes each item naturally from its children - itemHeight just tells the scroll math how many rows each item occupies:

<List
  items={data}
  itemHeight={3}
  renderItem={(item, { selected, focused }) => (
    <box style={{ flexDirection: 'column', bg: selected ? accent : null }}>
      <text style={{ bold: true }}>{item.name}</text>
      <text style={{ color: 'gray' }}>{item.description}</text>
      <text style={{ color: 'green' }}>{item.status}</text>
    </box>
  )}
/>

Keys: j/k or up/down, g/G for top/bottom, ctrl-d/u half page, ctrl-f/b full page, pageup/pagedown.

Table

Used in components

Column-based data table. Uses List internally.

<Table
  columns={[
    { header: 'Name', key: 'name', flexGrow: 1 },
    { header: 'Size', key: 'size', width: 10, color: 'gray', paddingX: 1 },
    { header: 'Type', render: (row, sel) => row.type.toUpperCase(), width: 8 },
  ]}
  data={rows}
  selected={selectedRow}
  onSelect={setRow}
  focused={fm.is('table')}
  separator={true}              // horizontal rule below header
  separatorChars={{ left: '', fill: '─', right: '' }}  // customizable
/>

Tabs

Used in chat

<Tabs
  items={['general', 'settings', 'logs']}
  selected={activeTab}
  onSelect={setTab}
  focused={fm.is('tabs')}
/>

Keys: left/right, tab/shift-tab. Wraps around.

Select

Used in modal-form, components, focus-demo

Dropdown selector. Can render inline or as overlay.

<Select
  items={['red', 'green', 'blue']}
  selected={color}
  onSelect={setColor}
  focused={fm.is('color')}
  overlay={false}          // true renders as floating overlay
  placeholder="pick one..."
  openIcon="▲"             // default ▲
  closedIcon="▼"           // default ▼
  renderItem={(item, { selected, index }) => <text>{item}</text>}
  style={{
    border: 'single', borderColor: 'green', bg: 'black',
    cursorBg: 'green', cursorTextColor: 'black',
    color: null, focusedColor: 'green',
  }}
/>

Keys: j/k or up/down to navigate, enter/space to select, escape to close.

Checkbox

Used in modal-form, components, focus-demo

<Checkbox
  checked={isChecked}
  label="Enable feature"
  onChange={setChecked}  // (newState: boolean) => void
  focused={fm.is('feature')}
  checkedIcon="[✓]"     // default '[x]'
  uncheckedIcon="[ ]"   // default '[ ]'
/>

Keys: space or enter to toggle.

Radio

Used in modal-form, components, focus-demo

<Radio
  options={['small', 'medium', 'large']}
  selected={size}
  onSelect={setSize}
  focused={fm.is('size')}
/>

Keys: j/k or up/down, enter/space to select. Renders / .

ProgressBar

Used in dashboard, components

<ProgressBar
  value={0.65}      // 0 to 1
  width={20}        // characters, default 20
  color="red"       // overrides theme accent
  label="65%"       // optional text after bar
/>

Spinner

Used in components

<Spinner
  label="loading..."
  color="magenta"    // overrides theme accent
  interval={80}      // ms, default 80
/>

Button

Used in modal-form

Focusable button. Enter or space to activate.

<Button
  label="save"
  onPress={() => save()}
  focused={fm.is('save')}
  variant="dim"   // optional, grays out when unfocused
/>

Modal

Used in modal-form, components, focus-demo

Centered overlay with dimmed backdrop. Height is driven by content.

<Modal
  open={isOpen}
  onClose={() => setOpen(false)}
  title="Confirm"
  width={40}     // default 40
>
  <text>Are you sure?</text>
  <Button label="ok" onPress={() => setOpen(false)} focused={fm.is('ok')} />
</Modal>

Keys: escape to close.

ScrollableText

Used in explorer, reader, highlight

Scrollable text viewer with optional scrollbar. Content can include ANSI escape sequences (SGR) - colors, bold, dim, etc. are parsed and rendered correctly. This means you can pipe output from any syntax highlighter (shiki, cli-highlight, etc.) directly into content.

<ScrollableText
  content={longText}
  focused={fm.is('preview')}
  scrollOffset={offset}    // controlled, or omit for internal state
  onScroll={setOffset}
  scrollbar={true}         // default false
  wrap={false}             // default true, set false for horizontal scroll
  thumbChar="█"            // default █
  trackChar="░"            // default │
/>

Keys: same as List (j/k, g/G, ctrl-d/u, ctrl-f/b, pageup/pagedown).

ScrollBox

Scrollable container for arbitrary children. Unlike ScrollableText which takes a string, ScrollBox wraps JSX elements.

<ScrollBox
  focused={fm.is('list')}
  scrollbar={true}          // default false
  scrollOffset={offset}     // controlled, or omit for internal state
  onScroll={setOffset}
  thumbChar="\u2588"        // default █
  trackChar="\u2502"        // default │
  style={{ flexGrow: 1 }}   // pass-through style for the scroll container
>
  {items.map(item => (
    <text key={item.id}>{item.name}</text>
  ))}
</ScrollBox>

Keys: same as List and ScrollableText.

SplitPane

Connected panels with shared borders and proper box-drawing junction characters. Sizes use CSS Grid-style fr units or fixed values.

import { SplitPane } from '@trendr/core'

<SplitPane direction="row" sizes={[20, '2fr', '1fr']} border="round" borderColor="gray">
  <box style={{ paddingX: 1 }}>
    <text>sidebar</text>
  </box>
  <box style={{ paddingX: 1 }}>
    <text>main content</text>
  </box>
  <box style={{ paddingX: 1 }}>
    <text>detail</text>
  </box>
</SplitPane>

Props:

  • direction - 'row' (vertical dividers) or 'column' (horizontal dividers)
  • sizes - array of fixed numbers or 'Nfr' strings. [20, '1fr'] = 20 cols fixed + rest. ['1fr', '1fr'] = even split. Defaults to equal fractions.
  • border - 'single' | 'double' | 'round' | 'bold'
  • borderColor - color for border and dividers
  • borderEdges - object with top, right, bottom, left booleans to render only specific sides. Omitted keys default to false.

Nesting works for complex layouts:

<SplitPane direction="column" sizes={['1fr', 8]} border="round">
  <SplitPane direction="row" sizes={[20, '1fr']} border="round">
    <box>nav</box>
    <box>main</box>
  </SplitPane>
  <box>status</box>
</SplitPane>

Animation

Animate numeric values with physics-based interpolators. Animated values are signals - they trigger re-renders as they update.

import { useAnimated, spring, ease, decay } from '@trendr/core'

const x = useAnimated(0, spring())    // spring physics
x.set(100)                            // animate to 100
x()                                   // read current value (tracks as signal)
x.snap(50)                            // jump instantly, no animation

useAnimated is the hook version (auto-cleanup on unmount). animated is the standalone version for use outside components.

Interpolators

spring({ frequency: 2, damping: 0.3 })   // underdamped spring (bouncy)
spring({ damping: 1 })                    // critically damped (no bounce)
ease(300)                                 // 300ms ease-out-cubic
ease(500, linear)                         // 500ms linear
decay({ deceleration: 0.998 })            // momentum-based decay

Switch interpolator mid-animation:

x.setInterpolator(ease(200))
x.set(newTarget)

Easing functions

linear, easeInQuad, easeOutQuad, easeInOutQuad, easeInCubic, easeOutCubic, easeInOutCubic, easeOutElastic(), easeOutBounce()

Tick callback

x.onTick((value) => { /* called each frame while animating */ })

Build

Uses esbuild. JSX configured with jsxImportSource: 'trend'.

node esbuild.config.js

Examples run via npm scripts:

npm run counter
npm run chat
npm run dashboard
npm run explorer
npm run highlight

About

direct-mode TUI renderer with JSX, signals, and per-cell diffing

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages