← Back to Engineering

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

2024-07-099 min readKameshvaran

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

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.

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).

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.

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:

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))
}

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

Step 4: Implementing Download Model

Create a model for managing file download progress:

Key Components:

  • StartDownloaderModel Function: Initializes a Bubble Tea program to manage the download process.
  • progressWriter Struct: Implements the io.Writer interface to manage and update the download progress.
  • downloadModel Struct: Implements the Bubble Tea tea.Model interface to manage the download's UI state.
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")
}

Final Entrypoint

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.