From f7922969878025f9b8bbe7e3e87489fa707adbcf Mon Sep 17 00:00:00 2001 From: Scott Friedman Date: Thu, 10 Jul 2025 22:55:16 -0700 Subject: [PATCH] Add TabBar component for tab navigation interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a simple, customizable tab bar component for Bubble Tea applications. The TabBar provides a horizontal tab navigation system that can be used to switch between different views within an application. Features: - Horizontal tabs with customizable styling - Active tab highlighting with different border colors - Keyboard navigation methods (Next, Prev, Activate) - Adjustable width to fit different layouts - Simple API integration with Bubble Tea applications 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- examples/tabbar/main.go | 101 +++++++++++++++++++++++++ tabbar/README.md | 94 +++++++++++++++++++++++ tabbar/tabbar.go | 130 ++++++++++++++++++++++++++++++++ tabbar/tabbar_test.go | 160 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 485 insertions(+) create mode 100644 examples/tabbar/main.go create mode 100644 tabbar/README.md create mode 100644 tabbar/tabbar.go create mode 100644 tabbar/tabbar_test.go diff --git a/examples/tabbar/main.go b/examples/tabbar/main.go new file mode 100644 index 00000000..ff44f8cc --- /dev/null +++ b/examples/tabbar/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/bubbles/tabbar" +) + +type model struct { + tabBar tabbar.TabBar + content []string + width int + height int +} + +func initialModel() model { + tabs := []string{"Home", "Projects", "Settings", "About"} + + // Create content for each tab + content := []string{ + "Welcome to the Home tab!", + "Here are your projects:\n - Project 1\n - Project 2\n - Project 3", + "Settings:\n - Theme: Dark\n - Notifications: On\n - Sounds: Off", + "About:\nThis is a simple example of the tabbar component.", + } + + return model{ + tabBar: tabbar.New(tabs, 0), + content: content, + width: 80, + height: 24, + } +} + +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 "q", "ctrl+c": + return m, tea.Quit + case "right", "tab": + return m, m.tabBar.Next() + case "left", "shift+tab": + return m, m.tabBar.Prev() + case "1", "2", "3", "4": + index := int(msg.Runes[0] - '1') + if index >= 0 && index < 4 { + return m, m.tabBar.Activate(index) + } + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.tabBar.SetWidth(msg.Width) + case tabbar.TabChangeMsg: + // No additional action needed, the tabBar state is already updated + } + + return m, nil +} + +func (m model) View() string { + // Tab content + activeTab := m.tabBar.ActiveTab() + tabContent := m.content[activeTab] + + // Style for the content area + contentStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#0074D9")). + Padding(1, 2). + Width(m.width - 4) + + // Help text + helpText := "\nUse ← and → arrows to switch tabs | 1-4 to jump to tab | q to quit" + + // Combine everything + return lipgloss.JoinVertical( + lipgloss.Left, + m.tabBar.View(), + "", + contentStyle.Render(tabContent), + helpText, + ) +} + +func main() { + p := tea.NewProgram(initialModel(), tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + fmt.Printf("Error running program: %v", err) + os.Exit(1) + } +} \ No newline at end of file diff --git a/tabbar/README.md b/tabbar/README.md new file mode 100644 index 00000000..c05f4321 --- /dev/null +++ b/tabbar/README.md @@ -0,0 +1,94 @@ +# TabBar Component for Bubble Tea + +A simple, customizable tab bar component for the [Bubble Tea](https://github.com/charmbracelet/bubbletea) TUI framework. + +![TabBar Demo](https://github.com/charmbracelet/bubbles/raw/master/tabbar/demo.gif) + +## Features + +- Horizontal tabs with customizable styling +- Active tab highlighting +- Keyboard navigation between tabs +- Adjustable width +- Simple API integration with Bubble Tea applications + +## Installation + +```bash +go get github.com/charmbracelet/bubbles +``` + +## Usage + +```go +import "github.com/charmbracelet/bubbles/tabbar" +``` + +### Basic Example + +```go +// Create tabs +tabs := []string{"Home", "Projects", "Settings", "About"} + +// Initialize the tab bar with Home tab active (index 0) +bar := tabbar.New(tabs, 0) + +// In your model's Update method: +switch msg := msg.(type) { +case tea.KeyMsg: + switch msg.String() { + case "right", "tab": + return m, m.tabBar.Next() + case "left", "shift+tab": + return m, m.tabBar.Prev() + } +case tabbar.TabChangeMsg: + // Handle tab change if needed +} + +// In your model's View method: +func (m model) View() string { + return m.tabBar.View() +} +``` + +### Customization + +You can customize the appearance of the tab bar: + +```go +// Customize colors +bar.ActiveBorderColor = lipgloss.Color("#ff0000") // Red for active tab +bar.InactiveBorderColor = lipgloss.Color("#333333") // Dark gray for inactive tabs + +// Set width to fit your layout +bar.SetWidth(100) +``` + +## API + +### Functions + +- `New(tabs []string, activeIndex int) TabBar` - Create a new tab bar +- `(t TabBar) ActiveTab() int` - Get the active tab index +- `(t *TabBar) Next() tea.Cmd` - Move to next tab +- `(t *TabBar) Prev() tea.Cmd` - Move to previous tab +- `(t *TabBar) Activate(index int) tea.Cmd` - Activate tab at specific index +- `(t *TabBar) SetWidth(width int)` - Set width of the tab bar +- `(t TabBar) View() string` - Render the tab bar + +### Message Types + +- `TabChangeMsg` - Sent when the active tab changes + +## Example + +See the [example](./example) directory for a complete working example. + +## License + +[MIT](https://github.com/charmbracelet/bubbles/blob/master/LICENSE) + +## Credits + +This component was contributed to the Bubble Tea ecosystem by the CloudWorkstation project. \ No newline at end of file diff --git a/tabbar/tabbar.go b/tabbar/tabbar.go new file mode 100644 index 00000000..15ffbb9e --- /dev/null +++ b/tabbar/tabbar.go @@ -0,0 +1,130 @@ +// Package tabbar provides a simple tab bar component for Bubble Tea applications. +package tabbar + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// TabChangeMsg is sent when the active tab changes. +type TabChangeMsg struct { + Index int +} + +// Tab represents a tab in the tab bar. +type Tab struct { + title string + style lipgloss.Style +} + +// TabBar is a simple tab bar component that can be used to switch between different views. +// It renders a horizontal list of tabs with customizable styles and borders. +type TabBar struct { + Tabs []Tab + ActiveTabIndex int + ActiveBorderColor lipgloss.Color + InactiveBorderColor lipgloss.Color + Width int +} + +// New creates a new tab bar with the given tab titles and active index. +func New(tabs []string, activeIndex int) TabBar { + // Default colors + activeBorderColor := lipgloss.Color("#0074D9") // Blue + inactiveBorderColor := lipgloss.Color("#AAAAAA") // Light gray + + // Create tab objects + tabItems := make([]Tab, len(tabs)) + for i, title := range tabs { + tabItems[i] = Tab{ + title: title, + style: lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(inactiveBorderColor). + Padding(0, 2), + } + } + + return TabBar{ + Tabs: tabItems, + ActiveTabIndex: activeIndex, + ActiveBorderColor: activeBorderColor, + InactiveBorderColor: inactiveBorderColor, + Width: 80, + } +} + +// ActiveTab returns the index of the active tab. +func (t TabBar) ActiveTab() int { + return t.ActiveTabIndex +} + +// Next activates the next tab and returns a command that will send a TabChangeMsg. +func (t *TabBar) Next() tea.Cmd { + if t.ActiveTabIndex < len(t.Tabs)-1 { + t.ActiveTabIndex++ + } else { + t.ActiveTabIndex = 0 + } + return func() tea.Msg { + return TabChangeMsg{Index: t.ActiveTabIndex} + } +} + +// Prev activates the previous tab and returns a command that will send a TabChangeMsg. +func (t *TabBar) Prev() tea.Cmd { + if t.ActiveTabIndex > 0 { + t.ActiveTabIndex-- + } else { + t.ActiveTabIndex = len(t.Tabs) - 1 + } + return func() tea.Msg { + return TabChangeMsg{Index: t.ActiveTabIndex} + } +} + +// Activate activates the tab at the given index and returns a command that will send a TabChangeMsg. +func (t *TabBar) Activate(index int) tea.Cmd { + if index >= 0 && index < len(t.Tabs) { + t.ActiveTabIndex = index + return func() tea.Msg { + return TabChangeMsg{Index: t.ActiveTabIndex} + } + } + return nil +} + +// SetWidth sets the width of the tab bar. +func (t *TabBar) SetWidth(width int) { + t.Width = width +} + +// View renders the tab bar. +func (t TabBar) View() string { + var renderedTabs []string + + // Calculate approximate width for each tab + tabWidth := (t.Width / len(t.Tabs)) - 4 // Account for borders and spacing + + for i, tab := range t.Tabs { + // Set border color based on active status + borderColor := t.InactiveBorderColor + if i == t.ActiveTabIndex { + borderColor = t.ActiveBorderColor + } + + // Set tab style + style := tab.style.Copy(). + BorderForeground(borderColor). + Width(tabWidth) + + if i == t.ActiveTabIndex { + style = style.Bold(true) + } + + // Render tab + renderedTabs = append(renderedTabs, style.Render(tab.title)) + } + + return lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) +} \ No newline at end of file diff --git a/tabbar/tabbar_test.go b/tabbar/tabbar_test.go new file mode 100644 index 00000000..8171f412 --- /dev/null +++ b/tabbar/tabbar_test.go @@ -0,0 +1,160 @@ +package tabbar + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func TestNewTabBar(t *testing.T) { + // Create a new TabBar + tabs := []string{"Tab 1", "Tab 2", "Tab 3"} + bar := New(tabs, 0) + + // Check initial state + if bar.ActiveTabIndex != 0 { + t.Errorf("Expected ActiveTabIndex to be 0, got %d", bar.ActiveTabIndex) + } + + if len(bar.Tabs) != len(tabs) { + t.Errorf("Expected %d tabs, got %d", len(tabs), len(bar.Tabs)) + } + + // Check tab titles + for i, tab := range bar.Tabs { + if tab.title != tabs[i] { + t.Errorf("Expected tab %d to have title %q, got %q", i, tabs[i], tab.title) + } + } +} + +func TestActiveTab(t *testing.T) { + // Create a new TabBar with second tab active + bar := New([]string{"Tab 1", "Tab 2", "Tab 3"}, 1) + + // Check ActiveTab method + if bar.ActiveTab() != 1 { + t.Errorf("Expected ActiveTab() to return 1, got %d", bar.ActiveTab()) + } +} + +func TestNextPrev(t *testing.T) { + // Create a new TabBar + bar := New([]string{"Tab 1", "Tab 2", "Tab 3"}, 0) + + // Test Next method + var cmd tea.Cmd = bar.Next() + if bar.ActiveTabIndex != 1 { + t.Errorf("After Next(), expected ActiveTabIndex to be 1, got %d", bar.ActiveTabIndex) + } + + // Execute command and check message + msg := cmd() + if tabChangeMsg, ok := msg.(TabChangeMsg); !ok || tabChangeMsg.Index != 1 { + t.Errorf("Expected Next() to return TabChangeMsg with Index 1, got %v", msg) + } + + // Test Next again (should go to last tab) + bar.Next() + if bar.ActiveTabIndex != 2 { + t.Errorf("After second Next(), expected ActiveTabIndex to be 2, got %d", bar.ActiveTabIndex) + } + + // Test wrap-around behavior + bar.Next() + if bar.ActiveTabIndex != 0 { + t.Errorf("After third Next(), expected ActiveTabIndex to wrap to 0, got %d", bar.ActiveTabIndex) + } + + // Test Prev method + bar.Prev() + if bar.ActiveTabIndex != 2 { + t.Errorf("After Prev(), expected ActiveTabIndex to be 2, got %d", bar.ActiveTabIndex) + } +} + +func TestActivate(t *testing.T) { + // Create a new TabBar + bar := New([]string{"Tab 1", "Tab 2", "Tab 3"}, 0) + + // Test Activate method with valid index + var cmd tea.Cmd = bar.Activate(2) + if bar.ActiveTabIndex != 2 { + t.Errorf("After Activate(2), expected ActiveTabIndex to be 2, got %d", bar.ActiveTabIndex) + } + + // Execute command and check message + msg := cmd() + if tabChangeMsg, ok := msg.(TabChangeMsg); !ok || tabChangeMsg.Index != 2 { + t.Errorf("Expected Activate(2) to return TabChangeMsg with Index 2, got %v", msg) + } + + // Test Activate method with invalid index (too low) + cmd = bar.Activate(-1) + if cmd != nil { + t.Error("Expected Activate(-1) to return nil command") + } + if bar.ActiveTabIndex != 2 { + t.Errorf("After Activate(-1), expected ActiveTabIndex to remain 2, got %d", bar.ActiveTabIndex) + } + + // Test Activate method with invalid index (too high) + cmd = bar.Activate(10) + if cmd != nil { + t.Error("Expected Activate(10) to return nil command") + } + if bar.ActiveTabIndex != 2 { + t.Errorf("After Activate(10), expected ActiveTabIndex to remain 2, got %d", bar.ActiveTabIndex) + } +} + +func TestSetWidth(t *testing.T) { + // Create a new TabBar + bar := New([]string{"Tab 1", "Tab 2"}, 0) + initialWidth := bar.Width + + // Test SetWidth method + bar.SetWidth(100) + if bar.Width != 100 { + t.Errorf("After SetWidth(100), expected Width to be 100, got %d", bar.Width) + } + + // Check that initial width was set correctly (default) + if initialWidth <= 0 { + t.Errorf("Expected default Width to be positive, got %d", initialWidth) + } +} + +func TestView(t *testing.T) { + // Create a new TabBar + bar := New([]string{"Tab 1", "Tab 2"}, 0) + bar.SetWidth(50) + + // Get rendered view + view := bar.View() + + // Basic sanity checks + if len(view) == 0 { + t.Error("Expected non-empty view") + } + + // Check that both tab titles appear in the rendered view + if !strings.Contains(view, "Tab 1") { + t.Error("Expected view to contain 'Tab 1'") + } + if !strings.Contains(view, "Tab 2") { + t.Error("Expected view to contain 'Tab 2'") + } + + // Check custom styling + bar.ActiveBorderColor = lipgloss.Color("#ff0000") + bar.InactiveBorderColor = lipgloss.Color("#000000") + + // Re-render view with custom styling + view = bar.View() + if len(view) == 0 { + t.Error("Expected non-empty view after styling changes") + } +} \ No newline at end of file