Charmbracelet TUI 生态系统指南

一个面向 Go 语言的现代化终端用户界面(TUI)工具集,包含四个核心库:Bubble TeaLip GlossBubblesGlamour

1. Bubble Tea --- TUI 框架

GitHub : charmbracelet/bubbletea

模块路径 : charm.land/bubbletea/v2(v2)/ github.com/charmbracelet/bubbletea(v1)

1.1 原理:Elm 架构(MVU)

Bubble Tea 基于 The Elm Architecture (Elm 语言提出的架构模式),核心思想是单向数据流,包含三个核心部分:

go 复制代码
┌─────────────────────────────────────────────────────┐
│                      Event Loop                       │
│                                                      │
│   ┌──────────┐    Message     ┌──────────┐          │
│   │  Model   │ ◄──────────── │  Update  │          │
│   │ (状态)   │               │ (更新)   │          │
│   └────┬─────┘               └──────────┘          │
│        │                                              │
│        ▼                                              │
│   ┌──────────┐                                        │
│   │   View   │ ───────► 终端渲染                       │
│   │ (视图)   │                                        │
│   └──────────┘                                        │
└─────────────────────────────────────────────────────┘
概念 说明
Model 应用程序的状态,用一个 struct 表示
Message (Msg) 事件消息(按键、网络响应、定时器等)
Update 接收当前 Model 和 Message,返回新 Model 和可选的 Command
View 根据当前 Model 渲染终端界面,返回字符串
Command (Cmd) 异步操作的描述(I/O、定时器等),由框架执行
Program 运行时引擎,驱动整个事件循环

数据流向:

  1. 程序启动 → 调用 Init() 获取初始命令
  2. View() 渲染初始界面
  3. 用户输入或系统事件 → 生成 Message
  4. Update() 处理 Message → 返回新 Model
  5. View() 重新渲染 → 回到步骤 3

1.2 基本用法

安装
go 复制代码
# v2(推荐)
go get charm.land/bubbletea/v2@latest

# v1
go get github.com/charmbracelet/bubbletea@latest
极简计数器示例
go 复制代码
package main

import (
    "fmt"
    "os"
    tea "charm.land/bubbletea/v2"
)

type model struct {
    count int
}

// 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.KeyPressMsg:
        switch msg.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        case "up", "k":
            m.count++
        case "down", "j":
            m.count--
        case "space":
            m.count = 0
        }
    }
    return m, nil
}

// View 渲染 UI
func (m model) View() tea.View {
    v := tea.NewView(fmt.Sprintf(
        "\n  Count: %d\n\n  ↑/k: up • ↓/j: down • space: reset • q: quit\n",
        m.count,
    ))
    v.AltScreen = true // 使用 alternate screen
    return v
}

func main() {
    p := tea.NewProgram(model{})
    if _, err := p.Run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

1.3 核心类型详解

类型 作用
tea.Model 接口,需实现 Init(), Update(), View()
tea.Msg 消息接口,所有事件都实现此接口
tea.Cmd 命令函数类型 func() tea.Msg
tea.Program 程序运行时,通过 tea.NewProgram() 创建
tea.KeyPressMsg 按键消息(v2)
tea.MouseClickMsg 鼠标点击消息
tea.WindowSizeMsg 窗口大小变化消息
tea.Quit 退出命令
tea.Batch(cmds...) 并行执行多个命令
tea.Sequence(cmds...) 顺序执行多个命令
tea.Tick() 定时器命令
tea.Every() 周期性定时器命令
tea.View v2 中 View 的返回类型,支持声明式终端特性

1.4 命令(Command)模式

命令是描述异步操作的数据(函数),而非直接执行:

go 复制代码
// 自定义命令示例
type tickMsg struct{}

func doTick() tea.Cmd {
    return func() tea.Msg {
        time.Sleep(5 * time.Second)
        return tickMsg{}
    }
}

使用自定义命令

go 复制代码
func (m model) Init() tea.Cmd {
    return doTick()
}

或使用 tea.Tick 更简单

go 复制代码
func (m model) Init() tea.Cmd {
    return tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
        return tickMsg{}
    })
}

对应Update函数处理tickMsg

go 复制代码
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyPressMsg:
       switch msg.String() {
       case "q", "ctrl+c":
          return m, tea.Quit
       case "up", "k":
          m.count++
       case "down", "j":
          m.count--
       case "space":
          m.count = 0
       }
    case tickMsg:
       m.count += 100
    }
    return m, nil
}

