bubbletea-docs

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Bubble Tea TUI Framework Skill

Bubble Tea TUI框架技能

Overview

概述

Bubble Tea is a powerful TUI framework for Go based on The Elm Architecture. Every program has a Model (state) and three methods:
Init()
(initial command),
Update()
(handle messages),
View()
(render UI as string).
This skill covers the complete Charm ecosystem:
  • Bubble Tea - Core TUI framework
  • Bubbles - Reusable UI components
  • Lip Gloss - Terminal styling
  • Huh - Interactive forms
Bubble Tea 是一款基于The Elm Architecture的强大Go语言TUI框架。每个程序都包含一个Model(状态)以及三个方法:
Init()
(初始化命令)、
Update()
(处理消息)、
View()
(将UI渲染为字符串)。
本技能涵盖完整的Charm生态系统:
  • Bubble Tea - 核心TUI框架
  • Bubbles - 可复用UI组件
  • Lip Gloss - 终端样式工具
  • Huh - 交互式表单工具

Installation

安装

bash
go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/bubbles    # UI components
go get github.com/charmbracelet/lipgloss   # Styling
go get github.com/charmbracelet/huh        # Forms
bash
go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/bubbles    # UI组件
go get github.com/charmbracelet/lipgloss   # 样式工具
go get github.com/charmbracelet/huh        # 表单工具

Quick Start

快速开始

go
package main

import (
    "fmt"
    "log"
    tea "github.com/charmbracelet/bubbletea"
)

type model struct {
    cursor   int
    choices  []string
    selected map[int]struct{}
}

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.choices)-1 { m.cursor++ }
        case "enter", " ":
            if _, ok := m.selected[m.cursor]; ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        }
    }
    return m, nil
}

func (m model) View() string {
    s := "Choose:\n\n"
    for i, choice := range m.choices {
        cursor, checked := " ", " "
        if m.cursor == i { cursor = ">" }
        if _, ok := m.selected[i]; ok { checked = "x" }
        s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
    }
    return s + "\nq to quit.\n"
}

func main() {
    p := tea.NewProgram(model{
        choices:  []string{"Option A", "Option B", "Option C"},
        selected: make(map[int]struct{}),
    })
    if _, err := p.Run(); err != nil { log.Fatal(err) }
}
go
package main

import (
    "fmt"
    "log"
    tea "github.com/charmbracelet/bubbletea"
)

type model struct {
    cursor   int
    choices  []string
    selected map[int]struct{}
}

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.choices)-1 { m.cursor++ }
        case "enter", " ":
            if _, ok := m.selected[m.cursor]; ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        }
    }
    return m, nil
}

func (m model) View() string {
    s := "Choose:\n\n"
    for i, choice := range m.choices {
        cursor, checked := " ", " "
        if m.cursor == i { cursor = ">" }
        if _, ok := m.selected[i]; ok { checked = "x" }
        s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
    }
    return s + "\nq to quit.\n"
}

func main() {
    p := tea.NewProgram(model{
        choices:  []string{"Option A", "Option B", "Option C"},
        selected: make(map[int]struct{}),
    })
    if _, err := p.Run(); err != nil { log.Fatal(err) }
}

Core Concepts

核心概念

Messages and Commands

消息与命令

go
// Custom messages
type tickMsg time.Time
type dataMsg struct{ data string }
type errMsg struct{ error }

// Command returns a message (runs async)
func fetchData() tea.Msg {
    resp, err := http.Get("https://api.example.com")
    if err != nil { return errMsg{err} }
    defer resp.Body.Close()
    // ... process response
    return dataMsg{data: "result"}
}

// Handle in Update
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case dataMsg:
        m.data = msg.data
    case errMsg:
        m.err = msg.error
    }
    return m, nil
}

// Start command in Init
func (m model) Init() tea.Cmd {
    return fetchData  // Runs async, sends message when done
}
go
// 自定义消息
type tickMsg time.Time
type dataMsg struct{ data string }
type errMsg struct{ error }

// 命令返回消息(异步执行)
func fetchData() tea.Msg {
    resp, err := http.Get("https://api.example.com")
    if err != nil { return errMsg{err} }
    defer resp.Body.Close()
    // ... 处理响应
    return dataMsg{data: "result"}
}

// 在Update中处理消息
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case dataMsg:
        m.data = msg.data
    case errMsg:
        m.err = msg.error
    }
    return m, nil
}

// 在Init中启动命令
func (m model) Init() tea.Cmd {
    return fetchData  // 异步执行,完成后发送消息
}

Batch and Sequence Commands

批量与序列命令

go
// Parallel execution
return tea.Batch(cmd1, cmd2, cmd3)

// Sequential execution
return tea.Sequence(step1, step2, tea.Quit)
go
// 并行执行
return tea.Batch(cmd1, cmd2, cmd3)

// 顺序执行
return tea.Sequence(step1, step2, tea.Quit)

Window Size Handling

窗口尺寸处理

go
type model struct {
    width, height int
    viewport      viewport.Model
    ready         bool
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.width, m.height = msg.Width, msg.Height
        if !m.ready {
            m.viewport = viewport.New(msg.Width, msg.Height-4)
            m.viewport.SetContent(m.content)
            m.ready = true
        } else {
            m.viewport.Width = msg.Width
            m.viewport.Height = msg.Height - 4
        }
    }
    return m, nil
}
go
type model struct {
    width, height int
    viewport      viewport.Model
    ready         bool
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.width, m.height = msg.Width, msg.Height
        if !m.ready {
            m.viewport = viewport.New(msg.Width, msg.Height-4)
            m.viewport.SetContent(m.content)
            m.ready = true
        } else {
            m.viewport.Width = msg.Width
            m.viewport.Height = msg.Height - 4
        }
    }
    return m, nil
}

Common Patterns

常见模式

Program Options

