Skip to content

Repository files navigation

É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+ 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 │
└────────────┘└─────────────────────────────────┘

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

Install

gleam add etui

Or in gleam.toml:

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

Quickstart

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}}

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

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

Styling

importetui/stylestyle.Indexed(1)// 16-color palettestyle.Rgb(255,128,0)// 24-bit true colorstyle.bold()// modifierstyle.italic()style.underline()style.reverse()

Themes

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.

Widget system

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)

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){ /* buildbuffer */ },fn(ev,m){ /* updatemodel */ },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

About

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

Resources

Contributing

Stars

11 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages