0%
March 8, 2025

CLI Program by Bubble Tea

bubble-tea

go

Visual Result

Project Structure

Entrypoint --- How do we start our CLI program?

The Model Interface

In main.go our func main() will execute the following function:

import (
  	"fmt"

    tea "github.com/charmbracelet/bubbletea"
)

func Start() {
  ... // instantiation
  if _, err := tea.NewProgram(&initialModel).Run(); err != nil {
      fmt.Fprintf(os.Stderr, "failed to start program: %v\n", err)
  }
}

The signature of the tea.NewProgram is

func NewProgram(model Model, opts ...ProgramOption) *Program

where the interface Model is defined by:

The Complete Start Method

As a spoiler, the complete Start() method will be like

func Start() {
	initialModel := ApplicationModel{
		Views: []View{
			NewProjectNameView(),
			NewMultiChoiceView(
				"Read configuration settings from:",
				[]string{
					"Command-line flas",
					"Environment variables",
				},
			),
			NewMultiChoiceView(
				"Pick your preferred router:",
				[]string{
					"Gorilla Mux",
					"HttpRouter",
				},
			),
		},
		CurrentViewIndex: 0,
		Progress:         progress.New(),
		ProgressChannel:  make(chan tea.Msg),
	}

	if _, err := tea.NewProgram(&initialModel).Run(); err != nil {
		fmt.Fprintf(os.Stderr, "failed to start program: %v\n", err)
	}
}

Let's first introduce our View interface:

View Interface

The Start method above has introduced View models, which is an interface of the form:

type View interface {
	View() string
	Update(msg tea.Msg, m *ApplicationModel) tea.Cmd
}

View is essentially a simplified version of Model. The Update method slightly deviates from Model's one as it accepts *ApplicationModel to get global state.

In the sequel we will define our ApplicationModel struct, which will implement Model interface and we embed all the necessary data into it.

Our ApplicationModel can be thought of as a View that contains many SubView's, which are ProjectNameView and MultiChoiceView in our case.

ApplicationModel Struct

type ApplicationModel struct {
  Views            []View
  CurrentViewIndex int
  CreateProject    bool
  Progress         progress.Model
  ProgressPercent  float64
  Quitting         bool
  ProgressChannel  chan tea.Msg
}
The Imports
import (
	"fmt"
	"log"
	"os"
	"project_generator/internal/projgenerator"

	"github.com/charmbracelet/bubbles/progress"
	tea "github.com/charmbracelet/bubbletea"
)
Implement Model Interface
Init
func (m *ApplicationModel) Init() tea.Cmd {
    return nil
}
View
func (m *ApplicationModel) View() string {
	log.Println("Cli View() > m.CreateProject: ", m.CreateProject)
	if m.Quitting {
		return "See you later!"
	} else if m.CreateProject {

	}
	log.Println("Cli View() m.", m.CurrentViewIndex)
	log.Println("m.Views[m.CurrentViewIndex]", m.Views[m.CurrentViewIndex])
	var results string
	for index := 0; index <= m.CurrentViewIndex; index++ {
		results += m.Views[index].View() + "\n"
	}
	return results
}
  • Here we concat all the view results. If we simply return m.Views[index].View(), the content of the previous view will disappear. This is not desirable, like if we have multiple choices, we wish to show the option chosen by the user.

  • Although this View() method can be considered as a Render() method in browser, it is not triggered automatically in a regular time frame. This is triggered by the Update() method of the main View:

Update

We execute the Update method of the current selected view.

func (m *ApplicationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	log.Println("cli update() msg:", msg)
	var cmd tea.Cmd
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.Type {
		case tea.KeyEsc, tea.KeyCtrlC:
			return m, tea.Quit
		}

	case createProjectMsg:
		log.Println("Createing project")
		appConfig := populateAppConfig(m.Views)
		go projgenerator.GenerateProject(appConfig, m.ProgressChannel)
		return m, listenToProgress(m.ProgressChannel)
	}

	log.Println(" m.CurrentViewIndex < len(m.Views)", m.CurrentViewIndex < len(m.Views))
	if m.CurrentViewIndex < len(m.Views) {
		cmd = m.Views[m.CurrentViewIndex].Update(msg, m)
	}
	return m, cmd
}
  • Any key press in the CLI will send a tea.Msg object to the Update() method of the main. And we redirect this tea.Msg to the m.Views[m.CurrentViewIndex].Update(msg, m)

  • We remark that we have introduced a custom createProjectMsg message type here, which is simply a struct defined by

    type createProjectMsg struct {}

    Recall that our View model return tea.Cmd in any update, and this custom message type createProjectMsg is the return value of the following function:

    func createProject() tea.Cmd {
      return tea.Tick(time.Second/60, func(t time.Time) tea.Msg {
        return createProjectMsg{}
      })
    }

    or simply

    func createProject() tea.Cmd {
      return func() tea.Msg {
        return createProjectMsg{}
      }
    }
  • Analogous to Redux, a tea.Cmd is like a ThunkAction, which is a function that returns { action, payload } in the redux world. If necessary we can define payload inside of createProjectMsg.

ProjectNameView

import (
	"fmt"
	"log"

	"github.com/charmbracelet/bubbles/textinput"
	tea "github.com/charmbracelet/bubbletea"
)

type ProjectNameView struct {
	inputModel    textinput.Model
	endingMessage string
}