运行程序后,程序启动5s后,count增加100

1.5 窗口大小事件

go 复制代码
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
}

1.6 鼠标事件

go 复制代码
// 在 View 中启用鼠标
func (m model) View() tea.View {
    v := tea.NewView("...")
    v.MouseMode = tea.MouseModeCellMotion
    return v
}

// 在 Update 中处理
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.MouseClickMsg:
        if msg.Button == tea.MouseLeft {
            // 处理左键点击
        }
    }
    return m, nil
}

1.7 v1 → v2 主要变化

v1 v2
tea.KeyMsg tea.KeyPressMsg
m.View() string m.View() tea.View
tea.WithAltScreen() v.AltScreen = true
tea.EnterAltScreen / tea.ExitAltScreen View 中声明式设置

2. Lip Gloss --- 终端样式定义

GitHub : charmbracelet/lipgloss

模块路径 : charm.land/lipgloss/v2(v2)/ github.com/charmbracelet/lipgloss(v1)

2.1 原理

Lip Gloss 采用声明式、链式调用的 API 设计,类似 CSS 的 styling 模型。它不直接输出 ANSI 转义码,而是构建一个 Style 对象,然后在渲染时根据终端能力自动进行颜色降级。

核心能力:

  • 方法链式调用构建样式
  • 自动颜色降级(True Color → 256 → 16)
  • ANSI 感知的宽度计算(正确处理中文字符等宽字符)
  • 边框、边距、填充、对齐等布局原理

2.2 安装

go 复制代码
# v2
go get charm.land/lipgloss/v2@latest

# v1
go get github.com/charmbracelet/lipgloss@latest
go 复制代码
// 链式调用构建样式
style := lipgloss.NewStyle().
    Bold(true).
    Italic(true).
    Foreground(lipgloss.Color("#FF69B4")). // 粉色文字
    Background(lipgloss.Color("#333")).    // 深灰背景
    Padding(1, 2).                         // 上下 1,左右 2
    Margin(1).                             // 四周外边距
    Width(30).                             // 固定宽度
    Align(lipgloss.Center)                 // 居中对齐

fmt.Println(style.Render("Hello, Lip Gloss!"))

// 更简洁的写法
title := lipgloss.NewStyle().
    Bold(true).
    Foreground(lipgloss.Color("205")). // 256 色
    Render("This is a title")
fmt.Println(title)

2.4 颜色系统

Lip Gloss 支持三种颜色模式,自动检测终端能力:

go 复制代码
// 1. ANSI 16 色 (4-bit)
lipgloss.Color("5")   // 品红
lipgloss.Color("9")   // 红色
lipgloss.Color("12")  // 浅蓝

// 2. ANSI 256 色 (8-bit)
lipgloss.Color("86")  // 青色
lipgloss.Color("201") // 热粉色
lipgloss.Color("202") // 橙色

// 3. True Color (24-bit) --- 推荐
lipgloss.Color("#FF69B4")  // 粉色
lipgloss.Color("#00FF00")  // 绿色
lipgloss.Color("#333333")  // 深灰

// 自适应颜色(根据终端背景自动调整)
lipgloss.AdaptiveColor{Light: "#FF69B4", Dark: "#FFB6C1"}

2.5 边框

go 复制代码
// 完整边框
style := lipgloss.NewStyle().
    Border(lipgloss.NormalBorder(), true).
    Padding(1).
    Render("Box with border")

// 双边线边框
style := lipgloss.NewStyle().
    Border(lipgloss.DoubleBorder(), true).
    Padding(1).
    Render("Double border")

// 圆角边框
style := lipgloss.NewStyle().
    Border(lipgloss.RoundedBorder(), true).
    Padding(1).
    Render("Rounded border")

// 隐藏边框
style := lipgloss.NewStyle().
    Border(lipgloss.HiddenBorder(), true).
    Padding(1).
    Render("Hidden border")

// 自定义特定边
style := lipgloss.NewStyle().
    Border(lipgloss.NormalBorder(), false).
    BorderTop(true).
    BorderBottom(true)

所有内置边框类型: NormalBorder(), DoubleBorder(), RoundedBorder(), BlockBorder(), HiddenBorder(), ThickBorder()

2.6 边距与填充

go 复制代码
// 统一设置
style := lipgloss.NewStyle().
    Padding(2).         // 上下左右各 2
    Margin(1)           // 上下左右各 1