程序选项

go
// Fullscreen
p := tea.NewProgram(m, tea.WithAltScreen())

// Mouse support
p := tea.NewProgram(m, tea.WithMouseCellMotion())

// Combined
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
go
// 全屏模式
p := tea.NewProgram(m, tea.WithAltScreen())

// 鼠标支持
p := tea.NewProgram(m, tea.WithMouseCellMotion())

// 组合选项
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())

Key Bindings with bubbles/key

使用bubbles/key处理按键绑定

go
import "github.com/charmbracelet/bubbles/key"

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

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

// In Update
case tea.KeyMsg:
    switch {
    case key.Matches(msg, keys.Up):
        m.cursor--
    case key.Matches(msg, keys.Quit):
        return m, tea.Quit
    }
go
import "github.com/charmbracelet/bubbles/key"

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

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

// 在Update中处理
case tea.KeyMsg:
    switch {
    case key.Matches(msg, keys.Up):
        m.cursor--
    case key.Matches(msg, keys.Quit):
        return m, tea.Quit
    }

Multiple Views

多视图切换

go
type model struct {
    state int  // 0=menu, 1=detail
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch m.state {
    case 0: return m.updateMenu(msg)
    case 1: return m.updateDetail(msg)
    }
    return m, nil
}

func (m model) View() string {
    switch m.state {
    case 0: return m.menuView()
    case 1: return m.detailView()
    }
    return ""
}

go
type model struct {
    state int  // 0=菜单页, 1=详情页
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch m.state {
    case 0: return m.updateMenu(msg)
    case 1: return m.updateDetail(msg)
    }
    return m, nil
}

func (m model) View() string {
    switch m.state {
    case 0: return m.menuView()
    case 1: return m.detailView()
    }
    return ""
}

Bubbles Components

Bubbles组件

Component Quick Reference

组件速查

ComponentImportInitKey Method
Spinner
bubbles/spinner
spinner.New()
.Tick
in Init
TextInput
bubbles/textinput
textinput.New()
.Focus()
,
.Value()
TextArea
bubbles/textarea
textarea.New()
.Focus()
,
.Value()
List
bubbles/list
list.New(items, delegate, w, h)
.SelectedItem()
Table
bubbles/table
table.New(opts...)
.SelectedRow()
Viewport
bubbles/viewport
viewport.New(w, h)
.SetContent()
,
.ScrollUp()
,
.ScrollDown()
Progress
bubbles/progress
progress.New()
.SetPercent()
Help
bubbles/help
help.New()
.View(keyMap)
FilePicker
bubbles/filepicker
filepicker.New()
.DidSelectFile()
Timer
bubbles/timer
timer.New(duration)
.Toggle()
Stopwatch
bubbles/stopwatch
stopwatch.New()
.Elapsed()
组件导入路径初始化方法核心方法
Spinner
bubbles/spinner
spinner.New()
在Init中返回
.Tick
TextInput
bubbles/textinput
textinput.New()
.Focus()
,
.Value()
TextArea
bubbles/textarea
textarea.New()
.Focus()
,
.Value()
List
bubbles/list
list.New(items, delegate, w, h)
.SelectedItem()
Table
bubbles/table
table.New(opts...)
.SelectedRow()
Viewport
bubbles/viewport
viewport.New(w, h)
.SetContent()
,
.ScrollUp()
,
.ScrollDown()
Progress
bubbles/progress
progress.New()
.SetPercent()
Help
bubbles/help
help.New()
.View(keyMap)
FilePicker
bubbles/filepicker
filepicker.New()
.DidSelectFile()
Timer
bubbles/timer
timer.New(duration)
.Toggle()
Stopwatch
bubbles/stopwatch
stopwatch.New()
.Elapsed()

Focus Management (Multiple Inputs)

焦点管理(多输入框)

go
type model struct {
    inputs     []textinput.Model
    focusIndex int
}

func (m *model) nextInput() tea.Cmd {
    m.inputs[m.focusIndex].Blur()
    m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
    return m.inputs[m.focusIndex].Focus()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "tab":
            return m, m.nextInput()
        }
    }
    // Update all inputs
    cmds := make([]tea.Cmd, len(m.inputs))
    for i := range m.inputs {
        m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
    }
    return m, tea.Batch(cmds...)
}
go
type model struct {
    inputs     []textinput.Model
    focusIndex int
}

func (m *model) nextInput() tea.Cmd {
    m.inputs[m.focusIndex].Blur()
    m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
    return m.inputs[m.focusIndex].Focus()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "tab":
            return m, m.nextInput()
        }
    }
    // 更新所有输入框
    cmds := make([]tea.Cmd, len(m.inputs))
    for i := range m.inputs {
        m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
    }
    return m, tea.Batch(cmds...)
}

Component Composition

组件组合

go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmds []tea.Cmd
    var cmd tea.Cmd

    // Update all components and collect commands
    m.spinner, cmd = m.spinner.Update(msg)
    cmds = append(cmds, cmd)

    m.textInput, cmd = m.textInput.Update(msg)
    cmds = append(cmds, cmd)

    m.viewport, cmd = m.viewport.Update(msg)
    cmds = append(cmds, cmd)

    return m, tea.Batch(cmds...)
}
go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmds []tea.Cmd
    var cmd tea.Cmd

    // 更新所有组件并收集命令
    m.spinner, cmd = m.spinner.Update(msg)
    cmds = append(cmds, cmd)

    m.textInput, cmd = m.textInput.Update(msg)
    cmds = append(cmds, cmd)

    m.viewport, cmd = m.viewport.Update(msg)
    cmds = append(cmds, cmd)

    return m, tea.Batch(cmds...)
}

Spinner Example

Spinner示例

go
s := spinner.New()
s.Spinner = spinner.Dot  // Line, Dot, MiniDot, Jump, Pulse, Points, Globe, Moon, Monkey
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))

