Building an Awesome Terminal User Interface Using Go, Bubble Tea, and Lip Gloss blog banner image

Building an Awesome Terminal User Interface Using Go, Bubble Tea, and Lip Gloss

bubble-tea.png

In the world of command-line interfaces (CLI), creating intuitive and visually appealing tools can greatly enhance user experience and productivity. In this tutorial, we'll explore how to build a modern CLI tool using Bubble Tea and Lip Gloss—two powerful libraries for developing terminal user interfaces (TUIs) in Go.

Introduction to Bubble Tea and Lip Gloss

Bubble Tea

Bubble Tea is a functional and declarative framework designed for building user interfaces in Go. It follows a model-update-view pattern, where application state updates based on events (e.g., user input), and the view renders accordingly.

Bubble Tea workflow

bubble-tea-workflow.png

Lip Gloss

Lip Gloss complements Bubble Tea by offering a simple yet powerful way to style terminal output. It allows you to define styles for text and containers, making it easy to create visually appealing CLI applications.

Setting Up Your Development Environment

Before diving into coding, ensure Go is installed on your machine. Use Go modules to install Bubble Tea and Lip Gloss:

go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/lipgloss
go get github.com/charmbracelet/bubbles/progress
go get github.com/charmbracelet/bubbles/textinput
go get github.com/charmbracelet/bubbles/list

Example: Building a Simple File Downloader Tool with Bubble Tea and Lip Gloss

In this example, we'll build a CLI tool using Bubble Tea and Lip Gloss to download files from a provided URL. The application will allow users to input a URL, select file permissions, and monitor the download progress—all within the terminal.

Step 1: Setting Up Lip Gloss Styles

First, define Lip Gloss styles for enhanced terminal output:

helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render
generalStatementStyle = lipgloss.NewStyle().BorderForeground().Bold(true)
successStatementStyle = lipgloss.NewStyle().Bold(true).Border(lipgloss.NormalBorder()).Background(lipgloss.Color("#04B575")).Foreground(lipgloss.Color("#000000")).Width(50).AlignHorizontal(lipgloss.Center)
placeHolderStyle = lipgloss.NewStyle().Italic(true)
infoStyle = lipgloss.NewStyle().Bold(true).Border(lipgloss.HiddenBorder()).Foreground(lipgloss.Color("#0000FF"))

Step 2: Implementing Text Input Model

Building a Text Input Model with Bubble Tea and Lip Gloss

The textInput package demonstrates how to create a text input model using Bubble Tea and Lip Gloss in Go. This model allows users to input a URL for downloading a file in a command-line interface (CLI).

textInput Model (textModel)

Key Components:

  • textModel Struct: Defines a model that wraps around the textinput.Model and handles user input events.
  • textInputModel Function: Initializes a new instance of textinput.Model with placeholder text and styles.
  • Init Method: Implements the tea.Model interface method Init, which initializes the text input with a blinking cursor.
  • Update Method: Handles user input events such as pressing Enter to capture the input URL and quitting with Ctrl+C.
  • View Method: Renders the text input view, showing the input field and placeholder text styled with Lip Gloss.

Usage:

The StartInputTextModel function initializes and runs the Bubble Tea program, allowing users to interactively enter a URL in the CLI for further processing, such as file downloading.

This example demonstrates how to leverage Bubble Tea's declarative model-update-view architecture alongside Lip Gloss for styling, enabling the creation of interactive CLI tools in Go.

type textModel struct {
	textInput textinput.Model
	err       error
}

var (
	placeHolderStyle = lipgloss.NewStyle().Italic(true)
	OutputValue      *string
)

func textInputModel() textModel {
	ti := textinput.New()
	ti.Placeholder = "Enter the url to be downloaded"
	ti.PlaceholderStyle = placeHolderStyle
	ti.Focus()
	return textModel{
		textInput: ti,
		err:       nil,
	}
}

func (m textModel) Init() tea.Cmd {
	return textinput.Blink
}

func (m textModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmd tea.Cmd
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.Type {
		case tea.KeyEnter:
			value := m.textInput.Value()
			OutputValue = &value
			return m, tea.Quit
		case tea.KeyCtrlC:
			return nil, tea.Quit
		}
	}
	m.textInput, cmd = m.textInput.Update(msg)
	return m, cmd
}

func (m textModel) View() string {
	return (m.textInput.View())
}

func StartInputTextModel() {
	p := tea.NewProgram(textInputModel())
	p.Run()
}

