Charm Bracelet
Examples

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 menu view handles navigation and selection:

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    // Switch to progress view
            return m, frame()  // Start animation timer
        }
    case tickMsg:
        // Auto-quit countdown
        if m.Ticks == 0 {
            m.Quitting = true
            return m, tea.Quit
        }
        m.Ticks--
        return m, tick()
    }
    return m, nil
}

The view shows a checklist with the current selection highlighted.

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.

views example demonstration

Final Code

main.go
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?