Native terminal UI toolkit built around The Elm Architecture
Dependencies
Warning: moonbit-community/rabbita_tui is experimental. Public APIs, package layout, widget names, and runtime behavior may change while it converges with Rabbita.
terminal event / command result
|
v
Msg ------> update(emit, msg, model) -> (Cmd, Model)
^ |
| v
Cmd <----------------------------- view(model){
"deps": {
"moonbit-community/rabbita_tui": "0.1.0",
"moonbitlang/async": "0.17.0"
},
"preferred-target": "native"
}import {
"moonbit-community/rabbita_tui" @tui,
"moonbit-community/rabbita_tui/widgets" @widgets,
"moonbitlang/async",
}
supported_targets = "+native"
options(
"is-main": true,
)moon run --target native path/to/your/package///|
using @tui {
border,
cell,
pad,
text,
type Cmd,
type Edge,
type Emit,
type Frame,
type Key,
type Node,
type ProgramOptions,
type Size,
type Style,
type Sub,
vstack,
}
///|
using @widgets {status_line}
///|
enum Msg {
KeyPressed(Key)
Resize(Size)
Quit
} derive(Eq, Debug)
///|
struct Model {
count : Int
width : Int
height : Int
} derive(Eq, Debug)
///|
fn initial_model() -> Model {
{ count: 0, width: 80, height: 24 }
}
///|
fn update(_emit : Emit[Msg], msg : Msg, model : Model) -> (Cmd, Model) {
match msg {
KeyPressed(key) =>
match key {
Up => (Cmd::none(), { ..model, count: model.count + 1 })
Down => (Cmd::none(), { ..model, count: model.count - 1 })
Char("q") | Ctrl("c") => (Cmd::quit(), model)
_ => (Cmd::none(), model)
}
Resize(size) =>
(Cmd::none(), { ..model, width: size.width, height: size.height })
Quit => (Cmd::quit(), model)
}
}
///|
fn view(model : Model) -> Node {
let title = text(style=Style::default().bold().fg(Ansi(39)), "Counter")
let body = text("count = \{model.count}")
let help = text("up/down change q quit")
let card = vstack(gap=1) [
title, body, help,
]
vstack [
status_line(
left="counter",
right="\{model.width}x\{model.height}",
width=model.width,
),
vstack(style=Style::default().padding(Edge::all(1)).border()) [
card,
],
]
}
///|
fn subscriptions(_model : Model) -> Sub[Msg] {
Sub::batch([
Sub::keys(key => KeyPressed(key)),
Sub::resize(size => Resize(size)),
])
}
///|
async fn main {
cell(model=initial_model(), update~, view~, subscriptions~).run_with_options(
ProgramOptions::inline(),
) catch {
NotATty(_) =>
println(
Frame::from_node(view(initial_model()), { width: 80, height: 24 }).to_string(),
)
NativeError(message) => println("terminal error: \{message}")
UnsupportedPlatform => println("native POSIX terminal required")
}
}///|
using @vector {type Vector}
///|
using @widgets {type TextInput}
///|
struct Model {
input : TextInput
items : Vector[String]
selected : Int
width : Int
height : Int
}///|
enum Msg {
Typed(Key)
Submitted
Resized(Size)
Loaded(Result[String, IOError])
Quit
}///|
fn update(emit : Emit[Msg], msg : Msg, model : Model) -> (Cmd, Model) {
match msg {
Submitted =>
(delay(emit(Loaded(Ok("done"))), 300), { ..model, status: "loading" })
Loaded(result) =>
match result {
Ok(value) => (Cmd::none(), { ..model, status: value })
Err(_) => (Cmd::none(), { ..model, status: "failed" })
}
Quit => (Cmd::quit(), model)
_ => (Cmd::none(), model)
}
}///|
fn update(emit : Emit[Msg], msg : Msg, model : Model) -> (Cmd, Model) {
match msg {
KeyPressed(key) => update_key(emit, key, model)
JobFinished(id) => update_job_finished(id, model)
Resize(size) => (Cmd::none(), resize_model(size, model))
}
}///|
fn view(model : Model) -> Node {
vstack [
text(style=Style::default().bold(), "Tasks"),
border(model.input.view(width=model.width - 4)),
status_line(left="enter submit", right="q quit", width=model.width),
]
}///|
fn subscriptions(_model : Model) -> Sub[Msg] {
Sub::batch([
Sub::keys(key => KeyPressed(key)),
Sub::mouse_events(mouse => MouseSeen(mouse)),
Sub::paste(value => Pasted(value)),
Sub::focus_changes(focused => FocusChanged(focused)),
Sub::resize(size => Resized(size)),
Sub::tick(100, Tick),
])
}| Command | Use it for |
|---|---|
| Cmd::none() | no side effect |
| emit(msg) | queue a message immediately |
| Cmd::batch([...]) | run commands concurrently |
| Cmd::sequence([...]) | run commands in order |
| delay(cmd, ms) / Cmd::delay(ms, cmd) | run a command later |
| Cmd::log / Cmd::err_log | log safely above the live TUI |
| Cmd::exec_process | temporarily restore terminal mode, run a shell command, resume |
| Cmd::suspend | release the terminal around async work |
| Cmd::quit | stop the program |
| Cmd::repaint | mark the frame dirty |
///|
fn update(emit : Emit[Msg], msg : Msg, model : Model) -> (Cmd, Model) {
match msg {
StartTimer => (delay(emit(TimerDone), 1000), { ..model, status: "waiting" })
TimerDone => (Cmd::none(), { ..model, status: "done" })
}
}Cmd::exec_process(
"git status --short",
code => emit(ProcessExited(code)),
)Cmd::sequence([
Cmd::log("saved configuration"),
Cmd::err_log("warning: using default profile"),
])ProgramOptions::default()
.alternate_screen(true)
.mouse(MouseButtonMotion)
.bracketed_paste(true)
.focus_events(true)
.hide_cursor(true)
.fps(30)
.max_messages_per_frame(128)
.resize_poll_millis(50)app.run()
app.run_with_options(ProgramOptions::inline())
app.run_returning_model()
app.run_with_cancel_token(options, token)
app.run_with_timeout(options, 5000)let token = CancelToken::new()
// pass token to the running program
token.cancel()| Function | Purpose |
|---|---|
| text(style?, value) | text node |
| vstack(gap?, style?) <| nodes / hstack(gap?, style?) <| nodes | vertical or horizontal layout |
| fragment(style?) <| nodes | render a sequence of child nodes |
| pad(edge=..., style?, node) | add padding |
| border(kind?, style?, node) | add a border |
| sized(width?, height?, style?, node) | force a size |
| clip(size=..., style?, node) | clip to a size |
| align(horizontal?, vertical?, style?, node) | align inside available space |
| fill(style?, value) | repeated fill |
let style = Style::default()
.fg(Ansi(16))
.bg(Ansi(250))
.bold()
.underline()
text(style~, "Ready")vstack(
gap=1,
style=Style::default()
.fg(Ansi(250))
.padding(Edge::all(1))
.border()
.size(width=48, height=8),
) [
text(style=Style::default().bold(), "Build status"),
text("All checks passed"),
]///|
using @widgets {
keymap,
progress,
status_line,
type TextInput,
type TextInputMsg,
}| Widget | Shape |
|---|---|
| TextInput | stateful: TextInput, TextInputMsg, update, view |
| Textarea | stateful: Textarea, TextareaMsg, value, update, view |
| List | stateful: List, ListMsg, selected_item, update, view |
| Viewport | stateful: Viewport, ViewportMsg, update, view |
| CommandPalette | stateful: CommandPalette, CommandPaletteMsg, items, selected_item, update, view |
| table / paginator / progress / timer / keymap / tabs | stateless render functions |
| status_line / spinner / modal | stateless terminal UI helpers |
///|
fn initial_model() -> Model {
{ input: TextInput(placeholder="Search"), completed: 0, total: 4 }
}///|
fn view(model : Model) -> Node {
vstack [
model.input.view(width=model.width),
progress(current=model.completed, total=model.total, width=model.width),
keymap(
bindings=[
{ keys: [Enter], help: "submit" },
{ keys: [Ctrl("c")], help: "quit" },
],
width=model.width,
),
]
}///|
fn update(_emit : Emit[Msg], msg : Msg, model : Model) -> (Cmd, Model) {
match msg {
KeyPressed(key) => {
let input_msg : TextInputMsg = TextInputKey(key)
(Cmd::none(), { ..model, input: model.input.update(input_msg) })
}
Submitted => (Cmd::none(), { ..model, saved: model.input.value })
}
}///|
async test "counter increments" {
let result = app().run_headless(
events=[Key(Up), Key(Up)],
options=HeadlessOptions::default().size({ width: 40, height: 10 }),
)
@debug.assert_eq(result.model.count, 2)
@debug.assert_eq(result.frames.length() > 0, true)
}let frame = Frame::from_node(view(model), { width: 80, height: 24 })
assert_true(frame.to_string().contains("Ready"))scripts/runtime_lab_tmux_test.shmoon run --target native examples/countermoon run --target native examples/codex-cli
moon run --target native examples/codex-cli -- --snapshotmoon run --target native examples/init-cli
moon run --target native examples/init-cli -- --snapshotmoon run --target native examples/runtime-lab
moon run --target native examples/runtime-lab -- --snapshot
scripts/runtime_lab_tmux_test.shtype Cmdpub(all) enum Key {
Char(String)
Enter
Escape
Backspace
Tab
BackTab
Up
Down
Left
Right
Home
End
Delete
PageUp
PageDown
Ctrl(String)
Alt(String)
Modified(Key, KeyModifiers)
Function(Int)
Unknown(String)
} derive(Eq, Debug)pub(all) struct Mouse {
button : MouseButton
action : MouseAction
x : Int
y : Int
} derive(Eq, Debug)async fn[Model, Msg] Program::run_headless(self : Program[Model, Msg], events? : Array[Event], options? : HeadlessOptions) -> ProgramRunResult[Model]async fn[Model, Msg] Program::run_returning_model(self : Program[Model, Msg]) -> Model raise TerminalErrorasync fn[Model, Msg] Program::run_with_cancel_token(self : Program[Model, Msg], options : ProgramOptions, token : CancelToken) -> Model raise TerminalErrorasync fn[Model, Msg] Program::run_with_options(self : Program[Model, Msg], options : ProgramOptions) -> Unit raise TerminalErrorasync fn[Model, Msg] Program::run_with_options_returning_model(self : Program[Model, Msg], options : ProgramOptions) -> Model raise TerminalErrorasync fn[Model, Msg] Program::run_with_timeout(self : Program[Model, Msg], options : ProgramOptions, milliseconds : Int) -> Model raise TerminalErrorpub(all) struct ProgramRunResult[Model] {
model : Model
terminal_commands : Array[TerminalCommand]
frames : Array[Frame]
quit : Bool
steps : Int
limit_reached : Bool
}pub(all) struct Style {
fg : Color
bg : Color
bold : Bool
dim : Bool
italic : Bool
underline : Bool
blink : Bool
reverse : Bool
strikethrough : Bool
width : Int
height : Int
padding : Edge
border : Border
align_horizontal : Align
align_vertical : Align
align_set : Bool
clip_width : Int
clip_height : Int
} derive(Eq, Debug)type Sub[Msg]pub(all) enum TerminalCommand {
QuitProgram
Repaint
Print(String)
PrintLine(String)
PrintErr(String)
PrintErrLine(String)
ClearScreen
EnterAlternateScreen
LeaveAlternateScreen
HideCursor
ShowCursor
EnableMouse(MouseMode)
DisableMouse
EnableBracketedPaste
DisableBracketedPaste
EnableFocusEvents
DisableFocusEvents
SetWindowTitle(String)
} derive(Eq, Debug)Native terminal UI toolkit built around The Elm Architecture
Dependencies