Step 3: Implementing List Model

Create a model for selecting file permissions using a list:

list Model (listModel)

Key Components:

  • listModel Struct: Implements the Bubble Tea tea.Model interface to manage a list of options for selecting file permissions.
  • Item and ItemDelegate: Defines item rendering and interaction logic for the list of permissions.
  • optionsRenderer Function: Renders the list of options using Bubble Tea's list.Model and handles user interaction to select permissions.
  • setPermissions Function: Sets file permissions based on the selected option ("RO", "RW", "RWX") using os.Chmod.
  • StartListModel Function: Initializes and runs the Bubble Tea program to present the list of permissions and handle user selection.
const listHeight = 10

var (
	generalStatementStyle = lipgloss.NewStyle().BorderForeground().Bold(true)
	successStatementStyle = lipgloss.NewStyle().Bold(true).Border(lipgloss.NormalBorder()).Background(lipgloss.Color("#04B575")).Foreground(lipgloss.Color("#000000")).Width(50).AlignHorizontal(lipgloss.Center)
	SelectedOptions       string
	OverrideValue         string
	DefaultValue          string

	permissions = []list.Item{
		Item("RO"),
		Item("RW"),
		Item("RWX"),
	}
)

type Item string

func (i Item) FilterValue() string { return "" }

type ItemDelegate struct{}

func (d ItemDelegate) Height() int                             { return 1 }
func (d ItemDelegate) Spacing() int                            { return 0 }
func (d ItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
	itemStyle := lipgloss.NewStyle().PaddingLeft(4)
	selecteditemStyle := lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("63"))
	i, ok := listItem.(Item)
	if !ok {
		return
	}

	str := fmt.Sprintf("%d. %s", index+1, i)

	fn := itemStyle.Render
	if index == m.Index() {
		fn = func(s ...string) string {
			return selecteditemStyle.Render("> " + strings.Join(s, " "))
		}
	}

	fmt.Fprint(w, fn(str))
}

type listModel struct {
	list     list.Model
	choice   string
	quitting bool
}

var m listModel

func (m listModel) Init() tea.Cmd {
	return nil
}

func (m listModel) 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.choice = string(i)
			}
			return m, tea.Quit
		}
		switch msg.Type {
		case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc:
			return m, tea.Quit
		}
	}

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

func (m listModel) View() string {
	quitTextStyle := lipgloss.NewStyle().Margin(1, 0, 2, 4)
	if m.choice != "" {
		SelectedOptions = (fmt.Sprintf(m.choice))
	}
	if m.quitting {
		return quitTextStyle.Render("Operation aborted")
	}
	return "\n" + m.list.View()
}

func optionsRenderer(s string, Items []list.Item) {
	const defaultWidth = 20

	l := list.New(Items, ItemDelegate{}, defaultWidth, listHeight)
	l.Title = s
	l.SetShowStatusBar(false)
	l.SetFilteringEnabled(false)
	l.Styles.Title = generalStatementStyle
	m := listModel{list: l}
	if _, err := tea.NewProgram(m).Run(); err != nil {
		fmt.Println("Error running program:", err)
		os.Exit(1)
	}
	if SelectedOptions == "RO" {
		setPermissions(0444)
	}
	if SelectedOptions == "RW" {
		setPermissions(0666)
	}
	if SelectedOptions == "RWX" {
		setPermissions(0777)
	}
}

func setPermissions(p os.FileMode) {
	err := os.Chmod(downloader.Filename, p)
	if err != nil {
		fmt.Println(err)
	} else {
		fmt.Println(successStatementStyle.Render("Provided url file download and permission setted"))
	}
}

func StartListModel() {
	optionsRenderer("Please select the file permissions", permissions)
}

Step 4: Implementing Download Model

Create a model for managing file download progress:

Download Model (downloadModel)

Key Components:

  • StartDownloaderModel Function: Initializes a Bubble Tea program to manage the download process. It retrieves a file from a provided URL, sets up the download progress using a custom progressWriter, and displays the progress using the downloadModel.

  • progressWriter Struct: Implements the io.Writer interface to manage and update the download progress. It handles writing downloaded data to a file and updating the progress bar.

  • downloadModel Struct: Implements the Bubble Tea tea.Model interface to manage the download's UI state. It updates the progress bar based on download progress and handles user interaction.

  • Final Pause and Update Methods: Includes functions and methods for handling UI updates and finalizing the download process.

func (m downloadModel) Init() tea.Cmd {
    return nil
}

