Étui logo

Hex versionHexDocsCILicense

Étui

A TUI library for Gleam. Pure functions, composable widgets, correct Unicode.

Inspired by ratatui: buffer-diff rendering, layout constraints, and an extensible widget system, all on the Erlang/BEAM.

étui (French): a small, fitted case that holds and protects delicate instruments. This library is that case for your terminal: a snug shell around buffers, widgets, and Unicode, so your app stays clean inside.

Requirements: Gleam 1.16+, Erlang/OTP 26+ for terminal apps. Node 22+ only for the JavaScript target smoke path.

┌─ Sidebar ──┐┌─ Main ──────────────────────────┐
│ > item 1 ││ Count: 42 │
│ item 2 ││ あいうえお CJK = 2 cells each │
│ item 3 ││ 👨‍👩‍👧‍👦 ZWJ family = 2 cells │
└────────────┘└─────────────────────────────────┘

Highlights

Unicode-correct. Cell width, not codepoints. cell_width("你好") == 4. Grapheme clusters come from Erlang's native UAX #29 segmentation. ZWJ sequences, combining marks, and regional indicators all cluster correctly.

Crash-restore.app.run wraps the event loop in Erlang try...after. The terminal is restored before any exception propagates, and on normal exit and supported abort paths.

No-jitter layout.geometry.resolve_sizes allocates on boundaries, not widths. Rounding errors don't accumulate across columns.

Testable without a terminal. Geometry and buffer diffing are pure functions. Tests run headless.

Install

gleam add etui

Or in gleam.toml:

[dependencies]
etui = ">= 1.0.0 and < 2.0.0"

Quickstart

import etui/app
import etui/backend
import etui/backend/default
import etui/buffer
import etui/geometry.{type Rect, Fill, Horizontal, Percentage}
import etui/widgets/block
import etui/widgets/paragraph
import gleam/int
pub type Model {
Model(count: Int, width: Int, height: Int)
}
pub fn main() {
let _ =
app.run_buffered(
default.new(),
Model(0, 80, 24),
view,
update,
fn(m) { m.count >= 10 },
16,
)
}
fn view(model: Model, screen: Rect) -> buffer.Buffer {
let chunks = geometry.split(Horizontal, screen, [Percentage(30), Fill])
let left = case chunks { [l, ..] -> l _ -> screen }
let right = case chunks { [_, r, ..] -> r _ -> screen }
let para = paragraph.paragraph_new("Count: " <> int.to_string(model.count))
let blk =
block.block_new()
|> block.with_border(block.Rounded)
|> block.with_title("App", block.Top)
buffer.buffer_new(screen)
|> block.render(left, block.block_new() |> block.with_border(block.Single))
|> block.render(right, blk)
|> paragraph.render(block.inner(right, blk), para)
}
fn update(event: backend.InputEvent, model: Model) -> Model {
case event {
backend.KeyPress("q") -> Model(..model, count: 10)
backend.KeyPress(" ") -> Model(..model, count: model.count + 1)
backend.Resize(w, h) -> Model(..model, width: w, height: h)
_ -> model
}
}

Widgets

WidgetModuleDescription
Blockwidgets/blockBorders, title, padding, bg fill
Paragraphwidgets/paragraphWrapping text, alignment
Listwidgets/listScrollable, selectable items
Tablewidgets/tableGrid with header, selection
Tabswidgets/tabsHorizontal tab bar
Gaugewidgets/gaugeProgress bar with label
HBarwidgets/hbarHorizontal bar chart
Chartwidgets/chartLine chart
Sparklinewidgets/sparklineInline data trend
Canvaswidgets/canvasBraille pixel drawing
Inputwidgets/inputText input, wide-char cursor
Scrollbarwidgets/scrollbarScroll indicator
Spinnerwidgets/spinnerAnimated loading indicator
Marqueewidgets/marqueeScrolling text ticker
Popupwidgets/popupCentered modal overlay
StatusBarwidgets/statusbarLeft/center/right status line
Linewidgets/lineHorizontal/vertical dividers
Progresswidgets/progressMulti-step progress tracker
GradientBarwidgets/gradient_barColor-gradient bar
Clearwidgets/clearErase area
Scenewidgets/sceneStatic composed layout
TextAreawidgets/textareaMulti-line editor
Treewidgets/treeExpand/collapse hierarchy, optional counts
Dialogwidgets/dialogModal with buttons
Formwidgets/formMulti-field input form
Notificationwidgets/notificationToast/banner
ScrollViewwidgets/scroll_viewScrollable region wrapper
Paginatorwidgets/paginatorPage indicator (dots / arabic)
Helpwidgets/helpKey binding help, short and full
Fieldsetwidgets/fieldsetHorizontal rule with title
MultiSelectwidgets/multi_selectCheckbox list with optional cap

Layout

import etui/geometry.{Horizontal, Vertical, Length, Percentage, Fill}
// Constraints: Length(n) fixed cells, Percentage(n) of total, Fill = remainder
let cols = geometry.split(Horizontal, area, [Length(20), Percentage(50), Fill])
let rows = geometry.split(Vertical, area, [Length(3), Fill])

Styling

import etui/style
style.Indexed(1) // 16-color palette
style.Rgb(255, 128, 0) // 24-bit true color
style.bold() // modifier
style.italic()
style.underline()
style.reverse()

Themes

import etui/theme
let t = theme.dracula() // dark purple, RGB
let t = theme.nord() // arctic dark, RGB
let t = theme.catppuccin_mocha() // pastel dark, RGB
let t = theme.gruvbox_dark() // retro groove, RGB
let t = theme.tokyo_night() // cool blue, RGB
let t = theme.dark() // ANSI 16-color (max compatibility)
// Use color slots directly
block.block_new() |> block.with_style(t.border, t.bg)
// Or use pre-built Style helpers
list_widget |> glist.with_highlight_style(theme.selection(t))
// Customize from a base
let custom = theme.Theme(..theme.nord(), accent: style.Rgb(255, 165, 0))

10 built-in themes. RGB (style.Rgb(r,g,b)) and 256-color (style.Indexed(n)) both supported. ANSI themes for terminals without true-color.

Widget system

Any fn(Buffer, Rect) -> Buffer is a widget. No registration, no traits.

import etui/widget
// Compose: border + inner content
let w = widget.compose(border_w, block.inner(area, blk), content_w)
// Layer: draw top over bottom
let w = widget.layer(background_w, overlay_w)
// Stack: multiple widgets in same area, in order
let w = widget.stack([bg_w, content_w, cursor_w])
// Stateful widget
let sw = widget.StatefulWidget(render: fn(buf, area, state: MyState) { ... })
widget.render_stateful(buf, area, sw, my_state)
// Animated widget
let aw: widget.AnimatedWidget = fn(buf, area, frame) { ... }
widget.freeze_frame(aw, current_frame)(buf, area)

App loop

Most apps use run_buffered: you return a Buffer, étui diffs it each frame.

app.run_buffered(
default.new(),
model,
fn(m, screen) { /* build buffer */ },
fn(ev, m) { /* update model */ },
fn(m) { m.quit },
16,
)
APIWhen
run_bufferedDefault full-screen UI
run_buffered_cursorInputs with visible hardware cursor
run_animatedFrame-based widgets (AnimState passed to view)
runLow-level List(RenderOp) control

On JavaScript (Node), the same functions return Promise(AppResult(_)).

Low-level RenderOp values: Write, MoveCursor, ClearScreen, EnterAltScreen, ExitAltScreen, EnableMouse, DisableMouse. Enable mouse with default.new_with_mouse().

Examples in this repo

Demos under dev/ (not published to Hex):

gleam run -m etui_showcase
gleam run -m etui_filebrowser
gleam run --target javascript -m etui_js_smoke

Docs

See docs/ (index: docs/README.md):

Contributors: CONTRIBUTING.md

License

MIT