Bubbles

TUIComponentsBubble-TeaTerminalGo

GitHub概要

charmbracelet/bubbles

TUI components for Bubble Tea 🫧

スター6,625
ウォッチ23
フォーク319
作成日:2020年1月18日
言語:Go
ライセンス:MIT License

トピックス

clielm-architecturehacktoberfestterminaltui

スター履歴

charmbracelet/bubbles Star History
データ取得日時: 2025/7/25 11:09

Bubbles

Bubblesは、Bubble Teaフレームワーク用の公式コンポーネントライブラリです。テキスト入力、リスト、スピナー、プログレスバーなど、一般的なTUIコンポーネントを提供し、開発を大幅に加速します。Charmbraceletエコシステムの一部として、高品質なコンポーネントが継続的に追加されています。

特徴

豊富なコンポーネント

  • 入力: textinput、textarea、textinput
  • 選択: list、filepicker、table
  • 表示: viewport、spinner、progress
  • ナビゲーション: paginator、help
  • 特殊: timer、stopwatch、cursor

Bubble Teaとの統合

  • Modelベース: Elmアーキテクチャに準拠
  • メッセージパッシング: コンポーネント間の通信
  • カスタマイズ可能: 外観と動作の調整
  • 組み合わせ可能: 複数コンポーネントの統合

スタイリング

  • Lip Gloss統合: 美しいデフォルトスタイル
  • テーマ対応: カスタムテーマの適用
  • アダプティブ: ターミナル能力に応じた調整
  • アニメーション: スムーズな動作

生産性

  • すぐに使える: 最小限の設定で動作
  • ドキュメント充実: 詳細な使用例
  • テスト済み: 高品質なコード
  • メンテナンス: Charmbraceletによる継続的更新

基本的な使用方法

インストール

go get github.com/charmbracelet/bubbles

テキスト入力

package main

import (
    "fmt"
    "os"
    
    "github.com/charmbracelet/bubbles/textinput"
    tea "github.com/charmbracelet/bubbletea"
)

type model struct {
    textInput textinput.Model
    err       error
}

func initialModel() model {
    ti := textinput.New()
    ti.Placeholder = "Enter your name"
    ti.Focus()
    ti.CharLimit = 156
    ti.Width = 20
    
    return model{
        textInput: ti,
        err:       nil,
    }
}

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

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.Type {
        case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc:
            return m, tea.Quit
        }
        
    case error:
        m.err = msg
        return m, nil
    }
    
    m.textInput, cmd = m.textInput.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return fmt.Sprintf(
        "What's your name?\n\n%s\n\n%s",
        m.textInput.View(),
        "(esc to quit)",
    ) + "\n"
}

func main() {
    if _, err := tea.NewProgram(initialModel()).Run(); err != nil {
        fmt.Printf("could not start program: %s\n", err)
        os.Exit(1)
    }
}

リストコンポーネント

package main

import (
    "fmt"
    "os"
    
    "github.com/charmbracelet/bubbles/list"
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
)

var docStyle = lipgloss.NewStyle().Margin(1, 2)

type item string

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

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.KeyMsg:
        if msg.String() == "ctrl+c" {
            return m, tea.Quit
        }
    case tea.WindowSizeMsg:
        h, v := docStyle.GetFrameSize()
        m.list.SetSize(msg.Width-h, msg.Height-v)
    }
    
    var cmd tea.Cmd
    m.list, cmd = m.list.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return docStyle.Render(m.list.View())
}

func main() {
    items := []list.Item{
        item("🍃 Bubble Tea"),
        item("🌈 Lip Gloss"),
        item("🍕 Bubbles"),
        item("🎆 Charm"),
        item("🍓 Glow"),
        item("🍰 Soft Serve"),
    }
    
    m := model{list: list.New(items, list.NewDefaultDelegate(), 0, 0)}
    m.list.Title = "Charmbracelet Tools"
    
    p := tea.NewProgram(m, tea.WithAltScreen())
    
    if _, err := p.Run(); err != nil {
        fmt.Println("Error running program:", err)
        os.Exit(1)
    }
}

プログレスバーとスピナー

package main

import (
    "fmt"
    "os"
    "strings"
    "time"
    
    "github.com/charmbracelet/bubbles/progress"
    "github.com/charmbracelet/bubbles/spinner"
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
)

type model struct {
    spinner  spinner.Model
    progress progress.Model
    done     bool
}

var (
    currentPkgNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211"))
    doneStyle           = lipgloss.NewStyle().Margin(1, 2)
    checkMark           = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓")
)

func newModel() model {
    p := progress.New(
        progress.WithDefaultGradient(),
        progress.WithWidth(40),
        progress.WithoutPercentage(),
    )
    s := spinner.New()
    s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
    
    return model{
        spinner:  s,
        progress: p,
    }
}

func (m model) Init() tea.Cmd {
    return tea.Batch(
        spinner.Tick,
        tickProgress(),
    )
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        return m, tea.Quit
        
    case tea.WindowSizeMsg:
        m.progress.Width = msg.Width - 4
        if m.progress.Width > 40 {
            m.progress.Width = 40
        }
        return m, nil
        
    case spinner.TickMsg:
        var cmd tea.Cmd
        m.spinner, cmd = m.spinner.Update(msg)
        return m, cmd
        
    case progressMsg:
        var cmds []tea.Cmd
        
        if msg >= 1.0 {
            m.done = true
            return m, tea.Quit
        }
        
        var cmd tea.Cmd
        m.progress, cmd = m.progress.Update(msg)
        cmds = append(cmds, cmd, tickProgress())
        
        return m, tea.Batch(cmds...)
    }
    
    return m, nil
}