func (m downloadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        return m, tea.Quit

    case tea.WindowSizeMsg:
        m.progress.Width = msg.Width - padding*2 - 4
        if m.progress.Width > maxWidth {
            m.progress.Width = maxWidth
        }
        return m, nil

    case progressErrMsg:
        m.err = msg.err
        return m, tea.Quit

    case progressMsg:
        var cmds []tea.Cmd

        if msg >= 1.0 {
            cmds = append(cmds, tea.Sequence(finalPause(), tea.Quit))
        }

        cmds = append(cmds, m.progress.SetPercent(float64(msg)))
        return m, tea.Batch(cmds...)

    case progress.FrameMsg:
        progressModel, cmd := m.progress.Update(msg)
        m.progress = progressModel.(progress.Model)
        return m, cmd

    default:
        return m, nil
    }
}

func (m downloadModel) View() string {
    if m.err != nil {
        return "Error downloading: " + m.err.Error() + "\n"
    }

    pad := strings.Repeat(" ", padding)
    return "\n" +
        pad + m.progress.View() + "\n\n" +
        pad + helpStyle("Press any key to quit")
}

Here we add the logics to download the url which we provide as input

var (
	p        *tea.Program
	Filename string
)

type progressWriter struct {
	total      int
	downloaded int
	file       *os.File
	reader     io.Reader
	onProgress func(float64)
}

func (pw *progressWriter) Start() {
	// TeeReader calls pw.Write() each time a new response is received
	_, err := io.Copy(pw.file, io.TeeReader(pw.reader, pw))
	if err != nil {
		p.Send(progressErrMsg{err})
	}
}

func (pw *progressWriter) Write(p []byte) (int, error) {
	pw.downloaded += len(p)
	if pw.total > 0 && pw.onProgress != nil {
		pw.onProgress(float64(pw.downloaded) / float64(pw.total))
	}
	return len(p), nil
}

func getResponse(url string) (*http.Response, error) {
	resp, err := http.Get(url) // nolint:gosec
	if err != nil {
		log.Fatal(err)
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("receiving status of %d for url: %s", resp.StatusCode, url)
	}
	return resp, nil
}

func Downloader() {
	url := textInput.OutputValue
	resp, err := getResponse(*url)
	if err != nil {
		fmt.Println("could not get response", err)
		os.Exit(1)
	}
	defer resp.Body.Close() // nolint:errcheck

	// Don't add TUI if the header doesn't include content size
	// it's impossible see progress without total
	if resp.ContentLength <= 0 {
		fmt.Println("can't parse content length, aborting download")
		os.Exit(1)
	}

	Filename = filepath.Base(*url)
	file, err := os.Create(Filename)
	if err != nil {
		fmt.Println("could not create file:", err)
		os.Exit(1)
	}
	defer file.Close() // nolint:errcheck

	pw := &progressWriter{
		total:  int(resp.ContentLength),
		file:   file,
		reader: resp.Body,
		onProgress: func(ratio float64) {
			p.Send(progressMsg(ratio))
		},
	}

	m := downloadModel{
		pw:       pw,
		progress: progress.New(progress.WithDefaultGradient()),
	}
	// Start Bubble Tea
	p = tea.NewProgram(m)

	// Start the download
	go pw.Start()

	if _, err := p.Run(); err != nil {
		fmt.Println("error running program:", err)
		os.Exit(1)
	}
}

Here is the final entrypoint for file-downloader application

Key Components:

  • Welcome Message: Displays a welcoming message styled with Lip Gloss (github.com/charmbracelet/lipgloss), providing an initial introduction to the CLI application.

  • TextInput Integration: Utilizes the textInput package to capture user input, specifically the URL of the file to be downloaded.

  • Downloader Integration: Initiates the file download process using the downloader package, which includes handling download progress and saving files locally.

  • List Integration: Incorporates the list package to present and select file permission options after download, enabling users to set file permissions conveniently.

var (
	infoStyle = lipgloss.NewStyle().Bold(true).Border(lipgloss.HiddenBorder()).Foreground(lipgloss.Color("#0000FF"))
)

func main() {
	fmt.Println(infoStyle.Render("Welcome to File-Manager Cli ! \nWhich is useful to download online files and set permissions instantly.."))
	textInput.StartInputTextModel()
	downloader.StartDownloaderModel()
	list.StartListModel()
}

Now we can build the binary and test the output

Output:

tui-output.gif

Related Posts