// 分别设置
style := lipgloss.NewStyle().
    PaddingTop(1).
    PaddingRight(3).
    PaddingBottom(1).
    PaddingLeft(3)

// 分轴设置
style := lipgloss.NewStyle().
    Padding(1, 4)       // 上下 1,左右 4

2.7 对齐

go 复制代码
// 水平对齐
style := lipgloss.NewStyle().
    Width(40).
    Align(lipgloss.Left)    // 左对齐(默认)
    // Align(lipgloss.Center) // 居中对齐
    // Align(lipgloss.Right)  // 右对齐

// 垂直对齐(需要设置高度)
style := lipgloss.NewStyle().
    Width(40).
    Height(5).
    Align(lipgloss.Center, lipgloss.Center)  // 水平和垂直都居中

2.8 布局组合

go 复制代码
// 水平拼接
block := lipgloss.JoinHorizontal(
    lipgloss.Top,
    leftPanel.Render("Left"),
    rightPanel.Render("Right"),
)

// 垂直拼接
block := lipgloss.JoinVertical(
    lipgloss.Left,
    header.Render("Header"),
    content.Render("Content"),
    footer.Render("Footer"),
)

// 在空白中定位
block := lipgloss.Place(
    80,                     // 宽度
    24,                     // 高度
    lipgloss.Center,        // 水平位置
    lipgloss.Center,        // 垂直位置
    content.Render("Hi"),   // 内容
)

2.9 宽度与大小

go 复制代码
// 获取字符串的渲染宽度(ANSI 感知)
width := lipgloss.Width("Hello")         // 5
width = lipgloss.Width("\033[31mHello")  // 5(正确忽略 ANSI 码)

// 设置最小宽度
style := lipgloss.NewStyle().Width(30)

2.10 颜色降级输出

go 复制代码
// 自动降级输出
lipgloss.Print("Hello")     // 类似 fmt.Print,但会降级颜色
lipgloss.Println("Hello")   // 类似 fmt.Println
lipgloss.Printf("Hello")    // 类似 fmt.Printf

// 降级到字符串
s := lipgloss.Sprint(styledText)
e := lipgloss.Sprintf("Hello %s", name)

2.11 文本格式化

go 复制代码
style := lipgloss.NewStyle().
    Bold(true).           // 加粗
    Italic(true).         // 斜体
    Underline(true).      // 下划线
    Strikethrough(true).  // 删除线
    Blink(true).          // 闪烁
    Faint(true)           // 淡色
  1. Bubbles --- 预构建 UI 组件

GitHub : charmbracelet/bubbles

模块路径 : charm.land/bubbles/v2(v2)/ github.com/charmbracelet/bubbles(v1)

3.1 原理

Bubbles 是 Bubble Tea 的官方组件库。每个组件都遵循相同的 Elm Architecture 模式------拥有自己的 Model、Update 和 View,可以嵌入到父组件中。

组件管理方式:父 Model 嵌入子 Model,父 Update 转发消息给子 Update,父 View 调用子 View。

3.2 安装

go 复制代码
# v2
go get charm.land/bubbles/v2@latest

# v1
go get github.com/charmbracelet/bubbles@latest

3.3 可用组件一览

组件 包路径 说明
textinput bubbles/textinput 单行文本输入框
textarea bubbles/textarea 多行文本输入框
table bubbles/table 表格组件(列+行)
list bubbles/list 列表,支持模糊筛选
spinner bubbles/spinner 加载动画
progress bubbles/progress 进度条(支持动画过渡)
paginator bubbles/paginator 分页器(点状或数字)
viewport bubbles/viewport 可滚动的视口
help bubbles/help 快捷键帮助视图
key bubbles/key 按键绑定管理
timer bubbles/timer 计时器
stopwatch bubbles/stopwatch 秒表

3.4 Text Input --- 文本输入框

go 复制代码
package main

import (
    "fmt"
    "log"

    "charm.land/bubbles/v2/textinput"
    tea "charm.land/bubbletea/v2"
)

type model struct {
    input textinput.Model
}

func (m model) Init() tea.Cmd {
    return m.input.Focus()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyPressMsg:
       if msg.String() == "enter" {
          return m, tea.Quit
       }
    }
    var cmd tea.Cmd
    m.input, cmd = m.input.Update(msg)
    return m, cmd
}