// In Init(): return m.spinner.Tick
// In Update(): handle spinner.TickMsg
go
s := spinner.New()
s.Spinner = spinner.Dot  // Line, Dot, MiniDot, Jump, Pulse, Points, Globe, Moon, Monkey
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))

// 在Init()中返回m.spinner.Tick
// 在Update()中处理spinner.TickMsg

List with Custom Items

自定义列表项

go
type item struct{ title, 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 }  // Required for filtering

items := []list.Item{item{"One", "Description"}}
l := list.New(items, list.NewDefaultDelegate(), 30, 10)
l.Title = "Select Item"
go
type item struct{ title, 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 }  // 过滤功能必填

items := []list.Item{item{"One", "Description"}}
l := list.New(items, list.NewDefaultDelegate(), 30, 10)
l.Title = "Select Item"

Table

表格组件

go
columns := []table.Column{
    {Title: "ID", Width: 10},
    {Title: "Name", Width: 20},
}
rows := []table.Row{
    {"1", "Alice"},
    {"2", "Bob"},
}
t := table.New(
    table.WithColumns(columns),
    table.WithRows(rows),
    table.WithFocused(true),
    table.WithHeight(7),
)

go
columns := []table.Column{
    {Title: "ID", Width: 10},
    {Title: "Name", Width: 20},
}
rows := []table.Row{
    {"1", "Alice"},
    {"2", "Bob"},
}
t := table.New(
    table.WithColumns(columns),
    table.WithRows(rows),
    table.WithFocused(true),
    table.WithHeight(7),
)

Lip Gloss Styling

Lip Gloss样式

Basic Styling

基础样式

go
import "github.com/charmbracelet/lipgloss"

var style = lipgloss.NewStyle().
    Bold(true).
    Foreground(lipgloss.Color("205")).
    Background(lipgloss.Color("#7D56F4")).
    Border(lipgloss.RoundedBorder()).
    Padding(1, 2)

output := style.Render("Hello, World!")
go
import "github.com/charmbracelet/lipgloss"

var style = lipgloss.NewStyle().
    Bold(true).
    Foreground(lipgloss.Color("205")).
    Background(lipgloss.Color("#7D56F4")).
    Border(lipgloss.RoundedBorder()).
    Padding(1, 2)

output := style.Render("Hello, World!")

Colors

颜色设置

go
// Hex colors
lipgloss.Color("#FF00FF")

// ANSI 256 colors
lipgloss.Color("205")

// Adaptive colors (auto light/dark background)
lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}

// Complete color profiles
lipgloss.CompleteColor{
    TrueColor: "#FF00FF",
    ANSI256:   "205",
    ANSI:      "5",
}
go
// 十六进制颜色
lipgloss.Color("#FF00FF")

// ANSI 256色
lipgloss.Color("205")

// 自适应颜色(自动适配明暗背景)
lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}

// 完整颜色配置
lipgloss.CompleteColor{
    TrueColor: "#FF00FF",
    ANSI256:   "205",
    ANSI:      "5",
}

Layout Composition

布局组合

go
// Horizontal layout
left := lipgloss.NewStyle().Width(20).Render("Left")
right := lipgloss.NewStyle().Width(20).Render("Right")
row := lipgloss.JoinHorizontal(lipgloss.Top, left, right)

// Vertical layout
header := "Header"
body := "Body content"
footer := "Footer"
page := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)

// Center content in box
centered := lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, content)
go
// 水平布局
left := lipgloss.NewStyle().Width(20).Render("Left")
right := lipgloss.NewStyle().Width(20).Render("Right")
row := lipgloss.JoinHorizontal(lipgloss.Top, left, right)

// 垂直布局
header := "Header"
body := "Body content"
footer := "Footer"
page := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)

// 内容居中显示
centered := lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, content)

Frame Size for Calculations

布局尺寸计算

go
// Account for padding/border/margin in calculations
h, v := docStyle.GetFrameSize()
m.list.SetSize(m.width-h, m.height-v)

// Dynamic content height
headerH := lipgloss.Height(m.header())
footerH := lipgloss.Height(m.footer())
m.viewport.Height = m.height - headerH - footerH
go
// 计算时考虑内边距/边框/外边距
h, v := docStyle.GetFrameSize()
m.list.SetSize(m.width-h, m.height-v)

// 动态内容高度
headerH := lipgloss.Height(m.header())
footerH := lipgloss.Height(m.footer())
m.viewport.Height = m.height - headerH - footerH

Border Styles

边框样式

go
lipgloss.NormalBorder()   // Standard box
lipgloss.RoundedBorder()  // Rounded corners
lipgloss.ThickBorder()    // Thick lines
lipgloss.DoubleBorder()   // Double lines
lipgloss.HiddenBorder()   // Invisible (for spacing)

go
lipgloss.NormalBorder()   // 标准边框
lipgloss.RoundedBorder()  // 圆角边框
lipgloss.ThickBorder()    // 粗线边框
lipgloss.DoubleBorder()   // 双线边框
lipgloss.HiddenBorder()   // 隐藏边框(仅用于间距)

Huh Forms

Huh表单

Quick Start

快速开始

go
import "github.com/charmbracelet/huh"

var name string
var confirmed bool

form := huh.NewForm(
    huh.NewGroup(
        huh.NewInput().
            Title("What's your name?").
            Value(&name),
        
        huh.NewConfirm().
            Title("Ready to proceed?").
            Value(&confirmed),
    ),
)

err := form.Run()
if err != nil {
    if err == huh.ErrUserAborted {
        fmt.Println("Cancelled")
        return
    }
    log.Fatal(err)
}
go
import "github.com/charmbracelet/huh"

var name string
var confirmed bool

