Views
What This Example Shows
This example demonstrates how to build an app with multiple views - like having different screens that show different content based on what the user is doing. Think of it like a mobile app where you tap through different screens, except here you navigate with keyboard keys.
The app shows a menu where you pick a task, then switches to a progress screen that shows a loading bar. It's a great pattern for multi-step workflows.
Understanding Multiple Views
Most real-world apps need more than just one screen. You might have:
- A main menu or dashboard
- Settings screens
- Loading or progress views
- Different modes for different tasks
This example shows a simple but effective pattern: use a flag in your model to track which view you're in, then call different update and view functions based on that state.
This example includes a custom progress bar implementation since it was created before the official Bubbles progress component was available. In new projects, you'd probably use the Bubbles version instead.
How It Works
Track Current View State
The model includes a Chosen boolean to know which view we're in:
type model struct {
Choice int // Which menu item is selected
Chosen bool // Are we past the menu screen?
Ticks int // Countdown timer
Progress float64 // Loading progress (0-1)
// ... other fields
}Route Messages to Different Updates
The main update function acts like a router, sending messages to different handlers:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Global quit handling
if msg, ok := msg.(tea.KeyMsg); ok {
if msg.String() == "q" || msg.String() == "ctrl+c" {
m.Quitting = true
return m, tea.Quit
}
}
// Route to appropriate view
if !m.Chosen {
return updateChoices(msg, m) // Menu view
}
return updateChosen(msg, m) // Progress view
}Render Different Views
Similarly, the main view function calls different renderers:
func (m model) View() string {
if m.Quitting {
return "\n See you later!\n\n"
}
if !m.Chosen {
return choicesView(m) // Show the menu
}
return chosenView(m) // Show the progress screen
}This pattern scales really well - you can add more views by expanding the routing logic and adding new view states to your model.
Code Breakdown
The main update and view functions act as routers:
// Main update routes messages based on current view
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle global quit keys
if msg, ok := msg.(tea.KeyMsg); ok {
k := msg.String()
if k == "q" || k == "esc" || k == "ctrl+c" {
m.Quitting = true
return m, tea.Quit
}
}
// Route to appropriate update function
if !m.Chosen {
return updateChoices(msg, m)
}
return updateChosen(msg, m)
}
// Main view routes rendering based on current view
func (m model) View() string {
if m.Quitting {
return "\n See you later!\n\n"
}
if !m.Chosen {
return choicesView(m)
}
return chosenView(m)
}The progress view handles the loading animation:
func updateChosen(msg tea.Msg, m model) (tea.Model, tea.Cmd) {
switch msg.(type) {
case frameMsg:
if !m.Loaded {
m.Frames++
// Smooth easing animation
m.Progress = ease.OutBounce(float64(m.Frames) / 100.0)
if m.Progress >= 1 {
m.Progress = 1
m.Loaded = true
m.Ticks = 3
return m, tick() // Start exit countdown
}
return m, frame() // Continue animation
}
case tickMsg:
// Exit countdown after loading completes
if m.Loaded {
if m.Ticks == 0 {
m.Quitting = true
return m, tea.Quit
}
m.Ticks--
return m, tick()
}
}
return m, nil
}The example includes extensive styling with lipgloss:
var (
keywordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211"))
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
ticksStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("79"))
checkboxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
// ... more styles
)
// Custom progress bar with gradient colors
func progressbar(percent float64) string {
w := float64(progressBarWidth)
fullSize := int(math.Round(w * percent))
var fullCells string
for i := 0; i < fullSize; i++ {
fullCells += ramp[i].Render(progressFullChar)
}
emptySize := int(w) - fullSize
emptyCells := strings.Repeat(progressEmpty, emptySize)
return fmt.Sprintf("%s%s %3.0f", fullCells, emptyCells, math.Round(percent*100))
}What Makes This Example Special
The progress bar doesn't just fill up linearly - it uses easing functions to create a bouncy, satisfying animation that feels much more polished. Instead of a boring straight line, you get this nice elastic effect that makes the loading feel fun to watch.
Both views are also smart about not running forever. They include countdown timers so if you walk away from your computer, the app will eventually quit on its own instead of sitting there indefinitely.
The navigation is pretty flexible too - you can use either vim-style keys (j/k) or regular arrow keys to move around the menu, so it works however you're used to navigating.
Since this example was built before the official Bubbles progress component was available, it shows you how to roll your own progress bar with gradient colors and smooth animations. It's actually a great learning example of how to build custom UI components from scratch.
Final Code
package main
import (
"fmt"
"math"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/fogleman/ease"
"github.com/lucasb-eyer/go-colorful"
)
const (
progressBarWidth = 71
progressFullChar = "█"
progressEmptyChar = "░"
dotChar = " • "
)
// General stuff for styling the view
var (
keywordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211"))
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
ticksStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("79"))
checkboxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
progressEmpty = subtleStyle.Render(progressEmptyChar)
dotStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")).Render(dotChar)
mainStyle = lipgloss.NewStyle().MarginLeft(2)
// Gradient colors we'll use for the progress bar
ramp = makeRampStyles("#B14FFF", "#00FFA3", progressBarWidth)
)
func main() {
initialModel := model{0, false, 10, 0, 0, false, false}
p := tea.NewProgram(initialModel)
if _, err := p.Run(); err != nil {
fmt.Println("could not start program:", err)
}
}
type (
tickMsg struct{}
frameMsg struct{}
)
func tick() tea.Cmd {
return tea.Tick(time.Second, func(time.Time) tea.Msg {
return tickMsg{}
})
}
func frame() tea.Cmd {
return tea.Tick(time.Second/60, func(time.Time) tea.Msg {
return frameMsg{}
})
}
type model struct {
Choice int
Chosen bool
Ticks int
Frames int
Progress float64
Loaded bool
Quitting bool
}
func (m model) Init() tea.Cmd {
return tick()
}
// Main update function.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Make sure these keys always quit
if msg, ok := msg.(tea.KeyMsg); ok {
k := msg.String()
if k == "q" || k == "esc" || k == "ctrl+c" {
m.Quitting = true
return m, tea.Quit
}
}
// Hand off the message and model to the appropriate update function for the
// appropriate view based on the current state.
if !m.Chosen {
return updateChoices(msg, m)
}
return updateChosen(msg, m)
}
// The main view, which just calls the appropriate sub-view
func (m model) View() string {
var s string
if m.Quitting {
return "\n See you later!\n\n"
}
if !m.Chosen {
s = choicesView(m)
} else {
s = chosenView(m)
}
return mainStyle.Render("\n" + s + "\n\n")
}
// Sub-update functions
// Update loop for the first view where you're choosing a task.
func updateChoices(msg tea.Msg, m model) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "j", "down":
m.Choice++
if m.Choice > 3 {
m.Choice = 3
}
case "k", "up":
m.Choice--
if m.Choice < 0 {
m.Choice = 0
}
case "enter":
m.Chosen = true
return m, frame()
}
case tickMsg:
if m.Ticks == 0 {
m.Quitting = true
return m, tea.Quit
}
m.Ticks--
return m, tick()
}
return m, nil
}
// Update loop for the second view after a choice has been made
func updateChosen(msg tea.Msg, m model) (tea.Model, tea.Cmd) {
switch msg.(type) {
case frameMsg:
if !m.Loaded {
m.Frames++
m.Progress = ease.OutBounce(float64(m.Frames) / float64(100))
if m.Progress >= 1 {
m.Progress = 1
m.Loaded = true
m.Ticks = 3
return m, tick()
}
return m, frame()
}
case tickMsg:
if m.Loaded {
if m.Ticks == 0 {
m.Quitting = true
return m, tea.Quit
}
m.Ticks--
return m, tick()
}
}
return m, nil
}
// Sub-views
// The first view, where you're choosing a task
func choicesView(m model) string {
c := m.Choice
tpl := "What to do today?\n\n"
tpl += "%s\n\n"
tpl += "Program quits in %s seconds\n\n"
tpl += subtleStyle.Render("j/k, up/down: select") + dotStyle +
subtleStyle.Render("enter: choose") + dotStyle +
subtleStyle.Render("q, esc: quit")
choices := fmt.Sprintf(
"%s\n%s\n%s\n%s",
checkbox("Plant carrots", c == 0),
checkbox("Go to the market", c == 1),
checkbox("Read something", c == 2),
checkbox("See friends", c == 3),
)
return fmt.Sprintf(tpl, choices, ticksStyle.Render(strconv.Itoa(m.Ticks)))
}
// The second view, after a task has been chosen
func chosenView(m model) string {
var msg string
switch m.Choice {
case 0:
msg = fmt.Sprintf("Carrot planting?\n\nCool, we'll need %s and %s...", keywordStyle.Render("libgarden"), keywordStyle.Render("vegeutils"))
case 1:
msg = fmt.Sprintf("A trip to the market?\n\nOkay, then we should install %s and %s...", keywordStyle.Render("marketkit"), keywordStyle.Render("libshopping"))
case 2:
msg = fmt.Sprintf("Reading time?\n\nOkay, cool, then we'll need a library. Yes, an %s.", keywordStyle.Render("actual library"))
default:
msg = fmt.Sprintf("It's always good to see friends.\n\nFetching %s and %s...", keywordStyle.Render("social-skills"), keywordStyle.Render("conversationutils"))
}
label := "Downloading..."
if m.Loaded {
label = fmt.Sprintf("Downloaded. Exiting in %s seconds...", ticksStyle.Render(strconv.Itoa(m.Ticks)))
}
return msg + "\n\n" + label + "\n" + progressbar(m.Progress) + "%"
}
func checkbox(label string, checked bool) string {
if checked {
return checkboxStyle.Render("[x] " + label)
}
return fmt.Sprintf("[ ] %s", label)
}
func progressbar(percent float64) string {
w := float64(progressBarWidth)
fullSize := int(math.Round(w * percent))
var fullCells string
for i := 0; i < fullSize; i++ {
fullCells += ramp[i].Render(progressFullChar)
}
emptySize := int(w) - fullSize
emptyCells := strings.Repeat(progressEmpty, emptySize)
return fmt.Sprintf("%s%s %3.0f", fullCells, emptyCells, math.Round(percent*100))
}
// Utils
// Generate a blend of colors.
func makeRampStyles(colorA, colorB string, steps float64) (s []lipgloss.Style) {
cA, _ := colorful.Hex(colorA)
cB, _ := colorful.Hex(colorB)
for i := 0.0; i < steps; i++ {
c := cA.BlendLuv(cB, i/steps)
s = append(s, lipgloss.NewStyle().Foreground(lipgloss.Color(colorToHex(c))))
}
return
}
// Convert a colorful.Color to a hexadecimal format.
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
}How is this guide?