dart_tui

pub.dev Dart SDK License: MIT

Elm-style terminal UI framework for Dart, inspired by Bubble Tea.

Build rich, interactive CLI applications with a clean Model–Update–View architecture, a full component library, and Lipgloss-quality styling — all in pure Dart.

showcase


Features

  • Model–Update–View — same architecture as Elm and Bubble Tea; pure, testable state
  • Async commands (Cmd) for timers, HTTP, subprocesses, and any async work
  • 20+ ready-made components — spinners, progress bars, text inputs, tables, trees, viewports, and more
  • Lipgloss-inspired styling — true-color RGB, borders, padding, alignment, gradients
  • Canvas compositing — paint styled blocks at arbitrary (x, y) positions with z-index layering
  • Cell-level diff renderer — only changed cells are written; zero flicker
  • Synchronized updates (CSI ?2026) for terminals that support them
  • Auto background detection — OSC 11 query fires at startup; your model receives BackgroundColorMsg
  • Fast startup — kernel snapshots cut warm-JIT from ~1 s to ~500 ms; AOT compiles to native

Installation

# pubspec.yaml
dependencies:
  dart_tui: ^1.0.0
dart pub get

Quick start

import 'package:dart_tui/dart_tui.dart';

void main() async {
  await Program(
    options: const ProgramOptions(altScreen: true),
  ).run(CounterModel());
}

final class CounterModel extends TeaModel {
  CounterModel({this.count = 0});
  final int count;

  @override
  Cmd? init() => tick(const Duration(seconds: 1), (_) => _TickMsg());

  @override
  (Model, Cmd?) update(Msg msg) {
    if (msg is _TickMsg) {
      if (count >= 5) return (this, () => quit());
      return (CounterModel(count: count + 1),
          tick(const Duration(seconds: 1), (_) => _TickMsg()));
    }
    if (msg is KeyMsg && (msg.key == 'q' || msg.key == 'ctrl+c')) {
      return (this, () => quit());
    }
    return (this, null);
  }

  @override
  View view() => newView('Count: $count\n\nPress q to quit.');
}

final class _TickMsg extends Msg {}

Core concepts

Model–Update–View

┌──────────────┐   Msg    ┌──────────────┐
│    Model     │ ──────▶  │    update    │
│  (your state)│          │  (pure fn)   │
└──────────────┘          └──────┬───────┘
        ▲                        │ (Model, Cmd?)
        │                        ▼
        │                 ┌──────────────┐
        └──── render ───  │     view     │
                          │  (pure fn)   │
                          └──────────────┘
Concept Description
Model Immutable state. Implement init(), update(Msg), view().
Msg Tagged event: key press, window resize, tick, custom data.
Cmd FutureOr<Msg?> Function() — async side-effect that delivers one message back.
View Declared output string plus optional cursor position, mouse mode, window title.
Program Owns the event loop, terminal raw mode, renderer, and signal handling.

Returning a value (prompt-style)

abstract class OutcomeModel<T> implements Model {
  T? get outcome; // non-null → program exits and returns this value
}

final String? result = await Program().runForResult<String>(MyPromptModel());

Commands

// Built-in helpers
Msg quit()
Msg interrupt()
Cmd tick(Duration d, Msg Function(DateTime) fn)       // one-shot delay
Cmd every(Duration d, Msg Function(DateTime) fn)      // repeating, wall-clock aligned
Cmd? batch(List<Cmd?> cmds)                           // concurrent
Cmd? sequence(List<Cmd?> cmds)                        // sequential
Cmd execProcess(String exe, List<String> args, {...}) // external process
Cmd requestBackgroundColor()                          // fire OSC 11 query manually

Program options

Program(
  options: const ProgramOptions(
    altScreen: true,
    hideCursor: true,
    tickInterval: Duration(milliseconds: 100),
    logFile: File('debug.log'),
  ),
  programOptions: [
    withFps(60),              // default 60, max 120
    withCellRenderer(),       // cell-level diff (less flicker on older terminals)
    withFilter((model, msg) { // intercept / transform messages
      if (msg is QuitMsg) return null; // suppress
      return msg;
    }),
  ],
).run(MyModel());

Styling

Inspired by Lipgloss. All styling is composable and immutable.

// True-color foreground, bold, 40-char centered block
final title = const Style(
  foregroundRgb: RgbColor(203, 166, 247), // Catppuccin Mauve
  isBold: true,
  width: 40,
  align: Align.center,
).render('Hello, dart_tui!');

// Borders + padding
final box = const Style(
  border: Border.rounded,
  foregroundRgb: RgbColor(137, 180, 250),
).withWidth(30).withPadding(EdgeInsets.all(1)).render(content);

// Layout helpers
final ui  = joinHorizontal(AlignVertical.top, [leftPane, rightPane]);
final mid = place(termWidth, termHeight, Align.center, AlignVertical.middle, content);

Gradient text

// Per-character true-color gradient across any number of colors
final rainbow = gradientText('dart_tui', [
  const RgbColor(203, 166, 247), // mauve
  const RgbColor(116, 199, 236), // sky
  const RgbColor(166, 227, 161), // green
]);

// Gradient background fill
final banner = gradientBackground('  Welcome!  ', [
  const RgbColor(30, 30, 46),
  const RgbColor(49, 50, 68),
], foreground: const Style(foregroundRgb: RgbColor(205, 214, 244)));

gradient

Light / dark background detection

Program sends \x1b]11;?\x07 (OSC 11) at startup — the response arrives automatically as BackgroundColorMsg in your model's update(). Use isDarkRgb() to branch styles:

case BackgroundColorMsg(:final rgb):
  final dark = isDarkRgb(rgb);
  return (MyModel(darkTheme: dark), null);

Canvas compositing

Paint styled text blocks at arbitrary (x, y) positions with z-index layering:

final canvas = Canvas(72, 22);
canvas.paint(2, 2, leftPanel.render(content), zIndex: 1);
canvas.paint(38, 2, rightPanel.render(content), zIndex: 1);
canvas.paint(18, 14, bannerStyle.render(animatedBanner), zIndex: 2);
// Higher zIndex draws on top of lower zIndex at overlapping cells.
return newView(canvas.render());

canvas


Component library

All components are in package:dart_tui/dart_tui.dart.

Spinner

Animated indeterminate activity indicator, driven by TickMsg.

SpinnerModel(style: Spinner.dot, prefix: 'Loading ')

spinner

Progress bar

Determinate progress (0.0–1.0) with / fill and configurable width/label.

ProgressModel(progress: 0.65, width: 40, showPercent: true)

progress_bar

Text input

Single-line input with cursor, charLimit, EchoMode (password), validate, tab-completion suggestions.

TextInputModel(placeholder: 'Type something…', charLimit: 80)

textinput

Text area

Multi-line editor with scroll, line-kill (Ctrl+K), and word movement.

textarea

Select list

Vertical list with keyboard cursor (↑↓ / jk). Embeds into parent models for menu flows.

SelectListModel(items: ['Option A', 'Option B', 'Option C'], height: 8)

list_default

Table

Scrollable data table with configurable headers, column widths, per-row/per-cell styling.

TableModel(
  columns: [TableColumn('City', 20), TableColumn('Pop', 12)],
  rows: data,
  styles: TableStyles(
    header: const Style(isBold: true, isUnderline: true),
    styleFunc: (row, col) => col == 1 ? rightAlign : null,
  ),
)

table

Tree

Hierarchical expandable list with Unicode box-drawing connectors. Navigate with ↑↓ / jk, toggle with Enter / Space, expand/collapse with →l / ←h.

TreeModel(
  root: TreeNode(label: 'Languages', isExpanded: true, children: [
    TreeNode(label: 'Dart', children: [TreeNode(label: 'Flutter')]),
    TreeNode(label: 'Go', children: [TreeNode(label: 'Bubble Tea')]),
  ]),
  height: 20,
)

tree

Viewport

Scrollable content pane with soft-wrap; useful for long text, logs, or file content.

ViewportModel(content: longText, height: 20, wrap: true)

pager

Timer & Stopwatch

TimerModel(duration: Duration(minutes: 5))   // countdown; .finished, .remaining
StopwatchModel()                              // elapsed time; .start()/.stop()/.reset()

timer

Paginator

Compact page indicator (dots or numeric) for multi-page flows.

PaginatorModel(totalPages: 5, activePage: 0)

paginator

Help

Compact / full keybinding reference panel built from a KeyMap.

final keyMap = KeyMap([
  KeyBinding(['↑', 'k'], 'move up'),
  KeyBinding(['↓', 'j'], 'move down'),
  KeyBinding(['enter'], 'select'),
  KeyBinding(['q'], 'quit'),
]);
HelpModel.fromKeyMap(keyMap)

help

File picker

Async directory browser with configurable extension filter and keyboard navigation.

FilePickerModel(
  initialDirectory: Directory.current,
  extensions: {'.dart', '.yaml'},
)

file_picker


Examples

48 runnable examples covering every feature:

