dart_tui 1.0.0+1
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 #
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.

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)));

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());

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 ')

Progress bar #
Determinate progress (0.0–1.0) with █/░ fill and configurable width/label.
ProgressModel(progress: 0.65, width: 40, showPercent: true)

Text input #
Single-line input with cursor, charLimit, EchoMode (password), validate, tab-completion suggestions.
TextInputModel(placeholder: 'Type something…', charLimit: 80)

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

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)

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,
),
)

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,
)

Viewport #
Scrollable content pane with soft-wrap; useful for long text, logs, or file content.
ViewportModel(content: longText, height: 20, wrap: true)

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

Paginator #
Compact page indicator (dots or numeric) for multi-page flows.
PaginatorModel(totalPages: 5, activePage: 0)

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)

File picker #
Async directory browser with configurable extension filter and keyboard navigation.
FilePickerModel(
initialDirectory: Directory.current,
extensions: {'.dart', '.yaml'},
)

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 #
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.