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 quitYou 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/v2Everything 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 fromInit.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 drawslayoutat the window's size and returns atea.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 exactlyncells.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.MouseModeCellMotionA 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
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.Rootrenders your layout fromView, and gets every message inUpdate. RowandColumnshare space between children, and the slot a child is in decides its share:Fixed,Fill, or its natural size.Boxdraws a Lip Gloss border around a child, andText(...).Style(...)styles and wraps text.Onattaches mouse handlers from theonpackage to the node they're about, and they can change the model directly.MinandCollapsibledecide 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