form := huh.NewForm(
    huh.NewGroup(
        huh.NewInput().
            Title("What's your name?").
            Value(&name),
        
        huh.NewConfirm().
            Title("Ready to proceed?").
            Value(&confirmed),
    ),
)

err := form.Run()
if err != nil {
    if err == huh.ErrUserAborted {
        fmt.Println("Cancelled")
        return
    }
    log.Fatal(err)
}

Field Types (GENERIC TYPES REQUIRED)

字段类型(需指定泛型)

go
// Input - single line text
huh.NewInput().Title("Name").Value(&name)

// Text - multi-line
huh.NewText().Title("Bio").Lines(5).Value(&bio)

// Select - MUST specify type
huh.NewSelect[string]().
    Title("Choose").
    Options(
        huh.NewOption("Option A", "a"),
        huh.NewOption("Option B", "b"),
    ).
    Value(&choice)

// MultiSelect - MUST specify type  
huh.NewMultiSelect[string]().
    Title("Choose many").
    Options(huh.NewOptions("A", "B", "C")...).
    Limit(2).
    Value(&choices)

// Confirm
huh.NewConfirm().
    Title("Sure?").
    Affirmative("Yes").
    Negative("No").
    Value(&confirmed)

// FilePicker
huh.NewFilePicker().
    Title("Select file").
    AllowedTypes([]string{".go", ".md"}).
    Value(&filepath)
go
// Input - 单行文本输入
huh.NewInput().Title("Name").Value(&name)

// Text - 多行文本输入
huh.NewText().Title("Bio").Lines(5).Value(&bio)

// Select - 必须指定类型
huh.NewSelect[string]().
    Title("Choose").
    Options(
        huh.NewOption("Option A", "a"),
        huh.NewOption("Option B", "b"),
    ).
    Value(&choice)

// MultiSelect - 必须指定类型  
huh.NewMultiSelect[string]().
    Title("Choose many").
    Options(huh.NewOptions("A", "B", "C")...).
    Limit(2).
    Value(&choices)

// Confirm - 确认框
huh.NewConfirm().
    Title("Sure?").
    Affirmative("Yes").
    Negative("No").
    Value(&confirmed)

// FilePicker - 文件选择器
huh.NewFilePicker().
    Title("Select file").
    AllowedTypes([]string{".go", ".md"}).
    Value(&filepath)

Validation

验证规则

go
huh.NewInput().
    Title("Email").
    Value(&email).
    Validate(func(s string) error {
        if !strings.Contains(s, "@") {
            return errors.New("invalid email")
        }
        return nil
    })
go
huh.NewInput().
    Title("Email").
    Value(&email).
    Validate(func(s string) error {
        if !strings.Contains(s, "@") {
            return errors.New("invalid email")
        }
        return nil
    })

Dynamic Forms (OptionsFunc/TitleFunc)

动态表单(OptionsFunc/TitleFunc)

go
var country string
var state string

huh.NewSelect[string]().
    Value(&state).
    TitleFunc(func() string {
        if country == "Canada" { return "Province" }
        return "State"
    }, &country).  // Recompute when country changes
    OptionsFunc(func() []huh.Option[string] {
        return huh.NewOptions(getStatesFor(country)...)
    }, &country)
go
var country string
var state string

huh.NewSelect[string]().
    Value(&state).
    TitleFunc(func() string {
        if country == "Canada" { return "Province" }
        return "State"
    }, &country).  // 当country变化时重新计算
    OptionsFunc(func() []huh.Option[string] {
        return huh.NewOptions(getStatesFor(country)...)
    }, &country)

Bubble Tea Integration

与Bubble Tea集成

go
type Model struct {
    form *huh.Form
}

func (m Model) Init() tea.Cmd {
    return m.form.Init()
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    form, cmd := m.form.Update(msg)
    if f, ok := form.(*huh.Form); ok {
        m.form = f
    }
    
    if m.form.State == huh.StateCompleted {
        // Form completed - access values
        name := m.form.GetString("name")
        return m, tea.Quit
    }
    
    return m, cmd
}

func (m Model) View() string {
    if m.form.State == huh.StateCompleted {
        return "Done!"
    }
    return m.form.View()
}
go
type Model struct {
    form *huh.Form
}

func (m Model) Init() tea.Cmd {
    return m.form.Init()
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    form, cmd := m.form.Update(msg)
    if f, ok := form.(*huh.Form); ok {
        m.form = f
    }
    
    if m.form.State == huh.StateCompleted {
        // 表单完成 - 获取值
        name := m.form.GetString("name")
        return m, tea.Quit
    }
    
    return m, cmd
}

func (m Model) View() string {
    if m.form.State == huh.StateCompleted {
        return "Done!"
    }
    return m.form.View()
}

Themes

主题设置

go
form.WithTheme(huh.ThemeCharm())      // Default pink/purple
form.WithTheme(huh.ThemeDracula())    // Dark purple/pink
form.WithTheme(huh.ThemeCatppuccin()) // Pastel
form.WithTheme(huh.ThemeBase16())     // Muted
form.WithTheme(huh.ThemeBase())       // Minimal
go
form.WithTheme(huh.ThemeCharm())      // 默认粉紫色主题
form.WithTheme(huh.ThemeDracula())    // 深紫粉色主题
form.WithTheme(huh.ThemeCatppuccin()) // 马卡龙主题
form.WithTheme(huh.ThemeBase16())     // 低饱和度主题
form.WithTheme(huh.ThemeBase())       // 极简主题

Spinner for Loading

加载动画

go
import "github.com/charmbracelet/huh/spinner"

err := spinner.New().
    Title("Processing...").
    Action(func() {
        time.Sleep(2 * time.Second)
        processData()
    }).
    Run()

go
import "github.com/charmbracelet/huh/spinner"

err := spinner.New().
    Title("Processing...").
    Action(func() {
        time.Sleep(2 * time.Second)
        processData()
    }).
    Run()

