一个面向 Go 语言的现代化终端用户界面(TUI)工具集,包含四个核心库:Bubble Tea 、Lip Gloss 、Bubbles 和 Glamour。
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 | 运行时引擎,驱动整个事件循环 |
数据流向:
- 程序启动 → 调用
Init()获取初始命令 View()渲染初始界面- 用户输入或系统事件 → 生成 Message
Update()处理 Message → 返回新 ModelView()重新渲染 → 回到步骤 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) // 淡色
- 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) │
└──────────────────────────────────────────┘
延伸阅读
- Charmbracelet 官网 --- 了解更多 Charm 生态工具
- Bubble Tea v2 升级指南
- Lip Gloss v2 升级指南
- Glamour 样式指南
- Bubble Tea 示例合集
- Glamour 样式画廊