charm-stack
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseCharm Stack TUI Development
Charm栈 TUI 开发
Build beautiful, functional terminal user interfaces using the Charm stack: Bubbletea (framework), Bubbles (components), Lipgloss (styling), and Huh (forms).
使用Charm技术栈构建美观、实用的终端用户界面:Bubbletea(框架)、Bubbles(组件库)、Lipgloss(样式库)、Huh(表单库)。
Your Role: TUI Architect
你的角色:TUI架构师
You build terminal applications using Elm Architecture patterns. You:
✅ Implement Model-Update-View - Core Bubbletea pattern
✅ Compose Bubbles components - Spinners, lists, text inputs
✅ Style with Lipgloss - Colors, borders, layouts
✅ Build forms with Huh - Interactive prompts
✅ Handle messages properly - KeyMsg, WindowMsg, custom messages
✅ Follow project patterns - Module structure from CLY
❌ Do NOT fight the framework - Use Elm Architecture
❌ Do NOT skip Init - Commands need initialization
❌ Do NOT ignore tea.Cmd - Critical for async operations
你需要使用Elm架构模式构建终端应用,你需要:
✅ 实现 Model-Update-View - Bubbletea核心模式
✅ 组合Bubbles组件 - 加载动画、列表、文本输入框
✅ 使用Lipgloss实现样式 - 颜色、边框、布局
✅ 使用Huh构建表单 - 交互式提示框
✅ 正确处理消息 - KeyMsg、WindowMsg、自定义消息
✅ 遵循项目模式 - 符合CLY的模块结构
❌ 不要违背框架设计 - 必须使用Elm架构
❌ 不要省略Init方法 - 命令需要初始化
❌ 不要忽略tea.Cmd - 对异步操作至关重要
Core Architecture: The Elm Architecture
核心架构:Elm架构
The Three Functions
三个核心函数
Every Bubbletea program has three parts:
Model - Application state
go
type model struct {
cursor int
choices []string
selected map[int]struct{}
}Init - Initial command
go
func (m model) Init() tea.Cmd {
return nil // or return a command
}Update - Handle messages, update state
go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// Handle keyboard
}
return m, nil
}View - Render UI
go
func (m model) View() string {
return "Hello, World!"
}每个Bubbletea程序都包含三个部分:
Model - 应用状态
go
type model struct {
cursor int
choices []string
selected map[int]struct{}
}Init - 初始命令
go
func (m model) Init() tea.Cmd {
return nil // 或返回一个命令
}Update - 处理消息,更新状态
go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// 处理键盘输入
}
return m, nil
}View - 渲染UI
go
func (m model) View() string {
return "Hello, World!"
}Message Flow
消息流
User Input → Msg → Update → Model → View → Screen
↑ ↓
└────────── tea.Cmd ─────────────────┘Key concepts:
- Messages are immutable events
- Update returns new model (don't mutate)
- Commands run async, generate more messages
- View is pure function of model state
用户输入 → Msg → Update → Model → View → 屏幕
↑ ↓
└────────── tea.Cmd ─────────────────┘核心概念:
- 消息是不可变的事件
- Update返回新的model(不要修改原model)
- 命令异步运行,生成更多消息
- View是基于model状态的纯函数
Bubbletea Patterns
Bubbletea 常用模式
Basic Program
基础程序
go
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
choices []string
cursor int
selected map[int]struct{}
}
func initialModel() model {
return model{
choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
selected: make(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", " ":
_, ok := m.selected[m.cursor]
if ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
return m, nil
}
func (m model) View() string {
s := "What should we buy?\n\n"
for i, choice := range m.choices {
cursor := " "
if m.cursor == i {
cursor = ">"
}
checked := " "
if _, ok := m.selected[i]; ok {
checked = "x"
}
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
s += "\nPress q to quit.\n"
return s
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Printf("Error: %v", err)
os.Exit(1)
}
}go
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
choices []string
cursor int
selected map[int]struct{}
}
func initialModel() model {
return model{
choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
selected: make(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", " ":
_, ok := m.selected[m.cursor]
if ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
return m, nil
}
func (m model) View() string {
s := "What should we buy?\n\n"
for i, choice := range m.choices {
cursor := " "
if m.cursor == i {
cursor = ">"
}
checked := " "
if _, ok := m.selected[i]; ok {
checked = "x"
}
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
s += "\nPress q to quit.\n"
return s
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Printf("Error: %v", err)
os.Exit(1)
}
}Commands (tea.Cmd)
命令 (tea.Cmd)
Commands enable async operations. They return messages.
Simple command:
go
func checkServer() tea.Msg {
// Do work
return statusMsg{online: true}
}
// In Update:
case tea.KeyMsg:
if msg.String() == "c" {
return m, checkServer // Execute command
}Command that runs async:
go
func fetchData() tea.Cmd {
return func() tea.Msg {
resp, err := http.Get("https://api.example.com/data")
if err != nil {
return errMsg{err}
}
return dataMsg{resp}
}
}Batch commands:
go
return m, tea.Batch(
cmd1,
cmd2,
cmd3,
)Tick command (for animations):
go
type tickMsg time.Time
func tick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// In Init:
func (m model) Init() tea.Cmd {
return tick()
}
// In Update:
case tickMsg:
m.lastTick = time.Time(msg)
return m, tick() // Keep ticking命令用于实现异步操作,执行后返回消息。
简单命令:
go
func checkServer() tea.Msg {
// 执行任务
return statusMsg{online: true}
}
// 在Update中:
case tea.KeyMsg:
if msg.String() == "c" {
return m, checkServer // 执行命令
}异步运行的命令:
go
func fetchData() tea.Cmd {
return func() tea.Msg {
resp, err := http.Get("https://api.example.com/data")
if err != nil {
return errMsg{err}
}
return dataMsg{resp}
}
}批量命令:
go
return m, tea.Batch(
cmd1,
cmd2,
cmd3,
)Tick命令(用于动画):
go
type tickMsg time.Time
func tick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// 在Init中:
func (m model) Init() tea.Cmd {
return tick()
}
// 在Update中:
case tickMsg:
m.lastTick = time.Time(msg)
return m, tick() // 持续触发tickWindow Size Handling
窗口大小处理
go
type model struct {
width int
height int
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
}
return m, nil
}
func (m model) View() string {
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
"Centered text",
)
}go
type model struct {
width int
height int
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
}
return m, nil
}
func (m model) View() string {
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
"Centered text",
)
}Program Options
程序选项
go
p := tea.NewProgram(
initialModel(),
tea.WithAltScreen(), // Use alternate screen buffer
tea.WithMouseCellMotion(), // Enable mouse
)go
p := tea.NewProgram(
initialModel(),
tea.WithAltScreen(), // 使用备用屏幕缓冲区
tea.WithMouseCellMotion(), // 启用鼠标支持
)Bubbles Components
Bubbles 组件
Bubbles provides ready-made components. Each is a tea.Model.
Bubbles提供开箱即用的组件,每个组件都是一个tea.Model。
Spinner
加载动画(Spinner)
go
import "github.com/charmbracelet/bubbles/spinner"
type model struct {
spinner spinner.Model
}
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return model{spinner: s}
}
func (m model) Init() tea.Cmd {
return m.spinner.Tick
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
func (m model) View() string {
return m.spinner.View() + " Loading..."
}Spinner types:
spinner.Linespinner.Dotspinner.MiniDotspinner.Jumpspinner.Pulsespinner.Pointsspinner.Globespinner.Moon
go
import "github.com/charmbracelet/bubbles/spinner"
type model struct {
spinner spinner.Model
}
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return model{spinner: s}
}
func (m model) Init() tea.Cmd {
return m.spinner.Tick
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
func (m model) View() string {
return m.spinner.View() + " Loading..."
}加载动画类型:
spinner.Linespinner.Dotspinner.MiniDotspinner.Jumpspinner.Pulsespinner.Pointsspinner.Globespinner.Moon
Text Input
文本输入框
go
import "github.com/charmbracelet/bubbles/textinput"
type model struct {
textInput textinput.Model
}
func initialModel() model {
ti := textinput.New()
ti.Placeholder = "Enter your name"
ti.Focus()
ti.CharLimit = 156
ti.Width = 20
return model{textInput: ti}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
name := m.textInput.Value()
// Use the name
return m, tea.Quit
}
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
func (m model) View() string {
return fmt.Sprintf(
"What's your name?\n\n%s\n\n%s",
m.textInput.View(),
"(esc to quit)",
)
}go
import "github.com/charmbracelet/bubbles/textinput"
type model struct {
textInput textinput.Model
}
func initialModel() model {
ti := textinput.New()
ti.Placeholder = "Enter your name"
ti.Focus()
ti.CharLimit = 156
ti.Width = 20
return model{textInput: ti}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
name := m.textInput.Value()
// 使用输入的名称
return m, tea.Quit
}
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
func (m model) View() string {
return fmt.Sprintf(
"What's your name?\n\n%s\n\n%s",
m.textInput.View(),
"(esc to quit)",
)
}List
列表
go
import "github.com/charmbracelet/bubbles/list"
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 }
type model struct {
list list.Model
}
func initialModel() model {
items := []list.Item{
item{title: "Raspberry Pi", desc: "A small computer"},
item{title: "Arduino", desc: "Microcontroller"},
item{title: "ESP32", desc: "WiFi & Bluetooth"},
}
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "Hardware"
return model{list: l}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
if msg.String() == "enter" {
selected := m.list.SelectedItem().(item)
// Use selected
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}go
import "github.com/charmbracelet/bubbles/list"
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 }
type model struct {
list list.Model
}
func initialModel() model {
items := []list.Item{
item{title: "Raspberry Pi", desc: "A small computer"},
item{title: "Arduino", desc: "Microcontroller"},
item{title: "ESP32", desc: "WiFi & Bluetooth"},
}
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "Hardware"
return model{list: l}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
if msg.String() == "enter" {
selected := m.list.SelectedItem().(item)
// 使用选中的项
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}Table
表格
go
import "github.com/charmbracelet/bubbles/table"
func initialModel() model {
columns := []table.Column{
{Title: "ID", Width: 4},
{Title: "Name", Width: 10},
{Title: "Status", Width: 10},
}
rows := []table.Row{
{"1", "Alice", "Active"},
{"2", "Bob", "Inactive"},
}
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(7),
)
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
t.SetStyles(s)
return model{table: t}
}go
import "github.com/charmbracelet/bubbles/table"
func initialModel() model {
columns := []table.Column{
{Title: "ID", Width: 4},
{Title: "Name", Width: 10},
{Title: "Status", Width: 10},
}
rows := []table.Row{
{"1", "Alice", "Active"},
{"2", "Bob", "Inactive"},
}
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(7),
)
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
t.SetStyles(s)
return model{table: t}
}Viewport (Scrolling)
视口(滚动功能)
go
import "github.com/charmbracelet/bubbles/viewport"
type model struct {
viewport viewport.Model
content string
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.viewport = viewport.New(msg.Width, msg.Height)
m.viewport.SetContent(m.content)
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m model) View() string {
return m.viewport.View()
}go
import "github.com/charmbracelet/bubbles/viewport"
type model struct {
viewport viewport.Model
content string
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.viewport = viewport.New(msg.Width, msg.Height)
m.viewport.SetContent(m.content)
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m model) View() string {
return m.viewport.View()
}Progress
进度条
go
import "github.com/charmbracelet/bubbles/progress"
type model struct {
progress progress.Model
percent float64
}
func initialModel() model {
return model{
progress: progress.New(progress.WithDefaultGradient()),
percent: 0.0,
}
}
func (m model) View() string {
return "\n" + m.progress.ViewAs(m.percent) + "\n\n"
}go
import "github.com/charmbracelet/bubbles/progress"
type model struct {
progress progress.Model
percent float64
}
func initialModel() model {
return model{
progress: progress.New(progress.WithDefaultGradient()),
percent: 0.0,
}
}
func (m model) View() string {
return "\n" + m.progress.ViewAs(m.percent) + "\n\n"
}Lipgloss Styling
Lipgloss 样式
Basic Styles
基础样式
go
import "github.com/charmbracelet/lipgloss"
var (
// Define styles
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("170")).
Background(lipgloss.Color("235")).
Padding(0, 1)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
successStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("42"))
)
// Use styles
title := titleStyle.Render("Hello")
err := errorStyle.Render("Error!")go
import "github.com/charmbracelet/lipgloss"
var (
// 定义样式
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("170")).
Background(lipgloss.Color("235")).
Padding(0, 1)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
successStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("42"))
)
// 使用样式
title := titleStyle.Render("Hello")
err := errorStyle.Render("Error!")Colors
颜色
go
// ANSI 16 colors
lipgloss.Color("5") // magenta
// ANSI 256 colors
lipgloss.Color("86") // aqua
lipgloss.Color("201") // hot pink
// True color (hex)
lipgloss.Color("#0000FF") // blue
lipgloss.Color("#FF6B6B") // red
// Adaptive (light/dark)
lipgloss.AdaptiveColor{
Light: "236",
Dark: "248",
}go
// ANSI 16色
lipgloss.Color("5") // 洋红色
// ANSI 256色
lipgloss.Color("86") // 水绿色
lipgloss.Color("201") // 亮粉色
// 真彩色(十六进制)
lipgloss.Color("#0000FF") // 蓝色
lipgloss.Color("#FF6B6B") // 红色
// 自适应(亮色/暗色模式)
lipgloss.AdaptiveColor{
Light: "236",
Dark: "248",
}Layout & Spacing
布局与间距
go
style := lipgloss.NewStyle().
Width(50).
Height(10).
Padding(1, 2). // top/bottom, left/right
Margin(1, 2, 3, 4). // top, right, bottom, left
Align(lipgloss.Center)go
style := lipgloss.NewStyle().
Width(50).
Height(10).
Padding(1, 2). // 上下、左右
Margin(1, 2, 3, 4). // 上、右、下、左
Align(lipgloss.Center)Borders
边框
go
style := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(1, 2)
// Border types
lipgloss.NormalBorder()
lipgloss.RoundedBorder()
lipgloss.ThickBorder()
lipgloss.DoubleBorder()
lipgloss.HiddenBorder()
// Selective borders
style.BorderTop(true).
BorderLeft(true)go
style := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(1, 2)
// 边框类型
lipgloss.NormalBorder()
lipgloss.RoundedBorder()
lipgloss.ThickBorder()
lipgloss.DoubleBorder()
lipgloss.HiddenBorder()
// 选择性设置边框
style.BorderTop(true).
BorderLeft(true)Joining Layouts
布局拼接
go
// Horizontal
row := lipgloss.JoinHorizontal(
lipgloss.Top, // Alignment
box1, box2, box3,
)
// Vertical
col := lipgloss.JoinVertical(
lipgloss.Left,
box1, box2, box3,
)
// Positions: Top, Center, Bottom, Left, Rightgo
// 水平拼接
row := lipgloss.JoinHorizontal(
lipgloss.Top, // 对齐方式
box1, box2, box3,
)
// 垂直拼接
col := lipgloss.JoinVertical(
lipgloss.Left,
box1, box2, box3,
)
// 可选位置:Top, Center, Bottom, Left, RightPositioning
定位
go
// Place in whitespace
centered := lipgloss.Place(
width, height,
lipgloss.Center, // horizontal
lipgloss.Center, // vertical
content,
)go
// 放置在空白区域中
centered := lipgloss.Place(
width, height,
lipgloss.Center, // 水平对齐
lipgloss.Center, // 垂直对齐
content,
)Advanced Styling
高级样式
go
style := lipgloss.NewStyle().
Bold(true).
Italic(true).
Underline(true).
Strikethrough(true).
Blink(true).
Faint(true).
Reverse(true)go
style := lipgloss.NewStyle().
Bold(true).
Italic(true).
Underline(true).
Strikethrough(true).
Blink(true).
Faint(true).
Reverse(true)Huh Forms
Huh 表单
Build interactive forms and prompts.
用于构建交互式表单和提示框。
Basic Form
基础表单
go
import "github.com/charmbracelet/huh"
var (
burger string
toppings []string
name string
)
func runForm() error {
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Choose your burger").
Options(
huh.NewOption("Classic", "classic"),
huh.NewOption("Chicken", "chicken"),
huh.NewOption("Veggie", "veggie"),
).
Value(&burger),
huh.NewMultiSelect[string]().
Title("Toppings").
Options(
huh.NewOption("Lettuce", "lettuce"),
huh.NewOption("Tomato", "tomato"),
huh.NewOption("Cheese", "cheese"),
).
Limit(3).
Value(&toppings),
),
huh.NewGroup(
huh.NewInput().
Title("What's your name?").
Value(&name).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("name required")
}
return nil
}),
),
)
return form.Run()
}go
import "github.com/charmbracelet/huh"
var (
burger string
toppings []string
name string
)
func runForm() error {
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Choose your burger").
Options(
huh.NewOption("Classic", "classic"),
huh.NewOption("Chicken", "chicken"),
huh.NewOption("Veggie", "veggie"),
).
Value(&burger),
huh.NewMultiSelect[string]().
Title("Toppings").
Options(
huh.NewOption("Lettuce", "lettuce"),
huh.NewOption("Tomato", "tomato"),
huh.NewOption("Cheese", "cheese"),
).
Limit(3).
Value(&toppings),
),
huh.NewGroup(
huh.NewInput().
Title("What's your name?").
Value(&name).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("name required")
}
return nil
}),
),
)
return form.Run()
}Field Types
字段类型
Input - Single line text:
go
huh.NewInput().
Title("Username").
Placeholder("Enter username").
Value(&username)Text - Multi-line text:
go
huh.NewText().
Title("Description").
CharLimit(400).
Value(&description)Select - Choose one:
go
huh.NewSelect[string]().
Title("Pick one").
Options(
huh.NewOption("Option 1", "opt1"),
huh.NewOption("Option 2", "opt2"),
).
Value(&choice)MultiSelect - Choose multiple:
go
huh.NewMultiSelect[string]().
Title("Pick several").
Options(...).
Limit(3).
Value(&choices)Confirm - Yes/No:
go
huh.NewConfirm().
Title("Are you sure?").
Affirmative("Yes").
Negative("No").
Value(&confirmed)Input - 单行文本:
go
huh.NewInput().
Title("Username").
Placeholder("Enter username").
Value(&username)Text - 多行文本:
go
huh.NewText().
Title("Description").
CharLimit(400).
Value(&description)Select - 单选:
go
huh.NewSelect[string]().
Title("Pick one").
Options(
huh.NewOption("Option 1", "opt1"),
huh.NewOption("Option 2", "opt2"),
).
Value(&choice)MultiSelect - 多选:
go
huh.NewMultiSelect[string]().
Title("Pick several").
Options(...).
Limit(3).
Value(&choices)Confirm - 确认框(是/否):
go
huh.NewConfirm().
Title("Are you sure?").
Affirmative("Yes").
Negative("No").
Value(&confirmed)Accessible Mode
无障碍模式
go
form := huh.NewForm(...)
form.WithAccessible(true) // Screen reader friendlygo
form := huh.NewForm(...)
form.WithAccessible(true) // 对屏幕阅读器友好In Bubble Tea
在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 done, get values
return m, tea.Quit
}
return m, cmd
}
func (m model) View() string {
if m.form.State == huh.StateCompleted {
return "Done!\n"
}
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 {
// 表单填写完成,获取值
return m, tea.Quit
}
return m, cmd
}
func (m model) View() string {
if m.form.State == huh.StateCompleted {
return "Done!\n"
}
return m.form.View()
}CLY Project Patterns
CLY项目模式
Module Structure
模块结构
modules/demo/spinner/
├── cmd.go # Register() and run()
└── spinner.go # Bubbletea modelcmd.go:
go
package spinner
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
func Register(parent *cobra.Command) {
cmd := &cobra.Command{
Use: "spinner",
Short: "Spinner demo",
RunE: run,
}
parent.AddCommand(cmd)
}
func run(cmd *cobra.Command, args []string) error {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
return err
}
return nil
}spinner.go:
go
package spinner
import (
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type model struct {
spinner spinner.Model
}
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return model{spinner: s}
}
func (m model) Init() tea.Cmd {
return m.spinner.Tick
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
func (m model) View() string {
return m.spinner.View() + " Loading...\n"
}modules/demo/spinner/
├── cmd.go # Register() 和 run() 方法
└── spinner.go # Bubbletea model定义cmd.go:
go
package spinner
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
func Register(parent *cobra.Command) {
cmd := &cobra.Command{
Use: "spinner",
Short: "Spinner demo",
RunE: run,
}
parent.AddCommand(cmd)
}
func run(cmd *cobra.Command, args []string) error {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
return err
}
return nil
}spinner.go:
go
package spinner
import (
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type model struct {
spinner spinner.Model
}
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return model{spinner: s}
}
func (m model) Init() tea.Cmd {
return m.spinner.Tick
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
func (m model) View() string {
return m.spinner.View() + " Loading...\n"
}Common Patterns
常用模式
Loading State
加载状态
go
type model struct {
loading bool
spinner spinner.Model
data []string
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "r" {
m.loading = true
return m, fetchData
}
case dataMsg:
m.loading = false
m.data = msg.data
return m, nil
}
if m.loading {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
}
func (m model) View() string {
if m.loading {
return m.spinner.View() + " Loading data..."
}
return renderData(m.data)
}go
type model struct {
loading bool
spinner spinner.Model
data []string
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "r" {
m.loading = true
return m, fetchData
}
case dataMsg:
m.loading = false
m.data = msg.data
return m, nil
}
if m.loading {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
}
func (m model) View() string {
if m.loading {
return m.spinner.View() + " Loading data..."
}
return renderData(m.data)
}Error Handling
错误处理
go
type model struct {
err error
}
type errMsg struct{ err error }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case errMsg:
m.err = msg.err
return m, nil
}
return m, nil
}
func (m model) View() string {
if m.err != nil {
return errorStyle.Render("Error: " + m.err.Error())
}
return normalView()
}go
type model struct {
err error
}
type errMsg struct{ err error }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case errMsg:
m.err = msg.err
return m, nil
}
return m, nil
}
func (m model) View() string {
if m.err != nil {
return errorStyle.Render("Error: " + m.err.Error())
}
return normalView()
}Multi-View Navigation
多视图导航
go
type view int
const (
viewMenu view = iota
viewList
viewDetail
)
type model struct {
currentView view
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "1":
m.currentView = viewMenu
case "2":
m.currentView = viewList
case "3":
m.currentView = viewDetail
}
}
return m, nil
}
func (m model) View() string {
switch m.currentView {
case viewMenu:
return renderMenu()
case viewList:
return renderList()
case viewDetail:
return renderDetail()
}
return ""
}go
type view int
const (
viewMenu view = iota
viewList
viewDetail
)
type model struct {
currentView view
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "1":
m.currentView = viewMenu
case "2":
m.currentView = viewList
case "3":
m.currentView = viewDetail
}
}
return m, nil
}
func (m model) View() string {
switch m.currentView {
case viewMenu:
return renderMenu()
case viewList:
return renderList()
case viewDetail:
return renderDetail()
}
return ""
}Best Practices
最佳实践
Model Immutability
Model不可变性
✅ GOOD - Return new state:
go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.counter++ // Modify copy
return m, nil
}❌ BAD - Mutate pointer:
go
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.counter++ // Mutates original
return m, nil
}✅ 正确 - 返回新状态:
go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.counter++ // 修改副本
return m, nil
}❌ 错误 - 修改指针:
go
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.counter++ // 修改原对象
return m, nil
}Quit Handling
退出处理
Always handle quit signals:
go
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
}始终处理退出信号:
go
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
}Alt Screen
备用屏幕
Use alt screen for full-screen apps:
go
p := tea.NewProgram(
initialModel(),
tea.WithAltScreen(),
)全屏应用使用备用屏幕:
go
p := tea.NewProgram(
initialModel(),
tea.WithAltScreen(),
)Component Composition
组件组合
Embed Bubbles components:
go
type model struct {
spinner spinner.Model
textInput textinput.Model
list list.Model
}
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)
return m, tea.Batch(cmds...)
}嵌入Bubbles组件:
go
type model struct {
spinner spinner.Model
textInput textinput.Model
list list.Model
}
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)
return m, tea.Batch(cmds...)
}Checklist
检查清单
- Model contains all state
- Init returns initial command
- Update handles all message types
- Update returns new model (immutable)
- View is pure function
- Quit handling present
- Window resize handled
- Commands for async ops
- Bubbles components updated
- Lipgloss for all styling
- Follows CLY module structure
- Model包含所有状态
- Init返回初始命令
- Update处理所有消息类型
- Update返回新model(不可变)
- View是纯函数
- 包含退出处理逻辑
- 处理窗口 resize 事件
- 异步操作使用命令实现
- 正确更新Bubbles组件
- 所有样式使用Lipgloss实现
- 遵循CLY模块结构
Resources
资源
- Bubbletea Tutorial
- Bubbletea Examples
- Bubbles Components
- Lipgloss Docs
- Huh Forms
- CLY examples:
modules/demo/*/
- Bubbletea 教程
- Bubbletea 示例
- Bubbles 组件库
- Lipgloss 文档
- Huh 表单库
- CLY示例:
modules/demo/*/