bubbletea.cr

Crystal bindings for Charm's Bubble Tea, Lip Gloss, Bubbles and Glamour (Go via FFI)

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 .dylib build and install_name instead 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/*/v2 is not wrapped yet.
  • The model is mutable; update returns only a command.
  • Several runes typed faster than one read arrive as one KeyMsg with runes = "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, cursor as a standalone component, custom list item delegates (the default delegate with its styles is used), text input validation functions.
  • A Tea::GoMsg is only valid during the update call 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.

Repository

bubbletea.cr

Owner
Statistic
  • 0
  • 0
  • 0
  • 1
  • 0
  • about 2 hours ago
  • November 2, 2026
License

Other

Links
Synced at

Mon, 02 Nov 2026 21:44:45 GMT

Languages