Tutorial: Task List
This tutorial builds a real terminal app from scratch. By the end you will have a task list with:
- a scrollable list of tasks
- an "Add task" dialog with a text input
- delete with confirmation
- toast notifications
- a status bar showing key hints
Each step is self-contained and runnable. You can stop at any step and have a working program.
What you will build
┌─ Tasks ──────────────────────────────────────────────────────────┐
│ ▶ Buy groceries │
│ Write tutorial │
│ Ship v0.1.0 │
│ │
└──────────────────────────────────────────────────────────────────┘
n · New Del · Delete Esc · Quit
Prerequisites
- Go 1.21 or later
- A true-color terminal (iTerm2, Ghostty, Windows Terminal, etc.)
- oat-latte installed:
go get github.com/antoniocali/oat-latte
Create a new module for the tutorial:
mkdir tasklist && cd tasklist
go mod init tasklist
go get github.com/antoniocali/oat-latte
Step 1 — Hello, terminal
Create main.go and get a window on screen.
package main
import (
"log"
oat "github.com/antoniocali/oat-latte"
"github.com/antoniocali/oat-latte/latte"
"github.com/antoniocali/oat-latte/widget"
)
func main() {
body := widget.NewText("Hello, terminal!")
app := oat.NewCanvas(
oat.WithTheme(latte.ThemeDark),
oat.WithBody(body),
)
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
Run it:
go run .
You should see Hello, terminal! centred in a dark terminal. Press Esc to quit.
NewCanvas owns the tcell screen and event loop. WithBody sets the component that fills the middle of the screen. WithTheme propagates a colour scheme to every component in the tree.
Step 2 — A list of tasks
Replace the Text with a List. A List is focusable and handles keyboard navigation automatically.
package main
import (
"log"
oat "github.com/antoniocali/oat-latte"
"github.com/antoniocali/oat-latte/latte"
"github.com/antoniocali/oat-latte/layout"
"github.com/antoniocali/oat-latte/widget"
)
func main() {
items := []widget.ListItem{
{Label: "Buy groceries"},
{Label: "Write tutorial"},
{Label: "Ship v0.1.0"},
}
list := widget.NewList(items)
body := layout.NewBorder(list).WithTitle("Tasks")
app := oat.NewCanvas(
oat.WithTheme(latte.ThemeDark),
oat.WithBody(body),
oat.WithPrimary(list),
)
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
Run it. Use ↑ ↓ to move the cursor, Esc to quit.
layout.NewBorder wraps any component in a titled box. WithPrimary(list) tells the canvas to give focus to the list on startup rather than picking the first focusable it finds in the tree.
Step 3 — Status bar with key hints
Add a StatusBar in the footer. It auto-populates from the focused component's KeyBindings().
package main
import (
"log"
oat "github.com/antoniocali/oat-latte"
"github.com/antoniocali/oat-latte/latte"
"github.com/antoniocali/oat-latte/layout"
"github.com/antoniocali/oat-latte/widget"
)
func main() {
items := []widget.ListItem{
{Label: "Buy groceries"},
{Label: "Write tutorial"},
{Label: "Ship v0.1.0"},
}
list := widget.NewList(items)
body := layout.NewBorder(list).WithTitle("Tasks")
statusBar := widget.NewStatusBar()
app := oat.NewCanvas(
oat.WithTheme(latte.ThemeDark),
oat.WithBody(body),
oat.WithAutoStatusBar(statusBar),
oat.WithPrimary(list),
)
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
The footer now shows the list's built-in key hints (↑↓ to move, Enter to select, Del to delete).
Step 4 — Add tasks with a dialog
When the user presses n, show a dialog with a text input.
This is the first time you need application-level state, so introduce an App struct.
package main
import (
"log"
oat "github.com/antoniocali/oat-latte"
"github.com/antoniocali/oat-latte/latte"
"github.com/antoniocali/oat-latte/layout"
"github.com/antoniocali/oat-latte/widget"
"github.com/gdamore/tcell/v2"
)
// App holds all shared state.
type App struct {
canvas *oat.Canvas
list *widget.List
items []widget.ListItem
}
// showNewDialog opens a modal to add a task.
func (a *App) showNewDialog() {
input := widget.NewEditText().
WithHint("Task name").
WithPlaceholder("What needs doing?")
cancelBtn := widget.NewButton("Cancel", func() {
a.canvas.HideDialog()
})
doAdd := func() {
name := input.GetText()
if name == "" {
return
}
a.items = append(a.items, widget.ListItem{Label: name})
a.list.SetItems(a.items)
a.canvas.HideDialog()
}
// Wire ^S on the input to trigger Add as well.
input.WithOnSave(func(_ string) { doAdd() })
createBtn := widget.NewButton("Add", doAdd)
btnRow := layout.NewHBox()
btnRow.AddChild(layout.NewHFill())
btnRow.AddChild(cancelBtn)
btnRow.AddChild(layout.NewHFill().WithMaxSize(2))
btnRow.AddChild(createBtn)
body := layout.NewPaddingUniform(
layout.NewVBox(
widget.NewText("Enter a name for the new task."),
layout.NewVFill().WithMaxSize(1),
input,
layout.NewVFill().WithMaxSize(1),
btnRow,
), 1)
dlg := widget.NewDialog("New Task").
WithChild(body).
WithMaxSize(50, 11)
a.canvas.ShowDialog(dlg)
}
// listProxy wraps the list to intercept the 'n' key.
type listProxy struct {
*widget.List
app *App
}
func (p *listProxy) HandleKey(ev *oat.KeyEvent) bool {
if ev.Key() == tcell.KeyRune && ev.Rune() == 'n' {
p.app.showNewDialog()
return true
}
return p.List.HandleKey(ev)
}
func (p *listProxy) KeyBindings() []oat.KeyBinding {
extra := []oat.KeyBinding{
{Key: tcell.KeyRune, Rune: 'n', Label: "n", Description: "New task"},
}
return append(extra, p.List.KeyBindings()...)
}
func main() {
a := &App{}
a.items = []widget.ListItem{
{Label: "Buy groceries"},
{Label: "Write tutorial"},
{Label: "Ship v0.1.0"},
}
a.list = widget.NewList(a.items)
proxy := &listProxy{List: a.list, app: a}
body := layout.NewBorder(proxy).WithTitle("Tasks")
statusBar := widget.NewStatusBar()
a.canvas = oat.NewCanvas(
oat.WithTheme(latte.ThemeDark),
oat.WithBody(body),
oat.WithAutoStatusBar(statusBar),
oat.WithPrimary(proxy),
)
if err := a.canvas.Run(); err != nil {
log.Fatal(err)
}
}
Press n to open the dialog, type a name, press Enter on the Add button (or Tab to reach it), and the list updates.
oat-latte uses a proxy pattern to add key handling to existing widgets without modifying them. Wrap the widget, override HandleKey for the keys you want to intercept, and delegate everything else back to the wrapped widget. KeyBindings() exposes the extra hints to the status bar.
See Focus — The proxy pattern for the full explanation.
Step 5 — Delete with confirmation
Wire the list's built-in OnDelete callback to show a confirm dialog before removing the item.
Add a showConfirmDialog method to App and hook WithOnDelete on the list:
// showConfirmDialog shows a yes/no dialog. onConfirm is called if the user
// chooses Yes.
func (a *App) showConfirmDialog(msg string, onConfirm func()) {
noBtn := widget.NewButton("No", func() {
a.canvas.HideDialog()
})
yesBtn := widget.NewButton("Yes", func() {
onConfirm()
a.canvas.HideDialog()
})
btnRow := layout.NewHBox()
btnRow.AddChild(layout.NewHFill())
btnRow.AddChild(noBtn)
btnRow.AddChild(layout.NewHFill().WithMaxSize(2))
btnRow.AddChild(yesBtn)
body := layout.NewPaddingUniform(
layout.NewVBox(
widget.NewText(msg),
layout.NewVFill().WithMaxSize(1),
btnRow,
), 1)
dlg := widget.NewDialog("Confirm").
WithChild(body).
WithMaxSize(48, 9)
a.canvas.ShowDialog(dlg)
}
Then, when building the list, attach the callback:
a.list = widget.NewList(a.items).
WithOnDelete(func(idx int, item widget.ListItem) {
a.showConfirmDialog(
"Delete \""+item.Label+"\"?",
func() {
a.items = append(a.items[:idx], a.items[idx+1:]...)
a.list.SetItems(a.items)
},
)
})
Press Del on a task — a confirmation box appears. No dismisses it; Yes removes the item.
Step 6 — Toast notifications
Add a NotificationManager so the app can show transient success/error toasts.
// Add to App struct:
notifs *widget.NotificationManager
Wire it up in main after creating the canvas:
a.notifs = widget.NewNotificationManager()
a.notifs.SetNotifyChannel(a.canvas.NotifyChannel())
a.canvas.ShowPersistentOverlay(a.notifs) // mount permanently — never dismissed by Esc
Now push a notification after any state change. In showNewDialog's Add callback:
a.items = append(a.items, widget.ListItem{Label: name})
a.list.SetItems(a.items)
a.canvas.HideDialog()
a.notifs.Push("Task added", widget.NotificationKindSuccess, 2*time.Second)
And in the confirm delete callback:
a.items = append(a.items[:idx], a.items[idx+1:]...)
a.list.SetItems(a.items)
a.notifs.Push("Task deleted", widget.NotificationKindSuccess, 2*time.Second)
Use ShowPersistentOverlay for NotificationManager. Unlike ShowDialog, it is never dismissed by Esc and always renders on top of modal dialogs without blocking input.
Complete program
Here is the finished main.go with all steps assembled:
package main
import (
"log"
"time"
oat "github.com/antoniocali/oat-latte"
"github.com/antoniocali/oat-latte/latte"
"github.com/antoniocali/oat-latte/layout"
"github.com/antoniocali/oat-latte/widget"
"github.com/gdamore/tcell/v2"
)
type App struct {
canvas *oat.Canvas
list *widget.List
notifs *widget.NotificationManager
items []widget.ListItem
}
func (a *App) showConfirmDialog(msg string, onConfirm func()) {
noBtn := widget.NewButton("No", func() {
a.canvas.HideDialog()
})
yesBtn := widget.NewButton("Yes", func() {
onConfirm()
a.canvas.HideDialog()
})
btnRow := layout.NewHBox()
btnRow.AddChild(layout.NewHFill())
btnRow.AddChild(noBtn)
btnRow.AddChild(layout.NewHFill().WithMaxSize(2))
btnRow.AddChild(yesBtn)
body := layout.NewPaddingUniform(
layout.NewVBox(
widget.NewText(msg),
layout.NewVFill().WithMaxSize(1),
btnRow,
), 1)
a.canvas.ShowDialog(
widget.NewDialog("Confirm").
WithChild(body).
WithMaxSize(48, 9),
)
}
func (a *App) showNewDialog() {
input := widget.NewEditText().
WithHint("Task name").
WithPlaceholder("What needs doing?")
doAdd := func() {
name := input.GetText()
if name == "" {
return
}
a.items = append(a.items, widget.ListItem{Label: name})
a.list.SetItems(a.items)
a.canvas.HideDialog()
a.notifs.Push("Task added", widget.NotificationKindSuccess, 2*time.Second)
}
input.WithOnSave(func(_ string) { doAdd() })
cancelBtn := widget.NewButton("Cancel", func() { a.canvas.HideDialog() })
addBtn := widget.NewButton("Add", doAdd)
btnRow := layout.NewHBox()
btnRow.AddChild(layout.NewHFill())
btnRow.AddChild(cancelBtn)
btnRow.AddChild(layout.NewHFill().WithMaxSize(2))
btnRow.AddChild(addBtn)
body := layout.NewPaddingUniform(
layout.NewVBox(
widget.NewText("Enter a name for the new task."),
layout.NewVFill().WithMaxSize(1),
input,
layout.NewVFill().WithMaxSize(1),
btnRow,
), 1)
a.canvas.ShowDialog(
widget.NewDialog("New Task").
WithChild(body).
WithMaxSize(50, 11),
)
}
// listProxy intercepts 'n' to open the new-task dialog.
type listProxy struct {
*widget.List
app *App
}
func (p *listProxy) HandleKey(ev *oat.KeyEvent) bool {
if ev.Key() == tcell.KeyRune && ev.Rune() == 'n' {
p.app.showNewDialog()
return true
}
return p.List.HandleKey(ev)
}
func (p *listProxy) KeyBindings() []oat.KeyBinding {
return append(
[]oat.KeyBinding{
{Key: tcell.KeyRune, Rune: 'n', Label: "n", Description: "New task"},
},
p.List.KeyBindings()...,
)
}
func main() {
a := &App{
items: []widget.ListItem{
{Label: "Buy groceries"},
{Label: "Write tutorial"},
{Label: "Ship v0.1.0"},
},
}
a.list = widget.NewList(a.items).
WithOnDelete(func(idx int, item widget.ListItem) {
a.showConfirmDialog(
"Delete \""+item.Label+"\"?",
func() {
a.items = append(a.items[:idx], a.items[idx+1:]...)
a.list.SetItems(a.items)
a.notifs.Push("Task deleted", widget.NotificationKindSuccess, 2*time.Second)
},
)
})
proxy := &listProxy{List: a.list, app: a}
body := layout.NewBorder(proxy).WithTitle("Tasks")
statusBar := widget.NewStatusBar()
a.canvas = oat.NewCanvas(
oat.WithTheme(latte.ThemeDark),
oat.WithBody(body),
oat.WithAutoStatusBar(statusBar),
oat.WithPrimary(proxy),
)
a.notifs = widget.NewNotificationManager()
a.notifs.SetNotifyChannel(a.canvas.NotifyChannel())
a.canvas.ShowPersistentOverlay(a.notifs)
if err := a.canvas.Run(); err != nil {
log.Fatal(err)
}
}
What's next
You now know how oat-latte's core pieces fit together. From here:
- Read Core Concepts to understand the Measure/Render pipeline in depth.
- Read Layout for
Grid,Stack,Padding, and flex sizing. - Read Focus for the proxy pattern, custom
KeyBindings, andFocusByRef. - Read Custom Components to build your own widgets.