func (m model) View() string {
    if m.done {
        return doneStyle.Render(fmt.Sprintf("%s Done!\n", checkMark))
    }
    
    pkgName := currentPkgNameStyle.Render("bubbles")
    prog := m.progress.View()
    cellsRemaining := m.progress.Width - lipgloss.Width(prog)
    
    gap := strings.Repeat(" ", cellsRemaining)
    
    return "\n" +
        m.spinner.View() + " Installing " + pkgName + gap + prog + "\n\n" +
        "Press any key to quit\n"
}

type progressMsg float64

func tickProgress() tea.Cmd {
    return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg {
        return progressMsg(time.Since(time.Now()).Seconds() / 3)
    })
}

func main() {
    if _, err := tea.NewProgram(newModel()).Run(); err != nil {
        fmt.Println("Error running program:", err)
        os.Exit(1)
    }
}

テーブルコンポーネント

package main

import (
    "fmt"
    "os"
    
    "github.com/charmbracelet/bubbles/table"
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
)

var baseStyle = lipgloss.NewStyle().
    BorderStyle(lipgloss.NormalBorder()).
    BorderForeground(lipgloss.Color("240"))

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
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "esc":
            if m.table.Focused() {
                m.table.Blur()
            } else {
                m.table.Focus()
            }
        case "q", "ctrl+c":
            return m, tea.Quit
        }
    }
    m.table, cmd = m.table.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return baseStyle.Render(m.table.View()) + "\n"
}

func main() {
    columns := []table.Column{
        {Title: "Rank", Width: 4},
        {Title: "City", Width: 10},
        {Title: "Country", Width: 10},
        {Title: "Population", Width: 10},
    }
    
    rows := []table.Row{
        {"1", "Tokyo", "Japan", "37,274,000"},
        {"2", "Delhi", "India", "32,065,760"},
        {"3", "Shanghai", "China", "28,516,904"},
        {"4", "Dhaka", "Bangladesh", "22,478,116"},
        {"5", "São Paulo", "Brazil", "22,429,800"},
    }
    
    t := table.New(
        table.WithColumns(columns),
        table.WithRows(rows),
        table.WithFocused(true),
        table.WithHeight(7),
    )
    
    s := table.DefaultStyles()
    s.Header = s.Header.
        BorderStyle(lipgloss.NormalBorder()).
        BorderForeground(lipgloss.Color("240")).
        BorderBottom(true).
        Bold(false)
    s.Selected = s.Selected.
        Foreground(lipgloss.Color("229")).
        Background(lipgloss.Color("57")).
        Bold(false)
    t.SetStyles(s)
    
    m := model{t}
    
    if _, err := tea.NewProgram(m).Run(); err != nil {
        fmt.Println("Error running program:", err)
        os.Exit(1)
    }
}

高度な機能

ファイルピッカー

import "github.com/charmbracelet/bubbles/filepicker"

fp := filepicker.New()
fp.AllowedTypes = []string{".txt", ".md", ".go"}
fp.CurrentDirectory, _ = os.Getwd()

ビューポート

import "github.com/charmbracelet/bubbles/viewport"

vp := viewport.New(80, 20)
vp.SetContent("Long text content...")
vp.KeyMap = viewport.DefaultKeyMap()

タイマーとストップウォッチ

import (
    "github.com/charmbracelet/bubbles/timer"
    "github.com/charmbracelet/bubbles/stopwatch"
)

// タイマー
t := timer.NewWithInterval(5*time.Minute, time.Second)

// ストップウォッチ
sw := stopwatch.NewWithInterval(time.Millisecond * 100)

カスタムコンポーネントの作成

type customInput struct {
    textinput.Model
    label string
    err   error
}

func NewCustomInput(label string) customInput {
    ti := textinput.New()
    ti.Placeholder = "Enter " + label
    ti.CharLimit = 50
    ti.Width = 30
    
    return customInput{
        Model: ti,
        label: label,
    }
}

func (c customInput) View() string {
    return fmt.Sprintf("%s: %s", c.label, c.Model.View())
}

エコシステム

関連プロジェクト

  • Bubble Tea: ベースフレームワーク
  • Lip Gloss: スタイリングライブラリ
  • Harmonica: アニメーションライブラリ
  • Log: ログライブラリ

採用例

  • GitHub CLI: 公式ツールで使用
  • Charm製ツール: Glow、Soft Serveなど
  • コミュニティプロジェクト: 多数のTUIアプリ

利点

  • 生産性: すぐに使えるコンポーネント
  • 品質: テスト済みで安定
  • 一貫性: 統一されたAPI設計
  • カスタマイズ: 柔軟な拡張性
  • メンテナンス: 活発な開発

制約事項

  • Bubble Tea依存: 単体では動作しない
  • 学習曲線: Elmアーキテクチャの理解が必要
  • コンポーネント限定: 提供されていない機能は自作

他のライブラリとの比較

項目Bubblestviewtermui
アーキテクチャElmウィジェットウィジェット
コンポーネント数
カスタマイズ性非常に高
学習コスト低〜中
エコシステムCharmbracelet独立独立

まとめ

Bubblesは、Bubble TeaフレームワークでTUIアプリケーションを構築する際に必須のコンポーネントライブラリです。高品質なコンポーネントが提供されているため、開発者はアプリケーションロジックに集中でき、生産性が大幅に向上します。Charmbraceletエコシステムの一部として、継続的な改善と新しいコンポーネントの追加が期待できます。