Sheets is a terminal spreadsheet built with Go and Bubble Tea. Here's how it works and what Go patterns it uses.

A spreadsheet in your terminal, written in Go


Most developers live in the terminal. We edit code there, manage Git there, monitor systems there. So why not run a spreadsheet there too?

Sheets is a terminal-based spreadsheet written in Go. Cells, formulas, navigation, all inside your terminal. But the reason I find it worth writing about isn’t the spreadsheet part. It’s the Go underneath. Sheets uses Bubble Tea, the Elm-inspired TUI framework, and leans on Go’s strengths in string parsing and clean architecture.

Let’s look at how it works.

The Elm Architecture in Go

Sheets is built on Bubble Tea, which implements The Elm Architecture (TEA) in Go. If you’ve worked with Bubble Tea before, you know the drill: a model holds state, an Update function handles messages, and a View function renders output.

Here’s the core shape of any Bubble Tea program:

package main

import (
	"fmt"
	"os"

	tea "github.com/charmbracelet/bubbletea"
)

type model struct {
	cursor int
	rows   int
	cols   int
	cells  [][]string
}

func (m model) Init() tea.Cmd {
	return nil
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case "ctrl+c", "q":
			return m, tea.Quit
		case "tab":
			m.cursor++
			if m.cursor >= m.rows*m.cols {
				m.cursor = 0
			}
		}
	}
	return m, nil
}

func (m model) View() string {
	return fmt.Sprintf("Cell %d selected\n", m.cursor)
}

func main() {
	p := tea.NewProgram(model{rows: 10, cols: 5, cells: make([][]string, 10)})
	if _, err := p.Run(); err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
}

Sheets follows this same pattern. The main model tracks the grid state, the selected cell, the current editing mode, and formula evaluation state. Every keypress flows through Update, and the entire grid re-renders via View.

Worth understanding if you’re building any kind of interactive Go application. The approach to managing state through context carries over well. Bubble Tea just formalizes it into a loop.

How cell references and formulas work

The interesting Go code in Sheets is the formula evaluation. When you type =A1+B2 into a cell, the program needs to:

  1. Parse the formula string
  2. Resolve cell references like A1 to actual values
  3. Evaluate the expression
  4. Handle circular references

Cell reference parsing is a classic string manipulation problem. In Go, you can parse a reference like A1 or BC42 with straightforward code:

package main

import (
	"fmt"
	"strings"
	"unicode"
)

// ParseCellRef takes a reference like "B12" and returns
// the column index and row index (0-based).
func ParseCellRef(ref string) (col int, row int, err error) {
	ref = strings.TrimSpace(strings.ToUpper(ref))

	i := 0
	for i < len(ref) && unicode.IsLetter(rune(ref[i])) {
		i++
	}

	if i == 0 || i == len(ref) {
		return 0, 0, fmt.Errorf("invalid cell reference: %s", ref)
	}

	colPart := ref[:i]
	rowPart := ref[i:]

	// Convert column letters to index (A=0, B=1, ..., Z=25, AA=26)
	col = 0
	for _, ch := range colPart {
		col = col*26 + int(ch-'A') + 1
	}
	col-- // 0-based

	// Parse row number
	rowNum := 0
	for _, ch := range rowPart {
		if !unicode.IsDigit(ch) {
			return 0, 0, fmt.Errorf("invalid row in reference: %s", ref)
		}
		rowNum = rowNum*10 + int(ch-'0')
	}
	row = rowNum - 1 // 0-based

	return col, row, nil
}

func main() {
	col, row, err := ParseCellRef("B3")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Printf("B3 → col=%d, row=%d\n", col, row) // col=1, row=2
}

This is a pattern you see everywhere in Go: building parsers by walking through runes. No regex needed for something this structured. If you’ve done any Advent of Code in Go, this kind of parsing will feel familiar.

Grid rendering with Lip Gloss

Sheets uses Lip Gloss for styling terminal output. Lip Gloss is another Charm library that handles colors, borders, and layout in the terminal.

Rendering a spreadsheet grid means building a table where each cell has a fixed width, borders, and possibly different styling for the selected cell. Here’s a simplified version:

package main

import (
	"fmt"
	"strings"

	"github.com/charmbracelet/lipgloss"
)

