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
13 changes: 8 additions & 5 deletions internal/tui/palette.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,13 +291,14 @@ func (m *Model) palPopup() string {
case len(m.pal.items) == 0 && m.pal.query != "":
rows = append(rows, th.acDim.Render("no matches for “"+m.pal.query+"”"))
default:
for i, e := range windowEntries(m.pal.items, m.pal.sel, maxPalRows) {
window, start := windowEntries(m.pal.items, m.pal.sel, maxPalRows)
for i, e := range window {
prefix, label := " ", th.acItem.Render(e.title)
detail := th.acDetail.Render(e.kind)
if e.hint != "" {
detail += th.acDetail.Render(" · ") + th.footerKey.Render(e.hint)
}
if i == m.pal.sel {
if start+i == m.pal.sel {
prefix, label = th.acSel.Render("› "), th.acSel.Render(e.title)
}
rows = append(rows, prefix+label+" "+detail)
Expand All @@ -310,9 +311,11 @@ func (m *Model) palPopup() string {
}

// windowEntries windows entries around sel without changing indices.
func windowEntries(entries []palEntry, sel, n int) []palEntry {
// It returns the window and the absolute index of its first row, so
// callers can map window-relative positions back onto sel.
func windowEntries(entries []palEntry, sel, n int) ([]palEntry, int) {
if len(entries) <= n {
return entries
return entries, 0
}
start := sel - n/2
if start < 0 {
Expand All @@ -321,7 +324,7 @@ func windowEntries(entries []palEntry, sel, n int) []palEntry {
if start+n > len(entries) {
start = len(entries) - n
}
return entries[start : start+n]
return entries[start : start+n], start
}

// palHeight is the palette's rendered height (border + title + rows).
Expand Down
52 changes: 52 additions & 0 deletions internal/tui/palette_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,58 @@ func TestPaletteOpenFilterRun(t *testing.T) {
}
}

// TestPaletteSelectionTracksScroll is a regression test for the ^K popup:
// the highlight must follow sel even after the list scrolls past the
// visible window (the highlight used to compare a window-relative index
// against the absolute sel, so it vanished or landed on the wrong row).
func TestPaletteSelectionTracksScroll(t *testing.T) {
m := wired(t)
_, cmd := m.Update(key("ctrl+k"))
m.Update(exec(cmd)) // sessions fetch

if len(m.pal.items) <= maxPalRows {
t.Fatalf("fixture too small to scroll: %d entries", len(m.pal.items))
}

highlighted := func(m *Model) string {
var row string
n := 0
for _, ln := range strings.Split(plain(m.palPopup()), "\n") {
if strings.Contains(ln, "›") {
row, n = ln, n+1
}
}
if n != 1 {
t.Fatalf("expected exactly one highlighted row, got %d", n)
}
return row
}

// Walk the selection past the visible window; the highlight must stay
// on the row the selection actually points at.
for i := 0; i < maxPalRows+2; i++ {
m.Update(key("down"))
}
sel := m.pal.sel
if sel != maxPalRows+2 {
t.Fatalf("down did not advance sel: got %d", sel)
}
if row := highlighted(m); !strings.Contains(row, m.pal.items[sel].title) {
t.Fatalf("scrolled highlight is on the wrong row:\n%q\nwant title %q", row, m.pal.items[sel].title)
}

// Walk back to the top; the highlight must still track.
for i := 0; i < sel; i++ {
m.Update(key("up"))
}
if m.pal.sel != 0 {
t.Fatalf("up did not return to top: got %d", m.pal.sel)
}
if row := highlighted(m); !strings.Contains(row, m.pal.items[0].title) {
t.Fatalf("top highlight is on the wrong row:\n%q\nwant title %q", row, m.pal.items[0].title)
}
}

// TestPaletteFromApproval verifies the palette works from the approval rung.
func TestPaletteFromApproval(t *testing.T) {
m := wired(t)
Expand Down