Add a dialog and a toast
A tutorial that adds a confirmation dialog with a dimmed backdrop, keyboard focus, and an auto-hiding toast notification to the notes app.
This tutorial continues from Build your first layout. You'll add a way to delete notes, with a confirmation dialog that animates in over a dimmed screen, buttons you can tab between or click, and a notification in the corner that hides itself after two seconds.
╭──────────────────────╮╭──────────────────────────────────────────────╮
│ Groceries ││ Groceries │
│ Ideas ││ Oat milk, lemons, basil, and a loaf of rye. │
│ Reading ╭────────────────────────────╮ │
│ │ │ │
│ │ Delete "Groceries"? │ │
│ │ This can't be undone. │ │
│ │ │ │
│ │ Cancel Delete │ │
│ │ │ │
│ ╰────────────────────────────╯ │
╰──────────────────────╯╰──────────────────────────────────────────────╯
↑/↓ choose, d delete, q quitDoing this without bloom would mean drawing one string over another, dimming every cell outside the dialog, keeping keyboard focus inside it, and running a timer to hide the notification. Here it takes three ideas:
- An overlay draws layers over a base layout. The dialog and the toast are layers.
- A presence, from the
animpackage, animates something being shown and hidden. - A focus remembers which node has keyboard focus, and can be confined to the dialog while it's open.
Keep the notes in the model
Deleting notes means the list has to be model state now, rather than a package variable. Change the model and add a constructor:
type model struct {
ui *bloom.Root
width, height int
notes []note
selected int
}
func newModel() *model {
return &model{
ui: bloom.New(),
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."},
},
}
}Replace notes with m.notes everywhere, and start the program with tea.NewProgram(newModel()).
Draw the dialog as a layer
bloom.Overlay(base, layers...) draws each layer over the base, without taking any space from it. A layer
made with bloom.Layer(child) sits in the middle of the screen at its natural size.
Add a color, and styles for the dialog and its buttons, to the var block:
surface = lipgloss.Color("#393552")
dialogBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(rose).Padding(1, 2)
button = lipgloss.NewStyle().Padding(0, 2).Background(surface)
buttonOn = button.Foreground(dark).Background(rose).Bold(true)Then, in View, keep the column you already have as base, build the dialog, and render both:
content := bloom.Column(
bloom.Text(fmt.Sprintf("Delete %q?", title)).Style(heading),
bloom.Text("This can't be undone."),
bloom.Fixed(1, nil),
bloom.Row(
bloom.Space(),
bloom.Text("Cancel").Style(button),
bloom.Text("Delete").Style(button),
).Gap(2),
)
dialog := bloom.Layer(bloom.Box(dialogBox, content))
v := m.ui.View(m.width, m.height, bloom.Overlay(base, dialog))title is the selected note's title. bloom.Fixed(1, nil) is a blank row, and bloom.Space() is empty space
that grows, pushing the buttons to the right. Gap(2) puts two columns between the children of the row.
Run it and the dialog is always on screen. Next, you'll make it appear only when needed.
Show and hide it with a presence
An anim.Presence is a number from 0 (hidden) to 1 (shown) that animates between the two. Add the anim and
fx imports:
"github.com/lrstanley/bloom/anim"
"github.com/lrstanley/bloom/fx"Add a presence to the model, and create it in newModel:
confirm anim.Presence confirm: anim.NewPresence(anim.Duration(180 * time.Millisecond)),confirm.Show() and confirm.Hide() start the animation and return a command that drives it. The animation
moves forward on messages, so every presence has to see every message: anim.Update(msg, &m.confirm) passes one
message to any number of animations and returns their commands. Return it from Update:
return m, tea.Batch(m.ui.Update(msg), anim.Update(msg, &m.confirm))Now give the layer to the presence. Presence(p, effects...) shows the layer only while p is visible, and runs
the effects with its progress: here it fades in while sliding up from the bottom of the screen.
dialog := bloom.Layer(bloom.Box(dialogBox, content)).
Modal().
Backdrop(fx.Dim{Amount: 0.6}).
Presence(m.confirm, fx.Fade{}, fx.Slide{From: bloom.Bottom})Two more options are set here:
Modal()makes the dialog take every mouse event while it's open. Clicks outside it don't reach the notes underneath.Backdrop(fx.Dim{Amount: 0.6})dims everything beneath the dialog. It follows the presence too, so the screen darkens as the dialog appears.
Open it with d by returning m.confirm.Show() from Update, and you have an animated modal dialog.
Move focus into the dialog
The buttons should respond to tab and enter, and focus shouldn't escape to the screen behind while the dialog is open. Add a button ID type:
type buttonID string
// confirmDialog is the ID of the dialog's layer, which is also its focus scope.
const confirmDialog = "confirm"And a bloom.Focus to the model. Its type parameter is the type of the IDs it moves between, so this one moves
between buttons:
focus bloom.Focus[buttonID]A button is text with an ID that's marked Focusable, styled by whether it has focus. on.Press (from
github.com/lrstanley/bloom/on) makes it press when it's clicked, or when enter or space is
pressed while it has focus:
func (m *model) button(id buttonID, label string) bloom.Child {
style := button
if m.focus.Is(id) {
style = buttonOn
}
return bloom.Text(label).Style(style).ID(id).Focusable().
On(on.Press(func() tea.Cmd { return m.press(id) }))
}Give the dialog layer the ID confirmDialog. A modal layer is a focus scope: after
m.focus.Push(confirmDialog), focus can only move between the focusable nodes inside it, until
m.focus.Pop() puts it back where it was.
func (m *model) openDialog() tea.Cmd {
if len(m.notes) == 0 {
return nil
}
m.focus.Push(confirmDialog)
m.focus.Set("cancel")
return m.confirm.Show()
}
func (m *model) closeDialog() tea.Cmd {
if !m.confirm.Shown() {
return nil
}
m.focus.Pop()
return m.confirm.Hide()
}m.focus.Update(msg, m.ui.Frame()) handles tab and shift+tab, using the layout of the last
frame drawn, and presses the focused button for enter and space. It returns the button's
command, and reports whether it handled the message, so only look at other keys when it didn't. Keep the command
in a cmd variable, declared with var cmd tea.Cmd at the top of Update, and add it to the batch you return:
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
var ok bool
if cmd, ok = m.focus.Update(msg, m.ui.Frame()); !ok {
cmd = m.key(msg)
}A click on the backdrop, outside the modal dialog, should close it. on.ClickOutside on the dialog's layer runs
for exactly that:
dialog := bloom.Layer(bloom.Box(dialogBox, content)).
ID(confirmDialog).
Modal().
On(on.ClickOutside(m.closeDialog)).
Backdrop(fx.Dim{Amount: 0.6}).
Presence(m.confirm, fx.Fade{}, fx.Slide{From: bloom.Bottom})Add a toast
A toast is one more layer. Add a color and a style for it next to the others:
teal = lipgloss.Color("#9ccfd8")
toastBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(teal).Padding(0, 1)The layer is anchored to the bottom-right corner with a one-cell margin. Anchor(x, y) takes where
the layer goes on each axis: bloom.Start, bloom.Center, or bloom.End.
toast := bloom.Layer(bloom.Box(toastBox, bloom.Text("Deleted "+m.deleted))).
Anchor(bloom.End, bloom.End).
Margin(1).
Presence(m.toast, fx.Slide{From: bloom.Right})
v := m.ui.View(m.width, m.height, bloom.Overlay(base, dialog, toast))Give it its own presence, toast anim.Presence, and a deleted string field for the message, with the presence
created with anim.NewPresence(), and add it to
anim.Update(msg, &m.confirm, &m.toast). ShowFor shows it and hides it again after a duration, with no timer
messages of your own:
func (m *model) press(id buttonID) tea.Cmd {
if id == "delete" {
m.deleted = m.notes[m.selected].title
m.notes = slices.Delete(m.notes, m.selected, m.selected+1)
m.selected = max(min(m.selected, len(m.notes)-1), 0)
return tea.Batch(m.closeDialog(), m.toast.ShowFor(2*time.Second))
}
return m.closeDialog()
}The complete program
package main
import (
"fmt"
"os"
"slices"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/lrstanley/bloom"
"github.com/lrstanley/bloom/anim"
"github.com/lrstanley/bloom/fx"
"github.com/lrstanley/bloom/on"
)
type note struct {
title, body string
}
// buttonID identifies a button in the dialog.
type buttonID string
// confirmDialog is the ID of the dialog's layer, which is also its focus scope.
const confirmDialog = "confirm"
var (
muted = lipgloss.Color("#6e6a86")
subtle = lipgloss.Color("#908caa")
rose = lipgloss.Color("#eb6f92")
gold = lipgloss.Color("#f6c177")
dark = lipgloss.Color("#191724")
surface = lipgloss.Color("#393552")
teal = lipgloss.Color("#9ccfd8")
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)
dialogBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(rose).Padding(1, 2)
button = lipgloss.NewStyle().Padding(0, 2).Background(surface)
buttonOn = button.Foreground(dark).Background(rose).Bold(true)
toastBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(teal).Padding(0, 1)
)
type model struct {
ui *bloom.Root
width, height int
notes []note
selected int
focus bloom.Focus[buttonID]
confirm anim.Presence
toast anim.Presence
deleted string
}
func newModel() *model {
return &model{
ui: bloom.New(),
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."},
},
confirm: anim.NewPresence(anim.Duration(180 * time.Millisecond)),
toast: anim.NewPresence(),
}
}
func (m *model) Init() tea.Cmd {
return m.ui.Init()
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
var ok bool
if cmd, ok = m.focus.Update(msg, m.ui.Frame()); !ok {
cmd = m.key(msg)
}
}
return m, tea.Batch(cmd, m.ui.Update(msg), anim.Update(msg, &m.confirm, &m.toast))
}
func (m *model) key(msg tea.KeyPressMsg) tea.Cmd {
if m.confirm.Shown() {
if msg.String() == "esc" {
return m.closeDialog()
}
return nil
}
switch msg.String() {
case "q":
return tea.Quit
case "up":
m.selected = max(m.selected-1, 0)
case "down":
m.selected = min(m.selected+1, len(m.notes)-1)
case "d":
return m.openDialog()
}
return nil
}
func (m *model) openDialog() tea.Cmd {
if len(m.notes) == 0 {
return nil
}
m.focus.Push(confirmDialog)
m.focus.Set("cancel")
return m.confirm.Show()
}
func (m *model) closeDialog() tea.Cmd {
if !m.confirm.Shown() {
return nil
}
m.focus.Pop()
return m.confirm.Hide()
}
func (m *model) press(id buttonID) tea.Cmd {
if id == "delete" {
m.deleted = m.notes[m.selected].title
m.notes = slices.Delete(m.notes, m.selected, m.selected+1)
m.selected = max(min(m.selected, len(m.notes)-1), 0)
return tea.Batch(m.closeDialog(), m.toast.ShowFor(2*time.Second))
}
return m.closeDialog()
}
func (m *model) button(id buttonID, label string) bloom.Child {
style := button
if m.focus.Is(id) {
style = buttonOn
}
return bloom.Text(label).Style(style).ID(id).Focusable().
On(on.Press(func() tea.Cmd { return m.press(id) }))
}
func (m *model) View() tea.View {
items := make([]bloom.Child, len(m.notes))
for i, n := range m.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
}))
}
sidebar := bloom.Box(panel, bloom.Column(items...))
body := bloom.Child(bloom.Text("Nothing here.").Style(prose))
if len(m.notes) > 0 {
current := m.notes[m.selected]
body = bloom.Column(
bloom.Text(current.title).Style(heading.Padding(0, 1)),
bloom.Text(current.body).Style(prose),
)
}
reader := bloom.Box(panel, body)
base := bloom.Column(
bloom.Fill(bloom.Row(
bloom.Fixed(24, sidebar).Collapsible(1),
bloom.Fill(reader).Min(30),
)),
bloom.Text(" ↑/↓ choose, d delete, q quit").Style(status).Truncate(),
)
title := ""
if len(m.notes) > 0 {
title = m.notes[m.selected].title
}
content := bloom.Column(
bloom.Text(fmt.Sprintf("Delete %q?", title)).Style(heading),
bloom.Text("This can't be undone."),
bloom.Fixed(1, nil),
bloom.Row(
bloom.Space(),
m.button("cancel", "Cancel"),
m.button("delete", "Delete"),
).Gap(2),
)
dialog := bloom.Layer(bloom.Box(dialogBox, content)).
ID(confirmDialog).
Modal().
On(on.ClickOutside(m.closeDialog)).
Backdrop(fx.Dim{Amount: 0.6}).
Presence(m.confirm, fx.Fade{}, fx.Slide{From: bloom.Bottom})
toast := bloom.Layer(bloom.Box(toastBox, bloom.Text("Deleted "+m.deleted))).
Anchor(bloom.End, bloom.End).
Margin(1).
Presence(m.toast, fx.Slide{From: bloom.Right})
v := m.ui.View(m.width, m.height, bloom.Overlay(base, dialog, toast))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}What you learned
OverlaydrawsLayers over a base layout, andAnchorandMarginplace them.Modalkeeps every mouse event from what's beneath a layer,on.ClickOutsideruns for a click on its backdrop, andBackdropapplies an effect to everything beneath it.- An
anim.Presenceanimates showing and hiding;Layer(...).Presence(p, effects...)uses it as a transition. Pass every message toanim.Updateso animations keep moving. bloom.Focustracks keyboard focus, andPushandPopconfine it to a dialog while it's open.ShowForshows something for a while, then hides it.
Next steps
Last updated on