diff --git a/dropdown/demo.gif b/dropdown/demo.gif new file mode 100644 index 000000000..07ad93ebb Binary files /dev/null and b/dropdown/demo.gif differ diff --git a/dropdown/demo.tape b/dropdown/demo.tape new file mode 100644 index 000000000..0724e13ad --- /dev/null +++ b/dropdown/demo.tape @@ -0,0 +1,47 @@ +Output dropdown.gif + +Set Shell "bash" +Set FontSize 14 +Set Width 640 +Set Height 420 +Set Theme "Catppuccin Mocha" +Set Padding 20 + +Type "go run ./examples/dropdown" +Enter +Sleep 1s + +# Open the first dropdown +Type " " +Sleep 500ms + +# Navigate down twice and select "Bubbles" +Type "jj" +Sleep 300ms +Type " " +Sleep 500ms + +# Reload options at runtime +Type "r" +Sleep 500ms + +# Open again and pick "Rust" +Type " " +Sleep 300ms +Type "j" +Sleep 300ms +Enter +Sleep 500ms + +# Tab to the disabled dropdown (no effect on input) +Type " " +Sleep 300ms +Tab +Sleep 500ms + +# Tab to empty dropdown +Tab +Sleep 500ms + +# Quit +Type "q" diff --git a/dropdown/dropdown.go b/dropdown/dropdown.go new file mode 100644 index 000000000..580aafdb7 --- /dev/null +++ b/dropdown/dropdown.go @@ -0,0 +1,460 @@ +// Package dropdown provides a single-choice dropdown component for Bubble Tea +// applications. It follows the same Model–Update–View pattern and conventions +// as the other Bubbles components (table, paginator, spinner, etc.). +package dropdown + +import ( + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +const ( + defaultMaxVisible = 6 + defaultWidth = 20 +) + +// KeyMap is the key bindings for different actions within the dropdown. +// It satisfies the help.KeyMap interface so it integrates with the help +// component out of the box. +type KeyMap struct { + // Open opens the dropdown when it is collapsed. + Open key.Binding + // Close closes the dropdown without changing the current selection. + Close key.Binding + // Confirm confirms the highlighted option and collapses the dropdown. + Confirm key.Binding + // Up moves the highlight cursor up. + Up key.Binding + // Down moves the highlight cursor down. + Down key.Binding +} + +// ShortHelp implements help.KeyMap. +func (km KeyMap) ShortHelp() []key.Binding { + return []key.Binding{km.Up, km.Down, km.Confirm, km.Close} +} + +// FullHelp implements help.KeyMap. +func (km KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {km.Up, km.Down}, + {km.Open, km.Confirm, km.Close}, + } +} + +// DefaultKeyMap returns the default set of key bindings for the dropdown. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Open: key.NewBinding( + key.WithKeys("enter", "space"), + key.WithHelp("enter/space", "open"), + ), + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "close"), + ), + Confirm: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + } +} + +// Styles contains style definitions for this dropdown component. By default, +// these values are generated by DefaultStyles. +type Styles struct { + // FocusedHeader is the style for the collapsed header when the component + // has keyboard focus. + FocusedHeader lipgloss.Style + // BlurredHeader is the style for the collapsed header when the component + // does not have keyboard focus. + BlurredHeader lipgloss.Style + // DisabledHeader is the style for the header when the component is disabled. + DisabledHeader lipgloss.Style + + // SelectedOption is the style for the currently highlighted option in the + // expanded list. + SelectedOption lipgloss.Style + // NormalOption is the style for options that are not highlighted. + NormalOption lipgloss.Style + + // OpenIndicator is the decorator appended to the header when expanded. + OpenIndicator lipgloss.Style + // ClosedIndicator is the decorator appended to the header when collapsed. + ClosedIndicator lipgloss.Style +} + +// DefaultStyles returns a set of default style definitions for this dropdown +// component. +func DefaultStyles() Styles { + return Styles{ + FocusedHeader: lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("212")). + Padding(0, 1), + + BlurredHeader: lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + Foreground(lipgloss.Color("240")). + Padding(0, 1), + + DisabledHeader: lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("238")). + Foreground(lipgloss.Color("238")). + Padding(0, 1), + + SelectedOption: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("212")). + PaddingLeft(1). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(lipgloss.Color("212")), + + NormalOption: lipgloss.NewStyle(). + PaddingLeft(2), //nolint:mnd + + OpenIndicator: lipgloss.NewStyle().Foreground(lipgloss.Color("212")).SetString("▼"), + ClosedIndicator: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).SetString("▶"), + } +} + +// Option represents a single selectable item in the dropdown list. +type Option struct { + // Label is the text displayed in the header and in the expanded list. + Label string + // Value is the semantic value associated with this option. It may differ + // from Label (e.g. "en" vs "English"). + Value string +} + +// SelectMsg is emitted when the user confirms a selection. Parent models +// should type-switch on this in their own Update to react to a completed +// selection. +type SelectMsg struct { + Option Option + Index int +} + +// CloseMsg is emitted when the user closes the dropdown without confirming a +// new selection (e.g. by pressing Escape). +type CloseMsg struct{} + +// Model is the Bubble Tea model for the dropdown component. +type Model struct { + // KeyMap encodes the keybindings recognized by the dropdown. + KeyMap KeyMap + + // Styles holds all visual style definitions for the dropdown. + Styles Styles + + // Placeholder is the text shown in the header when no option is selected. + Placeholder string + + // MaxVisible is the maximum number of options shown at once when expanded. + // Options beyond this count are accessed by scrolling. Defaults to 6. + MaxVisible int + + // Disabled prevents the dropdown from receiving keyboard input and renders + // it with DisabledHeader style. + Disabled bool + + options []Option + cursor int // index of the currently highlighted option + selected int // index of the committed selection; -1 means none + open bool + focus bool + scrollOffset int // index of the first option in the visible window + width int // inner content width for header label and option rows +} + +// New creates a new model with default settings. Use the With* option +// functions to configure it, for example: +// +// dd := dropdown.New( +// dropdown.WithOptions(opts...), +// dropdown.WithWidth(24), +// ) +func New(o ...OptionFunc) Model { + m := Model{ + KeyMap: DefaultKeyMap(), + Styles: DefaultStyles(), + Placeholder: "Select…", + MaxVisible: defaultMaxVisible, + selected: -1, + width: defaultWidth, + } + for _, opt := range o { + opt(&m) + } + return m +} + +// OptionFunc is used to set options in New. For example: +// +// dd := dropdown.New(dropdown.WithWidth(30)) +type OptionFunc func(*Model) + +// WithOptions sets the initial option list. +func WithOptions(opts ...Option) OptionFunc { + return func(m *Model) { + m.options = opts + } +} + +// WithWidth sets the inner content width used for rendering. +func WithWidth(w int) OptionFunc { + return func(m *Model) { + m.width = w + } +} + +// WithPlaceholder sets the placeholder text shown when nothing is selected. +func WithPlaceholder(s string) OptionFunc { + return func(m *Model) { + m.Placeholder = s + } +} + +// WithMaxVisible sets the maximum number of options visible at once when +// the dropdown is expanded. +func WithMaxVisible(n int) OptionFunc { + return func(m *Model) { + m.MaxVisible = n + } +} + +// WithStyles sets the styles. +func WithStyles(s Styles) OptionFunc { + return func(m *Model) { + m.Styles = s + } +} + +// WithKeyMap sets the key map. +func WithKeyMap(km KeyMap) OptionFunc { + return func(m *Model) { + m.KeyMap = km + } +} + +// SetStyles sets the styles after construction. +func (m *Model) SetStyles(s Styles) { + m.Styles = s +} + +// SetOptions replaces the current option list and resets the selection, +// cursor, and scroll offset. +func (m *Model) SetOptions(opts []Option) { + m.options = opts + m.selected = -1 + m.cursor = 0 + m.scrollOffset = 0 +} + +// Options returns a copy of the current option list. +func (m Model) Options() []Option { + out := make([]Option, len(m.options)) + copy(out, m.options) + return out +} + +// SelectedItem returns the currently selected option and true, or an empty +// Option and false when nothing has been selected. +func (m Model) SelectedItem() (Option, bool) { + if m.selected < 0 || m.selected >= len(m.options) { + return Option{}, false + } + return m.options[m.selected], true +} + +// SelectedIndex returns the index of the selected option, or -1 if nothing +// has been selected. +func (m Model) SelectedIndex() int { + return m.selected +} + +// IsOpen returns true when the dropdown is expanded. +func (m Model) IsOpen() bool { + return m.open +} + +// Width returns the inner content width. +func (m Model) Width() int { + return m.width +} + +// SetWidth sets the inner content width. +func (m *Model) SetWidth(w int) { + m.width = w +} + +// Focus grants keyboard focus to the dropdown. It returns nil to match the +// tea.Cmd return type convention used by textinput and other components. +func (m *Model) Focus() tea.Cmd { + m.focus = true + return nil +} + +// Blur removes keyboard focus. The dropdown is also closed so that an +// abandoned expanded list is not left on screen. +func (m *Model) Blur() { + m.focus = false + m.open = false +} + +// Focused returns the current focus state. +func (m Model) Focused() bool { + return m.focus +} + +// Update is the Bubble Tea update loop. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if !m.focus || m.Disabled { + return m, nil + } + + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil + } + + if !m.open { + if key.Matches(keyMsg, m.KeyMap.Open) && len(m.options) > 0 { + m.open = true + // Start the cursor on the already-selected item when reopening. + if m.selected >= 0 { + m.cursor = m.selected + m.clampScrollOffset() + } + } + return m, nil + } + + switch { + case key.Matches(keyMsg, m.KeyMap.Close): + m.open = false + return m, func() tea.Msg { return CloseMsg{} } + + case key.Matches(keyMsg, m.KeyMap.Confirm): + m.selected = m.cursor + m.open = false + sel, idx := m.options[m.selected], m.selected + return m, func() tea.Msg { return SelectMsg{Option: sel, Index: idx} } + + case key.Matches(keyMsg, m.KeyMap.Up): + if m.cursor > 0 { + m.cursor-- + m.clampScrollOffset() + } + + case key.Matches(keyMsg, m.KeyMap.Down): + if m.cursor < len(m.options)-1 { + m.cursor++ + m.clampScrollOffset() + } + } + + return m, nil +} + +// clampScrollOffset keeps cursor visible within the MaxVisible window. +func (m *Model) clampScrollOffset() { + maxVis := m.MaxVisible + if maxVis <= 0 { + maxVis = defaultMaxVisible + } + if m.cursor < m.scrollOffset { + m.scrollOffset = m.cursor + } + if m.cursor >= m.scrollOffset+maxVis { + m.scrollOffset = m.cursor - maxVis + 1 + } +} + +// View renders the dropdown. +func (m Model) View() string { + var sb strings.Builder + + // ── Header ──────────────────────────────────────────────────────────────── + indicatorStr := m.indicatorString() + // Keep one space between the label and the indicator. + availForLabel := m.width - ansi.StringWidth(indicatorStr) - 1 + if availForLabel < 0 { + availForLabel = 0 + } + label := m.headerLabel() + label = ansi.Truncate(label, availForLabel, "…") + // Pad so the header width stays fixed regardless of label length. + label += strings.Repeat(" ", max(0, availForLabel-ansi.StringWidth(label))) + + sb.WriteString(m.headerStyle().Render(label + " " + indicatorStr)) + + // ── Option list ─────────────────────────────────────────────────────────── + if !m.open || len(m.options) == 0 { + return sb.String() + } + + sb.WriteRune('\n') + + maxVis := m.MaxVisible + if maxVis <= 0 { + maxVis = defaultMaxVisible + } + end := m.scrollOffset + maxVis + if end > len(m.options) { + end = len(m.options) + } + + for i := m.scrollOffset; i < end; i++ { + optLabel := ansi.Truncate(m.options[i].Label, m.width, "…") + if i == m.cursor { + sb.WriteString(m.Styles.SelectedOption.Render(optLabel)) + } else { + sb.WriteString(m.Styles.NormalOption.Render(optLabel)) + } + if i < end-1 { + sb.WriteRune('\n') + } + } + + return sb.String() +} + +func (m Model) headerLabel() string { + if opt, ok := m.SelectedItem(); ok { + return opt.Label + } + return m.Placeholder +} + +func (m Model) indicatorString() string { + if m.open { + return m.Styles.OpenIndicator.String() + } + return m.Styles.ClosedIndicator.String() +} + +func (m Model) headerStyle() lipgloss.Style { + switch { + case m.Disabled: + return m.Styles.DisabledHeader + case m.focus: + return m.Styles.FocusedHeader + default: + return m.Styles.BlurredHeader + } +} diff --git a/dropdown/dropdown_test.go b/dropdown/dropdown_test.go new file mode 100644 index 000000000..0ca1215bc --- /dev/null +++ b/dropdown/dropdown_test.go @@ -0,0 +1,394 @@ +package dropdown + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +// helpers ────────────────────────────────────────────────────────────────── + +func testOptions() []Option { + return []Option{ + {Label: "Alpha", Value: "a"}, + {Label: "Beta", Value: "b"}, + {Label: "Gamma", Value: "c"}, + {Label: "Delta", Value: "d"}, + {Label: "Epsilon", Value: "e"}, + } +} + +// press sends a single key event and returns the updated model plus any +// emitted tea.Msg (by calling the returned Cmd immediately). +func press(m Model, code rune) (Model, tea.Msg) { + updated, cmd := m.Update(tea.KeyPressMsg{Code: code}) + var emitted tea.Msg + if cmd != nil { + emitted = cmd() + } + return updated, emitted +} + +func focused(opts []Option) Model { + m := New(WithOptions(opts...), WithWidth(20)) + m.Focus() //nolint:errcheck + return m +} + +// ── Focus / Blur ─────────────────────────────────────────────────────────── + +func TestFocused(t *testing.T) { + m := New(WithOptions(testOptions()...)) + if m.Focused() { + t.Fatal("expected unfocused on creation") + } + m.Focus() //nolint:errcheck + if !m.Focused() { + t.Fatal("expected focused after Focus()") + } + m.Blur() + if m.Focused() { + t.Fatal("expected unfocused after Blur()") + } +} + +func TestBlurClosesDropdown(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeyEnter) + if !m.open { + t.Fatal("expected open after Enter") + } + m.Blur() + if m.open { + t.Fatal("expected closed after Blur()") + } +} + +// ── Disabled ─────────────────────────────────────────────────────────────── + +func TestDisabledIgnoresInput(t *testing.T) { + m := focused(testOptions()) + m.Disabled = true + m, emitted := press(m, tea.KeyEnter) + if m.open { + t.Error("disabled dropdown should not open") + } + if emitted != nil { + t.Error("disabled dropdown should not emit messages") + } +} + +// ── Empty options ───────────────────────────────────────────────────────── + +func TestEmptyOptionsNoOpen(t *testing.T) { + m := New(WithWidth(20)) + m.Focus() //nolint:errcheck + m, _ = press(m, tea.KeyEnter) + if m.open { + t.Fatal("empty dropdown should not open") + } +} + +func TestEmptyOptionsNoPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("unexpected panic: %v", r) + } + }() + m := New(WithWidth(20)) + m.Focus() //nolint:errcheck + for _, k := range []rune{tea.KeyEnter, tea.KeyDown, tea.KeyUp, tea.KeyEscape} { + m, _ = press(m, k) + } + _ = m.View() +} + +// ── Opening / Closing ───────────────────────────────────────────────────── + +func TestOpenWithEnter(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeyEnter) + if !m.IsOpen() { + t.Fatal("should be open after Enter") + } +} + +func TestOpenWithSpace(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeySpace) + if !m.IsOpen() { + t.Fatal("should be open after Space") + } +} + +func TestCloseWithEscape(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeyEnter) + m, emitted := press(m, tea.KeyEscape) + if m.IsOpen() { + t.Fatal("should be closed after Escape") + } + if _, ok := emitted.(CloseMsg); !ok { + t.Fatalf("expected CloseMsg, got %T", emitted) + } +} + +func TestEscapeDoesNotChangeSelection(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeyEnter) + m, _ = press(m, tea.KeyDown) + m, _ = press(m, tea.KeyEscape) + if m.SelectedIndex() != -1 { + t.Fatalf("Escape should not commit selection; got index %d", m.SelectedIndex()) + } +} + +// ── Navigation ──────────────────────────────────────────────────────────── + +func TestCursorDownUp(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeyEnter) + m, _ = press(m, tea.KeyDown) + m, _ = press(m, tea.KeyDown) + if m.cursor != 2 { + t.Fatalf("expected cursor=2, got %d", m.cursor) + } + m, _ = press(m, tea.KeyUp) + if m.cursor != 1 { + t.Fatalf("expected cursor=1 after Up, got %d", m.cursor) + } +} + +func TestCursorNoWrapAtTop(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeyEnter) + m, _ = press(m, tea.KeyUp) + if m.cursor != 0 { + t.Fatalf("cursor should stay at 0, got %d", m.cursor) + } +} + +func TestCursorNoWrapAtBottom(t *testing.T) { + opts := testOptions() + m := focused(opts) + m, _ = press(m, tea.KeyEnter) + for i := 0; i < len(opts)-1; i++ { + m, _ = press(m, tea.KeyDown) + } + last := m.cursor + m, _ = press(m, tea.KeyDown) + if m.cursor != last { + t.Fatalf("cursor should not go past last option; got %d", m.cursor) + } +} + +func TestVimKeys(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeyEnter) + m, _ = m.Update(tea.KeyPressMsg{Text: "j"}) + if m.cursor != 1 { + t.Fatalf("expected cursor=1 after 'j', got %d", m.cursor) + } + m, _ = m.Update(tea.KeyPressMsg{Text: "k"}) + if m.cursor != 0 { + t.Fatalf("expected cursor=0 after 'k', got %d", m.cursor) + } +} + +// ── Selection (SelectMsg) ───────────────────────────────────────────────── + +func TestSelectEmitsSelectMsg(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeyEnter) // open + m, _ = press(m, tea.KeyDown) // cursor → 1 "Beta" + m, emitted := press(m, tea.KeyEnter) + + sel, ok := emitted.(SelectMsg) + if !ok { + t.Fatalf("expected SelectMsg, got %T", emitted) + } + if sel.Index != 1 { + t.Errorf("expected index 1, got %d", sel.Index) + } + if sel.Option.Value != "b" { + t.Errorf("expected value 'b', got %q", sel.Option.Value) + } +} + +func TestSelectCommitsSelection(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeyEnter) + m, _ = press(m, tea.KeyDown) + m, _ = press(m, tea.KeyDown) + m, _ = press(m, tea.KeyEnter) + + if m.SelectedIndex() != 2 { + t.Fatalf("expected selected=2, got %d", m.SelectedIndex()) + } + opt, ok := m.SelectedItem() + if !ok { + t.Fatal("SelectedItem should return true after selection") + } + if opt.Label != "Gamma" { + t.Errorf("expected 'Gamma', got %q", opt.Label) + } +} + +func TestSelectClosesDropdown(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeyEnter) + m, _ = press(m, tea.KeyEnter) + if m.IsOpen() { + t.Fatal("dropdown should close after selecting") + } +} + +func TestSelectedItemNoneInitially(t *testing.T) { + m := New(WithOptions(testOptions()...)) + _, ok := m.SelectedItem() + if ok { + t.Fatal("expected no selection initially") + } + if m.SelectedIndex() != -1 { + t.Fatalf("expected SelectedIndex=-1, got %d", m.SelectedIndex()) + } +} + +// ── Scroll offset ───────────────────────────────────────────────────────── + +func TestScrollOffsetAdvancesWithCursor(t *testing.T) { + m := New(WithOptions(testOptions()...), WithWidth(20), WithMaxVisible(3)) + m.Focus() //nolint:errcheck + m, _ = press(m, tea.KeyEnter) + for i := 0; i < 4; i++ { + m, _ = press(m, tea.KeyDown) + } + if m.scrollOffset < 2 { + t.Fatalf("expected scrollOffset>=2 at cursor=4 with MaxVisible=3, got %d", m.scrollOffset) + } + end := m.scrollOffset + m.MaxVisible + if m.cursor < m.scrollOffset || m.cursor >= end { + t.Fatalf("cursor %d outside visible window [%d, %d)", m.cursor, m.scrollOffset, end) + } +} + +func TestScrollOffsetRetreatsWithCursor(t *testing.T) { + opts := testOptions() + m := New(WithOptions(opts...), WithWidth(20), WithMaxVisible(3)) + m.Focus() //nolint:errcheck + m, _ = press(m, tea.KeyEnter) + for i := 0; i < len(opts)-1; i++ { + m, _ = press(m, tea.KeyDown) + } + prevOffset := m.scrollOffset + for i := 0; i < len(opts)-1; i++ { + m, _ = press(m, tea.KeyUp) + } + if m.scrollOffset >= prevOffset { + t.Fatalf("scrollOffset should have decreased (was %d, now %d)", prevOffset, m.scrollOffset) + } + if m.scrollOffset != 0 { + t.Fatalf("expected scrollOffset=0 at top, got %d", m.scrollOffset) + } +} + +// ── SetOptions ──────────────────────────────────────────────────────────── + +func TestSetOptionsResetsState(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeyEnter) + m, _ = press(m, tea.KeyDown) + m, _ = press(m, tea.KeyEnter) + if m.SelectedIndex() == -1 { + t.Fatal("expected a selection before SetOptions") + } + m.SetOptions([]Option{{Label: "X", Value: "x"}}) + if m.SelectedIndex() != -1 { + t.Fatal("SetOptions should reset selection to -1") + } + if m.cursor != 0 { + t.Fatal("SetOptions should reset cursor to 0") + } + if m.scrollOffset != 0 { + t.Fatal("SetOptions should reset scrollOffset to 0") + } +} + +// ── Option funcs ────────────────────────────────────────────────────────── + +func TestWithKeyMap(t *testing.T) { + km := DefaultKeyMap() + m := New(WithKeyMap(km)) + if m.KeyMap.Up.Keys()[0] != km.Up.Keys()[0] { + t.Fatal("WithKeyMap should set the KeyMap") + } +} + +func TestWithStyles(t *testing.T) { + s := DefaultStyles() + m := New(WithStyles(s)) + _ = m.View() // should not panic +} + +// ── View ────────────────────────────────────────────────────────────────── + +func TestViewDoesNotPanic(t *testing.T) { + cases := []struct { + name string + model Model + }{ + {"default", New()}, + {"focused", func() Model { m := New(WithOptions(testOptions()...)); m.Focus(); return m }()}, + {"open", func() Model { + m := New(WithOptions(testOptions()...)) + m.Focus() + m, _ = press(m, tea.KeyEnter) + return m + }()}, + {"disabled", func() Model { + m := New(WithOptions(testOptions()...)) + m.Disabled = true + return m + }()}, + {"empty", New()}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("View() panicked: %v", r) + } + }() + _ = tc.model.View() + }) + } +} + +func TestViewContainsSelectedLabel(t *testing.T) { + m := focused(testOptions()) + m, _ = press(m, tea.KeyEnter) + m, _ = press(m, tea.KeyDown) // cursor → 1 "Beta" + m, _ = press(m, tea.KeyEnter) + view := m.View() + if !containsSubstring(view, "Beta") { + t.Errorf("expected 'Beta' in view, got:\n%s", view) + } +} + +func TestViewContainsPlaceholder(t *testing.T) { + m := New(WithOptions(testOptions()...), WithPlaceholder("Pick one")) + view := m.View() + if !containsSubstring(view, "Pick one") { + t.Errorf("expected placeholder in view, got:\n%s", view) + } +} + +// containsSubstring is an inlined helper to avoid importing strings. +func containsSubstring(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/examples/dropdown/main.go b/examples/dropdown/main.go new file mode 100644 index 000000000..07b43d0ad --- /dev/null +++ b/examples/dropdown/main.go @@ -0,0 +1,150 @@ +// Example program demonstrating the dropdown component. +// +// Controls: +// +// - tab / shift+tab cycle focus between dropdowns +// - ↑/k, ↓/j navigate options (when expanded) +// - enter / space open or confirm selection +// - esc close without selecting +// - r reload options in the first dropdown at runtime +// - q / ctrl+c quit +package main + +import ( + "fmt" + "os" + "strings" + + "charm.land/bubbles/v2/dropdown" + tea "charm.land/bubbletea/v2" +) + +const numDropdowns = 3 + +var charmOpts = []dropdown.Option{ + {Label: "Bubble Tea", Value: "bubbletea"}, + {Label: "Lip Gloss", Value: "lipgloss"}, + {Label: "Bubbles", Value: "bubbles"}, + {Label: "Huh", Value: "huh"}, + {Label: "Wish", Value: "wish"}, +} + +var langOpts = []dropdown.Option{ + {Label: "Go", Value: "go"}, + {Label: "Rust", Value: "rust"}, + {Label: "Zig", Value: "zig"}, + {Label: "C", Value: "c"}, +} + +type model struct { + dropdowns [numDropdowns]dropdown.Model + focusIndex int + lastSelected string +} + +func initialModel() model { + // Dropdown 0: normal, interactive. + dd0 := dropdown.New( + dropdown.WithOptions(charmOpts...), + dropdown.WithWidth(18), + ) + dd0.Focus() //nolint:errcheck + + // Dropdown 1: disabled, with a pre-committed selection. + dd1 := dropdown.New( + dropdown.WithOptions(charmOpts...), + dropdown.WithWidth(18), + ) + // Pre-select "Lip Gloss" (index 1) by running Update with a temporary focus. + dd1.Focus() //nolint:errcheck + dd1, _ = dd1.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // open + dd1, _ = dd1.Update(tea.KeyPressMsg{Code: tea.KeyDown}) // cursor → 1 + dd1, _ = dd1.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // confirm + dd1.Blur() + dd1.Disabled = true + + // Dropdown 2: empty options — shows placeholder only, never opens. + dd2 := dropdown.New(dropdown.WithWidth(18)) + + return model{ + dropdowns: [numDropdowns]dropdown.Model{dd0, dd1, dd2}, + focusIndex: 0, + } +} + +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 "ctrl+c", "q": + return m, tea.Quit + + case "tab", "shift+tab": + step := 1 + if msg.String() == "shift+tab" { + step = -1 + } + m.dropdowns[m.focusIndex].Blur() + m.focusIndex = (m.focusIndex + step + numDropdowns) % numDropdowns + m.dropdowns[m.focusIndex].Focus() //nolint:errcheck + return m, nil + + case "r": + // Reload options at runtime (only when collapsed). + if !m.dropdowns[0].IsOpen() { + m.dropdowns[0].SetOptions(langOpts) + m.lastSelected = "(options reloaded)" + } + return m, nil + } + + case dropdown.SelectMsg: + m.lastSelected = fmt.Sprintf("selected %q (value: %q, index: %d)", + msg.Option.Label, msg.Option.Value, msg.Index) + return m, nil + + case dropdown.CloseMsg: + m.lastSelected = "(closed without selecting)" + return m, nil + } + + var cmd tea.Cmd + m.dropdowns[m.focusIndex], cmd = m.dropdowns[m.focusIndex].Update(msg) + return m, cmd +} + +func (m model) View() tea.View { + var sb strings.Builder + + sb.WriteString("Dropdown Component Demo\n") + sb.WriteString("───────────────────────────────────────────────\n\n") + + labels := []string{"Normal (tab to focus)", "Disabled", "Empty"} + for i, dd := range m.dropdowns { + sb.WriteString(fmt.Sprintf(" %s\n", labels[i])) + for _, line := range strings.Split(dd.View(), "\n") { + sb.WriteString(" " + line + "\n") + } + sb.WriteRune('\n') + } + + sb.WriteString("───────────────────────────────────────────────\n") + if m.lastSelected != "" { + sb.WriteString(" " + m.lastSelected + "\n") + } + sb.WriteString("\n tab/shift+tab: focus r: reload opts q: quit\n") + + return tea.NewView(sb.String()) +} + +func main() { + p := tea.NewProgram(initialModel()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +}