Packages

Typed Gleam bindings for beamtea — Bubble Tea / The Elm Architecture for the BEAM terminal.

Current section

Files

Jump to
gleamtea README.md
Raw

README.md

# gleamtea
[![Package Version](https://img.shields.io/hexpm/v/gleamtea)](https://hex.pm/packages/gleamtea)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/gleamtea/)
**A delightful terminal UI framework for Gleam** — typed bindings to
[beamtea](https://hex.pm/packages/beamtea), which is
[Bubble Tea](https://github.com/charmbracelet/bubbletea) / The Elm Architecture
for the BEAM, built on `prim_tty`.
Write your terminal app as three pure functions — `init`, `update`, `view`
and gleamtea owns the terminal, decodes keypresses, runs your commands, and
repaints the screen.
![gleamtea preview](.github/assets/preview.png)
```gleam
import gleamtea.{type Cmd, type Event, Key, None, Quit}
import gleamtea/color
import gleamtea/key.{Char, Down, Up}
import gleamtea/term
import gleam/int
pub fn main() {
gleamtea.program(init:, update:, view:)
|> gleamtea.start
}
fn init() -> #(Int, Cmd(Event(Nil))) {
#(0, None)
}
fn update(msg: Event(Nil), n: Int) -> #(Int, Cmd(Event(Nil))) {
case msg {
Key(Up) -> #(n + 1, None)
Key(Down) -> #(n - 1, None)
Key(Char(113)) -> #(n, Quit)
// q
_ -> #(n, None)
}
}
fn view(n: Int) -> String {
color.fg(color.Charmple, term.bold("count: " <> int.to_string(n)))
}
```
---
## Why this is (almost) all types and no glue
beamtea speaks plain Erlang terms: the atom `quit`, the tuple `{tick, 1000, Msg}`,
a keypress `{key, up}`. Gleam's custom types compile to *exactly* those terms —
so the binding is mostly a set of type definitions with zero marshalling cost.
| Gleam | Erlang term (beamtea) |
| ------------------------ | -------------------------- |
| `Quit` | `quit` |
| `Tick(1000, msg)` | `{tick, 1000, Msg}` |
| `Key(Up)` | `{key, up}` |
| `Key(Char(97))` | `{key, {char, 97}}` |
| `Key(Ctrl(99))` | `{key, {ctrl, 99}}` |
The escapes and colours (`gleamtea/term`, `gleamtea/color`) and the reusable
components (`gleamtea/spinner`, `gleamtea/table`, …) are thin FFI wrappers that
flatten beamtea's `iodata()` into ordinary Gleam `String`s.
---
## Installation
```sh
gleam add gleamtea
```
`beamtea` (an Erlang/rebar3 package) is pulled in automatically as a
dependency; Gleam builds it with rebar3.
---
## The Elm Architecture
A program is three functions, wired together with `gleamtea.program`:
- **`init() -> #(model, Cmd(msg))`** — the starting model and an initial command.
- **`update(msg, model) -> #(model, Cmd(msg))`** — fold a message into the model.
- **`view(model) -> String`** — render the model to the full screen.
Your message type is always `Event(user)`:
```gleam
pub type Event(user) {
Key(Key)
// a keypress — see gleamtea/key
Resize(#(Int, Int))
// the terminal was resized to #(cols, rows)
User(user)
// one of your own messages
}
```
Keyboard and resize events arrive as `Key(...)` / `Resize(...)`. Your own
messages — produced by commands like `Tick`, `Every` and `Task` — arrive
wrapped in `User(...)`, so everything stays type-safe.
### Commands
Return a `Cmd(msg)` from `init`/`update` to ask the runtime to do something:
| Command | Effect |
| ------------------- | ------------------------------------------------------------ |
| `None` | do nothing |
| `Quit` | quit after this update |
| `Msg(m)` | deliver `m` to `update` as soon as possible |
| `Tick(ms, m)` | deliver `m` once, after `ms` milliseconds |
| `Every(ms, m)` | deliver `m` repeatedly, every `ms` milliseconds |
| `Task(fn)` | run `fn` asynchronously; its result is delivered to `update` |
| `Batch([...])` | run several commands |
| `Seq([...])` | run several commands in sequence |
```gleam
// tick every 100 ms
#(model, Every(100, User(Tick)))
```
---
## Modules
### Core
| Module | What it gives you |
| ----------------- | ------------------------------------------------------------ |
| `gleamtea` | `program`, `start`, `start_with`, `Event`, `Cmd`, `Options`, `Layout`, `window_size` |
| `gleamtea/key` | the `Key` type (`Up`, `Enter`, `Ctrl(_)`, `Char(_)`, …) |
| `gleamtea/term` | ANSI escapes: `bold`, `faint`, `reverse`, `move`, `clear`, `fg_rgb`, `rule`, … |
| `gleamtea/color` | the Charm-inspired palette (`Charmple`, `Hotpink`, `Cyan`, …) |
| `gleamtea/style` | lipgloss-style blocks: colour, border, padding, align, `join_*` |
| `gleamtea/layout` | position content: `center`, `frame`, `place`, `overlay_center` |
| `gleamtea/util` | ANSI-aware `lines`, `pad_visible`, `truncate`, `visible_width` |
### Bubbles (reusable components)
| Module | Component |
| ---------------------- | ------------------------------------------- |
| `gleamtea/spinner` | animated spinner (8 styles) |
| `gleamtea/progress` | truecolor gradient progress bar |
| `gleamtea/timer` | countdown timer |
| `gleamtea/list` | scrollable, selectable list |
| `gleamtea/table` | scrollable, row-selectable data table |
| `gleamtea/textinput` | single-line text input |
| `gleamtea/textarea` | multi-line text editor |
| `gleamtea/viewport` | scrollable viewport over a block of text |
| `gleamtea/filepicker` | filesystem picker |
| `gleamtea/paginator` | paginator (dots or "N/M") |
| `gleamtea/keybind` | declarative key bindings |
| `gleamtea/help` | help view that renders `keybind` bindings |
A bubble is a little model with its own `update`/`view`. Store it in your model,
render it in your `view`, and **forward messages you don't handle to its
`update`** — self-animating bubbles (spinner, timer) keep themselves running by
returning their next tick:
```gleam
fn update(msg: Msg, model: Model) -> #(Model, Cmd(Msg)) {
case msg {
Key(Char(113)) -> #(model, Quit)
// q
other -> {
let #(spinner, cmd) = spinner.update(other, model.spinner)
#(Model(..model, spinner:), cmd)
}
}
}
```
---
## Styling & layout (beamtea 0.1.2)
`gleamtea/style` is a composable, lipgloss-inspired styling layer. Build a
`Style` with piped setters, then `render` it onto text — inline attributes
(colour, bold) compose with block layout (width, alignment, padding, border):
```gleam
import gleamtea/style
import gleamtea/color
style.new()
|> style.foreground(style.Named(color.Charmple))
|> style.bold
|> style.padding(style.VH(1, 3))
|> style.border(style.Rounded)
|> style.border_foreground(style.Rgb(0xEE, 0x6F, 0xF8)) // 24-bit truecolor
|> style.render("Hello, style!")
```
Colours can be a palette `Named(_)`, a raw xterm `Index(_)`, or 24-bit
`Rgb(r, g, b)`. Compose rendered blocks with `style.join_horizontal` and
`style.join_vertical`.
`gleamtea/layout` positions a block within the terminal — pair it with
`gleamtea.window_size()` to size to the screen from inside your `view`:
```gleam
import gleamtea/layout
let #(cols, rows) = gleamtea.window_size()
layout.center(card, cols, rows) // centre a block
layout.frame(content, cols, rows, color.Indigo) // bordered full-screen panel
layout.overlay_center(background, modal, cols, rows) // float a modal
```
Or let the runtime position the *whole* view automatically (it re-flows on
resize) with the `layout` option — `TopLeft` (default), `Center`, `TopCenter`
or `Fill`:
```gleam
gleamtea.start_with(program, gleamtea.Options(
alt_screen: True,
catch_ctrl_c: True,
layout: gleamtea.Center,
))
```
---
## Running a program
A full-screen TUI needs to own the terminal: raw mode, the alternate screen,
and Ctrl-C delivered as a byte rather than a signal. Don't launch from an
interactive `gleam run` (its shell fights you for the terminal). Instead use the
included launcher, which starts the VM with `-noshell -noinput +Bi` and puts the
tty in raw mode:
```sh
./bin/gleamtea-run counter
```
Under the hood it runs the compiled example's `main/0`; do the same for your own
app once it's compiled.
### Options
```gleam
gleamtea.program(init:, update:, view:)
|> gleamtea.start_with(gleamtea.Options(
alt_screen: True,
catch_ctrl_c: True,
layout: gleamtea.TopLeft,
))
```
- `alt_screen` — use the alternate screen buffer (like `vim`/`less`). Default `True`.
- `catch_ctrl_c` — quit on Ctrl-C instead of passing it to `update`. Default `True`.
- `layout` — position the whole view: `TopLeft` (default), `Center`, `TopCenter`, `Fill`.
`start` / `start_with` return `Ok(final_model)` on a clean exit, or
`Error(reason)` if the program could not start (e.g. stdout is not a terminal).
---
## Examples
Nineteen runnable examples live in [`src/examples/`](src/examples). Run any of
them with the launcher:
```sh
./bin/gleamtea-run <name>
```
| Example | Shows off |
| ------------------ | ----------------------------------------------- |
| `counter` | the core loop — no bubbles |
| `keys` | how raw input decodes into key events |
| `stopwatch` | `Every` timers and a `User` message |
| `palette` | the full colour palette |
| `spinner_demo` | the spinner bubble, cycling styles |
| `progress_demo` | a self-filling truecolor gradient bar + `Every` |
| `timer_demo` | a 10-second countdown |
| `list_demo` | a scrollable, selectable menu |
| `table_demo` | a selectable data table |
| `textinput_demo` | a focused text field with a live greeting |
| `textarea_demo` | a multi-line editor in a bordered box |
| `viewport_demo` | scrolling through a long document |
| `filepicker_demo` | browsing the filesystem |
| `help_demo` | key bindings + help + paginator |
| `dashboard_demo` | a grid of styled stat cards (`style` composition) |
| `statusbar_demo` | a full-width vim-style status bar |
| `tabs_demo` | a tabbed interface with a bordered panel |
| `modal_demo` | a floating yes/no modal over a backdrop |
| `finder_demo` | an fzf-style fuzzy finder in a floating modal |
---
## Development
```sh
gleam build # compile the library, bindings and examples
gleam test # run the test suite
gleam docs build # generate HTML docs into build/dev/docs
./bin/gleamtea-run counter
```
---
## Credits
gleamtea is a thin Gleam layer over [beamtea](https://github.com/tsirysndr/beamtea)
by Tsiry Sandratraina, which is itself inspired by Charmbracelet's
[Bubble Tea](https://github.com/charmbracelet/bubbletea) and
[Bubbles](https://github.com/charmbracelet/bubbles).
## License
[MIT](LICENSE)