dart_tui 1.0.0+1 copy "dart_tui: ^1.0.0+1" to clipboard
dart_tui: ^1.0.0+1 copied to clipboard

Elm-style terminal UI framework for Dart — Model–Update–View, async commands, 20+ components, Lipgloss-inspired styling.

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.

0
likes
160
points
30
downloads

Documentation

API reference

Publisher

verified publisherhashstudios.dev

Weekly Downloads

Elm-style terminal UI framework for Dart — Model–Update–View, async commands, 20+ components, Lipgloss-inspired styling.

Repository (GitHub)
View/report issues

Topics

#terminal #tui #cli #elm #bubbletea

License

MIT (license)

Dependencies

characters, meta, path

More

Packages that depend on dart_tui