diff options
| author | adamdotdevin <[email protected]> | 2025-07-14 11:44:19 -0500 |
|---|---|---|
| committer | adamdotdevin <[email protected]> | 2025-07-14 12:15:46 -0500 |
| commit | f1e7e7c1383075c87859cdbb1d08d510398302a9 (patch) | |
| tree | 9639d16863bccc13314e97c38eb79e42893608b7 /packages/tui/internal/components/list | |
| parent | 80b77caec04da4d5a97a92109c294c930f5e3c0f (diff) | |
| download | opencode-f1e7e7c1383075c87859cdbb1d08d510398302a9.tar.gz opencode-f1e7e7c1383075c87859cdbb1d08d510398302a9.zip | |
feat(tui): even better model selector
Diffstat (limited to 'packages/tui/internal/components/list')
| -rw-r--r-- | packages/tui/internal/components/list/list.go | 242 |
1 files changed, 222 insertions, 20 deletions
diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go index 8a18d28fc..2405440fe 100644 --- a/packages/tui/internal/components/list/list.go +++ b/packages/tui/internal/components/list/list.go @@ -11,7 +11,8 @@ import ( ) type ListItem interface { - Render(selected bool, width int) string + Render(selected bool, width int, isFirstInViewport bool) string + Selectable() bool } type List[T ListItem] interface { @@ -24,6 +25,8 @@ type List[T ListItem] interface { SetSelectedIndex(idx int) SetEmptyMessage(msg string) IsEmpty() bool + GetMaxVisibleItems() int + GetActualHeight() int } type listComponent[T ListItem] struct { @@ -72,14 +75,10 @@ func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch { case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)): - if c.selectedIdx > 0 { - c.selectedIdx-- - } + c.moveUp() return c, nil case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)): - if c.selectedIdx < len(c.items)-1 { - c.selectedIdx++ - } + c.moveDown() return c, nil } } @@ -87,8 +86,50 @@ func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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.items[i].Selectable() { + c.selectedIdx = i + return + } + } + + // If no selectable item found above, stay at current position +} + +// moveDown moves the selection down, skipping non-selectable items +func (c *listComponent[T]) moveDown() { + if len(c.items) == 0 { + return + } + + originalIdx := c.selectedIdx + for { + if c.selectedIdx < len(c.items)-1 { + c.selectedIdx++ + } else { + break + } + + if c.items[c.selectedIdx].Selectable() { + return + } + + // Prevent infinite loop + if c.selectedIdx == originalIdx { + break + } + } +} + func (c *listComponent[T]) GetSelectedItem() (T, int) { - if len(c.items) > 0 { + if len(c.items) > 0 && c.items[c.selectedIdx].Selectable() { return c.items[c.selectedIdx], c.selectedIdx } @@ -97,8 +138,13 @@ func (c *listComponent[T]) GetSelectedItem() (T, int) { } func (c *listComponent[T]) SetItems(items []T) { - c.selectedIdx = 0 c.items = items + c.selectedIdx = 0 + + // Ensure initial selection is on a selectable item + if len(items) > 0 && !items[0].Selectable() { + c.moveDown() + } } func (c *listComponent[T]) GetItems() []T { @@ -123,19 +169,19 @@ func (c *listComponent[T]) SetSelectedIndex(idx int) { } } -func (c *listComponent[T]) View() string { +func (c *listComponent[T]) GetMaxVisibleItems() int { + return c.maxVisibleItems +} + +func (c *listComponent[T]) GetActualHeight() int { items := c.items - maxWidth := c.maxWidth - if maxWidth == 0 { - maxWidth = 80 // Default width if not set + if len(items) == 0 { + return 1 // For empty message } + maxVisibleItems := min(c.maxVisibleItems, len(items)) startIdx := 0 - if len(items) <= 0 { - return c.fallbackMsg - } - if len(items) > maxVisibleItems { halfVisible := maxVisibleItems / 2 if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible { @@ -147,17 +193,142 @@ func (c *listComponent[T]) View() string { endIdx := min(startIdx+maxVisibleItems, len(items)) - listItems := make([]string, 0, maxVisibleItems) + 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]) 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, not item counts + startIdx, endIdx := c.calculateViewport() + + listItems := make([]string, 0, endIdx-startIdx) for i := startIdx; i < endIdx; i++ { item := items[i] - title := item.Render(i == c.selectedIdx, maxWidth) + isFirstInViewport := (i == startIdx) + title := item.Render(i == c.selectedIdx, maxWidth, isFirstInViewport) listItems = append(listItems, title) } return strings.Join(listItems, "\n") } +// calculateViewport determines which items to show based on available height +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 + } + + // If we have fewer items than max, show all + if len(items) <= c.maxVisibleItems { + return 0, len(items) + } + + // 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 + } + + // 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 + } + } + } + + // 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 + } + } + + return bestStart, bestEnd +} + +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 ListItem]( items []T, maxVisibleItems int, @@ -176,7 +347,7 @@ func NewListComponent[T ListItem]( // StringItem is a simple implementation of ListItem for string values type StringItem string -func (s StringItem) Render(selected bool, width int) string { +func (s StringItem) Render(selected bool, width int, isFirstInViewport bool) string { t := theme.CurrentTheme() baseStyle := styles.NewStyle() @@ -198,6 +369,37 @@ func (s StringItem) Render(selected bool, width int) string { 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, isFirstInViewport bool) string { + t := theme.CurrentTheme() + baseStyle := styles.NewStyle() + + truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...") + + headerStyle := baseStyle. + Foreground(t.Accent()). + Bold(true). + 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) +} + +func (h HeaderItem) Selectable() bool { + return false +} + // NewStringList creates a new list component with string items func NewStringList( items []string, |
