É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
| Widget | Module | Description |
|---|---|---|
| Block | widgets/block | Borders, title, padding, bg fill |
| Paragraph | widgets/paragraph | Wrapping text, alignment |
| List | widgets/list | Scrollable, selectable items |
| Table | widgets/table | Grid with header, selection |
| Tabs | widgets/tabs | Horizontal tab bar |
| Gauge | widgets/gauge | Progress bar with label |
| HBar | widgets/hbar | Horizontal bar chart |
| Chart | widgets/chart | Line chart |
| Sparkline | widgets/sparkline | Inline data trend |
| Canvas | widgets/canvas | Braille pixel drawing |
| Input | widgets/input | Text input, wide-char cursor |
| Scrollbar | widgets/scrollbar | Scroll indicator |
| Spinner | widgets/spinner | Animated loading indicator |
| Marquee | widgets/marquee | Scrolling text ticker |
| Popup | widgets/popup | Centered modal overlay |
| StatusBar | widgets/statusbar | Left/center/right status line |
| Line | widgets/line | Horizontal/vertical dividers |
| Progress | widgets/progress | Multi-step progress tracker |
| GradientBar | widgets/gradient_bar | Color-gradient bar |
| Clear | widgets/clear | Erase area |
| Scene | widgets/scene | Static composed layout |
| TextArea | widgets/textarea | Multi-line editor |
| Tree | widgets/tree | Expand/collapse hierarchy, optional counts |
| Dialog | widgets/dialog | Modal with buttons |
| Form | widgets/form | Multi-field input form |
| Notification | widgets/notification | Toast/banner |
| ScrollView | widgets/scroll_view | Scrollable region wrapper |
| Paginator | widgets/paginator | Page indicator (dots / arabic) |
| Help | widgets/help | Key binding help, short and full |
| Fieldset | widgets/fieldset | Horizontal rule with title |
| MultiSelect | widgets/multi_select | Checkbox 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,
)| API | When |
|---|---|
run_buffered | Default full-screen UI |
run_buffered_cursor | Inputs with visible hardware cursor |
run_animated |
Frame-based widgets (AnimState passed to view) |
run |
Low-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_smokeDocs
See docs/ (index: docs/README.md):
Contributors: CONTRIBUTING.md
License
MIT