on main help sign in
wip: initial rework
74f0b20Kujtim Hoxha
internal/tui/components/chat/chat.go+1−1
@@ -63,7 +63,7 @@ func New(app *app.App) MessageListCmp {
63 listCmp := list.New(
63 listCmp := list.New(
64 []list.Item{},
64 []list.Item{},
65 list.WithGap(1),
65 list.WithGap(1),
66− list.WithDirection(list.Backward),
66+ list.WithDirectionBackward(),
67 list.WithKeyMap(defaultListKeyMap),
67 list.WithKeyMap(defaultListKeyMap),
68 )
68 )
69 return &messageListCmp{
69 return &messageListCmp{
internal/tui/exp/list/filterable_test.go+58−66
@@ -1,68 +1,60 @@
1package list
1package list
2
2
3−import (
3+//
4− "fmt"
5− "slices"
6− "testing"
7−
8− "github.com/charmbracelet/x/exp/golden"
9− "github.com/stretchr/testify/assert"
10−)
11−
12−func TestFilterableList(t *testing.T) {
4+// func TestFilterableList(t *testing.T) {
13− t.Parallel()
5+// t.Parallel()
14− t.Run("should create simple filterable list", func(t *testing.T) {
6+// t.Run("should create simple filterable list", func(t *testing.T) {
15− t.Parallel()
7+// t.Parallel()
16− items := []FilterableItem{}
8+// items := []FilterableItem{}
17− for i := range 5 {
9+// for i := range 5 {
18− item := NewFilterableItem(fmt.Sprintf("Item %d", i))
10+// item := NewFilterableItem(fmt.Sprintf("Item %d", i))
19− items = append(items, item)
11+// items = append(items, item)
20− }
12+// }
21− l := NewFilterableList(
13+// l := NewFilterableList(
22− items,
14+// items,
23− WithFilterListOptions(WithDirection(Forward)),
15+// WithFilterListOptions(WithDirection(Forward)),
24− ).(*filterableList[FilterableItem])
16+// ).(*filterableList[FilterableItem])
25−
17+//
26− l.SetSize(100, 10)
18+// l.SetSize(100, 10)
27− cmd := l.Init()
19+// cmd := l.Init()
28− if cmd != nil {
20+// if cmd != nil {
29− cmd()
21+// cmd()
30− }
22+// }
31−
23+//
32− assert.Equal(t, items[0].ID(), l.selectedItem)
24+// assert.Equal(t, items[0].ID(), l.selectedItem)
33− golden.RequireEqual(t, []byte(l.View()))
25+// golden.RequireEqual(t, []byte(l.View()))
34− })
26+// })
35−}
27+// }
36−
28+//
37−func TestUpdateKeyMap(t *testing.T) {
29+// func TestUpdateKeyMap(t *testing.T) {
38− t.Parallel()
30+// t.Parallel()
39− l := NewFilterableList(
31+// l := NewFilterableList(
40− []FilterableItem{},
32+// []FilterableItem{},
41− WithFilterListOptions(WithDirection(Forward)),
33+// WithFilterListOptions(WithDirection(Forward)),
42− ).(*filterableList[FilterableItem])
34+// ).(*filterableList[FilterableItem])
43−
35+//
44− hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
36+// hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
45− fmt.Println(l.keyMap.Down.Keys())
37+// fmt.Println(l.keyMap.Down.Keys())
46− hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
38+// hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
47−
39+//
48− hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
40+// hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
49−
41+//
50− assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
42+// assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
51− assert.False(t, hasJ, "should not contain j")
43+// assert.False(t, hasJ, "should not contain j")
52− assert.False(t, hasUpperCaseK, "should also remove upper case K")
44+// assert.False(t, hasUpperCaseK, "should also remove upper case K")
53− assert.True(t, hasCtrlJ, "should still have ctrl+j")
45+// assert.True(t, hasCtrlJ, "should still have ctrl+j")
54−}
46+// }
55−
47+//
56−type filterableItem struct {
48+// type filterableItem struct {
57− *selectableItem
49+// *selectableItem
58−}
50+// }
59−
51+//
60−func NewFilterableItem(content string) FilterableItem {
52+// func NewFilterableItem(content string) FilterableItem {
61− return &filterableItem{
53+// return &filterableItem{
62− selectableItem: NewSelectableItem(content).(*selectableItem),
54+// selectableItem: NewSelectableItem(content).(*selectableItem),
63− }
55+// }
64−}
56+// }
65−
57+//
66−func (f *filterableItem) FilterValue() string {
58+// func (f *filterableItem) FilterValue() string {
67− return f.content
59+// return f.content
68−}
60+// }
internal/tui/exp/list/list.go+436−496
@@ -1,7 +1,6 @@
1package list
1package list
2
2
3import (
3import (
4− "slices"
5 "strings"
4 "strings"
6
5
7 "github.com/charmbracelet/bubbles/v2/key"
6 "github.com/charmbracelet/bubbles/v2/key"
@@ -17,46 +16,50 @@ type Item interface {
17 ID() string
16 ID() string
18}
17}
19
18
19+type (
20+ renderedMsg struct{}
20−type List[T Item] interface {
21+ List[T Item] interface {
21− util.Model
22+ util.Model
22− layout.Sizeable
23+ layout.Sizeable
23− layout.Focusable
24+ layout.Focusable
25+
26+ // Just change state
24− MoveUp(int) tea.Cmd
27+ MoveUp(int) tea.Cmd
25− MoveDown(int) tea.Cmd
28+ MoveDown(int) tea.Cmd
26− GoToTop() tea.Cmd
29+ GoToTop() tea.Cmd
27− GoToBottom() tea.Cmd
30+ GoToBottom() tea.Cmd
28− SelectItemAbove() tea.Cmd
31+ SelectItemAbove() tea.Cmd
29− SelectItemBelow() tea.Cmd
32+ SelectItemBelow() tea.Cmd
30− SetItems([]T) tea.Cmd
33+ SetItems([]T) tea.Cmd
31− SetSelected(string) tea.Cmd
34+ SetSelected(string) tea.Cmd
32− SelectedItem() *T
35+ SelectedItem() *T
33− Items() []T
36+ Items() []T
34− UpdateItem(string, T)
37+ UpdateItem(string, T) tea.Cmd
35− DeleteItem(string)
38+ DeleteItem(string) tea.Cmd
36− PrependItem(T) tea.Cmd
39+ PrependItem(T) tea.Cmd
37− AppendItem(T) tea.Cmd
40+ AppendItem(T) tea.Cmd
38−}
41+ }
42+)
39
43
40type direction int
44type direction int
41
45
42const (
46const (
43− Forward direction = iota
47+ DirectionForward direction = iota
44− Backward
48+ DirectionBackward
45)
49)
46
50
47const (
51const (
48− NotFound = -1
52+ ItemNotFound = -1
49− DefaultScrollSize = 2
53+ ViewportDefaultScrollSize = 2
50)
54)
51
55
52−type setSelectedMsg struct {
53− selectedItemID string
54−}
55−
56type renderedItem struct {
56type renderedItem struct {
57 id string
57 id string
58 view string
58 view string
59+ dirty bool
59 height int
60 height int
61+ start int
62+ end int
60}
63}
61
64
62type confOptions struct {
65type confOptions struct {
@@ -67,16 +70,20 @@ type confOptions struct {
67 keyMap KeyMap
70 keyMap KeyMap
68 direction direction
71 direction direction
69 selectedItem string
72 selectedItem string
73+ focused bool
70}
74}
75+
71type list[T Item] struct {
76type list[T Item] struct {
72 *confOptions
77 *confOptions
73
78
74− focused bool
75− offset int
79+ offset int
80+
81+ indexMap map[string]int
76− items []T
82+ items []T
83+
77− renderedItems []renderedItem
84+ renderedItems map[string]renderedItem
85+
78− rendered string
86+ rendered string
79− isReady bool
80}
87}
81
88
82type listOption func(*confOptions)
89type listOption func(*confOptions)
@@ -96,10 +103,17 @@ func WithGap(gap int) listOption {
96 }
103 }
97}
104}
98
105
99−// WithDirection sets the direction of the list.
106+// WithDirectionForward sets the direction to forward
100−func WithDirection(dir direction) listOption {
107+func WithDirectionForward() listOption {
108+ return func(l *confOptions) {
109+ l.direction = DirectionForward
110+ }
111+}
112+
113+// WithDirectionBackward sets the direction to forward
114+func WithDirectionBackward() listOption {
101 return func(l *confOptions) {
115 return func(l *confOptions) {
102− l.direction = dir
116+ l.direction = DirectionBackward
103 }
117 }
104}
118}
105
119
@@ -122,55 +136,60 @@ func WithWrapNavigation() listOption {
122 }
136 }
123}
137}
124
138
139+func WithFocus(focus bool) listOption {
140+ return func(l *confOptions) {
141+ l.focused = focus
142+ }
143+}
144+
125func New[T Item](items []T, opts ...listOption) List[T] {
145func New[T Item](items []T, opts ...listOption) List[T] {
126 list := &list[T]{
146 list := &list[T]{
127 confOptions: &confOptions{
147 confOptions: &confOptions{
128− direction: Forward,
148+ direction: DirectionForward,
129 keyMap: DefaultKeyMap(),
149 keyMap: DefaultKeyMap(),
150+ focused: true,
130 },
151 },
131− items: items,
152+ items: items,
153+ indexMap: make(map[string]int),
154+ renderedItems: map[string]renderedItem{},
132 }
155 }
133 for _, opt := range opts {
156 for _, opt := range opts {
134 opt(list.confOptions)
157 opt(list.confOptions)
135 }
158 }
159+
160+ for inx, item := range items {
161+ list.indexMap[item.ID()] = inx
162+ }
136 return list
163 return list
137}
164}
138
165
139// Init implements List.
166// Init implements List.
140func (l *list[T]) Init() tea.Cmd {
167func (l *list[T]) Init() tea.Cmd {
141− var cmds []tea.Cmd
168+ return l.render()
142− for _, item := range l.items {
143− cmd := item.Init()
144− cmds = append(cmds, cmd)
145− }
146− cmds = append(cmds, l.renderItems())
147− return tea.Batch(cmds...)
148}
169}
149
170
150// Update implements List.
171// Update implements List.
151func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
152 switch msg := msg.(type) {
173 switch msg := msg.(type) {
153− case setSelectedMsg:
154− return l, l.SetSelected(msg.selectedItemID)
155 case tea.KeyPressMsg:
174 case tea.KeyPressMsg:
156 if l.focused {
175 if l.focused {
157 switch {
176 switch {
158 case key.Matches(msg, l.keyMap.Down):
177 case key.Matches(msg, l.keyMap.Down):
159− return l, l.MoveDown(DefaultScrollSize)
178+ return l, l.MoveDown(ViewportDefaultScrollSize)
160 case key.Matches(msg, l.keyMap.Up):
179 case key.Matches(msg, l.keyMap.Up):
161− return l, l.MoveUp(DefaultScrollSize)
180+ return l, l.MoveUp(ViewportDefaultScrollSize)
162 case key.Matches(msg, l.keyMap.DownOneItem):
181 case key.Matches(msg, l.keyMap.DownOneItem):
163 return l, l.SelectItemBelow()
182 return l, l.SelectItemBelow()
164 case key.Matches(msg, l.keyMap.UpOneItem):
183 case key.Matches(msg, l.keyMap.UpOneItem):
165 return l, l.SelectItemAbove()
184 return l, l.SelectItemAbove()
166 case key.Matches(msg, l.keyMap.HalfPageDown):
185 case key.Matches(msg, l.keyMap.HalfPageDown):
167− return l, l.MoveDown(l.listHeight() / 2)
186+ return l, l.MoveDown(l.height / 2)
168 case key.Matches(msg, l.keyMap.HalfPageUp):
187 case key.Matches(msg, l.keyMap.HalfPageUp):
169− return l, l.MoveUp(l.listHeight() / 2)
188+ return l, l.MoveUp(l.height / 2)
170 case key.Matches(msg, l.keyMap.PageDown):
189 case key.Matches(msg, l.keyMap.PageDown):
171− return l, l.MoveDown(l.listHeight())
190+ return l, l.MoveDown(l.height)
172 case key.Matches(msg, l.keyMap.PageUp):
191 case key.Matches(msg, l.keyMap.PageUp):
173− return l, l.MoveUp(l.listHeight())
192+ return l, l.MoveUp(l.height)
174 case key.Matches(msg, l.keyMap.End):
193 case key.Matches(msg, l.keyMap.End):
175 return l, l.GoToBottom()
194 return l, l.GoToBottom()
176 case key.Matches(msg, l.keyMap.Home):
195 case key.Matches(msg, l.keyMap.Home):
@@ -197,585 +216,506 @@ func (l *list[T]) View() string {
197func (l *list[T]) viewPosition() (int, int) {
216func (l *list[T]) viewPosition() (int, int) {
198 start, end := 0, 0
217 start, end := 0, 0
199 renderedLines := lipgloss.Height(l.rendered) - 1
218 renderedLines := lipgloss.Height(l.rendered) - 1
200− if l.direction == Forward {
219+ if l.direction == DirectionForward {
201 start = max(0, l.offset)
220 start = max(0, l.offset)
202− end = min(l.offset+l.listHeight()-1, renderedLines)
221+ end = min(l.offset+l.height-1, renderedLines)
203 } else {
222 } else {
204− start = max(0, renderedLines-l.offset-l.listHeight()+1)
223+ start = max(0, renderedLines-l.offset-l.height+1)
205 end = max(0, renderedLines-l.offset)
224 end = max(0, renderedLines-l.offset)
206 }
225 }
207 return start, end
226 return start, end
208}
227}
209
228
210−func (l *list[T]) renderItem(item Item) renderedItem {
229+func (l *list[T]) recalculateItemPositions() {
211− view := item.View()
230+ currentContentHeight := 0
212− return renderedItem{
231+ for _, item := range l.items {
213− id: item.ID(),
232+ rItem, ok := l.renderedItems[item.ID()]
214− view: view,
233+ if !ok {
215− height: lipgloss.Height(view),
234+ continue
235+ }
236+ rItem.start = currentContentHeight
237+ rItem.end = currentContentHeight + rItem.height - 1
238+ l.renderedItems[item.ID()] = rItem
239+ currentContentHeight = rItem.end + 1 + l.gap
216 }
240 }
217}
241}
218
242
219−func (l *list[T]) renderView() {
243+func (l *list[T]) render() tea.Cmd {
220− var sb strings.Builder
244+ if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
221− for i, rendered := range l.renderedItems {
245+ return nil
222− sb.WriteString(rendered.view)
246+ }
223− if i < len(l.renderedItems)-1 {
247+ l.setDefaultSelected()
224− sb.WriteString(strings.Repeat("\n", l.gap+1))
248+ focusCmd := l.focusSelectedItem()
249+ // we are not rendering the first time
250+ if l.rendered != "" {
251+ l.rendered = ""
252+ // rerender everything will mostly hit cache
253+ _ = l.renderIterator(0, false)
254+ if l.direction == DirectionBackward {
255+ l.recalculateItemPositions()
256+ }
257+ // in the end scroll to the selected item
258+ if l.focused {
259+ l.scrollToSelection()
260+ }
261+ return focusCmd
262+ }
263+ finishIndex := l.renderIterator(0, true)
264+ // recalculate for the initial items
265+ if l.direction == DirectionBackward {
266+ l.recalculateItemPositions()
267+ }
268+ renderCmd := func() tea.Msg {
269+ // render the rest
270+ _ = l.renderIterator(finishIndex, false)
271+ // needed for backwards
272+ if l.direction == DirectionBackward {
273+ l.recalculateItemPositions()
225 }
274 }
275+ // in the end scroll to the selected item
276+ if l.focused {
277+ l.scrollToSelection()
278+ }
279+ return renderedMsg{}
226 }
280 }
227− l.rendered = sb.String()
281+ return tea.Batch(focusCmd, renderCmd)
228}
282}
229
283
230−func (l *list[T]) incrementOffset(n int) {
284+func (l *list[T]) setDefaultSelected() {
231− if !l.isReady {
285+ if l.selectedItem == "" {
232− return
286+ if l.direction == DirectionForward {
287+ l.selectFirstItem()
288+ } else {
289+ l.selectLastItem()
290+ }
233 }
291 }
234− renderedHeight := lipgloss.Height(l.rendered)
292+}
235− // no need for offset
293+
236− if renderedHeight <= l.listHeight() {
294+func (l *list[T]) scrollToSelection() {
295+ rItem, ok := l.renderedItems[l.selectedItem]
296+ if !ok {
297+ l.selectedItem = ""
298+ l.setDefaultSelected()
237 return
299 return
238 }
300 }
239− maxOffset := renderedHeight - l.listHeight()
301+
240− n = min(n, maxOffset-l.offset)
302+ start, end := l.viewPosition()
241− if n <= 0 {
303+ // item bigger or equal to the viewport do nothing
304+ if rItem.start <= start && rItem.end >= end {
242 return
305 return
243 }
306 }
244− l.offset += n
307+ // item already in view do nothing
245−}
308+ if rItem.start >= start && rItem.start <= end {
246−
309+ return
247−func (l *list[T]) decrementOffset(n int) {
310+ } else if rItem.end <= end && rItem.end >= start {
248− if !l.isReady {
249 return
311 return
250 }
312 }
313+
314+ if rItem.height >= l.height {
315+ if l.direction == DirectionForward {
251− n = min(n, l.offset)
316+ l.offset = rItem.start
252− if n <= 0 {
317+ } else {
318+ l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
319+ }
253 return
320 return
254 }
321 }
255− l.offset -= n
322+
256− if l.offset < 0 {
323+ itemMiddleStart := rItem.start + rItem.height/2 + 1
257− l.offset = 0
324+ if l.direction == DirectionForward {
325+ l.offset = itemMiddleStart - l.height/2
326+ } else {
327+ l.offset = max(0, lipgloss.Height(l.rendered)-(itemMiddleStart+l.height/2))
258 }
328 }
259}
329}
260
330
261−// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
331+func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
262−func (l *list[T]) changeSelectedWhenNotVisible() tea.Cmd {
332+ rItem, ok := l.renderedItems[l.selectedItem]
263− var cmds []tea.Cmd
333+ if !ok {
334+ return nil
335+ }
264 start, end := l.viewPosition()
336 start, end := l.viewPosition()
265− currentPosition := 0
337+ // item bigger than the viewport do nothing
266− itemWithinView := NotFound
338+ if rItem.start <= start && rItem.end >= end {
267− needsMove := false
339+ return nil
268−
340+ }
269− for i, item := range l.items {
341+ // item already in view do nothing
270− rendered := l.renderedItems[i]
342+ if rItem.start >= start && rItem.end <= end {
271− itemStart := currentPosition
343+ return nil
272− // we remove 1 so that we actually have the row, e.x 1 row => height 1 => start 0, end 0
344+ }
273− itemEnd := itemStart + rendered.height - 1
345+
274− if itemStart >= start && itemEnd <= end {
346+ itemMiddle := rItem.start + rItem.height/2
275− itemWithinView = i
347+
276− }
348+ if itemMiddle < start {
277− if item.ID() == l.selectedItem {
349+ // select the first item in the viewport
278− // item is completely above the viewport
350+ // the item is most likely an item coming after this item
279− if itemStart < start && itemEnd < start {
351+ inx := l.indexMap[rItem.id]
280− needsMove = true
352+ for {
353+ inx = l.firstSelectableItemBelow(inx)
354+ if inx == ItemNotFound {
355+ return nil
281 }
356 }
282− // item is completely below the viewport
357+ item, ok := l.renderedItems[l.items[inx].ID()]
283− if itemStart > end && itemEnd > end {
358+ if !ok {
284− needsMove = true
359+ continue
285 }
360 }
286− if needsMove {
361+
287− if focusable, ok := any(item).(layout.Focusable); ok {
362+ // If the item is bigger than the viewport, select it
288− cmds = append(cmds, focusable.Blur())
363+ if item.start <= start && item.end >= end {
289− }
364+ l.selectedItem = item.id
290− l.renderedItems[i] = l.renderItem(item)
365+ return l.render()
291− } else {
366+ }
367+ // item is in the view
368+ if item.start >= start && item.start <= end {
369+ l.selectedItem = item.id
292− return nil
370+ return l.render()
293 }
371 }
294 }
372 }
295− if itemWithinView != NotFound && needsMove {
373+ } else if itemMiddle > end {
296− newSelection := l.items[itemWithinView]
374+ // select the first item in the viewport
297− l.selectedItem = newSelection.ID()
375+ // the item is most likely an item coming after this item
298− if focusable, ok := any(newSelection).(layout.Focusable); ok {
376+ inx := l.indexMap[rItem.id]
299− cmds = append(cmds, focusable.Focus())
377+ for {
378+ inx = l.firstSelectableItemAbove(inx)
379+ if inx == ItemNotFound {
380+ return nil
381+ }
382+ item, ok := l.renderedItems[l.items[inx].ID()]
383+ if !ok {
384+ continue
385+ }
386+
387+ // If the item is bigger than the viewport, select it
388+ if item.start <= start && item.end >= end {
389+ l.selectedItem = item.id
390+ return l.render()
391+ }
392+ // item is in the view
393+ if item.end >= start && item.end <= end {
394+ l.selectedItem = item.id
395+ return l.render()
300 }
396 }
301− l.renderedItems[itemWithinView] = l.renderItem(newSelection)
302− break
303 }
397 }
304− currentPosition += rendered.height + l.gap
305 }
398 }
306− l.renderView()
307− return tea.Batch(cmds...)
399+ return nil
308}
400}
309
401
310−func (l *list[T]) MoveUp(n int) tea.Cmd {
402+func (l *list[T]) selectFirstItem() {
311− if l.direction == Forward {
403+ inx := l.firstSelectableItemBelow(-1)
312− l.decrementOffset(n)
404+ if inx != ItemNotFound {
313− } else {
405+ l.selectedItem = l.items[inx].ID()
314− l.incrementOffset(n)
315 }
406 }
316− return l.changeSelectedWhenNotVisible()
317}
407}
318
408
319−func (l *list[T]) MoveDown(n int) tea.Cmd {
409+func (l *list[T]) selectLastItem() {
320− if l.direction == Forward {
410+ inx := l.firstSelectableItemAbove(len(l.items))
321− l.incrementOffset(n)
411+ if inx != ItemNotFound {
322− } else {
412+ l.selectedItem = l.items[inx].ID()
323− l.decrementOffset(n)
324 }
413 }
325− return l.changeSelectedWhenNotVisible()
326}
414}
327
415
328−func (l *list[T]) firstSelectableItemBefore(inx int) int {
416+func (l *list[T]) firstSelectableItemAbove(inx int) int {
329 for i := inx - 1; i >= 0; i-- {
417 for i := inx - 1; i >= 0; i-- {
330 if _, ok := any(l.items[i]).(layout.Focusable); ok {
418 if _, ok := any(l.items[i]).(layout.Focusable); ok {
331 return i
419 return i
332 }
420 }
333 }
421 }
334 if inx == 0 && l.wrap {
422 if inx == 0 && l.wrap {
335− return l.firstSelectableItemBefore(len(l.items))
423+ return l.firstSelectableItemAbove(len(l.items))
336 }
424 }
337− return NotFound
425+ return ItemNotFound
338}
426}
339
427
340−func (l *list[T]) firstSelectableItemAfter(inx int) int {
428+func (l *list[T]) firstSelectableItemBelow(inx int) int {
341 for i := inx + 1; i < len(l.items); i++ {
429 for i := inx + 1; i < len(l.items); i++ {
342 if _, ok := any(l.items[i]).(layout.Focusable); ok {
430 if _, ok := any(l.items[i]).(layout.Focusable); ok {
343 return i
431 return i
344 }
432 }
345 }
433 }
346 if inx == len(l.items)-1 && l.wrap {
434 if inx == len(l.items)-1 && l.wrap {
347− return l.firstSelectableItemAfter(-1)
435+ return l.firstSelectableItemBelow(-1)
348 }
436 }
349− return NotFound
437+ return ItemNotFound
350}
438}
351
439
352−// moveToSelected needs to be called after the view is rendered
353−func (l *list[T]) moveToSelected(center bool) tea.Cmd {
440+func (l *list[T]) focusSelectedItem() tea.Cmd {
354− var cmds []tea.Cmd
355− if l.selectedItem == "" || !l.isReady {
441+ if l.selectedItem == "" || !l.focused {
356 return nil
442 return nil
357 }
443 }
358− currentPosition := 0
444+ var cmds []tea.Cmd
359− start, end := l.viewPosition()
360− for _, item := range l.renderedItems {
445+ for _, item := range l.items {
446+ if f, ok := any(item).(layout.Focusable); ok {
361− if item.id == l.selectedItem {
447+ if item.ID() == l.selectedItem && !f.IsFocused() {
362− itemStart := currentPosition
363− itemEnd := currentPosition + item.height - 1
364−
365− if start <= itemStart && itemEnd <= end {
366− return nil
367− }
368−
369− if center {
370− viewportCenter := l.listHeight() / 2
371− itemCenter := itemStart + item.height/2
372− targetOffset := itemCenter - viewportCenter
373− if l.direction == Forward {
374− if targetOffset > l.offset {
375− cmds = append(cmds, l.MoveDown(targetOffset-l.offset))
448+ cmds = append(cmds, f.Focus())
376− } else if targetOffset < l.offset {
449+ if cache, ok := l.renderedItems[item.ID()]; ok {
377− cmds = append(cmds, l.MoveUp(l.offset-targetOffset))
450+ cache.dirty = true
378− }
451+ l.renderedItems[item.ID()] = cache
379− } else {
380− renderedHeight := lipgloss.Height(l.rendered)
381− backwardTargetOffset := renderedHeight - targetOffset - l.listHeight()
382− if backwardTargetOffset > l.offset {
383− cmds = append(cmds, l.MoveUp(backwardTargetOffset-l.offset))
384− } else if backwardTargetOffset < l.offset {
385− cmds = append(cmds, l.MoveDown(l.offset-backwardTargetOffset))
386− }
387− }
388− } else {
389− if currentPosition < start {
390− cmds = append(cmds, l.MoveUp(start-currentPosition))
391 }
452 }
392− if currentPosition > end {
453+ } else if item.ID() != l.selectedItem && f.IsFocused() {
393− cmds = append(cmds, l.MoveDown(currentPosition-end))
454+ cmds = append(cmds, f.Blur())
455+ if cache, ok := l.renderedItems[item.ID()]; ok {
456+ cache.dirty = true
457+ l.renderedItems[item.ID()] = cache
394 }
458 }
395 }
459 }
396 }
460 }
397− currentPosition += item.height + l.gap
398 }
461 }
399 return tea.Batch(cmds...)
462 return tea.Batch(cmds...)
400}
463}
401
464
402−func (l *list[T]) SelectItemAbove() tea.Cmd {
465+func (l *list[T]) blurItems() tea.Cmd {
403− if !l.isReady {
404− return nil
405− }
406 var cmds []tea.Cmd
466 var cmds []tea.Cmd
407− for i, item := range l.items {
467+ for _, item := range l.items {
468+ if f, ok := any(item).(layout.Focusable); ok {
408− if l.selectedItem == item.ID() {
469+ if item.ID() == l.selectedItem && f.IsFocused() {
409− inx := l.firstSelectableItemBefore(i)
410− if inx == NotFound {
411− // no item above
412− return nil
413− }
414− // blur the current item
415− if focusable, ok := any(item).(layout.Focusable); ok {
416− cmds = append(cmds, focusable.Blur())
470+ cmds = append(cmds, f.Blur())
417− }
418− // rerender the item
419− l.renderedItems[i] = l.renderItem(item)
471+ if cache, ok := l.renderedItems[item.ID()]; ok {
420− // focus the item above
472+ cache.dirty = true
421− above := l.items[inx]
473+ l.renderedItems[item.ID()] = cache
422− if focusable, ok := any(above).(layout.Focusable); ok {
474+ }
423− cmds = append(cmds, focusable.Focus())
424 }
475 }
425− // rerender the item
426− l.renderedItems[inx] = l.renderItem(above)
427− l.selectedItem = above.ID()
428− break
429 }
476 }
430 }
477 }
431− l.renderView()
432− l.moveToSelected(false)
433 return tea.Batch(cmds...)
478 return tea.Batch(cmds...)
434}
479}
435
480
436−func (l *list[T]) SelectItemBelow() tea.Cmd {
481+// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
437− if !l.isReady {
482+// returns the last index
438− return nil
483+func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
439− }
484+ currentContentHeight := lipgloss.Height(l.rendered) - 1
440− var cmds []tea.Cmd
441− for i, item := range l.items {
485+ for i := startInx; i < len(l.items); i++ {
442− if l.selectedItem == item.ID() {
486+ if currentContentHeight >= l.height && limitHeight {
443− inx := l.firstSelectableItemAfter(i)
444− if inx == NotFound {
445− // no item below
446− return nil
487+ return i
447− }
488+ }
448− // blur the current item
489+ // cool way to go through the list in both directions
449− if focusable, ok := any(item).(layout.Focusable); ok {
490+ inx := i
450− cmds = append(cmds, focusable.Blur())
451− }
452− // rerender the item
453− l.renderedItems[i] = l.renderItem(item)
454
491
455− // focus the item below
492+ if l.direction != DirectionForward {
456− below := l.items[inx]
493+ inx = (len(l.items) - 1) - i
457− if focusable, ok := any(below).(layout.Focusable); ok {
458− cmds = append(cmds, focusable.Focus())
459− }
460− // rerender the item
461− l.renderedItems[inx] = l.renderItem(below)
462− l.selectedItem = below.ID()
463− break
464 }
494 }
465− }
466
495
467− l.renderView()
496+ item := l.items[inx]
468− l.moveToSelected(false)
497+ var rItem renderedItem
469− return tea.Batch(cmds...)
498+ if cache, ok := l.renderedItems[item.ID()]; ok && !cache.dirty {
499+ rItem = cache
500+ } else {
501+ rItem = l.renderItem(item)
502+ rItem.start = currentContentHeight
503+ rItem.end = currentContentHeight + rItem.height - 1
504+ l.renderedItems[item.ID()] = rItem
505+ }
506+ gap := l.gap + 1
507+ if inx == len(l.items)-1 {
508+ gap = 0
470−}
509+ }
471
510
472−func (l *list[T]) GoToTop() tea.Cmd {
511+ if l.direction == DirectionForward {
473− if !l.isReady {
512+ l.rendered += rItem.view + strings.Repeat("\n", gap)
474− return nil
513+ } else {
514+ l.rendered = rItem.view + strings.Repeat("\n", gap) + l.rendered
515+ }
516+ currentContentHeight = rItem.end + 1 + l.gap
475 }
517 }
476− l.offset = 0
518+ return len(l.items)
477− l.direction = Forward
478− return tea.Batch(l.selectFirstItem(), l.renderForward())
479}
519}
480
520
481−func (l *list[T]) GoToBottom() tea.Cmd {
521+func (l *list[T]) renderItem(item Item) renderedItem {
482− if !l.isReady {
522+ view := item.View()
483− return nil
523+ return renderedItem{
524+ id: item.ID(),
525+ view: view,
526+ height: lipgloss.Height(view),
484 }
527 }
485− l.offset = 0
486− l.direction = Backward
487−
488− return tea.Batch(l.selectLastItem(), l.renderBackward())
489}
528}
490
529
530+// AppendItem implements List.
491−func (l *list[T]) renderForward() tea.Cmd {
531+func (l *list[T]) AppendItem(T) tea.Cmd {
492− // TODO: figure out a way to preserve items that did not change
532+ panic("unimplemented")
493− l.renderedItems = make([]renderedItem, 0)
494− currentHeight := 0
495− currentIndex := 0
496− for i, item := range l.items {
497− currentIndex = i
498− if currentHeight-1 > l.listHeight() {
499− break
500− }
501− rendered := l.renderItem(item)
502− l.renderedItems = append(l.renderedItems, rendered)
503− currentHeight += rendered.height + l.gap
504− }
533+}
505
534
506− // initial render
535+// Blur implements List.
507− l.renderView()
536+func (l *list[T]) Blur() tea.Cmd {
537+ cmd := l.blurItems()
538+ return tea.Batch(cmd, l.render())
539+}
508
540
509− if currentIndex == len(l.items)-1 {
541+// DeleteItem implements List.
510− l.isReady = true
542+func (l *list[T]) DeleteItem(string) tea.Cmd {
511− return nil
543+ panic("unimplemented")
512− }
513− // render the rest
514− return func() tea.Msg {
515− for i := currentIndex; i < len(l.items); i++ {
516− rendered := l.renderItem(l.items[i])
517− l.renderedItems = append(l.renderedItems, rendered)
518− }
519− l.renderView()
520− l.isReady = true
521− return nil
522− }
523}
544}
524
545
546+// Focus implements List.
525−func (l *list[T]) renderBackward() tea.Cmd {
547+func (l *list[T]) Focus() tea.Cmd {
526− // TODO: figure out a way to preserve items that did not change
548+ l.focused = true
527− l.renderedItems = make([]renderedItem, 0)
528− currentHeight := 0
529− currentIndex := 0
530− for i := len(l.items) - 1; i >= 0; i-- {
531− currentIndex = i
532− if currentHeight > l.listHeight() {
533− break
534− }
535− rendered := l.renderItem(l.items[i])
536− l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
537− currentHeight += rendered.height + l.gap
538− }
539− // initial render
549+ return l.render()
540− l.renderView()
541− if currentIndex == 0 {
542− l.isReady = true
543− return nil
544− }
545− return func() tea.Msg {
546− for i := currentIndex; i >= 0; i-- {
547− rendered := l.renderItem(l.items[i])
548− l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
549− }
550− l.renderView()
551− l.isReady = true
552− return nil
553− }
554}
550}
555
551
556−func (l *list[T]) selectFirstItem() tea.Cmd {
552+// GetSize implements List.
557− var cmd tea.Cmd
553+func (l *list[T]) GetSize() (int, int) {
558− inx := l.firstSelectableItemAfter(-1)
559− if inx != NotFound {
560− l.selectedItem = l.items[inx].ID()
561− if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
562− cmd = focusable.Focus()
563− }
564− }
565− return cmd
554+ return l.width, l.height
566}
555}
567
556
557+// GoToBottom implements List.
568−func (l *list[T]) selectLastItem() tea.Cmd {
558+func (l *list[T]) GoToBottom() tea.Cmd {
569− var cmd tea.Cmd
559+ l.offset = 0
570− inx := l.firstSelectableItemBefore(len(l.items))
560+ l.direction = DirectionBackward
571− if inx != NotFound {
572− l.selectedItem = l.items[inx].ID()
561+ l.selectedItem = ""
573− if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
574− cmd = focusable.Focus()
575− }
576− }
577− return cmd
562+ return l.render()
578}
563}
579
564
565+// GoToTop implements List.
580−func (l *list[T]) renderItems() tea.Cmd {
566+func (l *list[T]) GoToTop() tea.Cmd {
581− if l.height <= 0 || l.width <= 0 {
567+ l.offset = 0
582− return nil
568+ l.direction = DirectionForward
583− }
569+ l.selectedItem = ""
584− if len(l.items) == 0 {
585− return nil
570+ return l.render()
586− }
571+}
587
572
588− if l.selectedItem == "" {
573+// IsFocused implements List.
589− if l.direction == Forward {
574+func (l *list[T]) IsFocused() bool {
590− l.selectFirstItem()
591− } else {
592− l.selectLastItem()
593− }
594− }
595− if l.direction == Forward {
596− return l.renderForward()
597− }
598− return l.renderBackward()
575+ return l.focused
599}
576}
600
577
578+// Items implements List.
601−func (l *list[T]) listHeight() int {
579+func (l *list[T]) Items() []T {
602− // for the moment its the same
603− return l.height
580+ return l.items
604}
581}
605
582
606−func (l *list[T]) SetItems(items []T) tea.Cmd {
583+func (l *list[T]) incrementOffset(n int) {
607− l.items = items
584+ renderedHeight := lipgloss.Height(l.rendered)
608− var cmds []tea.Cmd
585+ // no need for offset
609− for _, item := range l.items {
586+ if renderedHeight <= l.height {
610− cmds = append(cmds, item.Init())
587+ return
611− // Set height to 0 to let the item calculate its own height
588+ }
612− cmds = append(cmds, item.SetSize(l.width, 0))
589+ maxOffset := renderedHeight - l.height
590+ n = min(n, maxOffset-l.offset)
591+ if n <= 0 {
592+ return
613 }
593 }
594+ l.offset += n
595+}
614
596
615− cmds = append(cmds, l.renderItems())
597+func (l *list[T]) decrementOffset(n int) {
616− if l.selectedItem != "" {
598+ n = min(n, l.offset)
617− cmds = append(cmds, l.moveToSelected(true))
599+ if n <= 0 {
600+ return
601+ }
602+ l.offset -= n
603+ if l.offset < 0 {
604+ l.offset = 0
618 }
605 }
619− return tea.Batch(cmds...)
620}
606}
621
607
622−// GetSize implements List.
608+// MoveDown implements List.
623−func (l *list[T]) GetSize() (int, int) {
609+func (l *list[T]) MoveDown(n int) tea.Cmd {
624− return l.width, l.height
610+ if l.direction == DirectionForward {
611+ l.incrementOffset(n)
612+ } else {
613+ l.decrementOffset(n)
614+ }
615+ return l.changeSelectionWhenScrolling()
625}
616}
626
617
627−// SetSize implements List.
618+// MoveUp implements List.
628−func (l *list[T]) SetSize(width int, height int) tea.Cmd {
619+func (l *list[T]) MoveUp(n int) tea.Cmd {
629− l.width = width
620+ if l.direction == DirectionForward {
630− l.height = height
621+ l.decrementOffset(n)
631− var cmds []tea.Cmd
622+ } else {
632− for _, item := range l.items {
623+ l.incrementOffset(n)
633− cmds = append(cmds, item.SetSize(width, height))
634 }
624 }
625+ return l.changeSelectionWhenScrolling()
626+}
635
627
636− cmds = append(cmds, l.renderItems())
628+// PrependItem implements List.
637− return tea.Batch(cmds...)
629+func (l *list[T]) PrependItem(T) tea.Cmd {
630+ panic("unimplemented")
638}
631}
639
632
640−// Blur implements List.
633+// SelectItemAbove implements List.
641−func (l *list[T]) Blur() tea.Cmd {
634+func (l *list[T]) SelectItemAbove() tea.Cmd {
642− var cmd tea.Cmd
643− l.focused = false
644− for i, item := range l.items {
645− if item.ID() != l.selectedItem {
635+ inx, ok := l.indexMap[l.selectedItem]
646− continue
636+ if !ok {
647− }
637+ return nil
648− if focusable, ok := any(item).(layout.Focusable); ok {
649− cmd = focusable.Blur()
650− }
651− l.renderedItems[i] = l.renderItem(item)
652 }
638 }
653− l.renderView()
654− return cmd
655−}
656
639
657−// Focus implements List.
640+ newIndex := l.firstSelectableItemAbove(inx)
658−func (l *list[T]) Focus() tea.Cmd {
641+ if newIndex == ItemNotFound {
659− var cmd tea.Cmd
642+ // no item above
660− l.focused = true
643+ return nil
661− if l.selectedItem != "" {
662− for i, item := range l.items {
663− if item.ID() != l.selectedItem {
664− continue
665− }
666− if focusable, ok := any(item).(layout.Focusable); ok {
667− cmd = focusable.Focus()
668− }
669− if len(l.renderedItems) > i {
670− l.renderedItems[i] = l.renderItem(item)
671− }
672− }
673− l.renderView()
674 }
644 }
645+ item := l.items[newIndex]
646+ l.selectedItem = item.ID()
675− return cmd
647+ return l.render()
676}
648}
677
649
650+// SelectItemBelow implements List.
678−func (l *list[T]) SetSelected(id string) tea.Cmd {
651+func (l *list[T]) SelectItemBelow() tea.Cmd {
679− if l.selectedItem == id {
652+ inx, ok := l.indexMap[l.selectedItem]
653+ if !ok {
680 return nil
654 return nil
681 }
655 }
682− var cmds []tea.Cmd
656+
683− for i, item := range l.items {
657+ newIndex := l.firstSelectableItemBelow(inx)
684− if item.ID() == l.selectedItem {
658+ if newIndex == ItemNotFound {
685− if focusable, ok := any(item).(layout.Focusable); ok {
659+ // no item above
686− cmds = append(cmds, focusable.Blur())
660+ return nil
687− }
688− if len(l.renderedItems) > i {
689− l.renderedItems[i] = l.renderItem(item)
690− }
691− } else if item.ID() == id {
692− if focusable, ok := any(item).(layout.Focusable); ok {
693− cmds = append(cmds, focusable.Focus())
694− }
695− if len(l.renderedItems) > i {
696− l.renderedItems[i] = l.renderItem(item)
697− }
698− }
699 }
661 }
662+ item := l.items[newIndex]
700− l.selectedItem = id
663+ l.selectedItem = item.ID()
701− l.renderView()
664+ return l.render()
702− cmds = append(cmds, l.moveToSelected(true))
703− return tea.Batch(cmds...)
704}
665}
705
666
667+// SelectedItem implements List.
706func (l *list[T]) SelectedItem() *T {
668func (l *list[T]) SelectedItem() *T {
707− for _, item := range l.items {
669+ inx, ok := l.indexMap[l.selectedItem]
708− if item.ID() == l.selectedItem {
670+ if !ok {
709− return &item
671+ return nil
710− }
711 }
672 }
673+ if inx > len(l.items)-1 {
712− return nil
674+ return nil
675+ }
676+ item := l.items[inx]
677+ return &item
713}
678}
714
679
715−// IsFocused implements List.
680+// SetItems implements List.
716−func (l *list[T]) IsFocused() bool {
681+func (l *list[T]) SetItems(items []T) tea.Cmd {
682+ l.items = items
717− return l.focused
683+ return l.reset()
718}
684}
719
685
720−func (l *list[T]) Items() []T {
686+// SetSelected implements List.
687+func (l *list[T]) SetSelected(id string) tea.Cmd {
688+ l.selectedItem = id
721− return l.items
689+ return l.render()
722}
690}
723
691
724−func (l *list[T]) UpdateItem(id string, item T) {
692+func (l *list[T]) reset() tea.Cmd {
725− // TODO: preserve offset
693+ var cmds []tea.Cmd
694+ l.rendered = ""
695+ l.indexMap = make(map[string]int)
696+ l.renderedItems = make(map[string]renderedItem)
726 for inx, item := range l.items {
697 for inx, item := range l.items {
727− if item.ID() == id {
698+ l.indexMap[item.ID()] = inx
728− l.items[inx] = item
699+ if l.width > 0 && l.height > 0 {
729− l.renderedItems[inx] = l.renderItem(item)
700+ cmds = append(cmds, item.SetSize(l.width, l.height))
730− l.renderView()
731− return
732 }
701 }
733 }
702 }
703+ cmds = append(cmds, l.render())
704+ return tea.Batch(cmds...)
734}
705}
735
706
736−func (l *list[T]) DeleteItem(id string) {
707+// SetSize implements List.
737− // TODO: preserve offset
738− inx := NotFound
739− for i, item := range l.items {
740− if item.ID() == id {
741− inx = i
742− break
743− }
744− }
745−
746− l.items = slices.Delete(l.items, inx, inx+1)
747− l.renderedItems = slices.Delete(l.renderedItems, inx, inx+1)
748− l.renderView()
749−}
750−
751−func (l *list[T]) PrependItem(item T) tea.Cmd {
708+func (l *list[T]) SetSize(width int, height int) tea.Cmd {
752− // TODO: preserve offset
709+ oldWidth := l.width
753− var cmd tea.Cmd
710+ l.width = width
754− l.items = append([]T{item}, l.items...)
711+ l.height = height
755− l.renderedItems = append([]renderedItem{l.renderItem(item)}, l.renderedItems...)
712+ if oldWidth != width {
756− if len(l.items) == 1 {
713+ return l.reset()
757− cmd = l.SetSelected(item.ID())
758− }
759− // the viewport did not move and the last item was focused
760− if l.direction == Backward && l.offset == 0 && l.selectedItem == l.items[0].ID() {
761− cmd = l.SetSelected(item.ID())
762 }
714 }
763− l.renderView()
764− return cmd
715+ return nil
765}
716}
766
717
718+// UpdateItem implements List.
767−func (l *list[T]) AppendItem(item T) tea.Cmd {
719+func (l *list[T]) UpdateItem(string, T) tea.Cmd {
768− // TODO: preserve offset
720+ panic("unimplemented")
769− var cmd tea.Cmd
770− l.items = append(l.items, item)
771− l.renderedItems = append(l.renderedItems, l.renderItem(item))
772− if len(l.items) == 1 {
773− cmd = l.SetSelected(item.ID())
774− } else if l.direction == Backward && l.offset == 0 && l.selectedItem == l.items[len(l.items)-2].ID() {
775− // the viewport did not move and the last item was focused
776− cmd = l.SetSelected(item.ID())
777− } else {
778− l.renderView()
779− }
780− return cmd
781}
721}
internal/tui/exp/list/list_test.go+217−489
@@ -2,7 +2,7 @@ package list
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5− "sync"
5+ "strings"
6 "testing"
6 "testing"
7
7
8 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
@@ -11,623 +11,344 @@ import (
11 "github.com/charmbracelet/x/exp/golden"
11 "github.com/charmbracelet/x/exp/golden"
12 "github.com/google/uuid"
12 "github.com/google/uuid"
13 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
14)
15)
15
16
16−func TestListPosition(t *testing.T) {
17+func TestList(t *testing.T) {
17 t.Parallel()
18 t.Parallel()
18− type positionOffsetTest struct {
19+ t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
19− dir direction
20− test string
21− width int
22− height int
23− numItems int
24−
25− moveUp int
26− moveDown int
27−
28− expectedStart int
29− expectedEnd int
30− }
31− tests := []positionOffsetTest{
32− {
33− dir: Forward,
34− test: "should have correct position initially when forward",
35− moveUp: 0,
36− moveDown: 0,
37− width: 10,
38− height: 20,
39− numItems: 100,
40− expectedStart: 0,
41− expectedEnd: 19,
42− },
43− {
44− dir: Forward,
45− test: "should offset start and end by one when moving down by one",
46− moveUp: 0,
47− moveDown: 1,
48− width: 10,
49− height: 20,
50− numItems: 100,
51− expectedStart: 1,
52− expectedEnd: 20,
53− },
54− {
55− dir: Backward,
56− test: "should have correct position initially when backward",
57− moveUp: 0,
58− moveDown: 0,
59− width: 10,
60− height: 20,
61− numItems: 100,
62− expectedStart: 80,
63− expectedEnd: 99,
64− },
65− {
66− dir: Backward,
67− test: "should offset the start and end by one when moving up by one",
68− moveUp: 1,
69− moveDown: 0,
70− width: 10,
71− height: 20,
72− numItems: 100,
73− expectedStart: 79,
74− expectedEnd: 98,
75− },
76− }
77− for _, c := range tests {
78− t.Run(c.test, func(t *testing.T) {
79− t.Parallel()
80− items := []Item{}
81− for i := range c.numItems {
82− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
83− items = append(items, item)
84− }
85− l := New(items, WithDirection(c.dir)).(*list[Item])
86− l.SetSize(c.width, c.height)
87− cmd := l.Init()
88− if cmd != nil {
89− cmd()
90− }
91−
92− if c.moveUp > 0 {
93− l.MoveUp(c.moveUp)
94− }
95− if c.moveDown > 0 {
96− l.MoveDown(c.moveDown)
97− }
98− start, end := l.viewPosition()
99− assert.Equal(t, c.expectedStart, start)
100− assert.Equal(t, c.expectedEnd, end)
101− })
102− }
103−}
104−
105−func TestBackwardList(t *testing.T) {
106− t.Parallel()
107− t.Run("within height", func(t *testing.T) {
108 t.Parallel()
20 t.Parallel()
109 items := []Item{}
21 items := []Item{}
110 for i := range 5 {
22 for i := range 5 {
111 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
23 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
112 items = append(items, item)
24 items = append(items, item)
113 }
25 }
114− l := New(items, WithDirection(Backward), WithGap(1)).(*list[Item])
26+ l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
115− l.SetSize(10, 20)
116− cmd := l.Init()
27+ execCmd(l, l.Init())
117− if cmd != nil {
118− cmd()
119− }
120
28
121 // should select the last item
29 // should select the last item
122− assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
30+ assert.Equal(t, items[0].ID(), l.selectedItem)
31+ assert.Equal(t, 0, l.offset)
32+ require.Len(t, l.indexMap, 5)
33+ require.Len(t, l.items, 5)
34+ require.Len(t, l.renderedItems, 5)
35+ assert.Equal(t, 5, lipgloss.Height(l.rendered))
36+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
37+ start, end := l.viewPosition()
38+ assert.Equal(t, 0, start)
39+ assert.Equal(t, 4, end)
40+ for i := range 5 {
41+ assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
42+ assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
43+ }
44+
123 golden.RequireEqual(t, []byte(l.View()))
45 golden.RequireEqual(t, []byte(l.View()))
124 })
46 })
125− t.Run("should not change selected item", func(t *testing.T) {
47+ t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
126 t.Parallel()
48 t.Parallel()
127 items := []Item{}
49 items := []Item{}
128 for i := range 5 {
50 for i := range 5 {
129 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
51 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
130 items = append(items, item)
52 items = append(items, item)
131 }
53 }
132− l := New(items, WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
54+ l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
133− l.SetSize(10, 20)
134− cmd := l.Init()
55+ execCmd(l, l.Init())
135− if cmd != nil {
56+
136− cmd()
137− }
138 // should select the last item
57 // should select the last item
139− assert.Equal(t, l.selectedItem, items[2].ID())
58+ assert.Equal(t, items[4].ID(), l.selectedItem)
140− })
59+ assert.Equal(t, 0, l.offset)
141− t.Run("more than height", func(t *testing.T) {
60+ require.Len(t, l.indexMap, 5)
142− t.Parallel()
61+ require.Len(t, l.items, 5)
143− items := []Item{}
62+ require.Len(t, l.renderedItems, 5)
63+ assert.Equal(t, 5, lipgloss.Height(l.rendered))
64+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
65+ start, end := l.viewPosition()
66+ assert.Equal(t, 0, start)
67+ assert.Equal(t, 4, end)
144− for i := range 10 {
68+ for i := range 5 {
145− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
69+ assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
146− items = append(items, item)
70+ assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
147− }
148− l := New(items, WithDirection(Backward))
149− l.SetSize(10, 5)
150− cmd := l.Init()
151− if cmd != nil {
152− cmd()
153 }
71 }
154
72
155 golden.RequireEqual(t, []byte(l.View()))
73 golden.RequireEqual(t, []byte(l.View()))
156 })
74 })
157− t.Run("more than height multi line", func(t *testing.T) {
158− t.Parallel()
159− items := []Item{}
160− for i := range 10 {
161− item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
162− items = append(items, item)
163− }
164− l := New(items, WithDirection(Backward))
165− l.SetSize(10, 5)
166− cmd := l.Init()
167− if cmd != nil {
168− cmd()
169− }
170
75
171− golden.RequireEqual(t, []byte(l.View()))
172− })
173− t.Run("should move up", func(t *testing.T) {
76+ t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
174 t.Parallel()
77 t.Parallel()
175 items := []Item{}
78 items := []Item{}
176− for i := range 10 {
79+ for i := range 30 {
177 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
80 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
178 items = append(items, item)
81 items = append(items, item)
179 }
82 }
180− l := New(items, WithDirection(Backward))
83+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
181− l.SetSize(10, 5)
84+ execCmd(l, l.Init())
182− cmd := l.Init()
183− if cmd != nil {
184− cmd()
185− }
186−
187− l.MoveUp(1)
188− golden.RequireEqual(t, []byte(l.View()))
189− })
190
85
191− t.Run("should move at max to the top", func(t *testing.T) {
86+ // should select the last item
192− items := []Item{}
87+ assert.Equal(t, items[0].ID(), l.selectedItem)
88+ assert.Equal(t, 0, l.offset)
89+ require.Len(t, l.indexMap, 30)
90+ require.Len(t, l.items, 30)
91+ require.Len(t, l.renderedItems, 30)
92+ assert.Equal(t, 30, lipgloss.Height(l.rendered))
93+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
94+ start, end := l.viewPosition()
95+ assert.Equal(t, 0, start)
96+ assert.Equal(t, 9, end)
193− for i := range 10 {
97+ for i := range 30 {
194− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
98+ assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
195− items = append(items, item)
99+ assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
196− }
197− l := New(items, WithDirection(Backward)).(*list[Item])
198− l.SetSize(10, 5)
199− cmd := l.Init()
200− if cmd != nil {
201− cmd()
202 }
100 }
203
101
204− l.MoveUp(100)
205− assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
206 golden.RequireEqual(t, []byte(l.View()))
102 golden.RequireEqual(t, []byte(l.View()))
207 })
103 })
208− t.Run("should do nothing with wrong move number", func(t *testing.T) {
104+ t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
209 t.Parallel()
105 t.Parallel()
210 items := []Item{}
106 items := []Item{}
211− for i := range 10 {
107+ for i := range 30 {
212 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
108 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
213 items = append(items, item)
109 items = append(items, item)
214 }
110 }
215− l := New(items, WithDirection(Backward))
111+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
216− l.SetSize(10, 5)
217− cmd := l.Init()
112+ execCmd(l, l.Init())
218− if cmd != nil {
219− cmd()
220− }
221
113
222− l.MoveUp(-10)
114+ // should select the last item
223− golden.RequireEqual(t, []byte(l.View()))
115+ assert.Equal(t, items[29].ID(), l.selectedItem)
224− })
116+ assert.Equal(t, 0, l.offset)
225− t.Run("should move to the top", func(t *testing.T) {
117+ require.Len(t, l.indexMap, 30)
226− t.Parallel()
118+ require.Len(t, l.items, 30)
227− items := []Item{}
119+ require.Len(t, l.renderedItems, 30)
120+ assert.Equal(t, 30, lipgloss.Height(l.rendered))
121+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
122+ start, end := l.viewPosition()
123+ assert.Equal(t, 20, start)
124+ assert.Equal(t, 29, end)
228− for i := range 10 {
125+ for i := range 30 {
229− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
126+ assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
230− items = append(items, item)
127+ assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
231− }
232− l := New(items, WithDirection(Backward)).(*list[Item])
233− l.SetSize(10, 5)
234− cmd := l.Init()
235− if cmd != nil {
236− cmd()
237 }
128 }
238
129
239− l.GoToTop()
240− assert.Equal(t, l.direction, Forward)
241 golden.RequireEqual(t, []byte(l.View()))
130 golden.RequireEqual(t, []byte(l.View()))
242 })
131 })
243− t.Run("should select the item above", func(t *testing.T) {
132+
133+ t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
244 t.Parallel()
134 t.Parallel()
245 items := []Item{}
135 items := []Item{}
246− for i := range 10 {
136+ for i := range 30 {
247− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
137+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
138+ content = strings.TrimSuffix(content, "\n")
139+ item := NewSelectableItem(content)
248 items = append(items, item)
140 items = append(items, item)
249 }
141 }
250− l := New(items, WithDirection(Backward)).(*list[Item])
142+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
251− l.SetSize(10, 5)
252− cmd := l.Init()
143+ execCmd(l, l.Init())
253− if cmd != nil {
254− cmd()
255− }
256
144
257− selectedInx := len(l.items) - 2
145+ // should select the last item
258− currentItem := items[len(l.items)-1]
146+ assert.Equal(t, items[0].ID(), l.selectedItem)
259− nextItem := items[selectedInx]
147+ assert.Equal(t, 0, l.offset)
260− assert.False(t, nextItem.(SelectableItem).IsFocused())
148+ require.Len(t, l.indexMap, 30)
261− assert.True(t, currentItem.(SelectableItem).IsFocused())
149+ require.Len(t, l.items, 30)
262− cmd = l.SelectItemAbove()
150+ require.Len(t, l.renderedItems, 30)
263− if cmd != nil {
151+ expectedLines := 0
264− cmd()
152+ for i := range 30 {
153+ expectedLines += (i + 1) * 1
154+ }
155+ assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
156+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
157+ start, end := l.viewPosition()
158+ assert.Equal(t, 0, start)
159+ assert.Equal(t, 9, end)
160+ currentPosition := 0
161+ for i := range 30 {
162+ rItem := l.renderedItems[items[i].ID()]
163+ assert.Equal(t, currentPosition, rItem.start)
164+ assert.Equal(t, currentPosition+i, rItem.end)
165+ currentPosition += i + 1
265 }
166 }
266
167
267− assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
268− assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
269−
270 golden.RequireEqual(t, []byte(l.View()))
168 golden.RequireEqual(t, []byte(l.View()))
271 })
169 })
272− t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
170+ t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
273 t.Parallel()
171 t.Parallel()
274 items := []Item{}
172 items := []Item{}
275− for i := range 10 {
173+ for i := range 30 {
276− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
174+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
175+ content = strings.TrimSuffix(content, "\n")
176+ item := NewSelectableItem(content)
277 items = append(items, item)
177 items = append(items, item)
278 }
178 }
279− l := New(items, WithDirection(Backward)).(*list[Item])
179+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
280− l.SetSize(10, 5)
281− cmd := l.Init()
180+ execCmd(l, l.Init())
282− if cmd != nil {
283− cmd()
284− }
285
181
286− for range 5 {
182+ // should select the last item
287− cmd = l.SelectItemAbove()
183+ assert.Equal(t, items[29].ID(), l.selectedItem)
288− if cmd != nil {
184+ assert.Equal(t, 0, l.offset)
289− cmd()
185+ require.Len(t, l.indexMap, 30)
186+ require.Len(t, l.items, 30)
187+ require.Len(t, l.renderedItems, 30)
188+ expectedLines := 0
189+ for i := range 30 {
190+ expectedLines += (i + 1) * 1
290− }
191+ }
192+ assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
193+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
194+ start, end := l.viewPosition()
195+ assert.Equal(t, expectedLines-10, start)
196+ assert.Equal(t, expectedLines-1, end)
197+ currentPosition := 0
198+ for i := range 30 {
199+ rItem := l.renderedItems[items[i].ID()]
200+ assert.Equal(t, currentPosition, rItem.start)
201+ assert.Equal(t, currentPosition+i, rItem.end)
202+ currentPosition += i + 1
291 }
203 }
204+
292 golden.RequireEqual(t, []byte(l.View()))
205 golden.RequireEqual(t, []byte(l.View()))
293 })
206 })
294−}
295
207
296−func TestForwardList(t *testing.T) {
297− t.Parallel()
298− t.Run("within height", func(t *testing.T) {
208+ t.Run("should go to selected item and center", func(t *testing.T) {
299 t.Parallel()
209 t.Parallel()
300 items := []Item{}
210 items := []Item{}
301− for i := range 5 {
211+ for i := range 30 {
302− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
212+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
213+ content = strings.TrimSuffix(content, "\n")
214+ item := NewSelectableItem(content)
303 items = append(items, item)
215 items = append(items, item)
304 }
216 }
305− l := New(items, WithDirection(Forward), WithGap(1)).(*list[Item])
217+ l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item])
306− l.SetSize(10, 20)
307− cmd := l.Init()
218+ execCmd(l, l.Init())
308− if cmd != nil {
309− cmd()
310− }
311
219
312 // should select the last item
220 // should select the last item
313− assert.Equal(t, l.selectedItem, items[0].ID())
221+ assert.Equal(t, items[4].ID(), l.selectedItem)
314
222
315 golden.RequireEqual(t, []byte(l.View()))
223 golden.RequireEqual(t, []byte(l.View()))
316 })
224 })
317− t.Run("should not change selected item", func(t *testing.T) {
318− t.Parallel()
319− items := []Item{}
320− for i := range 5 {
321− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
322− items = append(items, item)
323− }
324− l := New(items, WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
325− l.SetSize(10, 20)
326− cmd := l.Init()
327− if cmd != nil {
328− cmd()
329− }
330− // should select the last item
331− assert.Equal(t, l.selectedItem, items[2].ID())
332− })
333− t.Run("more than height", func(t *testing.T) {
334− t.Parallel()
335− items := []Item{}
336− for i := range 10 {
337− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
338− items = append(items, item)
339− }
340− l := New(items, WithDirection(Forward)).(*list[Item])
341− l.SetSize(10, 5)
342− cmd := l.Init()
343− if cmd != nil {
344− cmd()
345− }
346
225
347− golden.RequireEqual(t, []byte(l.View()))
226+ t.Run("should go to selected item and center backwards", func(t *testing.T) {
348− })
349− t.Run("more than height multi line", func(t *testing.T) {
350 t.Parallel()
227 t.Parallel()
351 items := []Item{}
228 items := []Item{}
352− for i := range 10 {
229+ for i := range 30 {
353− item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
230+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
231+ content = strings.TrimSuffix(content, "\n")
232+ item := NewSelectableItem(content)
354 items = append(items, item)
233 items = append(items, item)
355 }
234 }
356− l := New(items, WithDirection(Forward)).(*list[Item])
235+ l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item])
357− l.SetSize(10, 5)
358− cmd := l.Init()
236+ execCmd(l, l.Init())
359− if cmd != nil {
360− cmd()
361− }
362
237
363− golden.RequireEqual(t, []byte(l.View()))
238+ // should select the last item
364− })
239+ assert.Equal(t, items[4].ID(), l.selectedItem)
365− t.Run("should move down", func(t *testing.T) {
366− t.Parallel()
367− items := []Item{}
368− for i := range 10 {
369− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
370− items = append(items, item)
371− }
372− l := New(items, WithDirection(Forward)).(*list[Item])
373− l.SetSize(10, 5)
374− cmd := l.Init()
375− if cmd != nil {
376− cmd()
377− }
378
240
379− l.MoveDown(1)
380 golden.RequireEqual(t, []byte(l.View()))
241 golden.RequireEqual(t, []byte(l.View()))
381 })
242 })
382− t.Run("should move at max to the bottom", func(t *testing.T) {
383− t.Parallel()
384− items := []Item{}
385− for i := range 10 {
386− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
387− items = append(items, item)
388− }
389− l := New(items, WithDirection(Forward)).(*list[Item])
390− l.SetSize(10, 5)
391− cmd := l.Init()
392− if cmd != nil {
393− cmd()
394− }
395
243
396− l.MoveDown(100)
397− assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
398− golden.RequireEqual(t, []byte(l.View()))
399− })
400− t.Run("should do nothing with wrong move number", func(t *testing.T) {
244+ t.Run("should go to selected item at the beginning", func(t *testing.T) {
401 t.Parallel()
245 t.Parallel()
402 items := []Item{}
246 items := []Item{}
403− for i := range 10 {
247+ for i := range 30 {
404− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
248+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
249+ content = strings.TrimSuffix(content, "\n")
250+ item := NewSelectableItem(content)
405 items = append(items, item)
251 items = append(items, item)
406 }
252 }
407− l := New(items, WithDirection(Forward)).(*list[Item])
253+ l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
408− l.SetSize(10, 5)
409− cmd := l.Init()
254+ execCmd(l, l.Init())
410− if cmd != nil {
411− cmd()
412− }
413
255
414− l.MoveDown(-10)
256+ // should select the last item
415− golden.RequireEqual(t, []byte(l.View()))
257+ assert.Equal(t, items[10].ID(), l.selectedItem)
416− })
417− t.Run("should move to the bottom", func(t *testing.T) {
418− t.Parallel()
419− items := []Item{}
420− for i := range 10 {
421− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
422− items = append(items, item)
423− }
424− l := New(items, WithDirection(Forward)).(*list[Item])
425− l.SetSize(10, 5)
426− cmd := l.Init()
427− if cmd != nil {
428− cmd()
429− }
430
258
431− l.GoToBottom()
432− assert.Equal(t, l.direction, Backward)
433 golden.RequireEqual(t, []byte(l.View()))
259 golden.RequireEqual(t, []byte(l.View()))
434 })
260 })
435− t.Run("should select the item below", func(t *testing.T) {
436− t.Parallel()
437− items := []Item{}
438− for i := range 10 {
439− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
440− items = append(items, item)
441− }
442− l := New(items, WithDirection(Forward)).(*list[Item])
443− l.SetSize(10, 5)
444− cmd := l.Init()
445− if cmd != nil {
446− cmd()
447− }
448
261
449− selectedInx := 1
450− currentItem := items[0]
451− nextItem := items[selectedInx]
452− assert.False(t, nextItem.(SelectableItem).IsFocused())
453− assert.True(t, currentItem.(SelectableItem).IsFocused())
454− cmd = l.SelectItemBelow()
455− if cmd != nil {
456− cmd()
457− }
458−
459− assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
460− assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
461−
462− golden.RequireEqual(t, []byte(l.View()))
463− })
464− t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
262+ t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
465 t.Parallel()
263 t.Parallel()
466 items := []Item{}
264 items := []Item{}
467− for i := range 10 {
265+ for i := range 30 {
468− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
266+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
267+ content = strings.TrimSuffix(content, "\n")
268+ item := NewSelectableItem(content)
469 items = append(items, item)
269 items = append(items, item)
470 }
270 }
471− l := New(items, WithDirection(Forward)).(*list[Item])
271+ l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
472− l.SetSize(10, 5)
473− cmd := l.Init()
272+ execCmd(l, l.Init())
474− if cmd != nil {
273+
475− cmd()
274+ // should select the last item
476− }
275+ assert.Equal(t, items[10].ID(), l.selectedItem)
477
276
478− for range 5 {
479− cmd = l.SelectItemBelow()
480− if cmd != nil {
481− cmd()
482− }
483− }
484 golden.RequireEqual(t, []byte(l.View()))
277 golden.RequireEqual(t, []byte(l.View()))
485 })
278 })
486}
279}
487
280
488−func TestListSelection(t *testing.T) {
281+func TestListMovement(t *testing.T) {
489 t.Parallel()
282 t.Parallel()
490− t.Run("should skip none selectable items initially", func(t *testing.T) {
283+ t.Run("should move viewport up", func(t *testing.T) {
491 t.Parallel()
284 t.Parallel()
492 items := []Item{}
285 items := []Item{}
493− items = append(items, NewSimpleItem("None Selectable"))
494− for i := range 5 {
286+ for i := range 30 {
495− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
287+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
288+ content = strings.TrimSuffix(content, "\n")
289+ item := NewSelectableItem(content)
496 items = append(items, item)
290 items = append(items, item)
497 }
291 }
498− l := New(items, WithDirection(Forward)).(*list[Item])
292+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
499− l.SetSize(100, 10)
500− cmd := l.Init()
293+ execCmd(l, l.Init())
501− if cmd != nil {
502− cmd()
503− }
504
294
505− assert.Equal(t, items[1].ID(), l.selectedItem)
295+ execCmd(l, l.MoveUp(25))
506− golden.RequireEqual(t, []byte(l.View()))
296+
507− })
508− t.Run("should select the correct item on startup", func(t *testing.T) {
509− t.Parallel()
510− items := []Item{}
511− for i := range 5 {
512− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
513− items = append(items, item)
514− }
515− l := New(items, WithDirection(Forward)).(*list[Item])
516− cmd := l.Init()
517− otherCmd := l.SetSelected(items[3].ID())
518− var wg sync.WaitGroup
519− if cmd != nil {
520− wg.Add(1)
521− go func() {
522− cmd()
523− wg.Done()
524− }()
525− }
526− if otherCmd != nil {
527− wg.Add(1)
528− go func() {
529− otherCmd()
530− wg.Done()
531− }()
532− }
533− wg.Wait()
534− l.SetSize(100, 10)
535− assert.Equal(t, items[3].ID(), l.selectedItem)
297+ assert.Equal(t, 25, l.offset)
536− golden.RequireEqual(t, []byte(l.View()))
537− })
538− t.Run("should skip none selectable items in the middle", func(t *testing.T) {
539− t.Parallel()
540− items := []Item{}
541− item := NewSelectableItem("Item initial")
542− items = append(items, item)
543− items = append(items, NewSimpleItem("None Selectable"))
544− for i := range 5 {
545− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
546− items = append(items, item)
547− }
548− l := New(items, WithDirection(Forward)).(*list[Item])
549− l.SetSize(100, 10)
550− cmd := l.Init()
551− if cmd != nil {
552− cmd()
553− }
554− l.SelectItemBelow()
555− assert.Equal(t, items[2].ID(), l.selectedItem)
556 golden.RequireEqual(t, []byte(l.View()))
298 golden.RequireEqual(t, []byte(l.View()))
557 })
299 })
558−}
559−
560−func TestListSetSelection(t *testing.T) {
561− t.Parallel()
562− t.Run("should move to the selected item", func(t *testing.T) {
300+ t.Run("should move viewport up and down", func(t *testing.T) {
563 t.Parallel()
301 t.Parallel()
564 items := []Item{}
302 items := []Item{}
565− for i := range 100 {
303+ for i := range 30 {
566− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
304+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
305+ content = strings.TrimSuffix(content, "\n")
306+ item := NewSelectableItem(content)
567 items = append(items, item)
307 items = append(items, item)
568 }
308 }
569− l := New(items, WithDirection(Forward)).(*list[Item])
309+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
570− l.SetSize(100, 10)
571− cmd := l.Init()
310+ execCmd(l, l.Init())
572− if cmd != nil {
573− cmd()
574− }
575
311
576− cmd = l.SetSelected(items[52].ID())
312+ execCmd(l, l.MoveUp(25))
577− if cmd != nil {
313+ execCmd(l, l.MoveDown(25))
578− cmd()
579− }
580
314
581− assert.Equal(t, items[52].ID(), l.selectedItem)
315+ assert.Equal(t, 0, l.offset)
582 golden.RequireEqual(t, []byte(l.View()))
316 golden.RequireEqual(t, []byte(l.View()))
583 })
317 })
584−}
585
318
586−func TestListChanges(t *testing.T) {
587− t.Parallel()
588− t.Run("should append an item to the end", func(t *testing.T) {
319+ t.Run("should move viewport down", func(t *testing.T) {
589 t.Parallel()
320 t.Parallel()
590− items := []SelectableItem{}
321+ items := []Item{}
591− for i := range 20 {
322+ for i := range 30 {
592− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
323+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
324+ content = strings.TrimSuffix(content, "\n")
325+ item := NewSelectableItem(content)
593 items = append(items, item)
326 items = append(items, item)
594 }
327 }
595− l := New(items, WithDirection(Backward)).(*list[SelectableItem])
328+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
596− l.SetSize(100, 10)
597− cmd := l.Init()
329+ execCmd(l, l.Init())
598− if cmd != nil {
599− cmd()
600− }
601
330
602− newItem := NewSelectableItem("New Item")
331+ execCmd(l, l.MoveDown(25))
603− l.AppendItem(newItem)
604
332
605− assert.Equal(t, 21, len(l.items))
333+ assert.Equal(t, 25, l.offset)
606− assert.Equal(t, 21, len(l.renderedItems))
607− assert.Equal(t, newItem.ID(), l.selectedItem)
608 golden.RequireEqual(t, []byte(l.View()))
334 golden.RequireEqual(t, []byte(l.View()))
609 })
335 })
610− t.Run("should should not change the selected if we moved the offset", func(t *testing.T) {
336+ t.Run("should move viewport down and up", func(t *testing.T) {
611 t.Parallel()
337 t.Parallel()
612− items := []SelectableItem{}
338+ items := []Item{}
613− for i := range 20 {
339+ for i := range 30 {
614− item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
340+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
341+ content = strings.TrimSuffix(content, "\n")
342+ item := NewSelectableItem(content)
615 items = append(items, item)
343 items = append(items, item)
616 }
344 }
617− l := New(items, WithDirection(Backward)).(*list[SelectableItem])
345+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
618− l.SetSize(100, 10)
619− cmd := l.Init()
346+ execCmd(l, l.Init())
620− if cmd != nil {
621− cmd()
622− }
623− l.MoveUp(1)
624
347
625− newItem := NewSelectableItem("New Item")
348+ execCmd(l, l.MoveDown(25))
626− l.AppendItem(newItem)
349+ execCmd(l, l.MoveUp(25))
627
350
628− assert.Equal(t, 21, len(l.items))
351+ assert.Equal(t, 0, l.offset)
629− assert.Equal(t, 21, len(l.renderedItems))
630− assert.Equal(t, l.items[19].ID(), l.selectedItem)
631 golden.RequireEqual(t, []byte(l.View()))
352 golden.RequireEqual(t, []byte(l.View()))
632 })
353 })
633}
354}
@@ -711,3 +432,10 @@ func (s *selectableItem) Focus() tea.Cmd {
711func (s *selectableItem) IsFocused() bool {
432func (s *selectableItem) IsFocused() bool {
712 return s.focused
433 return s.focused
713}
434}
435+
436+func execCmd(m tea.Model, cmd tea.Cmd) {
437+ for cmd != nil {
438+ msg := cmd()
439+ m, cmd = m.Update(msg)
440+ }
441+}
internal/tui/exp/list/testdata/TestBackwardList/more_than_height.goldendeleted−5
@@ -1,5 +0,0 @@
1−Item 5
2−Item 6
3−Item 7
4−Item 8
5−│Item 9
internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.goldendeleted−5
@@ -1,5 +0,0 @@
1−Line2
2−Item 8
3−Line2
4−│Item 9
5−│Line2
internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.goldendeleted−5
@@ -1,5 +0,0 @@
1−Item 5
2−Item 6
3−Item 7
4−Item 8
5−│Item 9
internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.goldendeleted−5
@@ -1,5 +0,0 @@
1−Item 0
2−Item 1
3−Item 2
4−Item 3
5−│Item 4
internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.goldendeleted−5
@@ -1,5 +0,0 @@
1−│Item 4
2−Item 5
3−Item 6
4−Item 7
5−Item 8
internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.goldendeleted−5
@@ -1,5 +0,0 @@
1−│Item 0
2−Item 1
3−Item 2
4−Item 3
5−Item 4
internal/tui/exp/list/testdata/TestBackwardList/should_move_up.goldendeleted−5
@@ -1,5 +0,0 @@
1−Item 4
2−Item 5
3−Item 6
4−Item 7
5−│Item 8
internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.goldendeleted−5
@@ -1,5 +0,0 @@
1−Item 5
2−Item 6
3−Item 7
4−│Item 8
5−Item 9
internal/tui/exp/list/testdata/TestBackwardList/within_height.goldendeleted−9
@@ -1,9 +0,0 @@
1−Item 0
2−
3−Item 1
4−
5−Item 2
6−
7−Item 3
8−
9−│Item 4
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.goldendeleted−6
@@ -1,6 +0,0 @@
1−> Type to filter 
2−│Item 0
3−Item 1
4−Item 2
5−Item 3
6−Item 4
internal/tui/exp/list/testdata/TestForwardList/more_than_height.goldendeleted−5
@@ -1,5 +0,0 @@
1−│Item 0
2−Item 1
3−Item 2
4−Item 3
5−Item 4
internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.goldendeleted−5
@@ -1,5 +0,0 @@
1−│Item 0
2−│Line2
3−Item 1
4−Line2
5−Item 2
internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.goldendeleted−5
@@ -1,5 +0,0 @@
1−│Item 0
2−Item 1
3−Item 2
4−Item 3
5−Item 4
internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_bottom.goldendeleted−5
@@ -1,5 +0,0 @@
1−│Item 5
2−Item 6
3−Item 7
4−Item 8
5−Item 9
internal/tui/exp/list/testdata/TestForwardList/should_move_down.goldendeleted−5
@@ -1,5 +0,0 @@
1−│Item 1
2−Item 2
3−Item 3
4−Item 4
5−Item 5
internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.goldendeleted−5
@@ -1,5 +0,0 @@
1−Item 1
2−Item 2
3−Item 3
4−Item 4
5−│Item 5
internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.goldendeleted−5
@@ -1,5 +0,0 @@
1−Item 5
2−Item 6
3−Item 7
4−Item 8
5−│Item 9
internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.goldendeleted−5
@@ -1,5 +0,0 @@
1−Item 0
2−│Item 1
3−Item 2
4−Item 3
5−Item 4
internal/tui/exp/list/testdata/TestForwardList/within_height.goldendeleted−9
@@ -1,9 +0,0 @@
1−│Item 0
2−
3−Item 1
4−
5−Item 2
6−
7−Item 3
8−
9−Item 4
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 0
2+Item 1
3+Item 2
4+Item 3
5+Item 4
6+Item 5
7+Item 6
8+Item 7
9+Item 8
10+Item 9
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 0
2+Item 1
3+Item 1
4+Item 2
5+Item 2
6+Item 2
7+Item 3
8+Item 3
9+Item 3
10+Item 3
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 29
2+│Item 29
3+│Item 29
4+│Item 29
5+│Item 29
6+│Item 29
7+│Item 29
8+│Item 29
9+│Item 29
10+│Item 29
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 20
2+Item 21
3+Item 22
4+Item 23
5+Item 24
6+Item 25
7+Item 26
8+Item 27
9+Item 28
10+│Item 29
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.goldennew file+5
@@ -0,0 +1,5 @@
1+│Item 0
2+Item 1
3+Item 2
4+Item 3
5+Item 4
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.goldennew file+5
@@ -0,0 +1,5 @@
1+Item 0
2+Item 1
3+Item 2
4+Item 3
5+│Item 4
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 3
2+Item 3
3+│Item 4
4+│Item 4
5+│Item 4
6+│Item 4
7+│Item 4
8+Item 5
9+Item 5
10+Item 5
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center_backwards.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 3
2+Item 3
3+│Item 4
4+│Item 4
5+│Item 4
6+│Item 4
7+│Item 4
8+Item 5
9+Item 5
10+Item 5
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 10
2+│Item 10
3+│Item 10
4+│Item 10
5+│Item 10
6+│Item 10
7+│Item 10
8+│Item 10
9+│Item 10
10+│Item 10
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 10
2+│Item 10
3+│Item 10
4+│Item 10
5+│Item 10
6+│Item 10
7+│Item 10
8+│Item 10
9+│Item 10
10+│Item 10
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 0
2+Item 1
3+Item 2
4+Item 3
5+Item 4
6+Item 5
7+Item 6
8+Item 7
9+Item 8
10+Item 9
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 0
2+Item 1
3+Item 1
4+Item 2
5+Item 2
6+Item 2
7+Item 3
8+Item 3
9+Item 3
10+Item 3
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 29
2+│Item 29
3+│Item 29
4+│Item 29
5+│Item 29
6+│Item 29
7+│Item 29
8+│Item 29
9+│Item 29
10+│Item 29
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 20
2+Item 21
3+Item 22
4+Item 23
5+Item 24
6+Item 25
7+Item 26
8+Item 27
9+Item 28
10+│Item 29
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.goldennew file+5
@@ -0,0 +1,5 @@
1+│Item 0
2+Item 1
3+Item 2
4+Item 3
5+Item 4
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.goldennew file+5
@@ -0,0 +1,5 @@
1+Item 0
2+Item 1
3+Item 2
4+Item 3
5+│Item 4
internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 11
2−Item 12
3−Item 13
4−Item 14
5−Item 15
6−Item 16
7−Item 17
8−Item 18
9−Item 19
10−│New Item
internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 15
2−Line2
3−Item 16
4−Line2
5−Item 17
6−Line2
7−Item 18
8−Line2
9−│Item 19
10−│Line2
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 6
2+Item 6
3+Item 6
4+│Item 7
5+│Item 7
6+│Item 7
7+│Item 7
8+│Item 7
9+│Item 7
10+│Item 7
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 0
2+Item 1
3+Item 1
4+Item 2
5+Item 2
6+Item 2
7+│Item 3
8+│Item 3
9+│Item 3
10+│Item 3
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 28
2+│Item 28
3+│Item 28
4+│Item 28
5+│Item 28
6+Item 29
7+Item 29
8+Item 29
9+Item 29
10+Item 29
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 29
2+│Item 29
3+│Item 29
4+│Item 29
5+│Item 29
6+│Item 29
7+│Item 29
8+│Item 29
9+│Item 29
10+│Item 29
internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.goldendeleted−5
@@ -1,5 +0,0 @@
1−Item 0
2−Item 1
3−Item 2
4−│Item 3
5−Item 4
internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_in_the_middle.goldendeleted−7
@@ -1,7 +0,0 @@
1−Item initial
2−None Selectable
3−│Item 0
4−Item 1
5−Item 2
6−Item 3
7−Item 4
internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_initially.goldendeleted−6
@@ -1,6 +0,0 @@
1−None Selectable
2−│Item 0
3−Item 1
4−Item 2
5−Item 3
6−Item 4
internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 47
2−Item 48
3−Item 49
4−Item 50
5−Item 51
6−│Item 52
7−Item 53
8−Item 54
9−Item 55
10−Item 56