Common Gotchas

常见陷阱

Bubble Tea Core

Bubble Tea核心

  1. Blocking in Update/View: Never block. Use commands for I/O:
    go
    // BAD
    time.Sleep(time.Second)  // Blocks event loop!
    
    // GOOD
    return m, func() tea.Msg {
        time.Sleep(time.Second)
        return doneMsg{}
    }
  2. Goroutines modifying model: Race condition! Use commands instead:
    go
    // BAD
    go func() { m.data = fetch() }()  // Race!
    
    // GOOD
    return m, func() tea.Msg { return dataMsg{fetch()} }
  3. Viewport before WindowSizeMsg: Initialize after receiving dimensions:
    go
    if !m.ready {
        m.viewport = viewport.New(msg.Width, msg.Height)
        m.ready = true
    }
  4. Using receiver methods incorrectly: Only use
    func (m *model)
    for internal helpers. Model interface methods must use value receivers
    func (m model)
    .
  5. Startup commands via Init: Don't use
    tea.EnterAltScreen
    in Init. Use
    tea.WithAltScreen()
    option instead.
  6. Messages not in order:
    tea.Batch
    results arrive in ANY order. Use
    tea.Sequence
    when order matters.
  1. Update/View中阻塞操作:绝对不要阻塞事件循环,使用命令处理I/O:
    go
    // 错误示例
    time.Sleep(time.Second)  // 阻塞事件循环!
    
    // 正确示例
    return m, func() tea.Msg {
        time.Sleep(time.Second)
        return doneMsg{}
    }
  2. 协程直接修改Model:会导致竞态条件!改用命令:
    go
    // 错误示例
    go func() { m.data = fetch() }()  // 竞态风险!
    
    // 正确示例
    return m, func() tea.Msg { return dataMsg{fetch()} }
  3. 在WindowSizeMsg前初始化Viewport:需在获取尺寸后初始化:
    go
    if !m.ready {
        m.viewport = viewport.New(msg.Width, msg.Height)
        m.ready = true
    }
  4. 接收器方法使用错误:仅内部辅助方法使用
    func (m *model)
    ,Model接口方法必须使用值接收器
    func (m model)
  5. 在Init中使用tea.EnterAltScreen:不要在Init中调用,改用
    tea.WithAltScreen()
    选项。
  6. 消息顺序不可靠
    tea.Batch
    返回的消息顺序随机,顺序敏感时使用
    tea.Sequence

Bubbles Components

Bubbles组件

  1. Spinner not animating: Must return
    m.spinner.Tick
    from
    Init()
    and handle
    spinner.TickMsg
    in Update.
  2. TextInput not accepting input: Must call
    .Focus()
    before input accepts keystrokes.
  3. Component updates not reflected: Always reassign after Update:
    go
    m.spinner, cmd = m.spinner.Update(msg)  // Must reassign!
  4. Multiple components losing commands: Use
    tea.Batch(cmds...)
    to combine all commands.
  5. List items not filtering: Must implement
    FilterValue() string
    on items.
  6. Table not responding: Must set
    table.WithFocused(true)
    or call
    t.Focus()
    .
  1. Spinner不动画:必须在
    Init()
    中返回
    m.spinner.Tick
    并在Update中处理
    spinner.TickMsg
  2. TextInput无法输入:必须先调用
    .Focus()
    才能接收按键输入。
  3. 组件更新不生效:更新后必须重新赋值:
    go
    m.spinner, cmd = m.spinner.Update(msg)  // 必须重新赋值!
  4. 多组件命令丢失:使用
    tea.Batch(cmds...)
    合并所有命令。
  5. 列表项无法过滤:必须在列表项上实现
    FilterValue() string
    方法。
  6. Table无响应:必须设置
    table.WithFocused(true)
    或调用
    t.Focus()

Lip Gloss

Lip Gloss

  1. Style method has no effect: Styles are immutable, must reassign:
    go
    // BAD
    style.Bold(true)  // Result discarded!
    
    // GOOD
    style = style.Bold(true)
  2. Alignment not working: Requires explicit Width:
    go
    style := lipgloss.NewStyle().Width(40).Align(lipgloss.Center)
  3. Width calculation wrong: Use
    lipgloss.Width()
    not
    len()
    for unicode.
  4. Layout arithmetic errors: Use
    style.GetFrameSize()
    to account for padding/border/margin.
  1. 样式方法无效果:样式是不可变的,必须重新赋值:
    go
    // 错误示例
    style.Bold(true)  // 结果被丢弃!
    
    // 正确示例
    style = style.Bold(true)
  2. 对齐不生效:必须显式设置宽度:
    go
    style := lipgloss.NewStyle().Width(40).Align(lipgloss.Center)
  3. 宽度计算错误:对Unicode文本使用
    lipgloss.Width()
    而非
    len()
  4. 布局计算错误:使用
    style.GetFrameSize()
    考虑内边距/边框/外边距。

Huh Forms

