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+ for the JavaScript target. Both targets run the full test suite and the full demos.
┌─ Sidebar ──┐┌─ Main ──────────────────────────┐
│ > item 1 ││ Count: 42 │
│ item 2 ││ あいうえお CJK = 2 cells each │
│ item 3 ││ 👨👩👧👦 ZWJ family = 2 cells │
└────────────┘└─────────────────────────────────┘
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. Drive the loop yourself with etui/terminal and restoring it is yours to arrange.
Your loop or ours.app.run_* owns the event loop; etui/terminal hands it back, for when the terminal is not the only thing your program is doing. Full screen, or Inline(rows) for a panel that leaves the shell's scrollback alone and stays on screen after you exit.
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.
gleam add etuiOr in gleam.toml:
[dependencies]
etui = ">= 2.0.0 and < 3.0.0"importetui/appimportetui/backendimportetui/backend/defaultimportetui/bufferimportetui/geometry.{typeRect,Fill,Horizontal,Percentage}importetui/widgets/blockimportetui/widgets/paragraphimportgleam/intpubtypeModel{Model(count:Int,width:Int,height:Int)}pubfnmain(){let_=app.run_buffered(default.new(),Model(0,80,24),view,update,fn(m){m.count>=10},16,)}fnview(model:Model,screen:Rect)->buffer.Buffer{letchunks=geometry.split(Horizontal,screen,[Percentage(30),Fill])letleft=casechunks{[l,..]->l_->screen}letright=casechunks{[_,r,..]->r_->screen}letpara=paragraph.paragraph_new("Count: "<>int.to_string(model.count))letblk=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)}fnupdate(event:backend.InputEvent,model:Model)->Model{caseevent{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}}| 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 |
importetui/geometry.{Horizontal,Vertical,Length,Percentage,Fill}// Constraints: Length(n) fixed cells, Percentage(n) of total, Fill = remainderletcols=geometry.split(Horizontal,area,[Length(20),Percentage(50),Fill])letrows=geometry.split(Vertical,area,[Length(3),Fill])importetui/stylestyle.Indexed(1)// 16-color palettestyle.Rgb(255,128,0)// 24-bit true colorstyle.bold()// modifierstyle.italic()style.underline()style.reverse()importetui/themelett=theme.dracula()// dark purple, RGBlett=theme.nord()// arctic dark, RGBlett=theme.catppuccin_mocha()// pastel dark, RGBlett=theme.gruvbox_dark()// retro groove, RGBlett=theme.tokyo_night()// cool blue, RGBlett=theme.dark()// ANSI 16-color (max compatibility)// Use color slots directlyblock.block_new()|>block.with_style(t.border,t.bg)// Or use pre-built Style helperslist_widget|>glist.with_highlight_style(theme.selection(t))// Customize from a baseletcustom=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.
Any fn(Buffer, Rect) -> Buffer is a widget. No registration, no traits.
importetui/widget// Compose: border + inner contentletw=widget.compose(border_w,block.inner(area,blk),content_w)// Layer: draw top over bottomletw=widget.layer(background_w,overlay_w)// Stack: multiple widgets in same area, in orderletw=widget.stack([bg_w,content_w,cursor_w])// Stateful widgetletsw=widget.StatefulWidget(render:fn(buf,area,state:MyState){...})widget.render_stateful(buf,area,sw,my_state)// Animated widgetletaw:widget.AnimatedWidget=fn(buf,area,frame){...}widget.freeze_frame(aw,current_frame)(buf,area)Most apps use run_buffered: you return a Buffer, étui diffs it each frame.
app.run_buffered(default.new(),model,fn(m,screen){ /* buildbuffer */ },fn(ev,m){ /* updatemodel */ },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().
Demos under dev/ (not published to Hex):
gleam run -m etui_showcase
gleam run -m etui_filebrowser
gleam run --target javascript -m etui_js_smokeSee docs/ (index: docs/README.md):
Contributors: CONTRIBUTING.md
MIT
