summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components/list
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-11-02 18:43:17 -0500
committerDax Raad <[email protected]>2025-11-02 18:43:33 -0500
commitf68374ad2223ddc213bdea9519ca6a699819ee0e (patch)
tree04f0fe21b8e12cd62d7274961bb0cff64f966f40 /packages/tui/internal/components/list
parent5e86c9b7916f75c7ad227b80eab18c7c54fc8ffe (diff)
downloadopencode-f68374ad2223ddc213bdea9519ca6a699819ee0e.tar.gz
opencode-f68374ad2223ddc213bdea9519ca6a699819ee0e.zip
DELETE GO BUBBLETEA CRAP HOORAY
Diffstat (limited to 'packages/tui/internal/components/list')
-rw-r--r--packages/tui/internal/components/list/list.go436
-rw-r--r--packages/tui/internal/components/list/list_test.go249
2 files changed, 0 insertions, 685 deletions
diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go
deleted file mode 100644
index a9823d0ab..000000000
--- a/packages/tui/internal/components/list/list.go
+++ /dev/null
@@ -1,436 +0,0 @@
-package list
-
-import (
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-// Item interface that all list items must implement
-type Item interface {
- Render(selected bool, width int, baseStyle styles.Style) string
- Selectable() bool
-}
-
-// RenderFunc defines how to render an item in the list
-type RenderFunc[T any] func(item T, selected bool, width int, baseStyle styles.Style) string
-
-// SelectableFunc defines whether an item is selectable
-type SelectableFunc[T any] func(item T) bool
-
-// Options holds configuration for the list component
-type Options[T any] struct {
- items []T
- maxVisibleHeight int
- fallbackMsg string
- useAlphaNumericKeys bool
- renderItem RenderFunc[T]
- isSelectable SelectableFunc[T]
- baseStyle styles.Style
-}
-
-// Option is a function that configures the list component
-type Option[T any] func(*Options[T])
-
-// WithItems sets the initial items for the list
-func WithItems[T any](items []T) Option[T] {
- return func(o *Options[T]) {
- o.items = items
- }
-}
-
-// WithMaxVisibleHeight sets the maximum visible height in lines
-func WithMaxVisibleHeight[T any](height int) Option[T] {
- return func(o *Options[T]) {
- o.maxVisibleHeight = height
- }
-}
-
-// WithFallbackMessage sets the message to show when the list is empty
-func WithFallbackMessage[T any](msg string) Option[T] {
- return func(o *Options[T]) {
- o.fallbackMsg = msg
- }
-}
-
-// WithAlphaNumericKeys enables j/k navigation keys
-func WithAlphaNumericKeys[T any](enabled bool) Option[T] {
- return func(o *Options[T]) {
- o.useAlphaNumericKeys = enabled
- }
-}
-
-// WithRenderFunc sets the function to render items
-func WithRenderFunc[T any](fn RenderFunc[T]) Option[T] {
- return func(o *Options[T]) {
- o.renderItem = fn
- }
-}
-
-// WithSelectableFunc sets the function to determine if items are selectable
-func WithSelectableFunc[T any](fn SelectableFunc[T]) Option[T] {
- return func(o *Options[T]) {
- o.isSelectable = fn
- }
-}
-
-// WithStyle sets the base style that gets passed to render functions
-func WithStyle[T any](style styles.Style) Option[T] {
- return func(o *Options[T]) {
- o.baseStyle = style
- }
-}
-
-type List[T any] interface {
- tea.Model
- tea.ViewModel
- SetMaxWidth(maxWidth int)
- GetSelectedItem() (item T, idx int)
- SetItems(items []T)
- GetItems() []T
- SetSelectedIndex(idx int)
- SetEmptyMessage(msg string)
- IsEmpty() bool
- GetMaxVisibleHeight() int
-}
-
-type listComponent[T any] struct {
- fallbackMsg string
- items []T
- selectedIdx int
- maxWidth int
- maxVisibleHeight int
- useAlphaNumericKeys bool
- width int
- height int
- renderItem RenderFunc[T]
- isSelectable SelectableFunc[T]
- baseStyle styles.Style
-}
-
-type listKeyMap struct {
- Up key.Binding
- Down key.Binding
- UpAlpha key.Binding
- DownAlpha key.Binding
-}
-
-var simpleListKeys = listKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("↑", "previous list item"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("↓", "next list item"),
- ),
- UpAlpha: key.NewBinding(
- key.WithKeys("k"),
- key.WithHelp("k", "previous list item"),
- ),
- DownAlpha: key.NewBinding(
- key.WithKeys("j"),
- key.WithHelp("j", "next list item"),
- ),
-}
-
-func (c *listComponent[T]) Init() tea.Cmd {
- return nil
-}
-
-func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
- c.moveUp()
- return c, nil
- case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
- c.moveDown()
- return c, nil
- }
- }
-
- return c, nil
-}
-
-// moveUp moves the selection up, skipping non-selectable items
-func (c *listComponent[T]) moveUp() {
- if len(c.items) == 0 {
- return
- }
-
- // Find the previous selectable item
- for i := c.selectedIdx - 1; i >= 0; i-- {
- if c.isSelectable(c.items[i]) {
- c.selectedIdx = i
- return
- }
- }
-
- // If no selectable item found above, wrap to the bottom
- for i := len(c.items) - 1; i > c.selectedIdx; i-- {
- if c.isSelectable(c.items[i]) {
- c.selectedIdx = i
- return
- }
- }
-}
-
-// moveDown moves the selection down, skipping non-selectable items
-func (c *listComponent[T]) moveDown() {
- if len(c.items) == 0 {
- return
- }
-
- originalIdx := c.selectedIdx
- // First try moving down from current position
- for i := c.selectedIdx + 1; i < len(c.items); i++ {
- if c.isSelectable(c.items[i]) {
- c.selectedIdx = i
- return
- }
- }
-
- // If no selectable item found below, wrap to the top
- for i := 0; i < originalIdx; i++ {
- if c.isSelectable(c.items[i]) {
- c.selectedIdx = i
- return
- }
- }
-}
-
-func (c *listComponent[T]) GetSelectedItem() (T, int) {
- if len(c.items) > 0 && c.isSelectable(c.items[c.selectedIdx]) {
- return c.items[c.selectedIdx], c.selectedIdx
- }
-
- var zero T
- return zero, -1
-}
-
-func (c *listComponent[T]) SetItems(items []T) {
- c.items = items
- c.selectedIdx = 0
-
- // Ensure initial selection is on a selectable item
- if len(items) > 0 && !c.isSelectable(items[0]) {
- c.moveDown()
- }
-}
-
-func (c *listComponent[T]) GetItems() []T {
- return c.items
-}
-
-func (c *listComponent[T]) SetEmptyMessage(msg string) {
- c.fallbackMsg = msg
-}
-
-func (c *listComponent[T]) IsEmpty() bool {
- return len(c.items) == 0
-}
-
-func (c *listComponent[T]) SetMaxWidth(width int) {
- c.maxWidth = width
-}
-
-func (c *listComponent[T]) SetSelectedIndex(idx int) {
- if idx >= 0 && idx < len(c.items) {
- c.selectedIdx = idx
- }
-}
-
-func (c *listComponent[T]) GetMaxVisibleHeight() int {
- return c.maxVisibleHeight
-}
-
-func (c *listComponent[T]) View() string {
- items := c.items
- maxWidth := c.maxWidth
- if maxWidth == 0 {
- maxWidth = 80 // Default width if not set
- }
-
- if len(items) <= 0 {
- return c.fallbackMsg
- }
-
- // Calculate viewport based on actual heights
- startIdx, endIdx := c.calculateViewport()
-
- listItems := make([]string, 0, endIdx-startIdx)
-
- for i := startIdx; i < endIdx; i++ {
- item := items[i]
-
- // Special handling for HeaderItem to remove top margin on first item
- if i == startIdx {
- // Check if this is a HeaderItem
- if _, ok := any(item).(Item); ok {
- if headerItem, isHeader := any(item).(HeaderItem); isHeader {
- // Render header without top margin when it's first
- t := theme.CurrentTheme()
- truncatedStr := truncate.StringWithTail(string(headerItem), uint(maxWidth-1), "...")
- headerStyle := c.baseStyle.
- Foreground(t.Accent()).
- Bold(true).
- MarginBottom(0).
- PaddingLeft(1)
- listItems = append(listItems, headerStyle.Render(truncatedStr))
- continue
- }
- }
- }
-
- title := c.renderItem(item, i == c.selectedIdx, maxWidth, c.baseStyle)
- listItems = append(listItems, title)
- }
-
- return strings.Join(listItems, "\n")
-}
-
-// calculateViewport determines which items to show based on available space
-func (c *listComponent[T]) calculateViewport() (startIdx, endIdx int) {
- items := c.items
- if len(items) == 0 {
- return 0, 0
- }
-
- // Calculate heights of all items
- itemHeights := make([]int, len(items))
- for i, item := range items {
- rendered := c.renderItem(item, false, c.maxWidth, c.baseStyle)
- itemHeights[i] = lipgloss.Height(rendered)
- }
-
- // Find the range of items that fit within maxVisibleHeight
- // Start by trying to center the selected item
- start := 0
- end := len(items)
-
- // Calculate height from start to selected
- heightToSelected := 0
- for i := 0; i <= c.selectedIdx && i < len(items); i++ {
- heightToSelected += itemHeights[i]
- }
-
- // If selected item is beyond visible height, scroll to show it
- if heightToSelected > c.maxVisibleHeight {
- // Start from selected and work backwards to find start
- currentHeight := itemHeights[c.selectedIdx]
- start = c.selectedIdx
-
- for i := c.selectedIdx - 1; i >= 0 && currentHeight+itemHeights[i] <= c.maxVisibleHeight; i-- {
- currentHeight += itemHeights[i]
- start = i
- }
- }
-
- // Calculate end based on start
- currentHeight := 0
- for i := start; i < len(items); i++ {
- if currentHeight+itemHeights[i] > c.maxVisibleHeight {
- end = i
- break
- }
- currentHeight += itemHeights[i]
- }
-
- return start, end
-}
-
-func abs(x int) int {
- if x < 0 {
- return -x
- }
- return x
-}
-
-func max(a, b int) int {
- if a > b {
- return a
- }
- return b
-}
-
-func NewListComponent[T any](opts ...Option[T]) List[T] {
- options := &Options[T]{
- baseStyle: styles.NewStyle(), // Default empty style
- }
-
- for _, opt := range opts {
- opt(options)
- }
-
- return &listComponent[T]{
- fallbackMsg: options.fallbackMsg,
- items: options.items,
- maxVisibleHeight: options.maxVisibleHeight,
- useAlphaNumericKeys: options.useAlphaNumericKeys,
- selectedIdx: 0,
- renderItem: options.renderItem,
- isSelectable: options.isSelectable,
- baseStyle: options.baseStyle,
- }
-}
-
-// StringItem is a simple implementation of Item for string values
-type StringItem string
-
-func (s StringItem) Render(selected bool, width int, baseStyle styles.Style) string {
- t := theme.CurrentTheme()
-
- truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
-
- var itemStyle styles.Style
- if selected {
- itemStyle = baseStyle.
- Background(t.Primary()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1)
- } else {
- itemStyle = baseStyle.
- Foreground(t.TextMuted()).
- PaddingLeft(1)
- }
-
- return itemStyle.Render(truncatedStr)
-}
-
-func (s StringItem) Selectable() bool {
- return true
-}
-
-// HeaderItem is a non-selectable header item for grouping
-type HeaderItem string
-
-func (h HeaderItem) Render(selected bool, width int, baseStyle styles.Style) string {
- t := theme.CurrentTheme()
-
- truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...")
-
- headerStyle := baseStyle.
- Foreground(t.Accent()).
- Bold(true).
- MarginTop(1).
- MarginBottom(0).
- PaddingLeft(1)
-
- return headerStyle.Render(truncatedStr)
-}
-
-func (h HeaderItem) Selectable() bool {
- return false
-}
-
-// Ensure StringItem and HeaderItem implement Item
-var _ Item = StringItem("")
-var _ Item = HeaderItem("")
diff --git a/packages/tui/internal/components/list/list_test.go b/packages/tui/internal/components/list/list_test.go
deleted file mode 100644
index 25cca8cf4..000000000
--- a/packages/tui/internal/components/list/list_test.go
+++ /dev/null
@@ -1,249 +0,0 @@
-package list
-
-import (
- "testing"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode/internal/styles"
-)
-
-// testItem is a simple test implementation of ListItem
-type testItem struct {
- value string
-}
-
-func (t testItem) Render(
- selected bool,
- width int,
- isFirstInViewport bool,
- baseStyle styles.Style,
-) string {
- return t.value
-}
-
-func (t testItem) Selectable() bool {
- return true
-}
-
-// createTestList creates a list with test items for testing
-func createTestList() *listComponent[testItem] {
- items := []testItem{
- {value: "item1"},
- {value: "item2"},
- {value: "item3"},
- }
- list := NewListComponent(
- WithItems(items),
- WithMaxVisibleHeight[testItem](5),
- WithFallbackMessage[testItem]("empty"),
- WithAlphaNumericKeys[testItem](false),
- WithRenderFunc(
- func(item testItem, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, false, baseStyle)
- },
- ),
- WithSelectableFunc(func(item testItem) bool {
- return item.Selectable()
- }),
- )
-
- return list.(*listComponent[testItem])
-}
-
-func TestArrowKeyNavigation(t *testing.T) {
- list := createTestList()
-
- // Test down arrow navigation
- downKey := tea.KeyPressMsg{Code: tea.KeyDown}
- updatedModel, _ := list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx := list.GetSelectedItem()
- if idx != 1 {
- t.Errorf("Expected selected index 1 after down arrow, got %d", idx)
- }
-
- // Test up arrow navigation
- upKey := tea.KeyPressMsg{Code: tea.KeyUp}
- updatedModel, _ = list.Update(upKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected selected index 0 after up arrow, got %d", idx)
- }
-}
-
-func TestJKKeyNavigation(t *testing.T) {
- items := []testItem{
- {value: "item1"},
- {value: "item2"},
- {value: "item3"},
- }
- // Create list with alpha keys enabled
- list := NewListComponent(
- WithItems(items),
- WithMaxVisibleHeight[testItem](5),
- WithFallbackMessage[testItem]("empty"),
- WithAlphaNumericKeys[testItem](true),
- WithRenderFunc(
- func(item testItem, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, false, baseStyle)
- },
- ),
- WithSelectableFunc(func(item testItem) bool {
- return item.Selectable()
- }),
- )
-
- // Test j key (down)
- jKey := tea.KeyPressMsg{Code: 'j', Text: "j"}
- updatedModel, _ := list.Update(jKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx := list.GetSelectedItem()
- if idx != 1 {
- t.Errorf("Expected selected index 1 after 'j' key, got %d", idx)
- }
-
- // Test k key (up)
- kKey := tea.KeyPressMsg{Code: 'k', Text: "k"}
- updatedModel, _ = list.Update(kKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected selected index 0 after 'k' key, got %d", idx)
- }
-}
-
-func TestCtrlNavigation(t *testing.T) {
- list := createTestList()
-
- // Test Ctrl-N (down)
- ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
- updatedModel, _ := list.Update(ctrlN)
- list = updatedModel.(*listComponent[testItem])
- _, idx := list.GetSelectedItem()
- if idx != 1 {
- t.Errorf("Expected selected index 1 after Ctrl-N, got %d", idx)
- }
-
- // Test Ctrl-P (up)
- ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
- updatedModel, _ = list.Update(ctrlP)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected selected index 0 after Ctrl-P, got %d", idx)
- }
-}
-
-func TestNavigationBoundaries(t *testing.T) {
- list := createTestList()
-
- // Test up arrow at first item (should wrap to last item)
- upKey := tea.KeyPressMsg{Code: tea.KeyUp}
- updatedModel, _ := list.Update(upKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx := list.GetSelectedItem()
- if idx != 2 {
- t.Errorf("Expected to wrap to index 2 when pressing up at first item, got %d", idx)
- }
-
- // Move to first item
- list.SetSelectedIndex(0)
-
- // Move to last item
- downKey := tea.KeyPressMsg{Code: tea.KeyDown}
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 2 {
- t.Errorf("Expected to be at index 2, got %d", idx)
- }
-
- // Test down arrow at last item (should wrap to first item)
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected to wrap to index 0 when pressing down at last item, got %d", idx)
- }
-}
-
-func TestEmptyList(t *testing.T) {
- emptyList := NewListComponent(
- WithItems([]testItem{}),
- WithMaxVisibleHeight[testItem](5),
- WithFallbackMessage[testItem]("empty"),
- WithAlphaNumericKeys[testItem](false),
- WithRenderFunc(
- func(item testItem, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, false, baseStyle)
- },
- ),
- WithSelectableFunc(func(item testItem) bool {
- return item.Selectable()
- }),
- )
-
- // Test navigation on empty list (should not crash)
- downKey := tea.KeyPressMsg{Code: tea.KeyDown}
- upKey := tea.KeyPressMsg{Code: tea.KeyUp}
- ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
- ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
-
- updatedModel, _ := emptyList.Update(downKey)
- emptyList = updatedModel.(*listComponent[testItem])
- updatedModel, _ = emptyList.Update(upKey)
- emptyList = updatedModel.(*listComponent[testItem])
- updatedModel, _ = emptyList.Update(ctrlN)
- emptyList = updatedModel.(*listComponent[testItem])
- updatedModel, _ = emptyList.Update(ctrlP)
- emptyList = updatedModel.(*listComponent[testItem])
-
- // Verify empty list behavior
- _, idx := emptyList.GetSelectedItem()
- if idx != -1 {
- t.Errorf("Expected index -1 for empty list, got %d", idx)
- }
-
- if !emptyList.IsEmpty() {
- t.Error("Expected IsEmpty() to return true for empty list")
- }
-}
-
-func TestWrapAroundNavigation(t *testing.T) {
- list := createTestList()
-
- // Start at first item (index 0)
- _, idx := list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected to start at index 0, got %d", idx)
- }
-
- // Press up arrow - should wrap to last item (index 2)
- upKey := tea.KeyPressMsg{Code: tea.KeyUp}
- updatedModel, _ := list.Update(upKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 2 {
- t.Errorf("Expected to wrap to index 2 when pressing up from first item, got %d", idx)
- }
-
- // Press down arrow - should wrap to first item (index 0)
- downKey := tea.KeyPressMsg{Code: tea.KeyDown}
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected to wrap to index 0 when pressing down from last item, got %d", idx)
- }
-
- // Navigate to middle and verify normal navigation still works
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 1 {
- t.Errorf("Expected to move to index 1, got %d", idx)
- }
-}