Huh表单

  1. Generic types required:
    Select
    and
    MultiSelect
    MUST have type parameter:
    go
    // BAD - won't compile
    huh.NewSelect()
    
    // GOOD
    huh.NewSelect[string]()
  2. Value takes pointer: Always pass pointer:
    .Value(&myVar)
    not
    .Value(myVar)
    .
  3. OptionsFunc not updating: Must pass binding variable:
    go
    .OptionsFunc(fn, &country)  // Recomputes when country changes
  4. Don't use Placeholder() in huh forms: Causes rendering bugs. Put examples in Description instead:
    go
    // BAD
    huh.NewInput().Placeholder("example@email.com")
    
    // GOOD
    huh.NewInput().Description("e.g. example@email.com")
  5. Loop variable closure capture: Capture explicitly in loops:
    go
    for _, name := range items {
        currentName := name  // Capture!
        huh.NewInput().Value(&configs[currentName].Field)
    }
  6. Don't intercept Enter before form: Let huh handle Enter for navigation.

  1. 必须指定泛型
    Select
    MultiSelect
    必须指定类型参数:
    go
    // 错误示例 - 无法编译
    huh.NewSelect()
    
    // 正确示例
    huh.NewSelect[string]()
  2. Value需传入指针:始终传入指针:
    .Value(&myVar)
    而非
    .Value(myVar)
  3. OptionsFunc不更新:必须传入绑定变量:
    go
    .OptionsFunc(fn, &country)  // 当country变化时重新计算
  4. 不要在Huh表单中使用Placeholder():会导致渲染错误,改用Description添加示例:
    go
    // 错误示例
    huh.NewInput().Placeholder("example@email.com")
    
    // 正确示例
    huh.NewInput().Description("e.g. example@email.com")
  5. 循环变量闭包捕获:在循环中显式捕获变量:
    go
    for _, name := range items {
        currentName := name  // 显式捕获!
        huh.NewInput().Value(&configs[currentName].Field)
    }
  6. 不要在表单前拦截Enter键:让Huh处理Enter键用于导航。

Debugging

调试

go
// Log to file (stdout is the TUI)
if os.Getenv("DEBUG") != "" {
    f, _ := tea.LogToFile("debug.log", "app")
    defer f.Close()
}
log.Println("Debug message")
go
// 日志写入文件(标准输出用于TUI)
if os.Getenv("DEBUG") != "" {
    f, _ := tea.LogToFile("debug.log", "app")
    defer f.Close()
}
log.Println("Debug message")

Best Practices

最佳实践

  1. Keep Update/View fast - The event loop blocks on these
  2. Use tea.Cmd for all I/O - HTTP, file, database operations
  3. Use tea.Batch for parallel - Multiple independent commands
  4. Use tea.Sequence for ordered - Commands that must run in order
  5. Store window dimensions - Handle tea.WindowSizeMsg, update components
  6. Initialize viewport after WindowSizeMsg - Dimensions aren't available at start
  7. Use value receivers -
    func (m model) Update
    not
    func (m *model) Update
  8. Define styles as package variables - Reuse instead of creating in loops
  9. Use AdaptiveColor - For light/dark terminal support
  10. Handle ErrUserAborted - Graceful Ctrl+C handling in huh forms
  1. 保持Update/View快速 - 事件循环会阻塞在这些方法上
  2. 所有I/O使用tea.Cmd - HTTP、文件、数据库操作
  3. 并行任务使用tea.Batch - 多个独立命令
  4. 顺序任务使用tea.Sequence - 必须按顺序执行的命令
  5. 存储窗口尺寸 - 处理tea.WindowSizeMsg,更新组件尺寸
  6. 在WindowSizeMsg后初始化Viewport - 启动时无法获取尺寸
  7. 使用值接收器 -
    func (m model) Update
    而非
    func (m *model) Update
  8. 将样式定义为包级变量 - 复用样式而非在循环中创建
  9. 使用AdaptiveColor - 适配明暗终端主题
  10. 处理ErrUserAborted - 在Huh表单中优雅处理Ctrl+C取消

Additional Resources

额外资源

  • references/API.md - Complete API reference
  • references/EXAMPLES.md - Extended code examples
  • references/TROUBLESHOOTING.md - Common errors and solutions
  • references/API.md - 完整API参考
  • references/EXAMPLES.md - 扩展代码示例
  • references/TROUBLESHOOTING.md - 常见错误与解决方案

Essential Imports

必备导入

go
import (
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
    "github.com/charmbracelet/lipgloss/table"  // Static tables
    "github.com/charmbracelet/lipgloss/tree"   // Hierarchical data
    "github.com/charmbracelet/bubbles/spinner"
    "github.com/charmbracelet/bubbles/textinput"
    "github.com/charmbracelet/bubbles/textarea"
    "github.com/charmbracelet/bubbles/list"
    "github.com/charmbracelet/bubbles/table"   // Interactive tables
    "github.com/charmbracelet/bubbles/viewport"
    "github.com/charmbracelet/bubbles/help"
    "github.com/charmbracelet/bubbles/key"
    "github.com/charmbracelet/bubbles/filepicker"
    "github.com/charmbracelet/bubbles/progress"
    "github.com/charmbracelet/bubbles/timer"
    "github.com/charmbracelet/bubbles/stopwatch"
    "github.com/charmbracelet/huh"
    "github.com/charmbracelet/huh/spinner"
    "github.com/charmbracelet/wish"            // SSH TUI server
    "github.com/lrstanley/bubblezone"          // Mouse zones
)

go
import (
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
    "github.com/charmbracelet/lipgloss/table"  // 静态表格
    "github.com/charmbracelet/lipgloss/tree"   // 层级数据
    "github.com/charmbracelet/bubbles/spinner"
    "github.com/charmbracelet/bubbles/textinput"
    "github.com/charmbracelet/bubbles/textarea"
    "github.com/charmbracelet/bubbles/list"
    "github.com/charmbracelet/bubbles/table"   // 交互式表格
    "github.com/charmbracelet/bubbles/viewport"
    "github.com/charmbracelet/bubbles/help"
    "github.com/charmbracelet/bubbles/key"
    "github.com/charmbracelet/bubbles/filepicker"
    "github.com/charmbracelet/bubbles/progress"
    "github.com/charmbracelet/bubbles/timer"
    "github.com/charmbracelet/bubbles/stopwatch"
    "github.com/charmbracelet/huh"
    "github.com/charmbracelet/huh/spinner"
    "github.com/charmbracelet/wish"            // SSH TUI服务器
    "github.com/lrstanley/bubblezone"          // 鼠标区域
)

Lip Gloss Tree (Hierarchical Data)

