bubbletea-docs
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseBubble Tea TUI Framework Skill
Bubble Tea TUI框架技能
Overview
概述
Bubble Tea is a powerful TUI framework for Go based on The Elm Architecture. Every program has a Model (state) and three methods: (initial command), (handle messages), (render UI as string).
Init()Update()View()This skill covers the complete Charm ecosystem:
- Bubble Tea - Core TUI framework
- Bubbles - Reusable UI components
- Lip Gloss - Terminal styling
- Huh - Interactive forms
Bubble Tea 是一款基于The Elm Architecture的强大Go语言TUI框架。每个程序都包含一个Model(状态)以及三个方法:(初始化命令)、(处理消息)、(将UI渲染为字符串)。
Init()Update()View()本技能涵盖完整的Charm生态系统:
- Bubble Tea - 核心TUI框架
- Bubbles - 可复用UI组件
- Lip Gloss - 终端样式工具
- Huh - 交互式表单工具
Installation
安装
bash
go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/bubbles # UI components
go get github.com/charmbracelet/lipgloss # Styling
go get github.com/charmbracelet/huh # Formsbash
go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/bubbles # UI组件
go get github.com/charmbracelet/lipgloss # 样式工具
go get github.com/charmbracelet/huh # 表单工具Quick Start
快速开始
go
package main
import (
"fmt"
"log"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
cursor int
choices []string
selected map[int]struct{}
}
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 "up", "k":
if m.cursor > 0 { m.cursor-- }
case "down", "j":
if m.cursor < len(m.choices)-1 { m.cursor++ }
case "enter", " ":
if _, ok := m.selected[m.cursor]; ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
return m, nil
}
func (m model) View() string {
s := "Choose:\n\n"
for i, choice := range m.choices {
cursor, checked := " ", " "
if m.cursor == i { cursor = ">" }
if _, ok := m.selected[i]; ok { checked = "x" }
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
return s + "\nq to quit.\n"
}
func main() {
p := tea.NewProgram(model{
choices: []string{"Option A", "Option B", "Option C"},
selected: make(map[int]struct{}),
})
if _, err := p.Run(); err != nil { log.Fatal(err) }
}go
package main
import (
"fmt"
"log"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
cursor int
choices []string
selected map[int]struct{}
}
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 "up", "k":
if m.cursor > 0 { m.cursor-- }
case "down", "j":
if m.cursor < len(m.choices)-1 { m.cursor++ }
case "enter", " ":
if _, ok := m.selected[m.cursor]; ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
return m, nil
}
func (m model) View() string {
s := "Choose:\n\n"
for i, choice := range m.choices {
cursor, checked := " ", " "
if m.cursor == i { cursor = ">" }
if _, ok := m.selected[i]; ok { checked = "x" }
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
return s + "\nq to quit.\n"
}
func main() {
p := tea.NewProgram(model{
choices: []string{"Option A", "Option B", "Option C"},
selected: make(map[int]struct{}),
})
if _, err := p.Run(); err != nil { log.Fatal(err) }
}Core Concepts
核心概念
Messages and Commands
消息与命令
go
// Custom messages
type tickMsg time.Time
type dataMsg struct{ data string }
type errMsg struct{ error }
// Command returns a message (runs async)
func fetchData() tea.Msg {
resp, err := http.Get("https://api.example.com")
if err != nil { return errMsg{err} }
defer resp.Body.Close()
// ... process response
return dataMsg{data: "result"}
}
// Handle in Update
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case dataMsg:
m.data = msg.data
case errMsg:
m.err = msg.error
}
return m, nil
}
// Start command in Init
func (m model) Init() tea.Cmd {
return fetchData // Runs async, sends message when done
}go
// 自定义消息
type tickMsg time.Time
type dataMsg struct{ data string }
type errMsg struct{ error }
// 命令返回消息(异步执行)
func fetchData() tea.Msg {
resp, err := http.Get("https://api.example.com")
if err != nil { return errMsg{err} }
defer resp.Body.Close()
// ... 处理响应
return dataMsg{data: "result"}
}
// 在Update中处理消息
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case dataMsg:
m.data = msg.data
case errMsg:
m.err = msg.error
}
return m, nil
}
// 在Init中启动命令
func (m model) Init() tea.Cmd {
return fetchData // 异步执行,完成后发送消息
}Batch and Sequence Commands
批量与序列命令
go
// Parallel execution
return tea.Batch(cmd1, cmd2, cmd3)
// Sequential execution
return tea.Sequence(step1, step2, tea.Quit)go
// 并行执行
return tea.Batch(cmd1, cmd2, cmd3)
// 顺序执行
return tea.Sequence(step1, step2, tea.Quit)Window Size Handling
窗口尺寸处理
go
type model struct {
width, height int
viewport viewport.Model
ready bool
}
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
if !m.ready {
m.viewport = viewport.New(msg.Width, msg.Height-4)
m.viewport.SetContent(m.content)
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - 4
}
}
return m, nil
}go
type model struct {
width, height int
viewport viewport.Model
ready bool
}
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
if !m.ready {
m.viewport = viewport.New(msg.Width, msg.Height-4)
m.viewport.SetContent(m.content)
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - 4
}
}
return m, nil
}Common Patterns
常见模式
Program Options
程序选项
go
// Fullscreen
p := tea.NewProgram(m, tea.WithAltScreen())
// Mouse support
p := tea.NewProgram(m, tea.WithMouseCellMotion())
// Combined
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())go
// 全屏模式
p := tea.NewProgram(m, tea.WithAltScreen())
// 鼠标支持
p := tea.NewProgram(m, tea.WithMouseCellMotion())
// 组合选项
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())Key Bindings with bubbles/key
使用bubbles/key处理按键绑定
go
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Quit key.Binding
}
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
}
// In Update
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Up):
m.cursor--
case key.Matches(msg, keys.Quit):
return m, tea.Quit
}go
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Quit key.Binding
}
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
}
// 在Update中处理
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Up):
m.cursor--
case key.Matches(msg, keys.Quit):
return m, tea.Quit
}Multiple Views
多视图切换
go
type model struct {
state int // 0=menu, 1=detail
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.state {
case 0: return m.updateMenu(msg)
case 1: return m.updateDetail(msg)
}
return m, nil
}
func (m model) View() string {
switch m.state {
case 0: return m.menuView()
case 1: return m.detailView()
}
return ""
}go
type model struct {
state int // 0=菜单页, 1=详情页
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.state {
case 0: return m.updateMenu(msg)
case 1: return m.updateDetail(msg)
}
return m, nil
}
func (m model) View() string {
switch m.state {
case 0: return m.menuView()
case 1: return m.detailView()
}
return ""
}Bubbles Components
Bubbles组件
Component Quick Reference
组件速查
| Component | Import | Init | Key Method |
|---|---|---|---|
| Spinner | | | |
| TextInput | | | |
| TextArea | | | |
| List | | | |
| Table | | | |
| Viewport | | | |
| Progress | | | |
| Help | | | |
| FilePicker | | | |
| Timer | | | |
| Stopwatch | | | |
| 组件 | 导入路径 | 初始化方法 | 核心方法 |
|---|---|---|---|
| Spinner | | | 在Init中返回 |
| TextInput | | | |
| TextArea | | | |
| List | | | |
| Table | | | |
| Viewport | | | |
| Progress | | | |
| Help | | | |
| FilePicker | | | |
| Timer | | | |
| Stopwatch | | | |
Focus Management (Multiple Inputs)
焦点管理(多输入框)
go
type model struct {
inputs []textinput.Model
focusIndex int
}
func (m *model) nextInput() tea.Cmd {
m.inputs[m.focusIndex].Blur()
m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
return m.inputs[m.focusIndex].Focus()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "tab":
return m, m.nextInput()
}
}
// Update all inputs
cmds := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
}
return m, tea.Batch(cmds...)
}go
type model struct {
inputs []textinput.Model
focusIndex int
}
func (m *model) nextInput() tea.Cmd {
m.inputs[m.focusIndex].Blur()
m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
return m.inputs[m.focusIndex].Focus()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "tab":
return m, m.nextInput()
}
}
// 更新所有输入框
cmds := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
}
return m, tea.Batch(cmds...)
}Component Composition
组件组合
go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
// Update all components and collect commands
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
m.textInput, cmd = m.textInput.Update(msg)
cmds = append(cmds, cmd)
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
// 更新所有组件并收集命令
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
m.textInput, cmd = m.textInput.Update(msg)
cmds = append(cmds, cmd)
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}Spinner Example
Spinner示例
go
s := spinner.New()
s.Spinner = spinner.Dot // Line, Dot, MiniDot, Jump, Pulse, Points, Globe, Moon, Monkey
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
// In Init(): return m.spinner.Tick
// In Update(): handle spinner.TickMsggo
s := spinner.New()
s.Spinner = spinner.Dot // Line, Dot, MiniDot, Jump, Pulse, Points, Globe, Moon, Monkey
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
// 在Init()中返回m.spinner.Tick
// 在Update()中处理spinner.TickMsgList with Custom Items
自定义列表项
go
type item struct{ title, desc string }
func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title } // Required for filtering
items := []list.Item{item{"One", "Description"}}
l := list.New(items, list.NewDefaultDelegate(), 30, 10)
l.Title = "Select Item"go
type item struct{ title, desc string }
func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title } // 过滤功能必填
items := []list.Item{item{"One", "Description"}}
l := list.New(items, list.NewDefaultDelegate(), 30, 10)
l.Title = "Select Item"Table
表格组件
go
columns := []table.Column{
{Title: "ID", Width: 10},
{Title: "Name", Width: 20},
}
rows := []table.Row{
{"1", "Alice"},
{"2", "Bob"},
}
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(7),
)go
columns := []table.Column{
{Title: "ID", Width: 10},
{Title: "Name", Width: 20},
}
rows := []table.Row{
{"1", "Alice"},
{"2", "Bob"},
}
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(7),
)Lip Gloss Styling
Lip Gloss样式
Basic Styling
基础样式
go
import "github.com/charmbracelet/lipgloss"
var style = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205")).
Background(lipgloss.Color("#7D56F4")).
Border(lipgloss.RoundedBorder()).
Padding(1, 2)
output := style.Render("Hello, World!")go
import "github.com/charmbracelet/lipgloss"
var style = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205")).
Background(lipgloss.Color("#7D56F4")).
Border(lipgloss.RoundedBorder()).
Padding(1, 2)
output := style.Render("Hello, World!")Colors
颜色设置
go
// Hex colors
lipgloss.Color("#FF00FF")
// ANSI 256 colors
lipgloss.Color("205")
// Adaptive colors (auto light/dark background)
lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}
// Complete color profiles
lipgloss.CompleteColor{
TrueColor: "#FF00FF",
ANSI256: "205",
ANSI: "5",
}go
// 十六进制颜色
lipgloss.Color("#FF00FF")
// ANSI 256色
lipgloss.Color("205")
// 自适应颜色(自动适配明暗背景)
lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}
// 完整颜色配置
lipgloss.CompleteColor{
TrueColor: "#FF00FF",
ANSI256: "205",
ANSI: "5",
}Layout Composition
布局组合
go
// Horizontal layout
left := lipgloss.NewStyle().Width(20).Render("Left")
right := lipgloss.NewStyle().Width(20).Render("Right")
row := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
// Vertical layout
header := "Header"
body := "Body content"
footer := "Footer"
page := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
// Center content in box
centered := lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, content)go
// 水平布局
left := lipgloss.NewStyle().Width(20).Render("Left")
right := lipgloss.NewStyle().Width(20).Render("Right")
row := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
// 垂直布局
header := "Header"
body := "Body content"
footer := "Footer"
page := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
// 内容居中显示
centered := lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, content)Frame Size for Calculations
布局尺寸计算
go
// Account for padding/border/margin in calculations
h, v := docStyle.GetFrameSize()
m.list.SetSize(m.width-h, m.height-v)
// Dynamic content height
headerH := lipgloss.Height(m.header())
footerH := lipgloss.Height(m.footer())
m.viewport.Height = m.height - headerH - footerHgo
// 计算时考虑内边距/边框/外边距
h, v := docStyle.GetFrameSize()
m.list.SetSize(m.width-h, m.height-v)
// 动态内容高度
headerH := lipgloss.Height(m.header())
footerH := lipgloss.Height(m.footer())
m.viewport.Height = m.height - headerH - footerHBorder Styles
边框样式
go
lipgloss.NormalBorder() // Standard box
lipgloss.RoundedBorder() // Rounded corners
lipgloss.ThickBorder() // Thick lines
lipgloss.DoubleBorder() // Double lines
lipgloss.HiddenBorder() // Invisible (for spacing)go
lipgloss.NormalBorder() // 标准边框
lipgloss.RoundedBorder() // 圆角边框
lipgloss.ThickBorder() // 粗线边框
lipgloss.DoubleBorder() // 双线边框
lipgloss.HiddenBorder() // 隐藏边框(仅用于间距)Huh Forms
Huh表单
Quick Start
快速开始
go
import "github.com/charmbracelet/huh"
var name string
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("What's your name?").
Value(&name),
huh.NewConfirm().
Title("Ready to proceed?").
Value(&confirmed),
),
)
err := form.Run()
if err != nil {
if err == huh.ErrUserAborted {
fmt.Println("Cancelled")
return
}
log.Fatal(err)
}go
import "github.com/charmbracelet/huh"
var name string
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("What's your name?").
Value(&name),
huh.NewConfirm().
Title("Ready to proceed?").
Value(&confirmed),
),
)
err := form.Run()
if err != nil {
if err == huh.ErrUserAborted {
fmt.Println("Cancelled")
return
}
log.Fatal(err)
}Field Types (GENERIC TYPES REQUIRED)
字段类型(需指定泛型)
go
// Input - single line text
huh.NewInput().Title("Name").Value(&name)
// Text - multi-line
huh.NewText().Title("Bio").Lines(5).Value(&bio)
// Select - MUST specify type
huh.NewSelect[string]().
Title("Choose").
Options(
huh.NewOption("Option A", "a"),
huh.NewOption("Option B", "b"),
).
Value(&choice)
// MultiSelect - MUST specify type
huh.NewMultiSelect[string]().
Title("Choose many").
Options(huh.NewOptions("A", "B", "C")...).
Limit(2).
Value(&choices)
// Confirm
huh.NewConfirm().
Title("Sure?").
Affirmative("Yes").
Negative("No").
Value(&confirmed)
// FilePicker
huh.NewFilePicker().
Title("Select file").
AllowedTypes([]string{".go", ".md"}).
Value(&filepath)go
// Input - 单行文本输入
huh.NewInput().Title("Name").Value(&name)
// Text - 多行文本输入
huh.NewText().Title("Bio").Lines(5).Value(&bio)
// Select - 必须指定类型
huh.NewSelect[string]().
Title("Choose").
Options(
huh.NewOption("Option A", "a"),
huh.NewOption("Option B", "b"),
).
Value(&choice)
// MultiSelect - 必须指定类型
huh.NewMultiSelect[string]().
Title("Choose many").
Options(huh.NewOptions("A", "B", "C")...).
Limit(2).
Value(&choices)
// Confirm - 确认框
huh.NewConfirm().
Title("Sure?").
Affirmative("Yes").
Negative("No").
Value(&confirmed)
// FilePicker - 文件选择器
huh.NewFilePicker().
Title("Select file").
AllowedTypes([]string{".go", ".md"}).
Value(&filepath)Validation
验证规则
go
huh.NewInput().
Title("Email").
Value(&email).
Validate(func(s string) error {
if !strings.Contains(s, "@") {
return errors.New("invalid email")
}
return nil
})go
huh.NewInput().
Title("Email").
Value(&email).
Validate(func(s string) error {
if !strings.Contains(s, "@") {
return errors.New("invalid email")
}
return nil
})Dynamic Forms (OptionsFunc/TitleFunc)
动态表单(OptionsFunc/TitleFunc)
go
var country string
var state string
huh.NewSelect[string]().
Value(&state).
TitleFunc(func() string {
if country == "Canada" { return "Province" }
return "State"
}, &country). // Recompute when country changes
OptionsFunc(func() []huh.Option[string] {
return huh.NewOptions(getStatesFor(country)...)
}, &country)go
var country string
var state string
huh.NewSelect[string]().
Value(&state).
TitleFunc(func() string {
if country == "Canada" { return "Province" }
return "State"
}, &country). // 当country变化时重新计算
OptionsFunc(func() []huh.Option[string] {
return huh.NewOptions(getStatesFor(country)...)
}, &country)Bubble Tea Integration
与Bubble Tea集成
go
type Model struct {
form *huh.Form
}
func (m Model) Init() tea.Cmd {
return m.form.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
if m.form.State == huh.StateCompleted {
// Form completed - access values
name := m.form.GetString("name")
return m, tea.Quit
}
return m, cmd
}
func (m Model) View() string {
if m.form.State == huh.StateCompleted {
return "Done!"
}
return m.form.View()
}go
type Model struct {
form *huh.Form
}
func (m Model) Init() tea.Cmd {
return m.form.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
if m.form.State == huh.StateCompleted {
// 表单完成 - 获取值
name := m.form.GetString("name")
return m, tea.Quit
}
return m, cmd
}
func (m Model) View() string {
if m.form.State == huh.StateCompleted {
return "Done!"
}
return m.form.View()
}Themes
主题设置
go
form.WithTheme(huh.ThemeCharm()) // Default pink/purple
form.WithTheme(huh.ThemeDracula()) // Dark purple/pink
form.WithTheme(huh.ThemeCatppuccin()) // Pastel
form.WithTheme(huh.ThemeBase16()) // Muted
form.WithTheme(huh.ThemeBase()) // Minimalgo
form.WithTheme(huh.ThemeCharm()) // 默认粉紫色主题
form.WithTheme(huh.ThemeDracula()) // 深紫粉色主题
form.WithTheme(huh.ThemeCatppuccin()) // 马卡龙主题
form.WithTheme(huh.ThemeBase16()) // 低饱和度主题
form.WithTheme(huh.ThemeBase()) // 极简主题Spinner for Loading
加载动画
go
import "github.com/charmbracelet/huh/spinner"
err := spinner.New().
Title("Processing...").
Action(func() {
time.Sleep(2 * time.Second)
processData()
}).
Run()go
import "github.com/charmbracelet/huh/spinner"
err := spinner.New().
Title("Processing...").
Action(func() {
time.Sleep(2 * time.Second)
processData()
}).
Run()Common Gotchas
常见陷阱
Bubble Tea Core
Bubble Tea核心
-
Blocking in Update/View: Never block. Use commands for I/O:go
// BAD time.Sleep(time.Second) // Blocks event loop! // GOOD return m, func() tea.Msg { time.Sleep(time.Second) return doneMsg{} } -
Goroutines modifying model: Race condition! Use commands instead:go
// BAD go func() { m.data = fetch() }() // Race! // GOOD return m, func() tea.Msg { return dataMsg{fetch()} } -
Viewport before WindowSizeMsg: Initialize after receiving dimensions:go
if !m.ready { m.viewport = viewport.New(msg.Width, msg.Height) m.ready = true } -
Using receiver methods incorrectly: Only usefor internal helpers. Model interface methods must use value receivers
func (m *model).func (m model) -
Startup commands via Init: Don't usein Init. Use
tea.EnterAltScreenoption instead.tea.WithAltScreen() -
Messages not in order:results arrive in ANY order. Use
tea.Batchwhen order matters.tea.Sequence
-
Update/View中阻塞操作:绝对不要阻塞事件循环,使用命令处理I/O:go
// 错误示例 time.Sleep(time.Second) // 阻塞事件循环! // 正确示例 return m, func() tea.Msg { time.Sleep(time.Second) return doneMsg{} } -
协程直接修改Model:会导致竞态条件!改用命令:go
// 错误示例 go func() { m.data = fetch() }() // 竞态风险! // 正确示例 return m, func() tea.Msg { return dataMsg{fetch()} } -
在WindowSizeMsg前初始化Viewport:需在获取尺寸后初始化:go
if !m.ready { m.viewport = viewport.New(msg.Width, msg.Height) m.ready = true } -
接收器方法使用错误:仅内部辅助方法使用,Model接口方法必须使用值接收器
func (m *model)。func (m model) -
在Init中使用tea.EnterAltScreen:不要在Init中调用,改用选项。
tea.WithAltScreen() -
消息顺序不可靠:返回的消息顺序随机,顺序敏感时使用
tea.Batch。tea.Sequence
Bubbles Components
Bubbles组件
-
Spinner not animating: Must returnfrom
m.spinner.Tickand handleInit()in Update.spinner.TickMsg -
TextInput not accepting input: Must callbefore input accepts keystrokes.
.Focus() -
Component updates not reflected: Always reassign after Update:go
m.spinner, cmd = m.spinner.Update(msg) // Must reassign! -
Multiple components losing commands: Useto combine all commands.
tea.Batch(cmds...) -
List items not filtering: Must implementon items.
FilterValue() string -
Table not responding: Must setor call
table.WithFocused(true).t.Focus()
-
Spinner不动画:必须在中返回
Init()并在Update中处理m.spinner.Tick。spinner.TickMsg -
TextInput无法输入:必须先调用才能接收按键输入。
.Focus() -
组件更新不生效:更新后必须重新赋值:go
m.spinner, cmd = m.spinner.Update(msg) // 必须重新赋值! -
多组件命令丢失:使用合并所有命令。
tea.Batch(cmds...) -
列表项无法过滤:必须在列表项上实现方法。
FilterValue() string -
Table无响应:必须设置或调用
table.WithFocused(true)。t.Focus()
Lip Gloss
Lip Gloss
-
Style method has no effect: Styles are immutable, must reassign:go
// BAD style.Bold(true) // Result discarded! // GOOD style = style.Bold(true) -
Alignment not working: Requires explicit Width:go
style := lipgloss.NewStyle().Width(40).Align(lipgloss.Center) -
Width calculation wrong: Usenot
lipgloss.Width()for unicode.len() -
Layout arithmetic errors: Useto account for padding/border/margin.
style.GetFrameSize()
-
样式方法无效果:样式是不可变的,必须重新赋值:go
// 错误示例 style.Bold(true) // 结果被丢弃! // 正确示例 style = style.Bold(true) -
对齐不生效:必须显式设置宽度:go
style := lipgloss.NewStyle().Width(40).Align(lipgloss.Center) -
宽度计算错误:对Unicode文本使用而非
lipgloss.Width()。len() -
布局计算错误:使用考虑内边距/边框/外边距。
style.GetFrameSize()
Huh Forms
Huh表单
-
Generic types required:and
SelectMUST have type parameter:MultiSelectgo// BAD - won't compile huh.NewSelect() // GOOD huh.NewSelect[string]() -
Value takes pointer: Always pass pointer:not
.Value(&myVar)..Value(myVar) -
OptionsFunc not updating: Must pass binding variable:go
.OptionsFunc(fn, &country) // Recomputes when country changes -
Don't use Placeholder() in huh forms: Causes rendering bugs. Put examples in Description instead:go
// BAD huh.NewInput().Placeholder("example@email.com") // GOOD huh.NewInput().Description("e.g. example@email.com") -
Loop variable closure capture: Capture explicitly in loops:go
for _, name := range items { currentName := name // Capture! huh.NewInput().Value(&configs[currentName].Field) } -
Don't intercept Enter before form: Let huh handle Enter for navigation.
-
必须指定泛型:和
Select必须指定类型参数:MultiSelectgo// 错误示例 - 无法编译 huh.NewSelect() // 正确示例 huh.NewSelect[string]() -
Value需传入指针:始终传入指针:而非
.Value(&myVar)。.Value(myVar) -
OptionsFunc不更新:必须传入绑定变量:go
.OptionsFunc(fn, &country) // 当country变化时重新计算 -
不要在Huh表单中使用Placeholder():会导致渲染错误,改用Description添加示例:go
// 错误示例 huh.NewInput().Placeholder("example@email.com") // 正确示例 huh.NewInput().Description("e.g. example@email.com") -
循环变量闭包捕获:在循环中显式捕获变量:go
for _, name := range items { currentName := name // 显式捕获! huh.NewInput().Value(&configs[currentName].Field) } -
不要在表单前拦截Enter键:让Huh处理Enter键用于导航。
Debugging
调试
go
// Log to file (stdout is the TUI)
if os.Getenv("DEBUG") != "" {
f, _ := tea.LogToFile("debug.log", "app")
defer f.Close()
}
log.Println("Debug message")go
// 日志写入文件(标准输出用于TUI)
if os.Getenv("DEBUG") != "" {
f, _ := tea.LogToFile("debug.log", "app")
defer f.Close()
}
log.Println("Debug message")Best Practices
最佳实践
- Keep Update/View fast - The event loop blocks on these
- Use tea.Cmd for all I/O - HTTP, file, database operations
- Use tea.Batch for parallel - Multiple independent commands
- Use tea.Sequence for ordered - Commands that must run in order
- Store window dimensions - Handle tea.WindowSizeMsg, update components
- Initialize viewport after WindowSizeMsg - Dimensions aren't available at start
- Use value receivers - not
func (m model) Updatefunc (m *model) Update - Define styles as package variables - Reuse instead of creating in loops
- Use AdaptiveColor - For light/dark terminal support
- Handle ErrUserAborted - Graceful Ctrl+C handling in huh forms
- 保持Update/View快速 - 事件循环会阻塞在这些方法上
- 所有I/O使用tea.Cmd - HTTP、文件、数据库操作
- 并行任务使用tea.Batch - 多个独立命令
- 顺序任务使用tea.Sequence - 必须按顺序执行的命令
- 存储窗口尺寸 - 处理tea.WindowSizeMsg,更新组件尺寸
- 在WindowSizeMsg后初始化Viewport - 启动时无法获取尺寸
- 使用值接收器 - 而非
func (m model) Updatefunc (m *model) Update - 将样式定义为包级变量 - 复用样式而非在循环中创建
- 使用AdaptiveColor - 适配明暗终端主题
- 处理ErrUserAborted - 在Huh表单中优雅处理Ctrl+C取消
Additional Resources
额外资源
- references/API.md - Complete API reference
- references/EXAMPLES.md - Extended code examples
- references/TROUBLESHOOTING.md - Common errors and solutions
- references/API.md - 完整API参考
- references/EXAMPLES.md - 扩展代码示例
- references/TROUBLESHOOTING.md - 常见错误与解决方案
Essential Imports
必备导入
go
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table" // Static tables
"github.com/charmbracelet/lipgloss/tree" // Hierarchical data
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/table" // Interactive tables
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/filepicker"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/timer"
"github.com/charmbracelet/bubbles/stopwatch"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/wish" // SSH TUI server
"github.com/lrstanley/bubblezone" // Mouse zones
)go
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table" // 静态表格
"github.com/charmbracelet/lipgloss/tree" // 层级数据
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/table" // 交互式表格
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/filepicker"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/timer"
"github.com/charmbracelet/bubbles/stopwatch"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/wish" // SSH TUI服务器
"github.com/lrstanley/bubblezone" // 鼠标区域
)Lip Gloss Tree (Hierarchical Data)
Lip Gloss Tree(层级数据)
Render tree structures with customizable enumerators:
go
import "github.com/charmbracelet/lipgloss/tree"
t := tree.Root("Root").
Child("Child 1").
Child(
tree.Root("Child 2").
Child("Grandchild 1").
Child("Grandchild 2"),
).
Child("Child 3")
// Custom enumerator
t.Enumerator(tree.RoundedEnumerator) // ├── └──
t.Enumerator(tree.BulletEnumerator) // • bullets
t.Enumerator(tree.NumberedEnumerator) // 1. 2. 3.
// Custom styling
t.EnumeratorStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99")))
t.ItemStyle(lipgloss.NewStyle().Bold(true))
fmt.Println(t)使用可自定义枚举符渲染树形结构:
go
import "github.com/charmbracelet/lipgloss/tree"
t := tree.Root("Root").
Child("Child 1").
Child(
tree.Root("Child 2").
Child("Grandchild 1").
Child("Grandchild 2"),
).
Child("Child 3")
// 自定义枚举符
t.Enumerator(tree.RoundedEnumerator) // ├── └──
t.Enumerator(tree.BulletEnumerator) // • 圆点
t.Enumerator(tree.NumberedEnumerator) // 1. 2. 3.
// 自定义样式
t.EnumeratorStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99")))
t.ItemStyle(lipgloss.NewStyle().Bold(true))
fmt.Println(t)Lip Gloss Table (Static Rendering)
Lip Gloss Table(静态渲染)
For high-performance static tables (reports, logs). Use for interactive selection.
bubbles/tablego
import "github.com/charmbracelet/lipgloss/table"
t := table.New().
Border(lipgloss.NormalBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))).
Headers("NAME", "AGE", "CITY").
Row("Alice", "30", "NYC").
Row("Bob", "25", "LA").
Wrap(true). // Enable text wrapping
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return lipgloss.NewStyle().Bold(true)
}
return lipgloss.NewStyle()
})
fmt.Println(t)When to use which table:
- : Static rendering, reports, logs, non-interactive
lipgloss/table - : Interactive selection, keyboard navigation, focused rows
bubbles/table
用于高性能静态表格(报表、日志)。交互式选择使用。
bubbles/tablego
import "github.com/charmbracelet/lipgloss/table"
t := table.New().
Border(lipgloss.NormalBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))).
Headers("NAME", "AGE", "CITY").
Row("Alice", "30", "NYC").
Row("Bob", "25", "LA").
Wrap(true). // 启用文本换行
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return lipgloss.NewStyle().Bold(true)
}
return lipgloss.NewStyle()
})
fmt.Println(t)表格选择指南:
- : 静态渲染、报表、日志、非交互式场景
lipgloss/table - : 交互式选择、键盘导航、焦点行场景
bubbles/table
Custom Component Pattern (Sub-Model)
自定义组件模式(子Model)
Create reusable Bubble Tea components by exposing , , :
InitUpdateViewgo
// counter.go - Reusable component
package counter
import tea "github.com/charmbracelet/bubbletea"
type Model struct {
Count int
Min int
Max int
}
func New(min, max int) Model {
return Model{Min: min, Max: max}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "+", "=":
if m.Count < m.Max { m.Count++ }
case "-":
if m.Count > m.Min { m.Count-- }
}
}
return m, nil
}
func (m Model) View() string {
return fmt.Sprintf("Count: %d", m.Count)
}
// parent.go - Using the component
type parentModel struct {
counter counter.Model
}
func (m parentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.counter, cmd = m.counter.Update(msg)
return m, cmd
}通过暴露, , 创建可复用Bubble Tea组件:
InitUpdateViewgo
// counter.go - 可复用组件
package counter
import tea "github.com/charmbracelet/bubbletea"
type Model struct {
Count int
Min int
Max int
}
func New(min, max int) Model {
return Model{Min: min, Max: max}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "+", "=":
if m.Count < m.Max { m.Count++ }
case "-":
if m.Count > m.Min { m.Count-- }
}
}
return m, nil
}
func (m Model) View() string {
return fmt.Sprintf("Count: %d", m.Count)
}
// parent.go - 使用组件
type parentModel struct {
counter counter.Model
}
func (m parentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.counter, cmd = m.counter.Update(msg)
return m, cmd
}ProgramContext Pattern (Production)
ProgramContext模式(生产环境)
Share global state across components without prop drilling:
go
// context.go
type ProgramContext struct {
Config *Config
Theme *Theme
Width int
Height int
StartTask func(Task) tea.Cmd // Callback for background tasks
}
// model.go
type Model struct {
ctx *ProgramContext
sidebar sidebar.Model
main main.Model
tasks map[string]Task
spinner spinner.Model
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.ctx.Width = msg.Width
m.ctx.Height = msg.Height
// Sync all components
m.sidebar.SetHeight(msg.Height)
m.main.SetSize(msg.Width - sidebarWidth, msg.Height)
}
return m, nil
}
// Initialize context with task callback
func NewModel(cfg *Config) Model {
ctx := &ProgramContext{Config: cfg}
m := Model{ctx: ctx, tasks: make(map[string]Task)}
ctx.StartTask = func(t Task) tea.Cmd {
m.tasks[t.ID] = t
return m.spinner.Tick
}
return m
}无需属性透传即可在组件间共享全局状态:
go
// context.go
type ProgramContext struct {
Config *Config
Theme *Theme
Width int
Height int
StartTask func(Task) tea.Cmd // 后台任务回调
}
// model.go
type Model struct {
ctx *ProgramContext
sidebar sidebar.Model
main main.Model
tasks map[string]Task
spinner spinner.Model
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.ctx.Width = msg.Width
m.ctx.Height = msg.Height
// 同步所有组件尺寸
m.sidebar.SetHeight(msg.Height)
m.main.SetSize(msg.Width - sidebarWidth, msg.Height)
}
return m, nil
}
// 初始化上下文及任务回调
func NewModel(cfg *Config) Model {
ctx := &ProgramContext{Config: cfg}
m := Model{ctx: ctx, tasks: make(map[string]Task)}
ctx.StartTask = func(t Task) tea.Cmd {
m.tasks[t.ID] = t
return m.spinner.Tick
}
return m
}Testing with teatest
使用teatest测试
go
import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest"
)
func TestModel(t *testing.T) {
m := NewModel()
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
// Send key presses
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
// Wait for specific output
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
return strings.Contains(string(bts), "Selected")
}, teatest.WithDuration(time.Second))
// Check final state
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
final := tm.FinalModel(t).(Model)
if final.selected != "expected" {
t.Errorf("expected 'expected', got %q", final.selected)
}
}go
import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest"
)
func TestModel(t *testing.T) {
m := NewModel()
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
// 发送按键
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
// 等待特定输出
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
return strings.Contains(string(bts), "Selected")
}, teatest.WithDuration(time.Second))
// 检查最终状态
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
final := tm.FinalModel(t).(Model)
if final.selected != "expected" {
t.Errorf("expected 'expected', got %q", final.selected)
}
}SSH Integration with Wish
使用Wish集成SSH
Serve TUI apps over SSH:
go
import (
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/ssh"
)
func main() {
s, err := wish.NewServer(
wish.WithAddress(":2222"),
wish.WithHostKeyPath(".ssh/term_info_ed25519"),
wish.WithMiddleware(
bubbletea.Middleware(teaHandler),
),
)
if err != nil { log.Fatal(err) }
log.Println("Starting SSH server on :2222")
if err := s.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pty, _, _ := s.Pty()
m := NewModel(pty.Window.Width, pty.Window.Height)
return m, []tea.ProgramOption{tea.WithAltScreen()}
}通过SSH提供TUI应用:
go
import (
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/ssh"
)
func main() {
s, err := wish.NewServer(
wish.WithAddress(":2222"),
wish.WithHostKeyPath(".ssh/term_info_ed25519"),
wish.WithMiddleware(
bubbletea.Middleware(teaHandler),
),
)
if err != nil { log.Fatal(err) }
log.Println("Starting SSH server on :2222")
if err := s.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pty, _, _ := s.Pty()
m := NewModel(pty.Window.Width, pty.Window.Height)
return m, []tea.ProgramOption{tea.WithAltScreen()}
}Mouse Zones with bubblezone
使用bubblezone实现鼠标区域
Define clickable regions without coordinate math:
go
import zone "github.com/lrstanley/bubblezone"
type model struct {
zone *zone.Manager
}
func newModel() model {
return model{zone: zone.New()}
}
func (m model) View() string {
// Wrap clickable elements
button1 := m.zone.Mark("btn1", "[Button 1]")
button2 := m.zone.Mark("btn2", "[Button 2]")
return lipgloss.JoinHorizontal(lipgloss.Top, button1, " ", button2)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseMsg:
// Check which zone was clicked
if m.zone.Get("btn1").InBounds(msg) {
// Button 1 clicked
}
if m.zone.Get("btn2").InBounds(msg) {
// Button 2 clicked
}
}
return m, nil
}
// Wrap program with zone.NewGlobal() for simpler API
func main() {
zone.NewGlobal()
p := tea.NewProgram(newModel(), tea.WithMouseCellMotion())
p.Run()
}无需坐标计算即可定义可点击区域:
go
import zone "github.com/lrstanley/bubblezone"
type model struct {
zone *zone.Manager
}
func newModel() model {
return model{zone: zone.New()}
}
func (m model) View() string {
// 包裹可点击元素
button1 := m.zone.Mark("btn1", "[Button 1]")
button2 := m.zone.Mark("btn2", "[Button 2]")
return lipgloss.JoinHorizontal(lipgloss.Top, button1, " ", button2)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseMsg:
// 检查点击区域
if m.zone.Get("btn1").InBounds(msg) {
// 按钮1被点击
}
if m.zone.Get("btn2").InBounds(msg) {
// 按钮2被点击
}
}
return m, nil
}
// 使用zone.NewGlobal()简化API
func main() {
zone.NewGlobal()
p := tea.NewProgram(newModel(), tea.WithMouseCellMotion())
p.Run()
}