func (m model) View() tea.View {
    return tea.NewView(fmt.Sprintf(
       "Enter your name:\n%s\n(press enter to quit)\n",
       m.input.View(),
    ))
}

func main() {
    input := textinput.New()
    input.Placeholder = "Jane Doe"
    input.Focus()
    input.CharLimit = 50
    input.SetWidth(40)

    p := tea.NewProgram(model{input: input})
    if _, err := p.Run(); err != nil {
       log.Fatal(err)
    }
}

3.5 Text Area --- 多行文本输入框

go 复制代码
package main

import (
    "fmt"
    "log"

    "charm.land/bubbles/v2/textarea"
    tea "charm.land/bubbletea/v2"
)

type model struct {
    textarea textarea.Model
    done     bool
}

func (m model) Init() tea.Cmd {
    return m.textarea.Focus()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyPressMsg:
       if msg.String() == "ctrl+c" {
          m.done = true
       }
    }
    var cmd tea.Cmd
    m.textarea, cmd = m.textarea.Update(msg)
    return m, cmd
}

func (m model) View() tea.View {
    if m.done {
       return tea.NewView(fmt.Sprintf("Content:\n%s\n", m.textarea.Value()))
    }
    return tea.NewView(fmt.Sprintf(
       "Compose (Ctrl+C to submit):\n%s\n",
       m.textarea.View(),
    ))
}

func main() {
    // 初始化
    ta := textarea.New()
    ta.Prompt = "> "
    ta.Placeholder = "Enter text here..."
    ta.ShowLineNumbers = true
    ta.Focus()
    ta.SetWidth(60)
    ta.SetHeight(10)

    p := tea.NewProgram(model{textarea: ta, done: false})
    if _, err := p.Run(); err != nil {
       log.Fatal(err)
    }
}

3.6 Table --- 表格

go 复制代码
package main

import (
    "log"

    "charm.land/bubbles/v2/table"
    tea "charm.land/bubbletea/v2"
)

type model struct {
    table table.Model
}

func (m model) Init() tea.Cmd { return nil }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    m.table, cmd = m.table.Update(msg)

    switch msg := msg.(type) {
    case tea.KeyPressMsg:
       switch msg.String() {
       case "q", "esc":
          return m, tea.Quit
       }
    case tea.WindowSizeMsg:
       m.table.SetWidth(msg.Width)
       m.table.SetHeight(msg.Height - 4)
    }
    return m, cmd
}

func (m model) View() tea.View {
    return tea.NewView(m.table.View())
}

func main() {

    // 初始化表格
    columns := []table.Column{
       {Title: "ID", Width: 4},
       {Title: "Name", Width: 15},
       {Title: "Country", Width: 10},
    }
    rows := []table.Row{
       {"1", "Ramsés II", "Egypt"},
       {"2", "Alexander", "Macedon"},
       {"3", "Cleopatra", "Egypt"},
    }

    t := table.New(
       table.WithColumns(columns),
       table.WithRows(rows),
       table.WithHeight(7),
       table.WithFocused(true),
    )

    p := tea.NewProgram(model{table: t})
    if _, err := p.Run(); err != nil {
       log.Fatal(err)
    }
}

3.7 List --- 列表

go 复制代码
package main

import (
    "fmt"
    "io"
    "log"

    "charm.land/bubbles/v2/list"
    tea "charm.land/bubbletea/v2"
)

// 定义列表项
type item string

func (i item) FilterValue() string { return string(i) }

// 定义项委托(控制渲染方式)
type itemDelegate struct{}

func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
    i := listItem.(item)
    if index == m.Index() {
       fmt.Fprint(w, "> "+string(i)) // 选中项加 >
    } else {
       fmt.Fprint(w, "  "+string(i))
    }
}

func (d itemDelegate) Height() int                             { return 1 }
func (d itemDelegate) Spacing() int                            { return 0 }
func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }

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.KeyPressMsg:
       if msg.String() == "q" {
          return m, tea.Quit
       }
    }
    var cmd tea.Cmd
    m.list, cmd = m.list.Update(msg)
    return m, cmd
}

func (m model) View() tea.View {
    return tea.NewView(m.list.View())
}

func main() {
    items := []list.Item{
       item("First item"),
       item("Second item"),
       item("Third item"),
    }

    l := list.New(items, itemDelegate{}, 80, 24)
    l.Title = "My List"

    p := tea.NewProgram(model{list: l})
    if _, err := p.Run(); err != nil {
       log.Fatal(err)
    }
}