Lip Gloss Tree(层级数据)

Render tree structures with customizable enumerators:
go
import "github.com/charmbracelet/lipgloss/tree"

t := tree.Root("Root").
    Child("Child 1").
    Child(
        tree.Root("Child 2").
            Child("Grandchild 1").
            Child("Grandchild 2"),
    ).
    Child("Child 3")

// Custom enumerator
t.Enumerator(tree.RoundedEnumerator)  // ├── └──
t.Enumerator(tree.BulletEnumerator)   // • bullets
t.Enumerator(tree.NumberedEnumerator) // 1. 2. 3.

// Custom styling
t.EnumeratorStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99")))
t.ItemStyle(lipgloss.NewStyle().Bold(true))

fmt.Println(t)

使用可自定义枚举符渲染树形结构:
go
import "github.com/charmbracelet/lipgloss/tree"

t := tree.Root("Root").
    Child("Child 1").
    Child(
        tree.Root("Child 2").
            Child("Grandchild 1").
            Child("Grandchild 2"),
    ).
    Child("Child 3")

// 自定义枚举符
t.Enumerator(tree.RoundedEnumerator)  // ├── └──
t.Enumerator(tree.BulletEnumerator)   // • 圆点
t.Enumerator(tree.NumberedEnumerator) // 1. 2. 3.

// 自定义样式
t.EnumeratorStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99")))
t.ItemStyle(lipgloss.NewStyle().Bold(true))

fmt.Println(t)

Lip Gloss Table (Static Rendering)

Lip Gloss Table(静态渲染)

For high-performance static tables (reports, logs). Use
bubbles/table
for interactive selection.
go
import "github.com/charmbracelet/lipgloss/table"

t := table.New().
    Border(lipgloss.NormalBorder()).
    BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))).
    Headers("NAME", "AGE", "CITY").
    Row("Alice", "30", "NYC").
    Row("Bob", "25", "LA").
    Wrap(true).  // Enable text wrapping
    StyleFunc(func(row, col int) lipgloss.Style {
        if row == table.HeaderRow {
            return lipgloss.NewStyle().Bold(true)
        }
        return lipgloss.NewStyle()
    })

fmt.Println(t)
When to use which table:
  • lipgloss/table
    : Static rendering, reports, logs, non-interactive
  • bubbles/table
    : Interactive selection, keyboard navigation, focused rows

用于高性能静态表格(报表、日志)。交互式选择使用
bubbles/table
go
import "github.com/charmbracelet/lipgloss/table"

t := table.New().
    Border(lipgloss.NormalBorder()).
    BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))).
    Headers("NAME", "AGE", "CITY").
    Row("Alice", "30", "NYC").
    Row("Bob", "25", "LA").
    Wrap(true).  // 启用文本换行
    StyleFunc(func(row, col int) lipgloss.Style {
        if row == table.HeaderRow {
            return lipgloss.NewStyle().Bold(true)
        }
        return lipgloss.NewStyle()
    })

fmt.Println(t)
表格选择指南
  • lipgloss/table
    : 静态渲染、报表、日志、非交互式场景
  • bubbles/table
    : 交互式选择、键盘导航、焦点行场景

Custom Component Pattern (Sub-Model)

自定义组件模式(子Model)

Create reusable Bubble Tea components by exposing
Init
,
Update
,
View
:
go
// counter.go - Reusable component
package counter

import tea "github.com/charmbracelet/bubbletea"

type Model struct {
    Count int
    Min   int
    Max   int
}

func New(min, max int) Model {
    return Model{Min: min, Max: max}
}

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

func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "+", "=":
            if m.Count < m.Max { m.Count++ }
        case "-":
            if m.Count > m.Min { m.Count-- }
        }
    }
    return m, nil
}

func (m Model) View() string {
    return fmt.Sprintf("Count: %d", m.Count)
}

// parent.go - Using the component
type parentModel struct {
    counter counter.Model
}

func (m parentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    m.counter, cmd = m.counter.Update(msg)
    return m, cmd
}

通过暴露
Init
,
Update
,
View
创建可复用Bubble Tea组件:
go
// counter.go - 可复用组件
package counter

import tea "github.com/charmbracelet/bubbletea"

type Model struct {
    Count int
    Min   int
    Max   int
}

func New(min, max int) Model {
    return Model{Min: min, Max: max}
}

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

func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "+", "=":
            if m.Count < m.Max { m.Count++ }
        case "-":
            if m.Count > m.Min { m.Count-- }
        }
    }
    return m, nil
}

func (m Model) View() string {
    return fmt.Sprintf("Count: %d", m.Count)
}

// parent.go - 使用组件
type parentModel struct {
    counter counter.Model
}

func (m parentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    m.counter, cmd = m.counter.Update(msg)
    return m, cmd
}

ProgramContext Pattern (Production)

ProgramContext模式(生产环境)

Share global state across components without prop drilling:
go
// context.go
type ProgramContext struct {
    Config     *Config
    Theme      *Theme
    Width      int
    Height     int
    StartTask  func(Task) tea.Cmd  // Callback for background tasks
}

// model.go
type Model struct {
    ctx       *ProgramContext
    sidebar   sidebar.Model
    main      main.Model
    tasks     map[string]Task
    spinner   spinner.Model
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.ctx.Width = msg.Width
        m.ctx.Height = msg.Height
        // Sync all components
        m.sidebar.SetHeight(msg.Height)
        m.main.SetSize(msg.Width - sidebarWidth, msg.Height)
    }
    return m, nil
}

// Initialize context with task callback
func NewModel(cfg *Config) Model {
    ctx := &ProgramContext{Config: cfg}
    m := Model{ctx: ctx, tasks: make(map[string]Task)}
    
    ctx.StartTask = func(t Task) tea.Cmd {
        m.tasks[t.ID] = t
        return m.spinner.Tick
    }
    
    return m
}

无需属性透传即可在组件间共享全局状态:
go
// context.go
type ProgramContext struct {
    Config     *Config
    Theme      *Theme
    Width      int
    Height     int
    StartTask  func(Task) tea.Cmd  // 后台任务回调
}

// model.go
type Model struct {
    ctx       *ProgramContext
    sidebar   sidebar.Model
    main      main.Model
    tasks     map[string]Task
    spinner   spinner.Model
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.ctx.Width = msg.Width
        m.ctx.Height = msg.Height
        // 同步所有组件尺寸
        m.sidebar.SetHeight(msg.Height)
        m.main.SetSize(msg.Width - sidebarWidth, msg.Height)
    }
    return m, nil
}

// 初始化上下文及任务回调
func NewModel(cfg *Config) Model {
    ctx := &ProgramContext{Config: cfg}
    m := Model{ctx: ctx, tasks: make(map[string]Task)}
    
    ctx.StartTask = func(t Task) tea.Cmd {
        m.tasks[t.ID] = t
        return m.spinner.Tick
    }
    
    return m
}

Testing with teatest

使用teatest测试

go
import (
    "testing"
    "time"
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/x/exp/teatest"
)

func TestModel(t *testing.T) {
    m := NewModel()
    tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
    
    // Send key presses
    tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
    tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
    
    // Wait for specific output
    teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
        return strings.Contains(string(bts), "Selected")
    }, teatest.WithDuration(time.Second))
    
    // Check final state
    tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
    tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
    
    final := tm.FinalModel(t).(Model)
    if final.selected != "expected" {
        t.Errorf("expected 'expected', got %q", final.selected)
    }
}

go
import (
    "testing"
    "time"
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/x/exp/teatest"
)

func TestModel(t *testing.T) {
    m := NewModel()
    tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
    
    // 发送按键
    tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
    tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
    
    // 等待特定输出
    teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
        return strings.Contains(string(bts), "Selected")
    }, teatest.WithDuration(time.Second))
    
    // 检查最终状态
    tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
    tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
    
    final := tm.FinalModel(t).(Model)
    if final.selected != "expected" {
        t.Errorf("expected 'expected', got %q", final.selected)
    }
}

SSH Integration with Wish

使用Wish集成SSH

Serve TUI apps over SSH:
go
import (
    "github.com/charmbracelet/wish"
    "github.com/charmbracelet/wish/bubbletea"
    "github.com/charmbracelet/ssh"
)

func main() {
    s, err := wish.NewServer(
        wish.WithAddress(":2222"),
        wish.WithHostKeyPath(".ssh/term_info_ed25519"),
        wish.WithMiddleware(
            bubbletea.Middleware(teaHandler),
        ),
    )
    if err != nil { log.Fatal(err) }
    
    log.Println("Starting SSH server on :2222")
    if err := s.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
    pty, _, _ := s.Pty()
    m := NewModel(pty.Window.Width, pty.Window.Height)
    return m, []tea.ProgramOption{tea.WithAltScreen()}
}

通过SSH提供TUI应用:
go
import (
    "github.com/charmbracelet/wish"
    "github.com/charmbracelet/wish/bubbletea"
    "github.com/charmbracelet/ssh"
)

func main() {
    s, err := wish.NewServer(
        wish.WithAddress(":2222"),
        wish.WithHostKeyPath(".ssh/term_info_ed25519"),
        wish.WithMiddleware(
            bubbletea.Middleware(teaHandler),
        ),
    )
    if err != nil { log.Fatal(err) }
    
    log.Println("Starting SSH server on :2222")
    if err := s.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
    pty, _, _ := s.Pty()
    m := NewModel(pty.Window.Width, pty.Window.Height)
    return m, []tea.ProgramOption{tea.WithAltScreen()}
}

Mouse Zones with bubblezone

使用bubblezone实现鼠标区域

Define clickable regions without coordinate math:
go
import zone "github.com/lrstanley/bubblezone"

type model struct {
    zone *zone.Manager
}

func newModel() model {
    return model{zone: zone.New()}
}

func (m model) View() string {
    // Wrap clickable elements
    button1 := m.zone.Mark("btn1", "[Button 1]")
    button2 := m.zone.Mark("btn2", "[Button 2]")
    
    return lipgloss.JoinHorizontal(lipgloss.Top, button1, " ", button2)
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.MouseMsg:
        // Check which zone was clicked
        if m.zone.Get("btn1").InBounds(msg) {
            // Button 1 clicked
        }
        if m.zone.Get("btn2").InBounds(msg) {
            // Button 2 clicked
        }
    }
    return m, nil
}

// Wrap program with zone.NewGlobal() for simpler API
func main() {
    zone.NewGlobal()
    p := tea.NewProgram(newModel(), tea.WithMouseCellMotion())
    p.Run()
}
无需坐标计算即可定义可点击区域:
go
import zone "github.com/lrstanley/bubblezone"

type model struct {
    zone *zone.Manager
}

func newModel() model {
    return model{zone: zone.New()}
}

func (m model) View() string {
    // 包裹可点击元素
    button1 := m.zone.Mark("btn1", "[Button 1]")
    button2 := m.zone.Mark("btn2", "[Button 2]")
    
    return lipgloss.JoinHorizontal(lipgloss.Top, button1, " ", button2)
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.MouseMsg:
        // 检查点击区域
        if m.zone.Get("btn1").InBounds(msg) {
            // 按钮1被点击
        }
        if m.zone.Get("btn2").InBounds(msg) {
            // 按钮2被点击
        }
    }
    return m, nil
}

// 使用zone.NewGlobal()简化API
func main() {
    zone.NewGlobal()
    p := tea.NewProgram(newModel(), tea.WithMouseCellMotion())
    p.Run()
}