summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components/list
diff options
context:
space:
mode:
authoradamdotdevin <[email protected]>2025-07-14 11:44:19 -0500
committeradamdotdevin <[email protected]>2025-07-14 12:15:46 -0500
commitf1e7e7c1383075c87859cdbb1d08d510398302a9 (patch)
tree9639d16863bccc13314e97c38eb79e42893608b7 /packages/tui/internal/components/list
parent80b77caec04da4d5a97a92109c294c930f5e3c0f (diff)
downloadopencode-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.go242
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,