Skip to content

Commit bc09aa9

Browse files
authored
Merge pull request #2407 from maxcleme/co-circular-navigation-completion
add circular navigation wrapping to completion component
2 parents 1588d8d + 0398629 commit bc09aa9

File tree

2 files changed

+206
-0
lines changed

2 files changed

+206
-0
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package completion
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
tea "charm.land/bubbletea/v2"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestCircularNavigation(t *testing.T) {
12+
t.Parallel()
13+
14+
t.Run("Up arrow at top wraps to bottom", func(t *testing.T) {
15+
t.Parallel()
16+
17+
m := New().(*manager)
18+
19+
// Open with multiple items
20+
m.Update(OpenMsg{
21+
Items: []Item{
22+
{Label: "item1", Value: "value1"},
23+
{Label: "item2", Value: "value2"},
24+
{Label: "item3", Value: "value3"},
25+
},
26+
})
27+
28+
// Initially selected should be 0
29+
assert.Equal(t, 0, m.selected, "initial selection should be 0")
30+
31+
// Press Up arrow at top
32+
m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
33+
34+
// Should wrap to bottom (selected=2, which is len(filteredItems)-1)
35+
assert.Equal(t, 2, m.selected, "up arrow at top should wrap to bottom")
36+
})
37+
38+
t.Run("Down arrow at bottom wraps to top", func(t *testing.T) {
39+
t.Parallel()
40+
41+
m := New().(*manager)
42+
43+
// Open with multiple items
44+
m.Update(OpenMsg{
45+
Items: []Item{
46+
{Label: "item1", Value: "value1"},
47+
{Label: "item2", Value: "value2"},
48+
{Label: "item3", Value: "value3"},
49+
},
50+
})
51+
52+
// Move to bottom (selected=2)
53+
m.selected = 2
54+
assert.Equal(t, 2, m.selected, "should be at bottom")
55+
56+
// Press Down arrow at bottom
57+
m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
58+
59+
// Should wrap to top (selected=0) and reset scroll offset
60+
assert.Equal(t, 0, m.selected, "down arrow at bottom should wrap to top")
61+
assert.Equal(t, 0, m.scrollOffset, "scroll offset should reset when wrapping to top")
62+
})
63+
64+
t.Run("scroll offset properly handled during wrapping", func(t *testing.T) {
65+
t.Parallel()
66+
67+
m := New().(*manager)
68+
69+
// Create more items than maxItems (10) to test scrolling
70+
items := make([]Item, 15)
71+
for i := range 15 {
72+
items[i] = Item{Label: fmt.Sprintf("item%d", i+1), Value: fmt.Sprintf("value%d", i+1)}
73+
}
74+
75+
m.Update(OpenMsg{Items: items})
76+
77+
// Move to bottom (selected=14, which is len(filteredItems)-1)
78+
m.selected = 14
79+
m.scrollOffset = 5 // Simulate scrolled state
80+
81+
// Press Down arrow (should wrap to top)
82+
m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
83+
84+
assert.Equal(t, 0, m.selected, "should wrap to top")
85+
assert.Equal(t, 0, m.scrollOffset, "scroll offset should reset to 0 when wrapping to top")
86+
87+
// Now at bottom, press Up arrow (should wrap to top)
88+
m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
89+
90+
assert.Equal(t, 14, m.selected, "should wrap to bottom (index 14)")
91+
// Scroll offset should be adjusted to show the bottom item
92+
expectedScrollOffset := max(0, 14-maxItems+1) // 14-10+1 = 5
93+
assert.Equal(t, expectedScrollOffset, m.scrollOffset, "scroll offset should be adjusted for bottom item")
94+
95+
// Now set to actual bottom and test up wrap
96+
m.selected = 14
97+
m.scrollOffset = 5
98+
})
99+
100+
t.Run("empty list navigation", func(t *testing.T) {
101+
t.Parallel()
102+
103+
m := New().(*manager)
104+
105+
// Open with empty items
106+
m.Update(OpenMsg{Items: []Item{}})
107+
108+
// Should not crash on navigation
109+
originalSelected := m.selected
110+
m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
111+
assert.Equal(t, originalSelected, m.selected, "up arrow on empty list should not change selection")
112+
113+
m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
114+
assert.Equal(t, originalSelected, m.selected, "down arrow on empty list should not change selection")
115+
})
116+
117+
t.Run("single item navigation", func(t *testing.T) {
118+
t.Parallel()
119+
120+
m := New().(*manager)
121+
122+
// Open with single item
123+
m.Update(OpenMsg{
124+
Items: []Item{
125+
{Label: "onlyItem", Value: "onlyValue"},
126+
},
127+
})
128+
129+
assert.Equal(t, 0, m.selected, "initial selection should be 0")
130+
131+
// Up arrow on single item (wrap to same item)
132+
m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
133+
assert.Equal(t, 0, m.selected, "up arrow with single item should stay at 0")
134+
// Down arrow on single item (wrap to same item)
135+
m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
136+
assert.Equal(t, 0, m.selected, "down arrow with single item should stay at 0")
137+
})
138+
139+
t.Run("normal navigation still works", func(t *testing.T) {
140+
t.Parallel()
141+
142+
m := New().(*manager)
143+
144+
// Open with multiple items
145+
m.Update(OpenMsg{
146+
Items: []Item{
147+
{Label: "item1", Value: "value1"},
148+
{Label: "item2", Value: "value2"},
149+
{Label: "item3", Value: "value3"},
150+
},
151+
})
152+
153+
// Normal down navigation
154+
assert.Equal(t, 0, m.selected, "should start at 0")
155+
156+
m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
157+
assert.Equal(t, 1, m.selected, "down arrow should move to 1")
158+
159+
m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
160+
assert.Equal(t, 2, m.selected, "down arrow should move to 2")
161+
162+
// Normal up navigation
163+
m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
164+
assert.Equal(t, 1, m.selected, "up arrow should move to 1")
165+
166+
m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
167+
assert.Equal(t, 0, m.selected, "up arrow should move to 0")
168+
})
169+
170+
t.Run("wrapping with filtered items", func(t *testing.T) {
171+
t.Parallel()
172+
173+
m := New().(*manager)
174+
175+
// Open with multiple items
176+
m.Update(OpenMsg{
177+
Items: []Item{
178+
{Label: "apple", Value: "apple"},
179+
{Label: "banana", Value: "banana"},
180+
{Label: "apricot", Value: "apricot"},
181+
{Label: "berry", Value: "berry"},
182+
},
183+
})
184+
185+
// Apply filter that reduces items
186+
m.Update(QueryMsg{Query: "ap"}) // Should match "apple" and "apricot"
187+
188+
assert.Len(t, m.filteredItems, 2, "should have 2 filtered items")
189+
assert.Equal(t, 0, m.selected, "selection should be at 0 after filtering")
190+
191+
// Test wrapping with filtered items
192+
m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
193+
assert.Equal(t, 1, m.selected, "up arrow should wrap to last filtered item (index 1)")
194+
195+
m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
196+
assert.Equal(t, 0, m.selected, "down arrow should wrap to first filtered item (index 0)")
197+
})
198+
}

pkg/tui/components/completion/completion.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,16 +236,24 @@ func (c *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
236236
case key.Matches(msg, c.keyMap.Up):
237237
if c.selected > 0 {
238238
c.selected--
239+
} else if len(c.filteredItems) > 0 {
240+
c.selected = len(c.filteredItems) - 1
239241
}
240242
if c.selected < c.scrollOffset {
241243
c.scrollOffset = c.selected
242244
}
245+
if c.selected >= c.scrollOffset+maxItems {
246+
c.scrollOffset = c.selected - maxItems + 1
247+
}
243248
cmd := c.notifySelectionChanged()
244249
return c, cmd
245250

246251
case key.Matches(msg, c.keyMap.Down):
247252
if c.selected < len(c.filteredItems)-1 {
248253
c.selected++
254+
} else if len(c.filteredItems) > 0 {
255+
c.selected = 0
256+
c.scrollOffset = 0
249257
}
250258
if c.selected >= c.scrollOffset+maxItems {
251259
c.scrollOffset = c.selected - maxItems + 1

0 commit comments

Comments
 (0)