Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 134 additions & 6 deletions internal/tui/selectlist.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tui

import (
"strconv"
"strings"

"charm.land/lipgloss/v2"
Expand All @@ -12,8 +13,19 @@ type SelectOption struct {
Detail string
}

// RenderSelectList renders a titled option list with a ● selector on the active item.
// RenderSelectList renders a titled option list with a ● selector on the
// active item. The whole list is rendered; use RenderSelectListWindowed when
// the option count can grow large.
func RenderSelectList(title, description string, options []SelectOption, cursor int) string {
return RenderSelectListWindowed(title, description, options, cursor, 0)
}

// RenderSelectListWindowed renders a select list that shows at most maxVisible
// options at once, scrolling to keep the cursor in view. A maxVisible of 0 (or
// any value >= len(options)) renders every option. When the list is windowed a
// "Showing X–Y of N" line is appended so the box height stays fixed regardless
// of how many options exist.
func RenderSelectListWindowed(title, description string, options []SelectOption, cursor, maxVisible int) string {
var s strings.Builder

selectorStyle := lipgloss.NewStyle().Foreground(BrandColor)
Expand All @@ -27,7 +39,23 @@ func RenderSelectList(title, description string, options []SelectOption, cursor
}
s.WriteString("\n")

for i, opt := range options {
windowed := maxVisible > 0 && len(options) > maxVisible

start, end := 0, len(options)
if windowed {
// Keep the cursor inside the [start, end) window.
start = cursor - maxVisible/2
if start < 0 {
start = 0
}
if start > len(options)-maxVisible {
start = len(options) - maxVisible
}
end = start + maxVisible
}

for i := start; i < end; i++ {
opt := options[i]
detail := ""
if opt.Detail != "" {
detail = " " + DimStyle.Render("("+opt.Detail+")")
Expand All @@ -37,10 +65,110 @@ func RenderSelectList(title, description string, options []SelectOption, cursor
} else {
s.WriteString(" " + FormatLabel(opt.Label, opt.Detail))
}
if i < len(options)-1 {
s.WriteString("\n")
}
s.WriteString("\n")
}

if windowed {
s.WriteString("\n")
s.WriteString(DimStyle.Render("Showing " + strconv.Itoa(start+1) + "–" + strconv.Itoa(end) + " of " + strconv.Itoa(len(options))))
}

return strings.TrimRight(s.String(), "\n")
}

// SelectList is a stateful, keyboard-navigable single-select list. It owns the
// cursor and windowing so callers only forward key strings and render. Use it
// for any fixed-height option picker (e.g. the project upgrade version select).
type SelectList struct {
title string
description string
options []SelectOption
cursor int
maxVisible int
}

// NewSelectList builds a SelectList. maxVisible caps how many options are shown
// at once (0 = show all); the window scrolls to keep the cursor visible.
func NewSelectList(title, description string, options []SelectOption, maxVisible int) *SelectList {
return &SelectList{
title: title,
description: description,
options: options,
maxVisible: maxVisible,
}
}

// Cursor returns the index of the currently highlighted option.
func (l *SelectList) Cursor() int { return l.cursor }

return s.String()
// Selected returns the currently highlighted option and whether one exists
// (false when the list is empty).
func (l *SelectList) Selected() (SelectOption, bool) {
if l.cursor < 0 || l.cursor >= len(l.options) {
return SelectOption{}, false
}
return l.options[l.cursor], true
}

// HandleKey applies a navigation key (up/down/k/j, pgup/pgdown, home/g, end/G)
// and reports whether it moved or consumed the cursor. Keys the list does not
// own (enter, esc, …) return false so the caller can act on them.
func (l *SelectList) HandleKey(key string) bool {
last := len(l.options) - 1
if last < 0 {
return false
}

switch key {
case "up", "k":
l.cursor--
case "down", "j":
l.cursor++
case "pgup":
l.cursor -= l.page()
case "pgdown":
l.cursor += l.page()
case "home", "g":
l.cursor = 0
case "end", "G":
l.cursor = last
default:
return false
}

if l.cursor < 0 {
l.cursor = 0
}
if l.cursor > last {
l.cursor = last
}
return true
}

// page is the jump distance for pgup/pgdown.
func (l *SelectList) page() int {
if l.maxVisible > 0 {
return l.maxVisible
}
return len(l.options)
}

// windowed reports whether the option count exceeds the visible window.
func (l *SelectList) windowed() bool {
return l.maxVisible > 0 && len(l.options) > l.maxVisible
}

// View renders the list at its current cursor position.
func (l *SelectList) View() string {
return RenderSelectListWindowed(l.title, l.description, l.options, l.cursor, l.maxVisible)
}

// Shortcuts returns the navigation hints to show in a footer. PgUp/PgDn is only
// included when the list is windowed.
func (l *SelectList) Shortcuts() []Shortcut {
shortcuts := []Shortcut{{Key: "↑/↓", Label: "Select"}}
if l.windowed() {
shortcuts = append(shortcuts, Shortcut{Key: "PgUp/PgDn", Label: "Jump"})
}
return shortcuts
}
148 changes: 148 additions & 0 deletions internal/tui/selectlist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package tui

import (
"strconv"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func makeOptions(n int) []SelectOption {
opts := make([]SelectOption, n)
for i := range opts {
opts[i] = SelectOption{Label: "v" + strconv.Itoa(i)}
}
return opts
}

// countOptionRows counts rendered option lines (each shows a "vN" label),
// ignoring the title/description/scroll-indicator lines.
func countOptionRows(out string) int {
n := 0
for _, line := range strings.Split(stripANSI(out), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "v") || strings.HasPrefix(trimmed, "● v") {
n++
}
}
return n
}

func TestRenderSelectListWindowedCapsVisibleRows(t *testing.T) {
t.Parallel()

out := stripANSI(RenderSelectListWindowed("Title", "", makeOptions(50), 0, 10))
assert.Equal(t, 10, countOptionRows(out), "should render exactly maxVisible rows")
assert.Contains(t, out, "Showing 1–10 of 50")
}

func TestRenderSelectListWindowedKeepsCursorVisible(t *testing.T) {
t.Parallel()

// Cursor near the end must remain within the rendered window.
out := stripANSI(RenderSelectListWindowed("Title", "", makeOptions(50), 48, 10))
assert.Equal(t, 10, countOptionRows(out))
assert.Contains(t, out, "● v48", "selected option must be visible")
assert.Contains(t, out, "Showing 41–50 of 50")
}

func TestRenderSelectListWindowedShowsAllWhenUnderCap(t *testing.T) {
t.Parallel()

out := stripANSI(RenderSelectListWindowed("Title", "", makeOptions(4), 0, 10))
assert.Equal(t, 4, countOptionRows(out))
assert.NotContains(t, out, "Showing", "no scroll indicator when everything fits")
}

func TestRenderSelectListRendersEveryOption(t *testing.T) {
t.Parallel()

out := stripANSI(RenderSelectList("Title", "", makeOptions(20), 0))
assert.Equal(t, 20, countOptionRows(out))
assert.NotContains(t, out, "Showing")
}

func TestSelectListPageAndHomeEndNavigation(t *testing.T) {
t.Parallel()

const maxVisible = 10
n := maxVisible*2 + 5
l := NewSelectList("Title", "", makeOptions(n), maxVisible)
last := n - 1

assert.True(t, l.HandleKey("pgdown"))
assert.Equal(t, maxVisible, l.Cursor(), "page down jumps by a page")

l.HandleKey("end")
assert.Equal(t, last, l.Cursor(), "end jumps to last")

l.HandleKey("pgdown")
assert.Equal(t, last, l.Cursor(), "page down past end clamps")

l.HandleKey("pgup")
assert.Equal(t, last-maxVisible, l.Cursor(), "page up jumps back a page")

l.HandleKey("home")
assert.Equal(t, 0, l.Cursor(), "home jumps to top")

l.HandleKey("pgup")
assert.Equal(t, 0, l.Cursor(), "page up past start clamps")
}

func TestSelectListUpDownClampAndVimKeys(t *testing.T) {
t.Parallel()

l := NewSelectList("Title", "", makeOptions(3), 0)

// "up" is a recognized nav key (consumed) even at the top; it just clamps.
assert.True(t, l.HandleKey("up"))
assert.Equal(t, 0, l.Cursor())

assert.True(t, l.HandleKey("j"))
assert.Equal(t, 1, l.Cursor())
l.HandleKey("j")
l.HandleKey("j") // past end clamps
assert.Equal(t, 2, l.Cursor())
l.HandleKey("k")
assert.Equal(t, 1, l.Cursor())
}

func TestSelectListHandleKeyIgnoresNonNavKeys(t *testing.T) {
t.Parallel()

l := NewSelectList("Title", "", makeOptions(3), 0)
assert.False(t, l.HandleKey("enter"))
assert.False(t, l.HandleKey("esc"))
assert.Equal(t, 0, l.Cursor())
}

func TestSelectListSelected(t *testing.T) {
t.Parallel()

l := NewSelectList("Title", "", makeOptions(3), 0)
opt, ok := l.Selected()
assert.True(t, ok)
assert.Equal(t, "v0", opt.Label)

empty := NewSelectList("Title", "", nil, 0)
_, ok = empty.Selected()
assert.False(t, ok)
assert.False(t, empty.HandleKey("down"), "empty list consumes nothing")
}

func TestSelectListShortcutsIncludesPagingWhenWindowed(t *testing.T) {
t.Parallel()

windowed := NewSelectList("Title", "", makeOptions(50), 10).Shortcuts()
var keys []string
for _, s := range windowed {
keys = append(keys, s.Key)
}
assert.Contains(t, keys, "PgUp/PgDn")

short := NewSelectList("Title", "", makeOptions(3), 10).Shortcuts()
for _, s := range short {
assert.NotEqual(t, "PgUp/PgDn", s.Key, "no paging hint when everything fits")
}
}