fix(tui): rework lists and search dialog

This commit is contained in:
adamdotdevin
2025-07-15 08:07:20 -05:00
parent b5c85d3806
commit 533f64fe26
16 changed files with 579 additions and 972 deletions

View File

@@ -5,17 +5,88 @@ import (
"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"
)
type ListItem interface {
Render(selected bool, width int, isFirstInViewport bool) string
// Item interface that all list items must implement
type Item interface {
Render(selected bool, width int, baseStyle styles.Style) string
Selectable() bool
}
type List[T ListItem] interface {
// 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)
@@ -25,19 +96,21 @@ type List[T ListItem] interface {
SetSelectedIndex(idx int)
SetEmptyMessage(msg string)
IsEmpty() bool
GetMaxVisibleItems() int
GetActualHeight() int
GetMaxVisibleHeight() int
}
type listComponent[T ListItem] struct {
type listComponent[T any] struct {
fallbackMsg string
items []T
selectedIdx int
maxWidth int
maxVisibleItems int
maxVisibleHeight int
useAlphaNumericKeys bool
width int
height int
renderItem RenderFunc[T]
isSelectable SelectableFunc[T]
baseStyle styles.Style
}
type listKeyMap struct {
@@ -94,7 +167,7 @@ func (c *listComponent[T]) moveUp() {
// Find the previous selectable item
for i := c.selectedIdx - 1; i >= 0; i-- {
if c.items[i].Selectable() {
if c.isSelectable(c.items[i]) {
c.selectedIdx = i
return
}
@@ -117,7 +190,7 @@ func (c *listComponent[T]) moveDown() {
break
}
if c.items[c.selectedIdx].Selectable() {
if c.isSelectable(c.items[c.selectedIdx]) {
return
}
@@ -129,7 +202,7 @@ func (c *listComponent[T]) moveDown() {
}
func (c *listComponent[T]) GetSelectedItem() (T, int) {
if len(c.items) > 0 && c.items[c.selectedIdx].Selectable() {
if len(c.items) > 0 && c.isSelectable(c.items[c.selectedIdx]) {
return c.items[c.selectedIdx], c.selectedIdx
}
@@ -142,7 +215,7 @@ func (c *listComponent[T]) SetItems(items []T) {
c.selectedIdx = 0
// Ensure initial selection is on a selectable item
if len(items) > 0 && !items[0].Selectable() {
if len(items) > 0 && !c.isSelectable(items[0]) {
c.moveDown()
}
}
@@ -169,48 +242,8 @@ func (c *listComponent[T]) SetSelectedIndex(idx int) {
}
}
func (c *listComponent[T]) GetMaxVisibleItems() int {
return c.maxVisibleItems
}
func (c *listComponent[T]) GetActualHeight() int {
items := c.items
if len(items) == 0 {
return 1 // For empty message
}
maxVisibleItems := min(c.maxVisibleItems, len(items))
startIdx := 0
if len(items) > maxVisibleItems {
halfVisible := maxVisibleItems / 2
if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
startIdx = c.selectedIdx - halfVisible
} else if c.selectedIdx >= len(items)-halfVisible {
startIdx = len(items) - maxVisibleItems
}
}
endIdx := min(startIdx+maxVisibleItems, len(items))
height := 0
for i := startIdx; i < endIdx; i++ {
item := items[i]
isFirstInViewport := (i == startIdx)
// Check if this is a HeaderItem and calculate its height
if _, ok := any(item).(HeaderItem); ok {
if isFirstInViewport {
height += 1 // No top margin
} else {
height += 2 // With top margin
}
} else {
height += 1 // Regular items take 1 line
}
}
return height
func (c *listComponent[T]) GetMaxVisibleHeight() int {
return c.maxVisibleHeight
}
func (c *listComponent[T]) View() string {
@@ -224,95 +257,88 @@ func (c *listComponent[T]) View() string {
return c.fallbackMsg
}
// Calculate viewport based on actual heights, not item counts
// 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]
isFirstInViewport := (i == startIdx)
title := item.Render(i == c.selectedIdx, maxWidth, isFirstInViewport)
// 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 height
// 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
}
// Helper function to calculate height of an item at given position
getItemHeight := func(idx int, isFirst bool) int {
if _, ok := any(items[idx]).(HeaderItem); ok {
if isFirst {
return 1 // No top margin
} else {
return 2 // With top margin
}
}
return 1 // Regular items
// 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)
}
// If we have fewer items than max, show all
if len(items) <= c.maxVisibleItems {
return 0, len(items)
// 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]
}
// Try to center the selected item in the viewport
// Start by trying to put selected item in the middle
targetStart := c.selectedIdx - c.maxVisibleItems/2
if targetStart < 0 {
targetStart = 0
}
// 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
// Find the actual start and end indices that fit within our height budget
bestStart := 0
bestEnd := 0
bestHeight := 0
// Try different starting positions around our target
for start := max(0, targetStart-2); start <= min(len(items)-1, targetStart+2); start++ {
currentHeight := 0
end := start
for end < len(items) && currentHeight < c.maxVisibleItems {
itemHeight := getItemHeight(end, end == start)
if currentHeight+itemHeight > c.maxVisibleItems {
break
}
currentHeight += itemHeight
end++
}
// Check if this viewport contains the selected item and is better than current best
if start <= c.selectedIdx && c.selectedIdx < end {
if currentHeight > bestHeight || (currentHeight == bestHeight && abs(start+end-2*c.selectedIdx) < abs(bestStart+bestEnd-2*c.selectedIdx)) {
bestStart = start
bestEnd = end
bestHeight = currentHeight
}
for i := c.selectedIdx - 1; i >= 0 && currentHeight+itemHeights[i] <= c.maxVisibleHeight; i-- {
currentHeight += itemHeights[i]
start = i
}
}
// If no good viewport found that contains selected item, just show from selected item
if bestEnd == 0 {
bestStart = c.selectedIdx
currentHeight := 0
for bestEnd = bestStart; bestEnd < len(items) && currentHeight < c.maxVisibleItems; bestEnd++ {
itemHeight := getItemHeight(bestEnd, bestEnd == bestStart)
if currentHeight+itemHeight > c.maxVisibleItems {
break
}
currentHeight += itemHeight
// 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 bestStart, bestEnd
return start, end
}
func abs(x int) int {
@@ -329,27 +355,32 @@ func max(a, b int) int {
return b
}
func NewListComponent[T ListItem](
items []T,
maxVisibleItems int,
fallbackMsg string,
useAlphaNumericKeys bool,
) List[T] {
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: fallbackMsg,
items: items,
maxVisibleItems: maxVisibleItems,
useAlphaNumericKeys: useAlphaNumericKeys,
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 ListItem for string values
// StringItem is a simple implementation of Item for string values
type StringItem string
func (s StringItem) Render(selected bool, width int, isFirstInViewport bool) string {
func (s StringItem) Render(selected bool, width int, baseStyle styles.Style) string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle()
truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
@@ -376,23 +407,18 @@ func (s StringItem) Selectable() bool {
// HeaderItem is a non-selectable header item for grouping
type HeaderItem string
func (h HeaderItem) Render(selected bool, width int, isFirstInViewport bool) string {
func (h HeaderItem) Render(selected bool, width int, baseStyle styles.Style) string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle()
truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...")
headerStyle := baseStyle.
Foreground(t.Accent()).
Bold(true).
MarginTop(1).
MarginBottom(0).
PaddingLeft(1)
// Only add top margin if this is not the first item in the viewport
if !isFirstInViewport {
headerStyle = headerStyle.MarginTop(1)
}
return headerStyle.Render(truncatedStr)
}
@@ -400,16 +426,6 @@ func (h HeaderItem) Selectable() bool {
return false
}
// NewStringList creates a new list component with string items
func NewStringList(
items []string,
maxVisibleItems int,
fallbackMsg string,
useAlphaNumericKeys bool,
) List[StringItem] {
stringItems := make([]StringItem, len(items))
for i, item := range items {
stringItems[i] = StringItem(item)
}
return NewListComponent(stringItems, maxVisibleItems, fallbackMsg, useAlphaNumericKeys)
}
// Ensure StringItem and HeaderItem implement Item
var _ Item = StringItem("")
var _ Item = HeaderItem("")

View File

@@ -4,6 +4,7 @@ import (
"testing"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/styles"
)
// testItem is a simple test implementation of ListItem
@@ -11,10 +12,19 @@ type testItem struct {
value string
}
func (t testItem) Render(selected bool, width int) 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{
@@ -22,7 +32,24 @@ func createTestList() *listComponent[testItem] {
{value: "item2"},
{value: "item3"},
}
list := NewListComponent(items, 5, "empty", false)
list := NewListComponent(
WithItems(items),
WithMaxVisibleItems[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()
}),
WithHeightFunc(func(item testItem, isFirstInViewport bool) int {
return 1
}),
)
return list.(*listComponent[testItem])
}
@@ -55,7 +82,23 @@ func TestJKKeyNavigation(t *testing.T) {
{value: "item3"},
}
// Create list with alpha keys enabled
list := NewListComponent(items, 5, "empty", true).(*listComponent[testItem])
list := NewListComponent(
WithItems(items),
WithMaxVisibleItems[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()
}),
WithHeightFunc(func(item testItem, isFirstInViewport bool) int {
return 1
}),
)
// Test j key (down)
jKey := tea.KeyPressMsg{Code: 'j', Text: "j"}
@@ -131,7 +174,23 @@ func TestNavigationBoundaries(t *testing.T) {
}
func TestEmptyList(t *testing.T) {
emptyList := NewListComponent([]testItem{}, 5, "empty", false).(*listComponent[testItem])
emptyList := NewListComponent(
WithItems([]testItem{}),
WithMaxVisibleItems[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()
}),
WithHeightFunc(func(item testItem, isFirstInViewport bool) int {
return 1
}),
)
// Test navigation on empty list (should not crash)
downKey := tea.KeyPressMsg{Code: tea.KeyDown}