func NewProjectNameView() *ProjectNameView {
	inputModel := newTextInput("Project Name")
	return &ProjectNameView{
		inputModel:    inputModel,
		endingMessage: "",
	}
}

func newTextInput(prompt string) textinput.Model {
	ti := textinput.New()
	ti.Placeholder = prompt
	ti.Focus()
	ti.CharLimit = 156
	ti.Width = 20
	return ti
}

func (v *ProjectNameView) View() string {
	endingDisplay := func() string {
		if len(v.endingMessage) > 0 {
			return fmt.Sprintf("\n\nNice, the project name \"%v\" is well received.\n\n", v.inputModel.Value())
		}
		return ""
	}()
	return "Please input a project name: \n\n" + v.inputModel.View() + endingDisplay
}

func (v *ProjectNameView) Update(msg tea.Msg, m *ApplicationModel) tea.Cmd {
	log.Println("ProjectNameView.Update() > msg:", msg)

	var cmd tea.Cmd
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.Type {
		case tea.KeyEnter:
			v.endingMessage = v.inputModel.Value()
			m.CurrentViewIndex++
		}
	}
	v.inputModel, cmd = v.inputModel.Update(msg)
	return cmd
}

MultiChoiceView

import (
	"log"
	"math"
	"project_generator/internal/projgenerator"
	"project_generator/internal/termstyle"
	"strings"
	"time"

	tea "github.com/charmbracelet/bubbletea"
)

type MultiChoiceView struct {
	Prompt          string
	Options         []string
	Selected        int
}

func NewMultiChoiceView(prompt string, options []string) *MultiChoiceView {
	return &MultiChoiceView{
		Prompt:          prompt,
		Options:         options,
		Selected:        0,
	}
}

func (v *MultiChoiceView) View() string {
	log.Println("MultiChoiceView.View()")
	var builder strings.Builder
	builder.WriteString(v.Prompt + "\n\n")

	for index, option := range v.Options {
		checkbox := Checkbox(option, index == v.Selected)
		builder.WriteString(checkbox + "\n")
	}

	instructions := termstyle.Subtle("enter:choose") + termstyle.Dot + termstyle.Subtle("esc or ctrl-c: quit")
	builder.WriteString("\n" + instructions)

	return builder.String()
}

func (v *MultiChoiceView) Update(msg tea.Msg, m *ApplicationModel) tea.Cmd {
	var cmd tea.Cmd
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.Type {
		case tea.KeyDown:
			v.Selected = int(math.Min(float64(v.Selected+1), float64(len(v.Options)-1)))
		case tea.KeyUp:
			v.Selected = int(math.Max(float64(v.Selected-1), float64(0)))
		case tea.KeyEnter:
			if m.CurrentViewIndex == len(m.Views)-1 {
				m.CreateProject = true
				log.Println("Return createProjectMsg")
				cmd = createProject()
			} else {
				m.CurrentViewIndex++
			}
		}
	}
	return cmd
}

func createProject() tea.Cmd {
	return tea.Tick(time.Second/60, func(time.Time) tea.Msg {
		return createProjectMsg{} // the type of msg used in update method.
	})
}

Termstyle

package termstyle

import (
	"fmt"
	"strconv"

	"github.com/charmbracelet/lipgloss"
	"github.com/lucasb-eyer/go-colorful"
	"github.com/muesli/termenv"
)

var (
	Term      = termenv.EnvColorProfile()
	Subtle    = makeFgStyle("241")
	Dot       = ColorFg(" • ", "236")
	HelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render
)

// Color a string's foreground with the given value.
func ColorFg(val, color string) string {
	return termenv.String(val).Foreground(Term.Color(color)).String()
}

// Return a function that will colorize the foreground of a given string.
func makeFgStyle(color string) func(string) string {
	return termenv.Style{}.Foreground(Term.Color(color)).Styled
}

// Generate a blend of colors.
func MakeRamp(colorA, colorB string, steps float64) (s []string) {
	cA, _ := colorful.Hex(colorA)
	cB, _ := colorful.Hex(colorB)

	for i := 0.0; i < steps; i++ {
		c := cA.BlendLuv(cB, i/steps)
		s = append(s, ColorToHex(c))
	}
	return
}

// Convert a colorful.Color to a hexadecimal format compatible with termenv.
func ColorToHex(c colorful.Color) string {
	return fmt.Sprintf("#%s%s%s", ColorFloatToHex(c.R), ColorFloatToHex(c.G), ColorFloatToHex(c.B))
}

// Helper function for converting colors to hex. Assumes a value between 0 and
// 1.
func ColorFloatToHex(f float64) (s string) {
	s = strconv.FormatInt(int64(f*255), 16)
	if len(s) == 1 {
		s = "0" + s
	}
	return
}

Entrypoint with Logging Setup

When executing our cli program there are not real-time log in our console. We need to log those information into another file:

package main

import (
	"log"
	"os"
	"project_generator/internal/cli"
)

const logFilePath = "project_creator.log"

var cleanup func() error

func init() {

	// Open or create the log file
	logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		log.Fatalf("error opening file: %v", err)
	}

	// Set log output to the file
	log.SetOutput(logFile)

	// Cleanup function to close the file
	cleanup = func() error {
		return logFile.Close()
	}
}

func main() {
	defer func() {
		if err := cleanup(); err != nil {
			log.Printf("Error during cleanup: %v\n", err)
			os.Exit(1)
		}
	}()

	cli.Start()
}