var (
	cellStyle = lipgloss.NewStyle().
			Width(12).
			Padding(0, 1).
			Border(lipgloss.NormalBorder(), false, true, false, false)

	selectedStyle = lipgloss.NewStyle().
			Width(12).
			Padding(0, 1).
			Bold(true).
			Foreground(lipgloss.Color("212")).
			Border(lipgloss.NormalBorder(), false, true, false, false)

	headerStyle = lipgloss.NewStyle().
			Width(12).
			Padding(0, 1).
			Bold(true).
			Align(lipgloss.Center).
			Border(lipgloss.NormalBorder(), false, true, true, false)
)

func renderGrid(cells [][]string, selRow, selCol int) string {
	rows := len(cells)
	cols := len(cells[0])

	var output strings.Builder

	// Header row
	headers := make([]string, cols)
	for c := 0; c < cols; c++ {
		headers[c] = headerStyle.Render(string(rune('A' + c)))
	}
	output.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, headers...))
	output.WriteString("\n")

	// Data rows
	for r := 0; r < rows; r++ {
		renderedCells := make([]string, cols)
		for c := 0; c < cols; c++ {
			style := cellStyle
			if r == selRow && c == selCol {
				style = selectedStyle
			}
			renderedCells[c] = style.Render(cells[r][c])
		}
		output.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, renderedCells...))
		output.WriteString("\n")
	}

	return output.String()
}

func main() {
	cells := [][]string{
		{"100", "200", "=A1+B1"},
		{"hello", "", "42"},
		{"", "test", ""},
	}
	fmt.Println(renderGrid(cells, 0, 2))
}

The Go pattern worth noting here: building strings with strings.Builder and using lipgloss.JoinHorizontal to compose styled elements. Each cell gets styled independently, then joined into rows. It’s a functional approach where you transform data into styled strings without mutating shared state.

Mode switching with Go’s type system

Terminal apps typically have modes: navigation, edit, command. Sheets handles this using Go’s type system with typed constants:

type Mode int

const (
	ModeNavigation Mode = iota
	ModeEdit
	ModeCommand
)

type model struct {
	mode     Mode
	cells    [][]string
	row, col int
	input    string
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch m.mode {
		case ModeNavigation:
			return m.handleNavigation(msg)
		case ModeEdit:
			return m.handleEdit(msg)
		case ModeCommand:
			return m.handleCommand(msg)
		}
	}
	return m, nil
}

func (m model) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
	switch msg.String() {
	case "h", "left":
		if m.col > 0 {
			m.col--
		}
	case "l", "right":
		m.col++
	case "j", "down":
		m.row++
	case "k", "up":
		if m.row > 0 {
			m.row--
		}
	case "enter", "i":
		m.mode = ModeEdit
		m.input = m.cells[m.row][m.col]
	}
	return m, nil
}

The iota pattern for modes is classic Go. Each mode gets its own handler method, keeping Update clean. The switch on m.mode is a state machine, which is a natural fit for interactive applications.

If you’ve used functional options in Go, you’ll appreciate this kind of design. Small, focused functions that each handle one concern.

Why Go works well for TUI tools

Go is a strong fit for terminal tools like this, and the reasons are pretty concrete.

You go build and get one binary. No runtime dependencies. Users can install via go install github.com/maaslalani/sheets@latest and it works. Terminal tools need to feel instant, and Go programs start in milliseconds.

Then there’s the Charm ecosystem. The combination of Bubble Tea, Lip Gloss, and Bubbles (pre-built components) gives you a real framework for terminal UIs. It’s mature enough that you’re not fighting the tooling anymore.

And because the TEA pattern makes Update a pure function (model in, model out), testing is dead simple:

func TestNavigationMoveRight(t *testing.T) {
	m := model{
		mode: ModeNavigation,
		row:  0,
		col:  0,
		cells: [][]string{
			{"a", "b", "c"},
		},
	}

	msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}
	updated, _ := m.handleNavigation(msg)
	result := updated.(model)

	if result.col != 1 {
		t.Errorf("expected col=1, got col=%d", result.col)
	}
}

No mocks, no dependency injection needed. Pass a message, check the state. That’s a direct benefit of the architecture, and I think it’s the most underappreciated part of building with Bubble Tea.

Where to go from here

Sheets is a compact project, but it touches several Go patterns worth knowing: the Elm Architecture via Bubble Tea, string parsing for cell references, mode-based state machines with iota, and composable rendering with Lip Gloss.

If you want to build your own terminal tools, Bubble Tea’s tutorials are the place to start. The Go TUI ecosystem has gotten good enough that you can build real, useful tools without spending half your time on escape codes and raw terminal handling. That wasn’t true a few years ago.