◆ Post 2024.07.04 · 4 min read

Build nice terminal UI with Bubble Tea

Building a nice terminal UI in Go with Bubble Tea

If you want to build text-based user interfaces in Go, Bubble Tea is a good option. It makes building TUI (Text User Interface) applications easier, and the results can look good and stay interactive.

What is Bubble Tea?

Bubble Tea is a Go library from Charm_, based on The Elm Architecture, which keeps state and UI updates predictable. It works well for dashboards, command-line tools, and games.

Key Features

  • Elm-inspired architecture: manage application state in a structured, predictable way.
  • Rendering: build interactive interfaces with the Bubble Tea rendering engine.
  • Concurrency: use Go’s concurrency model for responsive applications.
  • Cross-platform: works across operating systems.

Getting Started

I will use simple application that I created for demo purpose.

It’s a simple RSS reader that fetches the latest items from a feed and shows them in the terminal. It uses Bubble Tea for the TUI, so you can navigate the news items and read the full content of each article.

Main app run model and give TUI data. Project have all components related to TUI in tui package.

package main

import (
	"fmt"
	"log"
	"os"

	"github.com/abtris/rss-bubbletea/tui"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/mmcdole/gofeed"
)

func main() {
	file, _ := os.Open("data/podcast.xml")
	defer file.Close()
	fp := gofeed.NewParser()
	feed, err := fp.Parse(file)
	if err != nil {
		log.Fatal("parse feed failed", err)
	}

	model, err := tui.NewModel(feed)
	if err != nil {
		log.Fatal("create model failed", err)
	}

	if len(os.Getenv("DEBUG")) > 0 {
		f, err := tea.LogToFile("debug.log", "debug")
		if err != nil {
			fmt.Println("fatal:", err)
			os.Exit(1)
		}
		defer f.Close()
	}

	p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
	if err := p.Start(); err != nil {
		log.Fatal("start failed: ", err)
	}
}

model tui/model.go using list component from Bubbles component library.

package tui

import (
	"log"

	md "github.com/JohannesKaufmann/html-to-markdown"
	"github.com/charmbracelet/bubbles/list"
	"github.com/mmcdole/gofeed"
)

type model struct {
	list     list.Model
	choice   string
	content  string
	detail   bool
	quitting bool
}

const width = 80
const height = 40
const title = "RSS Reader"

func NewModel(data *gofeed.Feed) (*model, error) {
	var items []list.Item
	converter := md.NewConverter("", true, nil)
	for _, rssItem := range data.Items {
		markdown, err := converter.ConvertString(rssItem.Description)
		if err != nil {
			log.Println("Convert to markdown", err)
		}
		i := item{
			title: rssItem.Title,
			desc:  "Published at " + rssItem.Published + "\n\n" + markdown,
		}
		items = append(items, i)
	}
	l := list.New(items, list.NewDefaultDelegate(), width, height)
	l.Title = title

	return &model{
		list:    l,
		choice:  "",
		content: "",
		detail:  false,
	}, nil
}

interaction with interface is covered by tui/update.go

package tui

import (
	tea "github.com/charmbracelet/bubbletea"
)

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.list.SetWidth(msg.Width)
		return m, nil

	case tea.KeyMsg:
		switch keypress := msg.String(); keypress {
		case "q", "ctrl+c":
			m.quitting = true
			return m, tea.Quit

		case "enter":
			i, ok := m.list.SelectedItem().(item)
			if ok {
				m.detail = true
				m.choice = string(i.title)
				m.content = string(i.desc)
			}
			return m, nil
		case "b":
			if m.detail {
				m.choice = ""
				m.content = ""
			}
		case "p":
			if m.detail {
				changeIndex := m.list.Index() + 1
				if changeIndex <= 0 {
					changeIndex = 0
				}
				m.list.Select(changeIndex)
				i, ok := m.list.SelectedItem().(item)
				if ok {
					m.choice = string(i.title)
					m.content = string(i.desc)
				}
			}
		case "n":
			if m.detail {
				changeIndex := m.list.Index() - 1
				maxLength := len(m.list.Items())
				if changeIndex > (maxLength - 1) {
					changeIndex = maxLength - 1
				}
				m.list.Select(changeIndex)
				i, ok := m.list.SelectedItem().(item)
				if ok {
					m.choice = string(i.title)
					m.content = string(i.desc)
				}
			}
		}
	}

	var cmd tea.Cmd
	m.list, cmd = m.list.Update(msg)
	return m, cmd
}

and view tui/view.go take care about rendering. I’m using glamour for rendering markdown content.

package tui

import (
	"log"

	"github.com/charmbracelet/glamour"
)

func (m model) View() string {
	var s string
	if len(m.choice) > 0 {
		s += "# " + title
		s += "\n## " + m.choice
		s += "\n\n"
		s += m.content
		renderer, err := glamour.NewTermRenderer(
			glamour.WithAutoStyle(),
			glamour.WithWordWrap(width),
		)
		if err != nil {
			return ""
		}
		out, err := renderer.Render(s)
		if err != nil {
			log.Println(err)
		}
		return out
	}
	return m.list.View()
}

Whole demo is recorded using VHS.

Conclusion

Bubble Tea is a solid library for building terminal UIs in Go. Whether you’re writing a small command-line tool or a more involved interactive app, it gives you what you need without much ceremony.

I had talk about Bubble Tea on Go meetup #15.