Skip to main content
  1. Posts/
  2. Go/

TUI Components in Go with Bubble Tea

··530 words·3 mins·

In this document, I summarise what Bubble Tea components (colloquially named bubbles) I use for my personal projects, as I find more uses for them.

Timed Progress Bar #

cocainate -d 5s
running cocainate -d 5s with a timed progress bar

Required Packages & Constants #

The following packages are required in oder to access the Bubble Tea interfaces, component logic and styling functionality. In addition, I defined some utility variables for functions I use throughout the display logic.

import (
	"fmt"
	"time"

	// progress bar rendering
	"github.com/charmbracelet/bubbles/progress"
	// shared functions & typing definitions
	tea "github.com/charmbracelet/bubbletea"
	// colour support
	"github.com/charmbracelet/lipgloss"
)

var (
	quitMessage   = tea.Sequence(tea.ShowCursor, tea.Quit)
	renderMessage = tea.Sequence(tea.ShowCursor, tickCommand())
	helpStyle     = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render
)

Model #

Initialising a new progress bar requires the time it takes to complete, and a channel for termination signals. The initialisation function returns a reference to a tea.Program object that can be forcefully terminated when required.

type model struct {
	// display the total duration below the progress bar
	duration   time.Duration
	// percentage amount to increase the progress on each second
	amount     float64
	// current percentage
	percentage float64
	// progress bar type from Bubble Tea
	p          progress.Model
}

func New(duration time.Duration, signals chan os.Signal) *tea.Program {
	m := &model{
		duration:   duration,
		amount:     1 / duration.Seconds(),
		percentage: 0,
		p:          progress.New(progress.WithSolidFill("#FFFFFF")),
	}

	program := tea.NewProgram(m)
	go func() {
		program.Run()
		signals <- os.Interrupt
	}()
	return program
}

Initialisation #

In order for our progress bar model to comply with the tea.Model interface, it must have an Init, Update and View methods. The tickCommand function is shared between the Init and Update methods, and is used just to return the current time, such that the progress bar is initialised correctly and updated every second.

func tickCommand() tea.Cmd {
	return tea.Tick(time.Second, func(t time.Time) tea.Msg {
		return t
	})
}

func (m model) Init() tea.Cmd {
	return tickCommand()
}

Updating #

The Update method runs every second, and increments the progress bar with the previously-set amount variable. The first time it runs, it records the width of the terminal window, such that the progress bar’s width is appropriate to the window size. When any key press is detected, the progress bar is signaled to stop.

func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
	switch message := message.(type) {
	case tea.KeyMsg:
		return m, quitMessage
	case tea.WindowSizeMsg:
		m.p.Width = message.Width
		return m, nil
	case time.Time:
		m.percentage += m.amount
		if m.percentage >= 1.0 {
			return m, quitMessage
		}
		return m, tickCommand()
	default:
		return m, nil
	}
}

The View method simply returns a string representation of the progress bar after every time it updates. In this version, I also chose to display the current and total time below the progress bar, such that the current time can be read even if the progress bar has not updated graphically.

func (m model) View() string {
	return fmt.Sprintf("%s\n%s/%s\n%s",
		m.p.ViewAs(m.percentage),
		time.Duration(float64(m.duration)*m.percentage).Round(time.Second),
		m.duration, helpStyle("Press any key to quit"),
	)
}

Usage #

In the following example from the cocainate source code, a new progress bar program is initialised. It can either terminate naturally when it’s time is up, or it can terminate forcefully when the user stops the process.

program := progress_bar.New(s.Duration, s.Signals)
timer := time.NewTimer(s.Duration)
select {
case <-timer.C:
case <-s.Signals:
	timer.Stop()
	program.Kill()
}