tui-component-design
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTUI Component Design Patterns
TUI组件设计模式
Best practices for building maintainable, testable TUI components using Bubbletea v2 and the Charm ecosystem, based on the hive diff viewer implementation.
基于hive差异查看器的实现,本文介绍使用Bubbletea v2和Charm生态系统构建可维护、可测试的TUI组件的最佳实践。
Component Organization
组件组织
Single Responsibility Per File
单文件单一职责
Each component should be in its own file with clear boundaries:
internal/tui/diff/
├── model.go # Top-level compositor that orchestrates sub-components
├── diffviewer.go # Diff content display with scrolling and selection
├── filetree.go # File navigation tree with expand/collapse
├── lineparse.go # Pure function utilities for parsing diff lines
├── delta.go # External tool integration (syntax highlighting)
└── utils.go # Shared utilitiesKey principle: Each file should represent ONE component with its own Model, Update, and View methods.
每个组件应放在独立文件中,边界清晰:
internal/tui/diff/
├── model.go # 协调子组件的顶级组合器
├── diffviewer.go # 支持滚动和选择的差异内容展示组件
├── filetree.go # 支持展开/折叠的文件导航树
├── lineparse.go # 解析差异行的纯函数工具
├── delta.go # 外部工具集成(语法高亮)
└── utils.go # 共享工具函数**核心原则:**每个文件对应一个组件,包含独立的Model、Update和View方法。
Component Hierarchy Pattern
组件层级模式
For complex UIs, use a compositor pattern:
go
// Top-level Model composes sub-components
type Model struct {
fileTree FileTreeModel // Left panel
diffViewer DiffViewerModel // Right panel
focused FocusedPanel // Which component has focus
helpDialog *components.HelpDialog // Modal overlay
showHelp bool // Dialog visibility state
}
// Update delegates to focused component
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch m.focused {
case FocusFileTree:
m.fileTree, cmd = m.fileTree.Update(msg)
case FocusDiffViewer:
m.diffViewer, cmd = m.diffViewer.Update(msg)
}
return m, cmd
}Benefits:
- Each sub-component is independently testable
- Clear ownership of state and behavior
- Easy to reason about message flow
对于复杂UI,使用组合器模式:
go
// 顶级Model组合子组件
type Model struct {
fileTree FileTreeModel // 左侧面板
diffViewer DiffViewerModel // 右侧面板
focused FocusedPanel // 当前获焦的组件
helpDialog *components.HelpDialog // 模态弹窗
showHelp bool // 弹窗可见状态
}
// Update方法将事件委托给获焦组件
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch m.focused {
case FocusFileTree:
m.fileTree, cmd = m.fileTree.Update(msg)
case FocusDiffViewer:
m.diffViewer, cmd = m.diffViewer.Update(msg)
}
return m, cmd
}优势:
- 每个子组件可独立测试
- 状态和行为的归属清晰
- 消息流转易于追踪
Component Structure
组件结构
Standard Component Template
标准组件模板
go
// 1. Model struct with all state
type ComponentModel struct {
// Data
items []Item
// UI State
selected int
offset int
width int
height int
// Feature flags
iconStyle IconStyle
expanded bool
}
// 2. Constructor with dependencies
func NewComponent(data []Item, cfg *config.Config) ComponentModel {
return ComponentModel{
items: data,
selected: 0,
iconStyle: determineIconStyle(cfg),
}
}
// 3. Update handles messages
func (m ComponentModel) Update(msg tea.Msg) (ComponentModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKeyPress(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
// 4. View renders output
func (m ComponentModel) View() string {
return m.render()
}
// 5. Helper methods for complex logic
func (m ComponentModel) render() string {
// Rendering logic here
}go
// 1. 包含所有状态的Model结构体
type ComponentModel struct {
// 数据
items []Item
// UI状态
selected int
offset int
width int
height int
// 功能开关
iconStyle IconStyle
expanded bool
}
// 2. 带依赖的构造函数
func NewComponent(data []Item, cfg *config.Config) ComponentModel {
return ComponentModel{
items: data,
selected: 0,
iconStyle: determineIconStyle(cfg),
}
}
// 3. 处理消息的Update方法
func (m ComponentModel) Update(msg tea.Msg) (ComponentModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKeyPress(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
// 4. 渲染输出的View方法
func (m ComponentModel) View() string {
return m.render()
}
// 5. 处理复杂逻辑的辅助方法
func (m ComponentModel) render() string {
// 渲染逻辑
}State Management
状态管理
Avoid Hidden State
避免隐藏状态
Bad:
go
// State hidden in closures or package variables
var currentSelection int
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
currentSelection++ // Modifying hidden state
}Good:
go
// All state explicit in model
type Model struct {
currentSelection int
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.currentSelection++ // Clear, traceable state change
return m, nil
}错误示例:
go
// 状态隐藏在闭包或包变量中
var currentSelection int
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
currentSelection++ // 修改隐藏状态
}正确示例:
go
// 所有状态显式定义在Model中
type Model struct {
currentSelection int
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.currentSelection++ // 清晰、可追踪的状态变更
return m, nil
}Separate UI State from Data
分离UI状态与数据
go
type DiffViewerModel struct {
// Immutable data
file *gitdiff.File
content string
lines []string
// Mutable UI state
offset int // Scroll position
cursorLine int // Current line
selectionMode bool // Visual mode active
selectionStart int // Selection anchor
}Benefits:
- Easy to test rendering at different scroll positions
- Data can be shared/cached without UI state interference
- Clear separation of concerns
go
type DiffViewerModel struct {
// 不可变数据
file *gitdiff.File
content string
lines []string
// 可变UI状态
offset int // 滚动位置
cursorLine int // 当前行
selectionMode bool // 视觉模式是否激活
selectionStart int // 选择锚点
}优势:
- 易于测试不同滚动位置下的渲染效果
- 数据可共享/缓存,不受UI状态干扰
- 关注点分离清晰
Async Operations and Caching
异步操作与缓存
Pattern: Command-Based Async with Caching
模式:基于命令的异步处理+缓存
For expensive operations like syntax highlighting or external tool calls:
go
type ComponentModel struct {
cache map[string]*CachedResult
loading bool
}
// 1. Initiate async operation, return immediately
func (m *ComponentModel) SetData(data *Data) tea.Cmd {
filePath := data.Path
// Check cache first
if cached, ok := m.cache[filePath]; ok {
m.content = cached.content
m.lines = cached.lines
return nil
}
// Mark as loading, start async
m.loading = true
return func() tea.Msg {
content, lines := generateContent(data)
return contentGeneratedMsg{filePath, content, lines}
}
}
// 2. Handle completion message
func (m ComponentModel) Update(msg tea.Msg) (ComponentModel, tea.Cmd) {
switch msg := msg.(type) {
case contentGeneratedMsg:
// Cache result
m.cache[msg.filePath] = &CachedResult{
content: msg.content,
lines: msg.lines,
}
// Update display
m.content = msg.content
m.lines = msg.lines
m.loading = false
}
return m, nil
}Key points:
- Never block the UI thread
- Cache expensive computations
- Show loading state while processing
- Custom messages for async results
对于语法高亮或外部工具调用等耗时操作:
go
type ComponentModel struct {
cache map[string]*CachedResult
loading bool
}
// 1. 触发异步操作,立即返回
func (m *ComponentModel) SetData(data *Data) tea.Cmd {
filePath := data.Path
// 先检查缓存
if cached, ok := m.cache[filePath]; ok {
m.content = cached.content
m.lines = cached.lines
return nil
}
// 标记为加载中,启动异步任务
m.loading = true
return func() tea.Msg {
content, lines := generateContent(data)
return contentGeneratedMsg{filePath, content, lines}
}
}
// 2. 处理完成消息
func (m ComponentModel) Update(msg tea.Msg) (ComponentModel, tea.Cmd) {
switch msg := msg.(type) {
case contentGeneratedMsg:
// 缓存结果
m.cache[msg.filePath] = &CachedResult{
content: msg.content,
lines: msg.lines,
}
// 更新展示内容
m.content = msg.content
m.lines = msg.lines
m.loading = false
}
return m, nil
}关键点:
- 绝不要阻塞UI线程
- 缓存耗时计算结果
- 处理过程中显示加载状态
- 为异步结果定义自定义消息
External Tool Integration
外部工具集成
For tools like (syntax highlighting):
deltago
// 1. Check availability once at init
func NewDiffViewer(file *gitdiff.File) DiffViewerModel {
deltaAvailable := CheckDeltaAvailable() == nil
return DiffViewerModel{
deltaAvailable: deltaAvailable,
}
}
// 2. Separate pure function for testability
func generateDiffContent(file *gitdiff.File, deltaAvailable bool) (string, []string) {
diff := buildUnifiedDiff(file)
if !deltaAvailable {
return diff, strings.Split(diff, "\n")
}
// Apply syntax highlighting
return applyDelta(diff)
}
// 3. Make it async with proper error handling
func (m *ComponentModel) loadContent(file *gitdiff.File) tea.Cmd {
return func() tea.Msg {
content, lines := generateDiffContent(file, m.deltaAvailable)
return contentReadyMsg{content, lines}
}
}对于(语法高亮)这类工具:
deltago
// 1. 初始化时检查工具可用性
func NewDiffViewer(file *gitdiff.File) DiffViewerModel {
deltaAvailable := CheckDeltaAvailable() == nil
return DiffViewerModel{
deltaAvailable: deltaAvailable,
}
}
// 2. 分离纯函数以提升可测试性
func generateDiffContent(file *gitdiff.File, deltaAvailable bool) (string, []string) {
diff := buildUnifiedDiff(file)
if !deltaAvailable {
return diff, strings.Split(diff, "\n")
}
// 应用语法高亮
return applyDelta(diff)
}
// 3. 通过命令实现异步处理并完善错误处理
func (m *ComponentModel) loadContent(file *gitdiff.File) tea.Cmd {
return func() tea.Msg {
content, lines := generateDiffContent(file, m.deltaAvailable)
return contentReadyMsg{content, lines}
}
}Visual Modes and Complex Interactions
视觉模式与复杂交互
Mode-Based Keybindings
基于模式的快捷键绑定
For vim-style interfaces with normal/visual modes:
go
type Model struct {
mode Mode // Normal, Visual, Insert
selectionMode bool // Visual mode active
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
// Handle mode transitions first
if msg.Code == 'v' && !m.selectionMode {
m.selectionMode = true
m.selectionStart = m.cursorLine
return m, nil
}
if msg.Code == tea.KeyEscape && m.selectionMode {
m.selectionMode = false
return m, nil
}
// Handle mode-specific behavior
if m.selectionMode {
return m.handleVisualMode(msg)
}
return m.handleNormalMode(msg)
}
return m, nil
}对于支持普通/视觉模式的vim风格界面:
go
type Model struct {
mode Mode // 普通、视觉、插入模式
selectionMode bool // 视觉模式是否激活
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
// 先处理模式切换
if msg.Code == 'v' && !m.selectionMode {
m.selectionMode = true
m.selectionStart = m.cursorLine
return m, nil
}
if msg.Code == tea.KeyEscape && m.selectionMode {
m.selectionMode = false
return m, nil
}
// 处理模式特定行为
if m.selectionMode {
return m.handleVisualMode(msg)
}
return m.handleNormalMode(msg)
}
return m, nil
}Selection State Management
选择状态管理
For visual selection (highlighting lines):
go
type Model struct {
selectionMode bool
selectionStart int // Anchor point
cursorLine int // Active end
}
// Helper to get normalized selection range
func (m Model) SelectionRange() (start, end int, active bool) {
if !m.selectionMode {
return 0, 0, false
}
start = m.selectionStart
end = m.cursorLine
if start > end {
start, end = end, start
}
return start, end, true
}
// Use in rendering
func (m Model) View() string {
start, end, active := m.SelectionRange()
for i, line := range m.lines {
if active && i >= start && i <= end {
line = highlightStyle.Render(line)
}
// ... render line
}
}对于行高亮的视觉选择功能:
go
type Model struct {
selectionMode bool
selectionStart int // 锚点位置
cursorLine int // 当前激活端
}
// 获取标准化的选择范围辅助方法
func (m Model) SelectionRange() (start, end int, active bool) {
if !m.selectionMode {
return 0, 0, false
}
start = m.selectionStart
end = m.cursorLine
if start > end {
start, end = end, start
}
return start, end, true
}
// 在渲染中使用
func (m Model) View() string {
start, end, active := m.SelectionRange()
for i, line := range m.lines {
if active && i >= start && i <= end {
line = highlightStyle.Render(line)
}
// ... 渲染行
}
}Scroll Management
滚动管理
Viewport Pattern
视口模式
For scrollable content with fixed dimensions:
go
type Model struct {
lines []string
offset int // Top visible line
height int // Viewport height
}
// Calculate visible range
func (m Model) visibleLines() []string {
start := m.offset
end := min(m.offset + m.contentHeight(), len(m.lines))
return m.lines[start:end]
}
// Content height (excluding fixed UI elements)
func (m Model) contentHeight() int {
return m.height - headerHeight - footerHeight
}
// Scroll with cursor tracking
func (m Model) scrollDown() Model {
// Move cursor first
if m.cursorLine < len(m.lines)-1 {
m.cursorLine++
}
// Adjust viewport if cursor moved out of view
visibleBottom := m.offset + m.contentHeight() - 1
if m.cursorLine > visibleBottom {
m.offset++
}
return m
}Key principle: Cursor moves first, viewport follows to keep cursor visible.
针对固定尺寸的可滚动内容:
go
type Model struct {
lines []string
offset int // 顶部可见行
height int // 视口高度
}
// 计算可见范围
func (m Model) visibleLines() []string {
start := m.offset
end := min(m.offset + m.contentHeight(), len(m.lines))
return m.lines[start:end]
}
// 内容高度(排除固定UI元素)
func (m Model) contentHeight() int {
return m.height - headerHeight - footerHeight
}
// 跟随光标滚动
func (m Model) scrollDown() Model {
// 先移动光标
if m.cursorLine < len(m.lines)-1 {
m.cursorLine++
}
// 如果光标移出可视区域,调整视口
visibleBottom := m.offset + m.contentHeight() - 1
if m.cursorLine > visibleBottom {
m.offset++
}
return m
}**核心原则:**光标先移动,视口跟随以保持光标可见。
Editor Integration
编辑器集成
Opening External Editors
打开外部编辑器
Pattern for jumping to specific line in editor:
go
func (m Model) openInEditor(filePath string, lineNum int) tea.Cmd {
return func() tea.Msg {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vim"
}
// Format: editor +line file
arg := fmt.Sprintf("+%d", lineNum)
cmd := exec.Command(editor, arg, filePath)
// Important: Connect to terminal for interactive editors
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
return editorFinishedMsg{err: err}
}
}Critical: For vim/interactive editors, you must connect stdin/stdout/stderr or the editor won't work properly.
跳转到编辑器指定行的模式:
go
func (m Model) openInEditor(filePath string, lineNum int) tea.Cmd {
return func() tea.Msg {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vim"
}
// 格式:editor +line file
arg := fmt.Sprintf("+%d", lineNum)
cmd := exec.Command(editor, arg, filePath)
// 重要:连接终端以支持交互式编辑器
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
return editorFinishedMsg{err: err}
}
}**关键注意事项:**对于vim等交互式编辑器,必须连接stdin/stdout/stderr,否则编辑器无法正常工作。
Component Communication
组件通信
Message-Based Coordination
基于消息的协调
go
// Custom messages for component coordination
type (
fileSelectedMsg struct {
file *gitdiff.File
}
diffLoadedMsg struct {
content string
}
)
// Parent handles coordination
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case fileSelectedMsg:
// FileTree selected a file, tell DiffViewer
return m, m.diffViewer.LoadFile(msg.file)
}
// Delegate to children
var cmd tea.Cmd
m.fileTree, cmd = m.fileTree.Update(msg)
return m, cmd
}go
// 用于组件协调的自定义消息
type (
fileSelectedMsg struct {
file *gitdiff.File
}
diffLoadedMsg struct {
content string
}
)
// 父组件处理协调逻辑
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case fileSelectedMsg:
// 文件树选中了文件,通知差异查看器
return m, m.diffViewer.LoadFile(msg.file)
}
// 委托消息给子组件
var cmd tea.Cmd
m.fileTree, cmd = m.fileTree.Update(msg)
return m, cmd
}Focus Management
焦点管理
go
type FocusedPanel int
const (
FocusFileTree FocusedPanel = iota
FocusDiffViewer
)
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == tea.KeyTab {
// Switch focus
m.focused = (m.focused + 1) % 2
return m, nil
}
// Only focused component handles input
switch m.focused {
case FocusFileTree:
m.fileTree, cmd = m.fileTree.Update(msg)
case FocusDiffViewer:
m.diffViewer, cmd = m.diffViewer.Update(msg)
}
return m, cmd
}go
type FocusedPanel int
const (
FocusFileTree FocusedPanel = iota
FocusDiffViewer
)
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == tea.KeyTab {
// 切换焦点
m.focused = (m.focused + 1) % 2
return m, nil
}
// 仅获焦组件处理输入
switch m.focused {
case FocusFileTree:
m.fileTree, cmd = m.fileTree.Update(msg)
case FocusDiffViewer:
m.diffViewer, cmd = m.diffViewer.Update(msg)
}
return m, cmd
}Helper Modal Pattern
辅助弹窗模式
For overlays like help dialogs:
go
type Model struct {
helpDialog *components.HelpDialog
showHelp bool
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// Help dialog intercepts input when visible
if m.showHelp {
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == '?' {
m.showHelp = false
return m, nil
}
// Help dialog handles all input
*m.helpDialog, cmd = m.helpDialog.Update(msg)
return m, cmd
}
// Toggle help
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == '?' {
m.showHelp = true
return m, nil
}
// Normal input handling
// ...
}
func (m Model) View() string {
view := m.renderNormal()
if m.showHelp {
// Overlay help on top
return m.helpDialog.View(view)
}
return view
}针对帮助对话框这类浮层:
go
type Model struct {
helpDialog *components.HelpDialog
showHelp bool
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// 帮助对话框可见时拦截所有输入
if m.showHelp {
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == '?' {
m.showHelp = false
return m, nil
}
// 帮助对话框处理所有输入
*m.helpDialog, cmd = m.helpDialog.Update(msg)
return m, cmd
}
// 切换帮助弹窗
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == '?' {
m.showHelp = true
return m, nil
}
// 常规输入处理
// ...
}
func (m Model) View() string {
view := m.renderNormal()
if m.showHelp {
// 在顶层覆盖帮助内容
return m.helpDialog.View(view)
}
return view
}Common Pitfalls
常见陷阱
❌ Modifying State Outside Update
❌ 在Update外部修改状态
go
// BAD: State modified in View
func (m Model) View() string {
m.offset++ // NEVER modify state in View!
return m.render()
}View must be pure - no side effects!
go
// 错误:在View中修改状态
func (m Model) View() string {
m.offset++ // 绝不要在View中修改状态!
return m.render()
}View必须是纯函数 - 不能有任何副作用!
❌ Blocking Operations in Update
❌ 在Update中执行阻塞操作
go
// BAD: Blocking I/O in Update
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
content := os.ReadFile("large-file.txt") // BLOCKS UI!
m.content = string(content)
return m, nil
}Use commands for I/O.
go
// 错误:在Update中执行阻塞I/O
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
content := os.ReadFile("large-file.txt") // 阻塞UI线程!
m.content = string(content)
return m, nil
}使用命令处理I/O操作。
❌ Complex Logic in Update
❌ Update中包含复杂逻辑
go
// BAD: 200 lines of logic in Update
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
// ... 200 lines of key handling ...
}
}Extract to helper methods:
go
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKeyPress(msg)
}
}
func (m Model) handleKeyPress(msg tea.KeyPressMsg) (Model, tea.Cmd) {
// Clear logic here
}go
// 错误:Update中有200行逻辑
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
// ... 200行按键处理逻辑 ...
}
}提取到辅助方法中:
go
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKeyPress(msg)
}
}
func (m Model) handleKeyPress(msg tea.KeyPressMsg) (Model, tea.Cmd) {
// 清晰的逻辑代码
}Summary
总结
- One component per file with clear boundaries
- Compositor pattern for complex UIs (parent coordinates, children handle specifics)
- All state in Model - no hidden variables
- Commands for async - never block Update
- Cache expensive operations - external tools, rendering
- Mode-based behavior for complex interactions (vim-style)
- Focus management for multi-panel UIs
- Extract helper methods - keep Update readable
- Pure View - no side effects, deterministic output
- Message-based coordination - components communicate via messages
- 单文件对应一个组件,边界清晰
- 组合器模式构建复杂UI(父组件协调,子组件处理具体逻辑)
- 所有状态存于Model - 无隐藏变量
- 使用命令处理异步 - 绝不阻塞Update
- 缓存耗时操作 - 外部工具调用、渲染等
- 基于模式的行为实现复杂交互(vim风格)
- 焦点管理适配多面板UI
- 提取辅助方法 - 保持Update可读性
- 纯View函数 - 无副作用,输出确定
- 基于消息的协调 - 组件通过消息通信