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 小时前
C# 异常处理详解
开发语言·后端·c#
考虑考虑4 小时前
Sentinel安装
java·后端·微服务
IT_陈寒4 小时前
SpringBoot自动配置失灵?你可能忘了这个关键注解
前端·人工智能·后端
碎碎念_4924 小时前
SpringBoot + Vue 前后端分离从 0 到 1 完整环境配置流程
vue.js·spring boot·后端
逝水无殇4 小时前
C# 文件的输入与输出详解
开发语言·数据库·后端·c#
YIAN5 小时前
从手写 Agent 工具到 MCP 协议:一文搞懂大模型跨进程跨语言工具调用
后端·agent·mcp
小兔崽子去哪了5 小时前
Docker 删除镜像后磁盘空间没有释放?
后端·docker·容器
高明珠6 小时前
麒麟 V10 SP1 排查实录:ttyS5 每 10 秒莫名收到一批报文,元凶是 eGTouchD
后端
卷无止境6 小时前
Python FFI 技术深度解析:ctypes、cffi 与 pybind11 的性能差异与实践挑战
后端·python
ServBay6 小时前
AI Gateway 是什么?为什么每个平台都在做
后端·aigc·ai编程