bloom

Build your first layout

A step-by-step tutorial that builds a small notes app with a sidebar, a reader, a status bar, and clickable items.

In this tutorial you'll build a small notes browser: a list of notes on the left, the selected note on the right, and a status bar along the bottom. You can choose a note with the arrow keys or by clicking it, and the sidebar gets out of the way when the terminal is too narrow for both panes.

╭──────────────────────╮╭──────────────────────────────────────────────────────╮
│ Groceries            ││ Groceries                                            │
│ Ideas                ││ Oat milk, lemons, basil, and a loaf of rye.          │
│ Reading              ││                                                      │
│                      ││                                                      │
╰──────────────────────╯╰──────────────────────────────────────────────────────╯
 3 notes, ↑/↓ or click to choose, q to quit

You should know the basics of a Bubble Tea program: a model with Init, Update, and View methods. If you haven't written one before, the Bubble Tea tutorial covers it in a few minutes.

Set up the project

Create a new module and add bloom, Bubble Tea, and Lip Gloss:

mkdir notes && cd notes
go mod init example.com/notes
go get github.com/lrstanley/bloom@latest charm.land/bubbletea/v2 charm.land/lipgloss/v2

Everything below goes in main.go.

Start with an empty screen

Every bloom program keeps a *bloom.Root in its model. The root renders your layout, and remembers what it can between frames so it doesn't redo work. Your model also needs the window's size, which Bubble Tea sends in a tea.WindowSizeMsg.

package main

import (
	"fmt"
	"os"

	tea "charm.land/bubbletea/v2"
	"github.com/lrstanley/bloom"
)

type model struct {
	ui            *bloom.Root
	width, height int
}

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

func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.width, m.height = msg.Width, msg.Height
	case tea.KeyPressMsg:
		if msg.String() == "q" || msg.String() == "ctrl+c" {
			return m, tea.Quit
		}
	}
	return m, m.ui.Update(msg)
}

func (m *model) View() tea.View {
	v := m.ui.View(m.width, m.height, bloom.Text("Hello from bloom"))
	v.AltScreen = true
	return v
}

func main() {
	m := &model{ui: bloom.New()}
	if _, err := tea.NewProgram(m).Run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

Run it with go run ., and press q to quit.

There are three bloom calls here, and every program has them:

  • m.ui.Init() asks the terminal for its colors, which some effects use to blend. Return it from Init.
  • m.ui.Update(msg) lets the root see what the terminal reports, and keeps layout animations ticking. Pass it every message and return its command.
  • m.ui.View(width, height, layout) lays out and draws layout at the window's size and returns a tea.View.

Split the screen into panes

Layouts are built from nodes. bloom.Row places its children left to right, and bloom.Column places them top to bottom. How much space each child gets depends on the slot you wrap it in:

  • bloom.Fixed(n, child) gets exactly n cells.
  • bloom.Fill(child) gets an equal share of whatever is left over.
  • A child with no slot gets its natural size, the space its content needs.

Add the panes and a status bar. Replace View with this:

func (m *model) View() tea.View {
	layout := bloom.Column(
		bloom.Fill(bloom.Row(
			bloom.Fixed(24, bloom.Text("sidebar")),
			bloom.Fill(bloom.Text("reader")),
		)),
		bloom.Text(" status bar"),
	)

	v := m.ui.View(m.width, m.height, layout)
	v.AltScreen = true
	return v
}

The status bar has no slot, so it takes the one row its text needs. The row of panes is in a Fill slot, so it takes all the remaining height. Inside the row, the sidebar is 24 columns wide and the reader fills the rest.

Resize your terminal while it runs. The panes follow, without any width arithmetic in your code.

Add borders and styles

bloom.Box(style, child) draws a Lip Gloss style's border, padding, and background around a child, and the child gets the space inside it. Text(s).Style(style) renders text with a style at whatever size the node is given. Styled text also wraps to fit its width, and text without a style is clipped instead.

Add "charm.land/lipgloss/v2" to your imports, then add some styles and the notes to show, above type model:

type note struct {
	title, body string
}

var notes = []note{
	{"Groceries", "Oat milk, lemons, basil, and a loaf of rye."},
	{"Ideas", "A terminal garden that grows while you type."},
	{"Reading", "The Elements of Typographic Style, chapter 2."},
}

var (
	muted  = lipgloss.Color("#6e6a86")
	subtle = lipgloss.Color("#908caa")
	rose   = lipgloss.Color("#eb6f92")
	gold   = lipgloss.Color("#f6c177")
	dark   = lipgloss.Color("#191724")

	panel    = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(muted)
	item     = lipgloss.NewStyle().Padding(0, 1)
	selected = item.Foreground(dark).Background(rose)
	heading  = lipgloss.NewStyle().Bold(true).Foreground(gold)
	prose    = lipgloss.NewStyle().Padding(0, 1)
	status   = lipgloss.NewStyle().Foreground(subtle)
)

Add a selected int field to the model, then build the sidebar from the notes in View:

func (m *model) View() tea.View {
	items := make([]bloom.Child, len(notes))
	for i, n := range notes {
		style := item
		if i == m.selected {
			style = selected
		}
		items[i] = bloom.Text(n.title).Style(style)
	}

	current := notes[m.selected]
	sidebar := bloom.Box(panel, bloom.Column(items...))
	reader := bloom.Box(panel, bloom.Column(
		bloom.Text(current.title).Style(heading.Padding(0, 1)),
		bloom.Text(current.body).Style(prose),
	))
	hints := fmt.Sprintf(" %d notes, ↑/↓ or click to choose, q to quit", len(notes))

	layout := bloom.Column(
		bloom.Fill(bloom.Row(
			bloom.Fixed(24, sidebar),
			bloom.Fill(reader),
		)),
		bloom.Text(hints).Style(status).Truncate(),
	)

	v := m.ui.View(m.width, m.height, layout)
	v.AltScreen = true
	return v
}

Truncate keeps the status bar on one line and cuts it short with "…" when the terminal is narrow, rather than letting it wrap onto a second row.

Then handle the arrow keys in Update, next to the quit keys:

	case tea.KeyPressMsg:
		switch msg.String() {
		case "q", "ctrl+c":
			return m, tea.Quit
		case "up":
			m.selected = max(m.selected-1, 0)
		case "down":
			m.selected = min(m.selected+1, len(notes)-1)
		}

Make the notes clickable

Mouse handlers go on the nodes they're about, with On. The handlers come from the on package, so import it:

import "github.com/lrstanley/bloom/on"

Give each sidebar item a click handler in View:

		items[i] = bloom.Text(n.title).Style(style).On(on.Click(func() tea.Cmd {
			m.selected = i
			return nil
		}))

And turn on mouse reporting, before returning the view:

	v.MouseMode = tea.MouseModeCellMotion

A click on a note now runs its handler. bloom works out which node the pointer is over, and runs the handler on the Bubble Tea event loop just before Update, so it can change the model directly, as Update does. That works because View has a pointer receiver: the handler captures m, the model itself, not a copy.

Handle narrow terminals

Make the terminal very narrow and the reader gets squeezed until its text barely fits. Two slot options fix that:

  • .Min(30) on the reader stops it from shrinking below 30 columns.
  • .Collapsible(1) on the sidebar lets bloom drop it entirely when the minimum sizes don't all fit.
		bloom.Fill(bloom.Row(
			bloom.Fixed(24, sidebar).Collapsible(1),
			bloom.Fill(reader).Min(30),
		)),

Narrow the terminal below 54 columns (24 for the sidebar plus 30 for the reader) and the sidebar disappears. Widen it again and the sidebar comes back. When several slots are collapsible, the one with the lowest priority goes first.

The complete program

main.go
package main

import (
	"fmt"
	"os"

	tea "charm.land/bubbletea/v2"
	"charm.land/lipgloss/v2"
	"github.com/lrstanley/bloom"
	"github.com/lrstanley/bloom/on"
)

type note struct {
	title, body string
}

var notes = []note{
	{"Groceries", "Oat milk, lemons, basil, and a loaf of rye."},
	{"Ideas", "A terminal garden that grows while you type."},
	{"Reading", "The Elements of Typographic Style, chapter 2."},
}

var (
	muted  = lipgloss.Color("#6e6a86")
	subtle = lipgloss.Color("#908caa")
	rose   = lipgloss.Color("#eb6f92")
	gold   = lipgloss.Color("#f6c177")
	dark   = lipgloss.Color("#191724")

	panel    = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(muted)
	item     = lipgloss.NewStyle().Padding(0, 1)
	selected = item.Foreground(dark).Background(rose)
	heading  = lipgloss.NewStyle().Bold(true).Foreground(gold)
	prose    = lipgloss.NewStyle().Padding(0, 1)
	status   = lipgloss.NewStyle().Foreground(subtle)
)

type model struct {
	ui            *bloom.Root
	width, height int
	selected      int
}

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

func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.width, m.height = msg.Width, msg.Height
	case tea.KeyPressMsg:
		switch msg.String() {
		case "q", "ctrl+c":
			return m, tea.Quit
		case "up":
			m.selected = max(m.selected-1, 0)
		case "down":
			m.selected = min(m.selected+1, len(notes)-1)
		}
	}
	return m, m.ui.Update(msg)
}

func (m *model) View() tea.View {
	items := make([]bloom.Child, len(notes))
	for i, n := range notes {
		style := item
		if i == m.selected {
			style = selected
		}
		items[i] = bloom.Text(n.title).Style(style).On(on.Click(func() tea.Cmd {
			m.selected = i
			return nil
		}))
	}

	current := notes[m.selected]
	sidebar := bloom.Box(panel, bloom.Column(items...))
	reader := bloom.Box(panel, bloom.Column(
		bloom.Text(current.title).Style(heading.Padding(0, 1)),
		bloom.Text(current.body).Style(prose),
	))
	hints := fmt.Sprintf(" %d notes, ↑/↓ or click to choose, q to quit", len(notes))

	layout := bloom.Column(
		bloom.Fill(bloom.Row(
			bloom.Fixed(24, sidebar).Collapsible(1),
			bloom.Fill(reader).Min(30),
		)),
		bloom.Text(hints).Style(status).Truncate(),
	)

	v := m.ui.View(m.width, m.height, layout)
	v.AltScreen = true
	v.MouseMode = tea.MouseModeCellMotion
	return v
}

func main() {
	m := &model{ui: bloom.New()}
	if _, err := tea.NewProgram(m).Run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

What you learned

  • A *bloom.Root renders your layout from View, and gets every message in Update.
  • Row and Column share space between children, and the slot a child is in decides its share: Fixed, Fill, or its natural size.
  • Box draws a Lip Gloss border around a child, and Text(...).Style(...) styles and wraps text.
  • On attaches mouse handlers from the on package to the node they're about, and they can change the model directly.
  • Min and Collapsible decide what gives way when space runs out.

Next, add a dialog and a toast to this app. For more on how rows and columns share space, see Sizing with slots.

Last updated on

On this page