bubbletea.cr
bubbletea.cr
Crystal bindings for Charm's terminal UI stack:
- Bubble Tea: the Elm-style program loop (
Tea) - Lip Gloss: styles, layout, tables (
Lipgloss) - Bubbles: list, table, text input, text area, viewport, spinner, progress, help/key maps, paginator (
Bubbles) - Glamour: markdown rendering (
Glamour)
The Go libraries are compiled into one C shared library (libbubbletea_go.so) and bound through Crystal's lib FFI. Bubble Tea's real program, renderer and input parser run in Go; your model, update, view and commands run in Crystal. See DESIGN.md for the architecture.
require "bubbletea"
class Counter
include Tea::Model
STYLE = Lipgloss::Style.new.bold.foreground("#FF5F87").padding(0, 1)
getter count = 0
def update(msg : Tea::Msg)
case msg
when Tea::KeyMsg
case msg.key
when "q", "ctrl+c" then return Tea.quit
when "up", "+" then @count += 1
when "down", "-" then @count -= 1
end
end
end
def view : String
"Count:#{STYLE.render(@count.to_s)}\n\n(↑/↓, q to quit)\n"
end
end
model = Tea::Program.new(Counter.new).run
puts "Final count: #{model.count}"
Installation
Linux only for now (x86_64, aarch64).
1. The shard
# shard.yml
dependencies:
bubbletea:
git: https://github.com/leoc/bubbletea.cr
version: ~> 0.2
shards install runs make lib as postinstall: with Go ≥ 1.24 and a C compiler on the PATH it builds lib/bubbletea/go/build/libbubbletea_go.so, and the shard links it with an rpath automatically.
2. Or the shared library from Nix
Without Go, build the library with the flake and point the compiler at it:
nix build github:leoc/bubbletea.cr#libbubbletea-go
export BUBBLETEA_LIB_DIR=$PWD/result/lib # read at compile time, adds -L and an rpath
crystal build src/app.cr
Any other way works too, as long as the linker finds -lbubbletea_go (CRYSTAL_LIBRARY_PATH/LIBRARY_PATH) and the loader finds the .so at run time (LD_LIBRARY_PATH or an rpath).
Usage
Models, messages, commands
A model includes Tea::Model and implements update(msg) and view; init is optional. update mutates the model and returns a command or nil.
| Message | When |
|---|---|
Tea::KeyMsg |
key press; match on msg.key ("a", "ctrl+c", "enter", "alt+x", "up") |
Tea::MouseMsg |
mouse (mouse: Tea::MouseMode::CellMotion / AllMotion); x, y, button, action |
Tea::WindowSizeMsg |
at start and on resize |
Tea::FocusMsg, Tea::BlurMsg |
with report_focus: true |
your own class Foo < Tea::Msg |
returned by your commands |
| Command | |
|---|---|
Tea.cmd { FooMsg.new } |
custom async work in its own fiber; the result goes to update |
Tea.tick(1.second) { |t| TickMsg.new(t) } |
once after a delay |
Tea.every(1.second) { |t| ... } |
like tick, aligned to the clock |
Tea.batch(a, b) / Tea.sequence(a, b) |
concurrently / one after another |
Tea.msg(msg) |
deliver a message |
Tea.quit, Tea.enter_alt_screen, Tea.exit_alt_screen, Tea.enable_mouse_cell_motion, Tea.disable_mouse, Tea.set_window_title(t), Tea.println(t), Tea.clear_screen, Tea.window_size, ... |
Bubble Tea's built-ins |
Tea::Program.new(model,
alt_screen: true, # full screen
mouse: Tea::MouseMode::CellMotion, # or AllMotion
report_focus: false,
fps: 60,
).run
Program#send(msg) and Program#quit work from other fibers. Program#interrupted? is true after a SIGINT.
Lip Gloss
Styles are values: every setter returns a copy.
box = Lipgloss::Style.new
.border(:rounded)
.border_foreground(Lipgloss::AdaptiveColor.new(light: "#874BFD", dark: "#7D56F4"))
.padding(1, 2)
.width(30)
.align(:center)
puts box.render("Hello")
puts Lipgloss.join_horizontal(:top, box.render("left"), box.render("right"))
puts Lipgloss.place(80, 20, :center, :center, "centred")
puts Lipgloss::Table.new
.border(:normal)
.headers("Name", "Lang")
.row("Bubble Tea", "Go")
.row("bubbletea.cr", "Crystal")
.style_func { |row, _col| row == Lipgloss::Table::HEADER_ROW ? Lipgloss::Style.new.bold : nil }
Supported: bold/italic/underline/strikethrough/reverse/blink/faint, foreground/background (hex, ANSI 256, ANSI, AdaptiveColor, CompleteColor, CompleteAdaptiveColor), width/height/max sizes, horizontal and vertical alignment, padding and margin (CSS shorthand and per side), margin background, borders (all Lip Gloss borders, custom Lipgloss::Border, per side, colours), inline, tab width, transform (:upper/:lower), set_string, inherit, frame sizes; join_horizontal, join_vertical, place*, width, height, size; tables with per-cell styles, border toggles, width/height/offset/wrap. Lipgloss.color_profile= and Lipgloss.dark_background= override detection.
Bubbles
The components are the real Go Bubbles behind a handle. Forward messages to them in update and return their commands:
class Search
include Tea::Model
@input = Bubbles::TextInput.new(placeholder: "Search", width: 30)
@spinner = Bubbles::Spinner.new(Bubbles::Spinner::Type::Dot)
def init
Tea.batch(@input.focus, @spinner.tick) # cursor blink, spinner animation
end
def update(msg : Tea::Msg)
return Tea.quit if msg.is_a?(Tea::KeyMsg) && msg.key == "esc"
Tea.batch(@input.update(msg), @spinner.update(msg))
end
def view : String
"#{@spinner.view} #{@input.view}"
end
end
Ticks, blinks and animation frames arrive as Tea::GoMsg; pass them on to all components in the same update call (each ignores what is not its own).
| Component | Highlights |
|---|---|
Bubbles::TextInput |
value, focus, blur, placeholder, char limit, password echo, suggestions, styles, custom key map |
Bubbles::TextArea |
multi-line editing, line numbers, line, column, line_count, styles |
Bubbles::Viewport |
content=, scrolling methods, scroll_percent, mouse wheel |
Bubbles::List(T) |
any Crystal items with title (and description, filter_value) or strings; filtering, pagination, status messages, spinner, selected_item returns your object |
Bubbles::Table |
columns/rows, selected_row, cursor, styles, help |
Bubbles::Spinner |
twelve built-in types or custom frames, tick |
Bubbles::Progress |
view_as(0.4) or animated set_percent, gradients, solid fill |
Bubbles::Paginator |
arabic or dots, slice_bounds, on_last_page? |
Bubbles::Help + Bubbles::Key |
key bindings (Key::Binding.new("up", "k", help: {"↑/k", "up"}), matches?), key maps, short/full help views; List and Table are key maps too |
Key maps of components can be overridden by binding name, e.g. Bubbles::TextInput.new(key_map: {"delete_character_backward" => Bubbles::Key::Binding.new("ctrl+h")}); component.key_map lists the names.
Glamour
puts Glamour.render(File.read("README.md"), style: "dark", width: 80)
renderer = Glamour::Renderer.new(style: "auto", width: 60, emoji: true)
viewport.content = renderer.render(markdown)
Styles: auto (from the detected background), dark, light, notty, ascii, dracula, pink, tokyo-night, or a Glamour JSON style via style_json:.
Examples
nix develop # Go, Crystal, shards, make
make lib
crystal run examples/counter.cr
crystal run examples/list.cr # cursor, selection, mouse wheel and clicks
crystal run examples/layout.cr # alt screen, tabs, table, clock, async loading
crystal run examples/spinner.cr # spinner while a background job runs
crystal run examples/progress.cr # animated progress bar
crystal run examples/textinput.cr # form with focus handling, suggestions, password
crystal run examples/textarea.cr # editor with line numbers
crystal run examples/viewport.cr # markdown pager (viewport + Glamour)
crystal run examples/table.cr # interactive table
crystal run examples/list_bubbles.cr # filterable list, status messages, help
crystal run examples/paginator.cr # dots pagination
crystal run examples/help.cr # key bindings and help toggle
crystal run examples/glamour.cr -- README.md dark
Development
nix develop
make lib # go/build/libbubbletea_go.so
make spec # crystal spec (headless, no TTY needed)
nix flake check # builds the library with Nix, runs specs, compiles examples, checks formatting
After changing go/go.mod, set vendorHash in flake.nix to lib.fakeHash (or any other wrong hash) and take the correct one from the error of nix build .#libbubbletea-go; an unchanged hash silently reuses the old vendor directory.
Limitations
- Linux only so far; macOS needs a
.dylibbuild andinstall_nameinstead of the rpath (untested). - Wraps the v1 API line (Bubble Tea v1.3, Lip Gloss v1.1, Bubbles v1.0, Glamour v1.0).
charm.land/*/v2is not wrapped yet. - The model is mutable;
updatereturns only a command. - Several runes typed faster than one read arrive as one
KeyMsgwithrunes="ab"(Bubble Tea behaviour). tea.Exec/tea.ExecProcess(handing the terminal to a subprocess) are not wrapped yet.- Not wrapped from Bubbles:
filepicker,timer,stopwatch,cursoras a standalone component, custom list item delegates (the default delegate with its styles is used), text input validation functions. - A
Tea::GoMsgis only valid during theupdatecall that received it. - Component getters are FFI calls; avoid calling them in tight loops (cache what you need in
update). - A crash inside Go code that is not a Go panic (e.g. a nil dereference) is reported by Crystal's segfault handler, not as a Go panic; see DESIGN.md.
- Every value crossing the boundary is copied; very large views (megabytes) per frame cost a few copies.
License
MIT, see LICENSE, including the notices of the wrapped Charm libraries.
bubbletea.cr
- 0
- 0
- 0
- 1
- 0
- about 2 hours ago
- November 2, 2026
Other
Mon, 02 Nov 2026 21:44:45 GMT