3.8 Spinner --- 加载动画

go 复制代码
package main

import (
    "fmt"
    "log"

    "charm.land/bubbles/v2/spinner"
    tea "charm.land/bubbletea/v2"
    "charm.land/lipgloss/v2"
)

type model struct {
    spinner spinner.Model
    loading bool
}

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 spinner.TickMsg:
       var cmd tea.Cmd
       m.spinner, cmd = m.spinner.Update(msg)
       return m, cmd
    }
    return m, nil
}

func (m model) View() tea.View {
    return tea.NewView(fmt.Sprintf("\n  %s Loading...\n", m.spinner.View()))
}

func main() {
    // 初始化
    s := spinner.New()
    s.Spinner = spinner.Moon
    s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))

    p := tea.NewProgram(model{spinner: s})
    if _, err := p.Run(); err != nil {
       log.Fatal(err)
    }
}

内置 spinner 类型: spinner.Line, spinner.Dot, spinner.MiniDot, spinner.Jump, spinner.Pulse, spinner.Points, spinner.Globe, spinner.Moon, spinner.Monkey 等。

3.9 Progress Bar --- 进度条

go 复制代码
package main

import (
    "log"

    "charm.land/bubbles/v2/progress"
    tea "charm.land/bubbletea/v2"
)

type model struct {
    progress progress.Model
    percent  float64
}

func (m model) View() tea.View {
    return tea.NewView(m.progress.View())
}

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.KeyPressMsg:
       if msg.String() == "+" {
          m.percent = min(m.percent+0.1, 1.0)
          return m, m.progress.SetPercent(m.percent)
       }
    }
    var cmd tea.Cmd
    m.progress, cmd = m.progress.Update(msg)
    return m, cmd
}

// 初始化

func main() {
    // 初始化
    p := progress.New(
       progress.WithDefaultBlend(),
       progress.WithoutPercentage(), // 隐藏百分比文字
       progress.WithFillCharacters('█', '░'),
    )

    pr := tea.NewProgram(model{progress: p})
    if _, err := pr.Run(); err != nil {
       log.Fatal(err)
    }
}

3.10 Paginator --- 分页器

go 复制代码
package main

import (
    "fmt"
    "log"

    "charm.land/bubbles/v2/paginator"
    tea "charm.land/bubbletea/v2"
)

type model struct {
    paginator paginator.Model
    data      []string
}

func (m model) Init() tea.Cmd { return nil }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    m.paginator, cmd = m.paginator.Update(msg)

    switch msg := msg.(type) {
    case tea.KeyPressMsg:
       switch msg.String() {
       case "q", "ctrl+c":
          return m, tea.Quit
       }
    }
    return m, cmd
}

func (m model) View() tea.View {
    start, end := m.paginator.GetSliceBounds(len(m.data))
    pageData := m.data[start:end]

    s := "Items on this page:\n"
    for _, item := range pageData {
       s += fmt.Sprintf("  - %s\n", item)
    }
    s += "\n" + m.paginator.View() + "\n"
    s += "(h/l: prev/next page, q: quit)"
    return tea.NewView(s)
}

func main() {
    data := make([]string, 100)
    for i := 0; i < 100; i++ {
       data[i] = fmt.Sprintf("Item %d", i+1)
    }

    p := paginator.New(paginator.WithPerPage(5))
    p.Type = paginator.Dots
    p.SetTotalPages(len(data))

    prog := tea.NewProgram(model{paginator: p, data: data})
    if _, err := prog.Run(); err != nil {
       log.Fatal(err)
    }
}

3.11 Viewport --- 滚动视口

go 复制代码
package main

import (
    "log"
    "strings"

    "charm.land/bubbles/v2/viewport"
    tea "charm.land/bubbletea/v2"
)

type model struct {
    viewport viewport.Model
    ready    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.WindowSizeMsg:
       m.viewport.SetWidth(msg.Width)
       m.viewport.SetHeight(msg.Height - 2)
       if !m.ready {
          m.ready = true
       }
    case tea.KeyPressMsg:
       if msg.String() == "q" {
          return m, tea.Quit
       }
    }
    var cmd tea.Cmd
    m.viewport, cmd = m.viewport.Update(msg)
    return m, cmd
}

func (m model) View() tea.View {
    if !m.ready {
       return tea.NewView("Initializing...")
    }
    return tea.NewView(m.viewport.View())
}

func main() {
    vp := viewport.New(viewport.WithWidth(40), viewport.WithHeight(20))
    vp.KeyMap = viewport.DefaultKeyMap()
    vp.MouseWheelEnabled = true
    vp.SetContent(strings.Repeat("Hello, TUI!\n", 100))

    p := tea.NewProgram(model{viewport: vp})
    if _, err := p.Run(); err != nil {
       log.Fatal(err)
    }
}

3.12 Help --- 帮助视图

go 复制代码
package main

import (
    "fmt"
    "log"

    "charm.land/bubbles/v2/help"
    "charm.land/bubbles/v2/key"
    tea "charm.land/bubbletea/v2"
)

type keyMap struct {
    Up   key.Binding
    Down key.Binding
    Help key.Binding
    Quit key.Binding
}

func (k keyMap) ShortHelp() []key.Binding {
    return []key.Binding{k.Up, k.Down, k.Quit}
}

func (k keyMap) FullHelp() [][]key.Binding {
    return [][]key.Binding{
       {k.Up, k.Down},
       {k.Help, k.Quit},
    }
}

var keys = keyMap{
    Up:   key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "move up")),
    Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "move down")),
    Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")),
    Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
}

type model struct {
    help     help.Model
    showHelp 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.KeyPressMsg:
       switch msg.String() {
       case "q", "ctrl+c":
          return m, tea.Quit
       case "?":
          m.showHelp = !m.showHelp
          m.help.ShowAll = m.showHelp
       }
    }
    var cmd tea.Cmd
    m.help, cmd = m.help.Update(msg)
    return m, cmd
}

func (m model) View() tea.View {
    return tea.NewView(fmt.Sprintf(
       "Content here...\n\n%s",
       m.help.View(keys),
    ))
}

func main() {
    h := help.New()
    h.ShowAll = false

    p := tea.NewProgram(model{help: h})
    if _, err := p.Run(); err != nil {
       log.Fatal(err)
    }
}

4. Glamour --- Markdown 渲染

GitHub : charmbracelet/glamour

模块路径 : charm.land/glamour/v2(v2)/ github.com/charmbracelet/glamour(v1)

4.1 原理

Glamour 是一个基于样式表 的 Markdown 渲染器。它利用 Goldmark 解析 Markdown,然后通过 JSON 样式表控制每个 Markdown 元素的 ANSI 样式(颜色、字体效果、缩进等)。

它与 Lip Gloss 的关系:

  • Lip Gloss = 底层样式定义 + 布局
  • Glamour = 上层 Markdown → ANSI 的渲染引擎(内部使用 Lip Gloss)

4.2 安装

bash 复制代码
# v2
go get charm.land/glamour/v2@latest

# v1
go get github.com/charmbracelet/glamour@latest

4.3 基础渲染

go 复制代码
func main() {
    in := `# Hello World

This is **bold** and *italic* text.

- List item 1
- List item 2
- List item 3

> A blockquote here.

Check out [Charm](https://charm.sh).

~~~go
    package main
    import "fmt"
    func main() {
       fmt.Println("Hello!")
    }
~~~
`

    // 简单渲染(使用内置 "dark" 样式)
    out, err := glamour.Render(in, "dark")
    if err != nil {
       panic(err)
    }
    fmt.Print(out)
}

4.4 自定义渲染器

go 复制代码
import "github.com/charmbracelet/glamour"

r, _ := glamour.NewTermRenderer(
    glamour.WithStylePath("dark"),    // 样式主题
    glamour.WithWordWrap(80),         // 换行宽度
)

out, err := r.Render(markdownContent)
if err != nil {
    // 处理错误
}
fmt.Print(out)

4.5 颜色降级

Glamour 本身是"纯净"的渲染器,不感知终端能力。需要配合 Lip Gloss 做颜色降级:

go 复制代码
import (
    "github.com/charmbracelet/glamour"
    "github.com/charmbracelet/lipgloss"
)

r, _ := glamour.NewTermRenderer(
    glamour.WithWordWrap(80),
)

out, err := r.Render(in)
if err != nil {
    // 处理错误
}

// 使用 Lip Gloss 降级颜色到终端支持的范围
lipgloss.Print(out)

4.6 内置样式

样式名 说明
"dark" 深色主题(默认)
"light" 浅色主题
"notty" 无终端风格(纯文本)
"ascii" ASCII 风格

4.7 通过环境变量控制样式

go 复制代码
export GLAMOUR_STYLE=dark
# 或指向自定义 JSON 样式文件
export GLAMOUR_STYLE=/path/to/style.json
go 复制代码
// 使用环境变量配置
out, err := glamour.RenderWithEnvironmentConfig(in)

// 或在自定义渲染器中使用
r, _ := glamour.NewTermRenderer(
    glamour.WithEnvironmentConfig(),
)

4.8 自定义样式表

Glamour 样式是 JSON 格式,可以为每个 Markdown 元素定义样式:

go 复制代码
{
  "document": {
    "block_prefix": "\n",
    "block_suffix": "\n",
    "color": "252",
    "margin": 2
  },
  "h1": {
    "prefix": " ",
    "suffix": " ",
    "color": "228",
    "background_color": "63",
    "bold": true
  },
  "h2": {
    "prefix": "## ",
    "color": "39",
    "bold": true
  },
  "code_block": {
    "color": "200",
    "theme": "solarized-dark"
  },
  "link": {
    "color": "30",
    "underline": true
  },
  "block_quote": {
    "indent": 1,
    "indent_token": "│ "
  }
}

支持的 Markdown 元素: document, heading, h1--h6, paragraph, text, strong, emph, strikethrough, link, link_text, image, image_text, code, code_block, block_quote, list, item, enumeration, task, hr, table, thead, tbody, tr, th, td 等。

5. 综合示例

带样式的待办列表应用

go 复制代码
package main

import (
    "fmt"
    "os"

    "charm.land/bubbles/v2/textinput"
    tea "charm.land/bubbletea/v2"
    "charm.land/lipgloss/v2"
)

// ---- 样式定义 ----
var (
    titleStyle = lipgloss.NewStyle().
          Bold(true).
          Foreground(lipgloss.Color("#FFF")).
          Background(lipgloss.Color("#7C3AED")).
          Padding(0, 2).
          Render

    itemStyle = lipgloss.NewStyle().
          PaddingLeft(2).
          Render

    selectedItemStyle = lipgloss.NewStyle().
             PaddingLeft(2).
             Foreground(lipgloss.Color("#7C3AED")).
             Render

    doneItemStyle = lipgloss.NewStyle().
          PaddingLeft(2).
          Foreground(lipgloss.Color("#10B981")).
          Strikethrough(true).
          Render

    inputStyle = lipgloss.NewStyle().
          PaddingLeft(2).
          Border(lipgloss.NormalBorder(), false).
          BorderTop(true).
          BorderBackground(lipgloss.Color("#7C3AED"))
)

// ---- Model ----
type item struct {
    text string
    done bool
}

type model struct {
    items    []item
    cursor   int
    input    textinput.Model
    adding   bool
    quitting 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.KeyPressMsg:
       switch {
       case msg.String() == "q" && !m.adding:
          m.quitting = true
          return m, tea.Quit

       case msg.String() == "n" && !m.adding:
          m.adding = true
          m.input.Focus()
          return m, textinput.Blink

       case msg.String() == "enter" && m.adding:
          val := m.input.Value()
          if val != "" {
             m.items = append(m.items, item{text: val})
          }
          m.input.SetValue("")
          m.adding = false
          return m, nil

       case msg.String() == "esc" && m.adding:
          m.input.SetValue("")
          m.adding = false
          return m, nil

       case msg.String() == "up" && !m.adding && m.cursor > 0:
          m.cursor--

       case msg.String() == "down" && !m.adding && m.cursor < len(m.items)-1:
          m.cursor++

       case msg.String() == "space" && !m.adding && len(m.items) > 0:
          m.items[m.cursor].done = !m.items[m.cursor].done
       }
    }

    if m.adding {
       var cmd tea.Cmd
       m.input, cmd = m.input.Update(msg)
       return m, cmd
    }

    return m, nil
}

func (m model) View() tea.View {
    v := tea.NewView("")

    // 标题
    s := titleStyle(" 📋 TODO List ") + "\n\n"

    // 列表项
    for i, item := range m.items {
       check := "[ ]"
       if item.done {
          check = "[✓]"
       }

       line := fmt.Sprintf("%s %s", check, item.text)

       if i == m.cursor {
          s += selectedItemStyle("→ " + line)
       } else if item.done {
          s += doneItemStyle("  " + line)
       } else {
          s += itemStyle("  " + line)
       }
       s += "\n"
    }

    if len(m.items) == 0 {
       s += itemStyle("  (no items yet)\n")
    }

    // 输入模式
    if m.adding {
       s += "\n" + inputStyle.Render("New: "+m.input.View())
    } else {
       s += "\n\n  [n] new  [space] toggle  [↑/↓] move  [q] quit\n"
    }

    v.AltScreen = true
    return tea.NewView(s)
}

func main() {
    p := tea.NewProgram(model{
       input: textinput.New(),
    })
    if _, err := p.Run(); err != nil {
       fmt.Fprintln(os.Stderr, err)
       os.Exit(1)
    }
}

Markdown 阅读器示例

go 复制代码
package main

import (
    "fmt"
    "os"

    "charm.land/bubbles/v2/viewport"
    tea "charm.land/bubbletea/v2"
    "charm.land/glamour/v2"
    "charm.land/lipgloss/v2"
)

type model struct {
    viewport viewport.Model
    ready    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.WindowSizeMsg:
       if !m.ready {
          m.viewport = viewport.New(viewport.WithWidth(msg.Width), viewport.WithHeight(msg.Height-2))
          m.viewport.YPosition = 0

          // 渲染 Markdown
          md := `# Welcome

This is rendered with **Glamour**!

- Beautiful Markdown rendering
- Custom stylesheets
- Syntax highlighting

> "Charm builds tools for the terminal."

~~~go
      func main(){
             fmt.Println("Hello, TUI!")
      }
~~~
`
          r, _ := glamour.NewTermRenderer(
             glamour.WithStylePath("dark"),
             glamour.WithWordWrap(msg.Width),
          )
          content, _ := r.Render(md)
          m.viewport.SetContent(content)
          m.ready = true
       } else {
          m.viewport.SetWidth(msg.Width)
          m.viewport.SetWidth(msg.Height - 2)
       }
    }

    var cmd tea.Cmd
    m.viewport, cmd = m.viewport.Update(msg)
    return m, cmd
}

func (m model) View() tea.View {
    if !m.ready {
       return tea.NewView("\n  Initializing...")
    }

    header := lipgloss.NewStyle().
       Bold(true).
       Foreground(lipgloss.Color("#FFF")).
       Background(lipgloss.Color("#7C3AED")).
       Width(m.viewport.Width()).
       Align(lipgloss.Center).
       Render(" 📖 Markdown Reader ")

    return tea.NewView(fmt.Sprintf("%s\n%s", header, m.viewport.View()))
}

func main() {
    p := tea.NewProgram(model{})
    if _, err := p.Run(); err != nil {
       fmt.Fprintln(os.Stderr, err)
       os.Exit(1)
    }
}

库层级关系

bash 复制代码
        ┌──────────────────────────────────────────┐
        │           你的 TUI 应用程序                 │
        ├──────────────────────────────────────────┤
        │  Bubbles (预构建组件)                       │
        │   textinput, table, list, viewport ...     │
        ├──────────────────────────────────────────┤
        │  Bubble Tea (框架层 - 事件循环)              │
        │   Model → Update → View 循环               │
        ├──────────────────┬───────────────────────┤
        │  Lip Gloss       │  Glamour               │
        │  (样式 + 布局)    │  (Markdown → ANSI)      │
        ├──────────────────┴───────────────────────┤
        │            终端 (TTY / ANSI)               │
        └──────────────────────────────────────────┘

延伸阅读

相关推荐
颜进强1 小时前
AI性能参数-截断、延迟与流式输出
前端·后端·ai编程
浮游本尊1 小时前
Java学习第44天 - 本地二级缓存 Caffeine、Redis 分布式锁与热点 Key / 库存预扣
后端
浮游本尊2 小时前
Java学习第43天 - Redis 缓存基础、Cache-Aside 模式与缓存一致性
后端
云技纵横2 小时前
线程池 OOM 实战:无界队列配错,5 万个任务撑爆 JVM
后端
渣波2 小时前
拒绝 SQL 焦虑!手把手带你用 NestJS + Prisma + DTO 写出“防弹”级后端代码
javascript·数据库·后端
用户61541317281272 小时前
# 写接口自动化时,我在断言上栽过的两个跟头
后端
SamDeepThinking2 小时前
Java微服务练习方式
java·后端·微服务
IT_陈寒2 小时前
Vue的响应式真把我坑惨了,原来问题出在这
前端·人工智能·后端
codedx3 小时前
LangChain 和 LangGraph 构建的 Agent 项目模版
后端·langchain·agent