Example What it shows
simple.dart Tick-driven countdown, minimal model
textinput.dart Single-line text input
textinputs.dart Multi-field form with Tab focus
textarea.dart Multi-line editor
autocomplete.dart Tab-completion suggestions
list_simple.dart Basic SelectListModel
list_default.dart List with selection state
table.dart City data table
tree.dart Expandable language/framework tree
spinner.dart Animated spinner
spinners.dart All built-in spinner styles
progress_bar.dart Interactive progress bar
progress_animated.dart Auto-incrementing progress
pager.dart Scrollable viewport
file_picker.dart Directory browser
help.dart HelpModel + KeyMap
timer.dart Countdown timer
stopwatch.dart Elapsed-time stopwatch
paginator.dart Page dot indicator
gradient.dart Per-character gradient text
canvas.dart Canvas compositing with z-index
color_profile.dart ColorProfile + BackgroundColorMsg
package_manager.dart Spinner + progress multi-step
composable_views.dart Timer + spinner composition
tabs.dart Tabbed interface
mouse.dart Mouse click / scroll events
exec_cmd.dart External editor via execProcess
http.dart HTTP fetch with spinner
result.dart OutcomeModel returning a value
isbn_form.dart Validated TextInputModel
showcase.dart Full-featured gallery
all_features.dart Component integration demo
(+ 16 more) window_size, fullscreen, cursor_style, pipe, send_msg, realtime, prevent_quit, sequence, focus_blur, vanish, print_key, views, set_window_title, altscreen_toggle, prompts_chain, shopping_list

Run any example:

# JIT (source, slower first run)
dart run example/simple.dart

# Kernel snapshot (~2× faster startup)
make kernel EXAMPLE=simple
dart run tool/bin/simple.dill

Development

Prerequisites

  • Dart SDK ≥ 3.5 (or Flutter SDK via fvm)
  • VHS — only needed to re-record GIFs

Makefile targets

make test                     # run all unit tests
make analyze                  # dart analyze lib/
make run EXAMPLE=simple       # run example/simple.dart (JIT)
make kernels                  # compile all examples to .dill snapshots
make run-fast EXAMPLE=simple  # run tool/bin/simple.dill (kernel snapshot)
make bench EXAMPLE=simple     # startup benchmark (3 runs, reports median)
make gifs                     # build kernels then re-record all GIFs
make gif EXAMPLE=simple       # re-record one GIF
make new-example NAME=my_app  # scaffold example/my_app.dart from template
make clean                    # remove tool/bin/ build artifacts

Creating a new example

make new-example NAME=my_feature
# → creates example/my_feature.dart with a minimal TeaModel scaffold
make run EXAMPLE=my_feature

The generated file has everything wired up: Program, TeaModel, key handling, and a styled view. Add your state and logic from there.

Fast startup with kernel snapshots

Pre-compile examples to skip JIT at runtime:

# Build one
bash tool/build.sh --kernel example/simple.dart

# Build all
bash tool/build.sh --kernel

# Benchmark
fvm dart run tool/startup_bench.dart --dill tool/bin/simple.dill

Typical results:

Mode Startup
JIT source (cold) ~1 400 ms
JIT source (warm) ~1 050 ms
Kernel snapshot ~550 ms
AOT (dart compile exe) ~100 ms

Measured on WSL2 / Linux. Native Linux: ~350 ms kernel, ~80 ms AOT.

Re-recording GIFs

make gifs          # builds all kernels, then records all 48 GIFs
make gif EXAMPLE=showcase   # record one

Requires VHS and ffmpeg on your PATH (or at ~/go-packages/bin/vhs and ~/ffmpeg-local).


Architecture notes

Event loop

stdin bytes
    │
    ▼
TerminalInputDecoder
    │ (KeyPressMsg, WindowSizeMsg, BackgroundColorMsg, …)
    ▼
Queue<Msg>
    │
    ▼  drain all pending messages first
for msg in queue:
    model = model.update(msg)
    fire cmd (unawaited — result enqueues next message)
    │
    ▼  render once per batch (FPS-throttled)
renderer.render(model.view())

Key properties:

  • All pending messages are drained before each render — rapid key presses never block each other
  • Commands are fire-and-forget; their result arrives as the next message
  • The FPS cap (default 60) only throttles screen output, not message processing

Renderers

Renderer Strategy When to use
AnsiRenderer (default) Line-level diff Most terminals
CellRenderer Cell-level diff (per grapheme cluster) Terminals without ?2026 sync

License

MIT. Inspired by Bubble Tea by Charm. See LICENSE.

Libraries

dart_tui
Elm-style terminal UIs for Dart, inspired by Bubble Tea.