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.
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 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.
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
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.
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"))
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).
textModel
)textinput.Model
and handles user input events.textinput.Model
with placeholder text and styles.tea.Model
interface method Init
, which initializes the text input with a blinking cursor.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()
}
Create a model for selecting file permissions using a list:
listModel
)tea.Model
interface to manage a list of options for selecting file permissions.list.Model
and handles user interaction to select permissions.os.Chmod
.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)
}
Create a model for managing file download progress:
downloadModel
)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)
}
}
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