Skip to content

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  │ <───┘
           渲染     │ (状态)  │
                    └─────────┘
  1. Model(模型):应用的状态,是一个普通的结构体。
  2. Update(更新):接收当前 Model 和一个消息(Message),返回新的 Model 和一个命令(Cmd)。这是一个纯函数。
  3. 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@latest
go
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 框架:

  1. Bubbletea 简介:Charm 生态的 TUI 框架,采用 Elm 架构。
  2. Elm 架构:Model(状态)- Update(更新)- View(视图)三层分离,状态可预测。
  3. 核心三要素
    • Model:结构体存储应用状态。
    • Update:处理消息,返回新 Model 和 Cmd。
    • View:根据 Model 渲染界面字符串。
  4. 消息系统:KeyMsg、WindowSizeMsg、自定义消息,通过 Cmd 触发异步操作。
  5. 内置组件:textinput、list、spinner、viewport、progress 等可组合组件。
  6. 键盘事件:通过 KeyMsg 的 Type、String()、Runes 判断按键。
  7. 多页面管理:在 Model 中维护当前页面状态,View 根据页面渲染不同内容。
  8. Lipgloss 样式:定义颜色、边框、对齐等样式,让 TUI 更美观。
  9. 完整示例:TUI TODO 应用支持添加、切换完成、删除、清除已完成等操作。

Bubbletea 让构建复杂的终端应用变得简单。下一篇我们将讨论 CLI 工具的发布与分发,让开发好的工具能够被用户方便地安装和使用。