Appearance
Bubbletea 与 TUI 框架
前面几篇介绍的交互式 CLI 是"问答式"的——一次问一个问题,用户回答后进入下一步。而 TUI(Terminal User Interface)则是"应用式"的——在终端中渲染一个完整的界面,用户可以自由导航、输入、切换视图。Bubbletea 是 Charm 生态中最流行的 Go TUI 框架,采用 Elm 架构,简洁而强大。本篇将系统讲解 Bubbletea 的核心概念和组件用法。
一、Bubbletea 简介
Bubbletea 是 Charm(charm.sh)开源的 Go TUI 框架,灵感来自 Elm 语言的架构。它的核心特点:
- Elm 架构:Model-Update-View 三层分离,状态管理清晰。
- 纯函数 Update:所有状态变更通过消息触发,无副作用。
- 组合式组件:textinput、list、spinner、viewport 等组件可自由组合。
- 跨平台:支持 Linux、macOS、Windows,自动处理终端差异。
- Lipgloss 样式:配合 Lipgloss 库实现精美的终端 UI 样式。
- 高性能渲染:基于差异渲染,只更新变化的区域。
Bubbletea 适用于构建:
- 交互式数据浏览工具(如
gh的 issue 列表) - 终端仪表盘
- 文件管理器
- 交互式安装向导
- 终端游戏
二、Elm 架构:Model-Update-View
Elm 架构是 Bubbletea 的核心设计理念。它将应用分为三个部分:
┌─────────┐ 消息 ┌─────────┐
│ View │ ────────────> │ Update │
│ (渲染) │ │ (更新) │
└─────────┘ └─────────┘
▲ │
│ │ 新 Model
│ ┌─────────┐ │
└──────── │ Model │ <───┘
渲染 │ (状态) │
└─────────┘- Model(模型):应用的状态,是一个普通的结构体。
- Update(更新):接收当前 Model 和一个消息(Message),返回新的 Model 和一个命令(Cmd)。这是一个纯函数。
- View(视图):接收当前 Model,返回要渲染的字符串。
这种架构的好处是:
- 状态可预测:所有状态变更都通过 Update 函数,没有隐藏的副作用。
- 易于测试:Update 是纯函数,给定输入总有确定输出。
- 易于调试:可以记录所有消息,回放状态变化。
- 组合性好:多个组件可以独立维护自己的 Model-Update-View。
三、安装与第一个 Bubbletea 程序
bash
go get github.com/charmbracelet/bubbletea@latest一个最小的 Bubbletea 程序:
go
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
// model 是应用状态
type model struct {
count int
}
// initialModel 创建初始状态
func initialModel() model {
return model{count: 0}
}
// Init 返回初始命令,这里不需要
func (m model) Init() tea.Cmd {
return nil
}
// Update 处理消息,返回新状态和命令
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
m.count++
case "down", "j":
m.count--
case "q", "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}
// View 返回要渲染的界面
func (m model) View() string {
return fmt.Sprintf(
"计数器: %d\n\n按 ↑/↓ 增减,按 q 退出",
m.count,
)
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}运行后,按上下箭头增减计数,按 q 退出。这就是一个完整的 TUI 应用。
四、Model 定义
Model 是应用状态的容器。它需要实现 tea.Model 接口,即 Init()、Update() 和 View() 三个方法。
go
package main
import (
"fmt"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// TodoItem 表示一个待办事项
type TodoItem struct {
Title string
Done bool
}
// model 是应用状态
type model struct {
todos []TodoItem
cursor int
selected map[int]bool
editing bool
input string
}
func initialModel() model {
return model{
todos: []TodoItem{
{Title: "学习 Bubbletea", Done: false},
{Title: "写一个 TUI 应用", Done: false},
{Title: "分享给朋友", Done: false},
},
selected: make(map[int]bool),
}
}
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.todos)-1 {
m.cursor++
}
case "enter", " ":
m.selected[m.cursor] = !m.selected[m.cursor]
case "t":
m.todos[m.cursor].Done = !m.todos[m.cursor].Done
}
}
return m, nil
}
func (m model) View() string {
var b strings.Builder
b.WriteString("TODO 列表\n\n")
for i, todo := range m.todos {
cursor := " "
if m.cursor == i {
cursor = ">"
}
checked := " "
if todo.Done {
checked = "x"
}
selected := ""
if m.selected[i] {
selected = " (已选中)"
}
b.WriteString(fmt.Sprintf("%s [%s] %s%s\n", cursor, checked, todo.Title, selected))
}
b.WriteString("\n操作: ↑↓ 移动 | 空格 选中 | t 切换完成 | q 退出")
return b.String()
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}五、Update 函数:消息处理
Update 是核心逻辑所在。它接收当前 Model 和一条消息,返回新的 Model 和可选的命令。
Bubbletea 的消息类型:
| 消息类型 | 说明 |
|---|---|
tea.KeyMsg | 键盘按键 |
tea.MouseMsg | 鼠标事件 |
tea.WindowSizeMsg | 终端窗口大小变化 |
tea.QuitMsg | 退出信号 |
| 自定义消息 | 用户定义的消息类型 |
go
package main
import (
"fmt"
"os"
"time"
tea "github.com/charmbracelet/bubbletea"
)
// tickMsg 是自定义消息,表示一次定时器触发
type tickMsg time.Time
// tickCmd 返回一个命令,1 秒后发送 tickMsg
func tickCmd() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
type model struct {
time time.Time
count int
}
func initialModel() model {
return model{time: time.Now()}
}
func (m model) Init() tea.Cmd {
// 启动时发出第一次 tick
return tickCmd()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tickMsg:
// 收到 tick,更新时间,并安排下一次 tick
m.time = msg
m.count++
return m, tickCmd()
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() string {
return fmt.Sprintf(
"当前时间: %s\nTick 次数: %d\n\n按 q 退出",
m.time.Format("2006-01-02 15:04:05"),
m.count,
)
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}
tea.Cmd是一个返回tea.Msg的函数。它用于在 Update 中触发异步操作(如定时器、网络请求、文件读取)。Cmd 返回的 Msg 会被送回 Update,形成循环。
六、View 函数:渲染界面
View 接收 Model,返回一个字符串。Bubbletea 会将这个字符串渲染到终端。每次 Model 变化后,View 都会被重新调用。
go
package main
import (
"fmt"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
width int
height int
items []string
cursor int
}
func initialModel() model {
return model{
items: []string{"首页", "设置", "关于", "退出"},
}
}
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.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.items)-1 {
m.cursor++
}
case "enter":
if m.items[m.cursor] == "退出" {
return m, tea.Quit
}
case "q", "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() string {
var b strings.Builder
// 标题
b.WriteString("┌──────────────────┐\n")
b.WriteString("│ 主菜单 │\n")
b.WriteString("├──────────────────┤\n")
// 菜单项
for i, item := range m.items {
cursor := " "
if i == m.cursor {
cursor = ">"
}
b.WriteString(fmt.Sprintf("│ %s %-14s │\n", cursor, item))
}
b.WriteString("└──────────────────┘\n")
b.WriteString("\n↑↓ 选择 | Enter 确认 | Q 退出")
// 显示窗口大小
if m.width > 0 {
b.WriteString(fmt.Sprintf("\n窗口: %dx%d", m.width, m.height))
}
return b.String()
}
func main() {
p := tea.NewProgram(initialModel(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}
tea.WithAltScreen()使程序使用终端的备用屏幕(Alt Screen),退出后恢复原终端内容,类似于vim的行为。
七、Bubbletea 内置组件
Bubbletea 生态提供了一系列可组合的组件。
1. textinput:文本输入
go
package main
import (
"fmt"
"os"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
textInput textinput.Model
err error
}
func initialModel() model {
ti := textinput.New()
ti.Placeholder = "输入你的名字"
ti.Focus()
ti.CharLimit = 30
ti.Width = 30
return model{textInput: ti}
}
func (m model) Init() tea.Cmd {
return textinput.Blink
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
fmt.Printf("\n你好,%s!\n", m.textInput.Value())
return m, tea.Quit
case tea.KeyCtrlC, tea.KeyEsc:
return m, tea.Quit
}
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
func (m model) View() string {
return fmt.Sprintf(
"请输入你的名字:\n\n%s\n\n按 Enter 确认,Esc 退出",
m.textInput.View(),
)
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}2. list:列表选择
go
package main
import (
"fmt"
"os"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)
// item 实现 list.Item 接口
type item struct {
title string
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 (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 "enter":
// 获取选中项
if selectedItem, ok := m.list.SelectedItem().(item); ok {
fmt.Printf("\n你选择了: %s\n", selectedItem.title)
return m, tea.Quit
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m model) View() string {
return "\n" + m.list.View()
}
func main() {
items := []list.Item{
item{title: "Gin", desc: "高性能 Web 框架"},
item{title: "Echo", desc: "简洁的 Web 框架"},
item{title: "Fiber", desc: "基于 fasthttp 的框架"},
item{title: "Chi", desc: "标准库风格的路由"},
}
m := model{list: list.New(items, list.NewDefaultDelegate(), 0, 0)}
m.list.Title = "选择 Go Web 框架"
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}3. spinner:加载动画
go
package main
import (
"fmt"
"os"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
spinner spinner.Model
loading bool
finished bool
}
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.Dot
return model{spinner: s, loading: true}
}
func (m model) Init() tea.Cmd {
return spinner.Tick
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
}
// 模拟加载完成
if m.loading {
time.Sleep(2 * time.Second)
m.loading = false
m.finished = true
}
return m, nil
}
func (m model) View() string {
if m.finished {
return "加载完成! 按 q 退出"
}
return fmt.Sprintf("%s 正在加载...\n\n按 q 取消", m.spinner.View())
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}4. viewport:可滚动区域
go
package main
import (
"fmt"
"os"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
viewport viewport.Model
ready bool
content string
}
func initialModel() model {
// 生成一些内容
var lines []string
for i := 1; i <= 100; i++ {
lines = append(lines, fmt.Sprintf("第 %d 行: 这是一些示例内容,用于演示可滚动区域。", i))
}
return model{content: strings.Join(lines, "\n")}
}
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.WindowSizeMsg:
if !m.ready {
m.viewport = viewport.New(msg.Width, msg.Height)
m.viewport.SetContent(m.content)
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height
}
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
}
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m model) View() string {
if !m.ready {
return "正在初始化..."
}
return m.viewport.View()
}
func main() {
p := tea.NewProgram(initialModel(), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}5. progress:进度条
go
package main
import (
"fmt"
"os"
"time"
"github.com/charmbracelet/bubbles/progress"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
progress progress.Model
percent float64
}
func initialModel() model {
p := progress.New(progress.WithDefaultGradient())
p.Width = 50
return model{progress: p}
}
func (m model) Init() tea.Cmd {
return tick()
}
type tickMsg struct{}
func tick() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return tickMsg{}
})
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tickMsg:
m.percent += 0.05
if m.percent >= 1.0 {
m.percent = 1.0
return m, nil
}
return m, tick()
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
}
var cmd tea.Cmd
m.progress, cmd = m.progress.Update(msg)
return m, cmd
}
func (m model) View() string {
if m.percent >= 1.0 {
return "完成! 按 q 退出"
}
return fmt.Sprintf("进度: %s %.0f%%", m.progress.ViewAs(m.percent), m.percent*100)
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}八、键盘事件处理
Bubbletea 通过 tea.KeyMsg 处理键盘输入。KeyMsg 提供了多种方式判断按键:
go
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
lastKey string
keys []string
}
func initialModel() model {
return model{}
}
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:
// 方式一:通过 String() 获取按键字符串
keyStr := msg.String()
// 方式二:通过 Type 判断按键类型
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyEsc:
m.keys = m.keys[:0] // 清空
return m, nil
case tea.KeyEnter:
m.lastKey = "Enter"
case tea.KeyUp:
m.lastKey = "Up"
case tea.KeyDown:
m.lastKey = "Down"
case tea.KeyLeft:
m.lastKey = "Left"
case tea.KeyRight:
m.lastKey = "Right"
case tea.KeySpace:
m.lastKey = "Space"
case tea.KeyBackspace:
if len(m.keys) > 0 {
m.keys = m.keys[:len(m.keys)-1]
}
return m, nil
case tea.KeyRunes:
// 普通字符输入
m.lastKey = string(msg.Runes)
m.keys = append(m.keys, string(msg.Runes))
}
// 方式三:直接比较字符串
if keyStr == "q" {
return m, tea.Quit
}
m.lastKey = keyStr
}
return m, nil
}
func (m model) View() string {
s := "键盘事件演示\n\n"
s += fmt.Sprintf("最后按键: %s\n\n", m.lastKey)
s += fmt.Sprintf("已输入: %s\n\n", m.keys)
s += "操作:\n"
s += " - 方向键: 显示方向\n"
s += " - 字母键: 追加到列表\n"
s += " - Backspace: 删除最后\n"
s += " - Esc: 清空\n"
s += " - Ctrl+C / Q: 退出"
return s
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}九、多页面/多视图管理
实际应用中,TUI 通常有多个页面(如主菜单、设置页、关于页)。通过在 Model 中维护一个"当前页面"状态来实现页面切换。
go
package main
import (
"fmt"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// page 表示当前页面
type page int
const (
pageHome page = iota
pageSettings
pageAbout
)
type model struct {
currentPage page
cursor int
name string
email string
notifs bool
}
func initialModel() model {
return model{currentPage: pageHome}
}
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":
if m.currentPage == pageHome {
return m, tea.Quit
}
m.currentPage = pageHome
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < 2 {
m.cursor++
}
case "enter":
switch m.currentPage {
case pageHome:
switch m.cursor {
case 0:
m.currentPage = pageSettings
m.cursor = 0
case 1:
m.currentPage = pageAbout
case 2:
return m, tea.Quit
}
}
case "t", " ":
if m.currentPage == pageSettings && m.cursor == 2 {
m.notifs = !m.notifs
}
}
}
return m, nil
}
func (m model) View() string {
switch m.currentPage {
case pageHome:
return m.viewHome()
case pageSettings:
return m.viewSettings()
case pageAbout:
return m.viewAbout()
}
return ""
}
func (m model) viewHome() string {
var b strings.Builder
b.WriteString("主菜单\n\n")
menu := []string{"设置", "关于", "退出"}
for i, item := range menu {
cursor := " "
if m.cursor == i {
cursor = ">"
}
b.WriteString(fmt.Sprintf("%s %s\n", cursor, item))
}
b.WriteString("\n↑↓ 导航 | Enter 选择 | Q 退出")
return b.String()
}
func (m model) viewSettings() string {
var b strings.Builder
b.WriteString("设置\n\n")
settings := []string{"名称: " + m.name, "邮箱: " + m.email}
if m.notifs {
settings = append(settings, "通知: [x] 开启")
} else {
settings = append(settings, "通知: [ ] 关闭")
}
for i, item := range settings {
cursor := " "
if m.cursor == i {
cursor = ">"
}
b.WriteString(fmt.Sprintf("%s %s\n", cursor, item))
}
b.WriteString("\n↑↓ 导航 | T/空格 切换 | Q 返回")
return b.String()
}
func (m model) viewAbout() string {
return "关于\n\n这是一个多页面 TUI 示例。\n用 Bubbletea 构建。\n\n按 Q 返回主菜单"
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}十、样式:Lipgloss
Lipgloss 是 Bubbletea 的样式库,可以定义颜色、边框、对齐、间距等样式。
安装:
bash
go get github.com/charmbracelet/lipgloss@latestgo
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// 定义样式
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 2)
itemStyle = lipgloss.NewStyle().
PaddingLeft(2)
selectedStyle = lipgloss.NewStyle().
PaddingLeft(2).
Foreground(lipgloss.Color("#7D56F4")).
Bold(true)
borderStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, 2)
successStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575")).
Bold(true)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
Bold(true)
)
type model struct {
items []string
cursor int
}
func initialModel() model {
return model{
items: []string{"创建项目", "打开项目", "设置", "退出"},
}
}
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.items)-1 {
m.cursor++
}
case "enter":
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() string {
var items []string
for i, item := range m.items {
style := itemStyle
if i == m.cursor {
style = selectedStyle
}
items = append(items, style.Render(item))
}
content := titleStyle.Render("主菜单") + "\n\n"
for _, item := range items {
content += item + "\n"
}
content += "\n" + lipgloss.NewStyle().Faint(true).Render("↑↓ 导航 | Enter 确认 | Q 退出")
return borderStyle.Render(content)
}
func main() {
p := tea.NewProgram(initialModel(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}十一、完整示例:交互式 TODO 应用(TUI 版)
下面综合运用 Bubbletea 和 Lipgloss,构建一个功能完整的 TUI TODO 应用。
go
package main
import (
"fmt"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
)
// 样式定义
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
MarginBottom(1)
cursorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true)
doneStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575"))
pendingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF8700"))
faintStyle = lipgloss.NewStyle().
Faint(true)
borderStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, 2)
)
// Todo 表示一个待办事项
type Todo struct {
Title string
Done bool
}
// mode 表示当前模式
type mode int
const (
modeNormal mode = iota
modeAdding
)
// model 应用状态
type model struct {
todos []Todo
cursor int
mode mode
textInput textinput.Model
width int
height int
}
func initialModel() model {
ti := textinput.New()
ti.Placeholder = "输入待办事项..."
ti.CharLimit = 50
ti.Width = 30
return model{
todos: []Todo{
{Title: "学习 Bubbletea", Done: true},
{Title: "写一个 TUI TODO 应用", Done: false},
{Title: "分享给朋友", Done: false},
},
mode: modeNormal,
textInput: ti,
}
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.KeyMsg:
// 在添加模式下,键盘事件优先交给 textinput 处理
if m.mode == modeAdding {
return m.handleAddingInput(msg)
}
return m.handleNormalInput(msg)
}
return m, cmd
}
// handleNormalInput 处理普通模式的键盘输入
func (m model) handleNormalInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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.todos)-1 {
m.cursor++
}
case "enter", " ":
if len(m.todos) > 0 {
m.todos[m.cursor].Done = !m.todos[m.cursor].Done
}
case "a":
// 进入添加模式
m.mode = modeAdding
m.textInput.SetValue("")
m.textInput.Focus()
return m, textinput.Blink
case "d":
// 删除当前项
if len(m.todos) > 0 {
m.todos = append(m.todos[:m.cursor], m.todos[m.cursor+1:]...)
if m.cursor >= len(m.todos) && m.cursor > 0 {
m.cursor--
}
}
case "D":
// 删除所有已完成的
var remaining []Todo
for _, t := range m.todos {
if !t.Done {
remaining = append(remaining, t)
}
}
m.todos = remaining
if m.cursor >= len(m.todos) && m.cursor > 0 {
m.cursor = len(m.todos) - 1
}
}
return m, nil
}
// handleAddingInput 处理添加模式的键盘输入
func (m model) handleAddingInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
// 添加新待办
title := strings.TrimSpace(m.textInput.Value())
if title != "" {
m.todos = append(m.todos, Todo{Title: title, Done: false})
m.cursor = len(m.todos) - 1
}
m.mode = modeNormal
m.textInput.Blur()
return m, nil
case "esc":
// 取消添加
m.mode = modeNormal
m.textInput.Blur()
return m, nil
}
// 其他按键交给 textinput 处理
m.textInput, _ = m.textInput.Update(msg)
return m, nil
}
func (m model) View() string {
var b strings.Builder
// 标题
b.WriteString(titleStyle.Render("TODO 应用"))
b.WriteString("\n")
// 统计
doneCount := 0
for _, t := range m.todos {
if t.Done {
doneCount++
}
}
stats := fmt.Sprintf("总计 %d 项 | 完成 %d 项 | 待办 %d 项",
len(m.todos), doneCount, len(m.todos)-doneCount)
b.WriteString(faintStyle.Render(stats))
b.WriteString("\n\n")
// TODO 列表
if len(m.todos) == 0 {
b.WriteString(faintStyle.Render(" 暂无待办事项,按 a 添加"))
b.WriteString("\n")
} else {
for i, todo := range m.todos {
cursor := " "
if i == m.cursor {
cursor = cursorStyle.Render(">")
}
var status string
var title string
if todo.Done {
status = doneStyle.Render("✓")
title = faintStyle.Render(todo.Title)
} else {
status = pendingStyle.Render("○")
title = todo.Title
}
b.WriteString(fmt.Sprintf(" %s %s %s\n", cursor, status, title))
}
}
b.WriteString("\n")
// 添加模式或操作提示
if m.mode == modeAdding {
b.WriteString(fmt.Sprintf("新增: %s\n", m.textInput.View()))
b.WriteString(faintStyle.Render("Enter 确认 | Esc 取消"))
} else {
b.WriteString(faintStyle.Render("操作: ↑↓ 导航 | 空格 切换完成 | a 添加 | d 删除 | D 清除已完成 | q 退出"))
}
return borderStyle.Render(b.String())
}
func main() {
p := tea.NewProgram(initialModel(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}运行示例:
bash
$ go run main.go界面效果:
╭───────────────────────────────────────────╮
│ TODO 应用 │
│ │
│ 总计 3 项 | 完成 1 项 | 待办 2 项 │
│ │
│ │
│ > ✓ 学习 Bubbletea │
│ ○ 写一个 TUI TODO 应用 │
│ ○ 分享给朋友 │
│ │
│ 操作: ↑↓ 导航 | 空格 切换完成 | a 添加 │
│ d 删除 | D 清除已完成 | q 退出 │
╰───────────────────────────────────────────╯十二、小结
本篇系统学习了 Bubbletea TUI 框架:
- Bubbletea 简介:Charm 生态的 TUI 框架,采用 Elm 架构。
- Elm 架构:Model(状态)- Update(更新)- View(视图)三层分离,状态可预测。
- 核心三要素:
- Model:结构体存储应用状态。
- Update:处理消息,返回新 Model 和 Cmd。
- View:根据 Model 渲染界面字符串。
- 消息系统:KeyMsg、WindowSizeMsg、自定义消息,通过 Cmd 触发异步操作。
- 内置组件:textinput、list、spinner、viewport、progress 等可组合组件。
- 键盘事件:通过 KeyMsg 的 Type、String()、Runes 判断按键。
- 多页面管理:在 Model 中维护当前页面状态,View 根据页面渲染不同内容。
- Lipgloss 样式:定义颜色、边框、对齐等样式,让 TUI 更美观。
- 完整示例:TUI TODO 应用支持添加、切换完成、删除、清除已完成等操作。
Bubbletea 让构建复杂的终端应用变得简单。下一篇我们将讨论 CLI 工具的发布与分发,让开发好的工具能够被用户方便地安装和使用。