on main help sign in
wip: initial rework
commit74f0b2097097f8b8e256f15e8bab159f29b765fb authorKujtim Hoxha <kujtimii.h@gmail.com> dateWed 23 Jul 2025, 13:00 (1 year ago) parenta80d6c6
49 files changed+892−1204
internal/tui/components/chat/chat.go+1−1 internal/tui/exp/list/filterable_test.go+58−66 internal/tui/exp/list/list.go+436−496 internal/tui/exp/list/list_test.go+217−489 internal/tui/exp/list/testdata/TestBackwardList/more_than_height.golden−5 internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.golden−5 internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.golden−5 internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden−5 internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden−5 internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.golden−5 internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden−5 internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.golden−5 internal/tui/exp/list/testdata/TestBackwardList/within_height.golden−9 internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden−6 internal/tui/exp/list/testdata/TestForwardList/more_than_height.golden−5 internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.golden−5 internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.golden−5 internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_bottom.golden−5 internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden−5 internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden−5 internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.golden−5 internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.golden−5 internal/tui/exp/list/testdata/TestForwardList/within_height.golden−9 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden+10 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden+10 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden+10 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden+10 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden+5 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden+5 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center.golden+10 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center_backwards.golden+10 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden+10 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden+10 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden+10 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden+10 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden+10 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden+10 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden+5 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden+5 internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden−10 internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden+10 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden+10 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden+10 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden+10 internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden−5 internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_in_the_middle.golden−7 internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_initially.golden−6 internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden−10
internal/tui/components/chat/chat.go+1−1
@@ -63,7 +63,7 @@ func New(app *app.App) MessageListCmp {
6363 listCmp := list.New(
6464 []list.Item{},
6565 list.WithGap(1),
66− list.WithDirection(list.Backward),
66+ list.WithDirectionBackward(),
6767 list.WithKeyMap(defaultListKeyMap),
6868 )
6969 return &messageListCmp{
internal/tui/exp/list/filterable_test.go+58−66
@@ -1,68 +1,60 @@
11package list
22
3−import (
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) {
13− t.Parallel()
14− t.Run("should create simple filterable list", func(t *testing.T) {
15− t.Parallel()
16− items := []FilterableItem{}
17− for i := range 5 {
18− item := NewFilterableItem(fmt.Sprintf("Item %d", i))
19− items = append(items, item)
20− }
21− l := NewFilterableList(
22− items,
23− WithFilterListOptions(WithDirection(Forward)),
24− ).(*filterableList[FilterableItem])
25−
26− l.SetSize(100, 10)
27− cmd := l.Init()
28− if cmd != nil {
29− cmd()
30− }
31−
32− assert.Equal(t, items[0].ID(), l.selectedItem)
33− golden.RequireEqual(t, []byte(l.View()))
34− })
35−}
36−
37−func TestUpdateKeyMap(t *testing.T) {
38− t.Parallel()
39− l := NewFilterableList(
40− []FilterableItem{},
41− WithFilterListOptions(WithDirection(Forward)),
42− ).(*filterableList[FilterableItem])
43−
44− hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
45− fmt.Println(l.keyMap.Down.Keys())
46− hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
47−
48− hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
49−
50− assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
51− assert.False(t, hasJ, "should not contain j")
52− assert.False(t, hasUpperCaseK, "should also remove upper case K")
53− assert.True(t, hasCtrlJ, "should still have ctrl+j")
54−}
55−
56−type filterableItem struct {
57− *selectableItem
58−}
59−
60−func NewFilterableItem(content string) FilterableItem {
61− return &filterableItem{
62− selectableItem: NewSelectableItem(content).(*selectableItem),
63− }
64−}
65−
66−func (f *filterableItem) FilterValue() string {
67− return f.content
68−}
3+//
4+// func TestFilterableList(t *testing.T) {
5+// t.Parallel()
6+// t.Run("should create simple filterable list", func(t *testing.T) {
7+// t.Parallel()
8+// items := []FilterableItem{}
9+// for i := range 5 {
10+// item := NewFilterableItem(fmt.Sprintf("Item %d", i))
11+// items = append(items, item)
12+// }
13+// l := NewFilterableList(
14+// items,
15+// WithFilterListOptions(WithDirection(Forward)),
16+// ).(*filterableList[FilterableItem])
17+//
18+// l.SetSize(100, 10)
19+// cmd := l.Init()
20+// if cmd != nil {
21+// cmd()
22+// }
23+//
24+// assert.Equal(t, items[0].ID(), l.selectedItem)
25+// golden.RequireEqual(t, []byte(l.View()))
26+// })
27+// }
28+//
29+// func TestUpdateKeyMap(t *testing.T) {
30+// t.Parallel()
31+// l := NewFilterableList(
32+// []FilterableItem{},
33+// WithFilterListOptions(WithDirection(Forward)),
34+// ).(*filterableList[FilterableItem])
35+//
36+// hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
37+// fmt.Println(l.keyMap.Down.Keys())
38+// hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
39+//
40+// hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
41+//
42+// assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
43+// assert.False(t, hasJ, "should not contain j")
44+// assert.False(t, hasUpperCaseK, "should also remove upper case K")
45+// assert.True(t, hasCtrlJ, "should still have ctrl+j")
46+// }
47+//
48+// type filterableItem struct {
49+// *selectableItem
50+// }
51+//
52+// func NewFilterableItem(content string) FilterableItem {
53+// return &filterableItem{
54+// selectableItem: NewSelectableItem(content).(*selectableItem),
55+// }
56+// }
57+//
58+// func (f *filterableItem) FilterValue() string {
59+// return f.content
60+// }
internal/tui/exp/list/list.go+436−496
@@ -1,7 +1,6 @@
11package list
22
33import (
4− "slices"
54 "strings"
65
76 "github.com/charmbracelet/bubbles/v2/key"
@@ -17,46 +16,50 @@ type Item interface {
1716 ID() string
1817}
1918
20−type List[T Item] interface {
21− util.Model
22− layout.Sizeable
23− layout.Focusable
24− MoveUp(int) tea.Cmd
25− MoveDown(int) tea.Cmd
26− GoToTop() tea.Cmd
27− GoToBottom() tea.Cmd
28− SelectItemAbove() tea.Cmd
29− SelectItemBelow() tea.Cmd
30− SetItems([]T) tea.Cmd
31− SetSelected(string) tea.Cmd
32− SelectedItem() *T
33− Items() []T
34− UpdateItem(string, T)
35− DeleteItem(string)
36− PrependItem(T) tea.Cmd
37− AppendItem(T) tea.Cmd
38−}
19+type (
20+ renderedMsg struct{}
21+ List[T Item] interface {
22+ util.Model
23+ layout.Sizeable
24+ layout.Focusable
25+
26+ // Just change state
27+ MoveUp(int) tea.Cmd
28+ MoveDown(int) tea.Cmd
29+ GoToTop() tea.Cmd
30+ GoToBottom() tea.Cmd
31+ SelectItemAbove() tea.Cmd
32+ SelectItemBelow() tea.Cmd
33+ SetItems([]T) tea.Cmd
34+ SetSelected(string) tea.Cmd
35+ SelectedItem() *T
36+ Items() []T
37+ UpdateItem(string, T) tea.Cmd
38+ DeleteItem(string) tea.Cmd
39+ PrependItem(T) tea.Cmd
40+ AppendItem(T) tea.Cmd
41+ }
42+)
3943
4044type direction int
4145
4246const (
43− Forward direction = iota
44− Backward
47+ DirectionForward direction = iota
48+ DirectionBackward
4549)
4650
4751const (
48− NotFound = -1
49− DefaultScrollSize = 2
52+ ItemNotFound = -1
53+ ViewportDefaultScrollSize = 2
5054)
5155
52−type setSelectedMsg struct {
53− selectedItemID string
54−}
55−
5656type renderedItem struct {
5757 id string
5858 view string
59+ dirty bool
5960 height int
61+ start int
62+ end int
6063}
6164
6265type confOptions struct {
@@ -67,16 +70,20 @@ type confOptions struct {
6770 keyMap KeyMap
6871 direction direction
6972 selectedItem string
73+ focused bool
7074}
75+
7176type list[T Item] struct {
7277 *confOptions
7378
74− focused bool
75− offset int
76− items []T
77− renderedItems []renderedItem
78− rendered string
79− isReady bool
79+ offset int
80+
81+ indexMap map[string]int
82+ items []T
83+
84+ renderedItems map[string]renderedItem
85+
86+ rendered string
8087}
8188
8289type listOption func(*confOptions)
@@ -96,10 +103,17 @@ func WithGap(gap int) listOption {
96103 }
97104}
98105
99−// WithDirection sets the direction of the list.
100−func WithDirection(dir direction) listOption {
106+// WithDirectionForward sets the direction to forward
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 {
101115 return func(l *confOptions) {
102− l.direction = dir
116+ l.direction = DirectionBackward
103117 }
104118}
105119
@@ -122,55 +136,60 @@ func WithWrapNavigation() listOption {
122136 }
123137}
124138
139+func WithFocus(focus bool) listOption {
140+ return func(l *confOptions) {
141+ l.focused = focus
142+ }
143+}
144+
125145func New[T Item](items []T, opts ...listOption) List[T] {
126146 list := &list[T]{
127147 confOptions: &confOptions{
128− direction: Forward,
148+ direction: DirectionForward,
129149 keyMap: DefaultKeyMap(),
150+ focused: true,
130151 },
131− items: items,
152+ items: items,
153+ indexMap: make(map[string]int),
154+ renderedItems: map[string]renderedItem{},
132155 }
133156 for _, opt := range opts {
134157 opt(list.confOptions)
135158 }
159+
160+ for inx, item := range items {
161+ list.indexMap[item.ID()] = inx
162+ }
136163 return list
137164}
138165
139166// Init implements List.
140167func (l *list[T]) Init() tea.Cmd {
141− var cmds []tea.Cmd
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...)
168+ return l.render()
148169}
149170
150171// Update implements List.
151172func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
152173 switch msg := msg.(type) {
153− case setSelectedMsg:
154− return l, l.SetSelected(msg.selectedItemID)
155174 case tea.KeyPressMsg:
156175 if l.focused {
157176 switch {
158177 case key.Matches(msg, l.keyMap.Down):
159− return l, l.MoveDown(DefaultScrollSize)
178+ return l, l.MoveDown(ViewportDefaultScrollSize)
160179 case key.Matches(msg, l.keyMap.Up):
161− return l, l.MoveUp(DefaultScrollSize)
180+ return l, l.MoveUp(ViewportDefaultScrollSize)
162181 case key.Matches(msg, l.keyMap.DownOneItem):
163182 return l, l.SelectItemBelow()
164183 case key.Matches(msg, l.keyMap.UpOneItem):
165184 return l, l.SelectItemAbove()
166185 case key.Matches(msg, l.keyMap.HalfPageDown):
167− return l, l.MoveDown(l.listHeight() / 2)
186+ return l, l.MoveDown(l.height / 2)
168187 case key.Matches(msg, l.keyMap.HalfPageUp):
169− return l, l.MoveUp(l.listHeight() / 2)
188+ return l, l.MoveUp(l.height / 2)
170189 case key.Matches(msg, l.keyMap.PageDown):
171− return l, l.MoveDown(l.listHeight())
190+ return l, l.MoveDown(l.height)
172191 case key.Matches(msg, l.keyMap.PageUp):
173− return l, l.MoveUp(l.listHeight())
192+ return l, l.MoveUp(l.height)
174193 case key.Matches(msg, l.keyMap.End):
175194 return l, l.GoToBottom()
176195 case key.Matches(msg, l.keyMap.Home):
@@ -197,585 +216,506 @@ func (l *list[T]) View() string {
197216func (l *list[T]) viewPosition() (int, int) {
198217 start, end := 0, 0
199218 renderedLines := lipgloss.Height(l.rendered) - 1
200− if l.direction == Forward {
219+ if l.direction == DirectionForward {
201220 start = max(0, l.offset)
202− end = min(l.offset+l.listHeight()-1, renderedLines)
221+ end = min(l.offset+l.height-1, renderedLines)
203222 } else {
204− start = max(0, renderedLines-l.offset-l.listHeight()+1)
223+ start = max(0, renderedLines-l.offset-l.height+1)
205224 end = max(0, renderedLines-l.offset)
206225 }
207226 return start, end
208227}
209228
210−func (l *list[T]) renderItem(item Item) renderedItem {
211− view := item.View()
212− return renderedItem{
213− id: item.ID(),
214− view: view,
215− height: lipgloss.Height(view),
229+func (l *list[T]) recalculateItemPositions() {
230+ currentContentHeight := 0
231+ for _, item := range l.items {
232+ rItem, ok := l.renderedItems[item.ID()]
233+ if !ok {
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
216240 }
217241}
218242
219−func (l *list[T]) renderView() {
220− var sb strings.Builder
221− for i, rendered := range l.renderedItems {
222− sb.WriteString(rendered.view)
223− if i < len(l.renderedItems)-1 {
224− sb.WriteString(strings.Repeat("\n", l.gap+1))
243+func (l *list[T]) render() tea.Cmd {
244+ if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
245+ return nil
246+ }
247+ l.setDefaultSelected()
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()
225274 }
275+ // in the end scroll to the selected item
276+ if l.focused {
277+ l.scrollToSelection()
278+ }
279+ return renderedMsg{}
226280 }
227− l.rendered = sb.String()
281+ return tea.Batch(focusCmd, renderCmd)
228282}
229283
230−func (l *list[T]) incrementOffset(n int) {
231− if !l.isReady {
232− return
284+func (l *list[T]) setDefaultSelected() {
285+ if l.selectedItem == "" {
286+ if l.direction == DirectionForward {
287+ l.selectFirstItem()
288+ } else {
289+ l.selectLastItem()
290+ }
233291 }
234− renderedHeight := lipgloss.Height(l.rendered)
235− // no need for offset
236− if renderedHeight <= l.listHeight() {
292+}
293+
294+func (l *list[T]) scrollToSelection() {
295+ rItem, ok := l.renderedItems[l.selectedItem]
296+ if !ok {
297+ l.selectedItem = ""
298+ l.setDefaultSelected()
237299 return
238300 }
239− maxOffset := renderedHeight - l.listHeight()
240− n = min(n, maxOffset-l.offset)
241− if n <= 0 {
301+
302+ start, end := l.viewPosition()
303+ // item bigger or equal to the viewport do nothing
304+ if rItem.start <= start && rItem.end >= end {
242305 return
243306 }
244− l.offset += n
245−}
246−
247−func (l *list[T]) decrementOffset(n int) {
248− if !l.isReady {
307+ // item already in view do nothing
308+ if rItem.start >= start && rItem.start <= end {
309+ return
310+ } else if rItem.end <= end && rItem.end >= start {
249311 return
250312 }
251− n = min(n, l.offset)
252− if n <= 0 {
313+
314+ if rItem.height >= l.height {
315+ if l.direction == DirectionForward {
316+ l.offset = rItem.start
317+ } else {
318+ l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
319+ }
253320 return
254321 }
255− l.offset -= n
256− if l.offset < 0 {
257− l.offset = 0
322+
323+ itemMiddleStart := rItem.start + rItem.height/2 + 1
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))
258328 }
259329}
260330
261−// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
262−func (l *list[T]) changeSelectedWhenNotVisible() tea.Cmd {
263− var cmds []tea.Cmd
331+func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
332+ rItem, ok := l.renderedItems[l.selectedItem]
333+ if !ok {
334+ return nil
335+ }
264336 start, end := l.viewPosition()
265− currentPosition := 0
266− itemWithinView := NotFound
267− needsMove := false
268−
269− for i, item := range l.items {
270− rendered := l.renderedItems[i]
271− itemStart := currentPosition
272− // we remove 1 so that we actually have the row, e.x 1 row => height 1 => start 0, end 0
273− itemEnd := itemStart + rendered.height - 1
274− if itemStart >= start && itemEnd <= end {
275− itemWithinView = i
276− }
277− if item.ID() == l.selectedItem {
278− // item is completely above the viewport
279− if itemStart < start && itemEnd < start {
280− needsMove = true
337+ // item bigger than the viewport do nothing
338+ if rItem.start <= start && rItem.end >= end {
339+ return nil
340+ }
341+ // item already in view do nothing
342+ if rItem.start >= start && rItem.end <= end {
343+ return nil
344+ }
345+
346+ itemMiddle := rItem.start + rItem.height/2
347+
348+ if itemMiddle < start {
349+ // select the first item in the viewport
350+ // the item is most likely an item coming after this item
351+ inx := l.indexMap[rItem.id]
352+ for {
353+ inx = l.firstSelectableItemBelow(inx)
354+ if inx == ItemNotFound {
355+ return nil
281356 }
282− // item is completely below the viewport
283− if itemStart > end && itemEnd > end {
284− needsMove = true
357+ item, ok := l.renderedItems[l.items[inx].ID()]
358+ if !ok {
359+ continue
285360 }
286− if needsMove {
287− if focusable, ok := any(item).(layout.Focusable); ok {
288− cmds = append(cmds, focusable.Blur())
289− }
290− l.renderedItems[i] = l.renderItem(item)
291− } else {
292− return nil
361+
362+ // If the item is bigger than the viewport, select it
363+ if item.start <= start && item.end >= end {
364+ l.selectedItem = item.id
365+ return l.render()
366+ }
367+ // item is in the view
368+ if item.start >= start && item.start <= end {
369+ l.selectedItem = item.id
370+ return l.render()
293371 }
294372 }
295− if itemWithinView != NotFound && needsMove {
296− newSelection := l.items[itemWithinView]
297− l.selectedItem = newSelection.ID()
298− if focusable, ok := any(newSelection).(layout.Focusable); ok {
299− cmds = append(cmds, focusable.Focus())
373+ } else if itemMiddle > end {
374+ // select the first item in the viewport
375+ // the item is most likely an item coming after this item
376+ inx := l.indexMap[rItem.id]
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()
300396 }
301− l.renderedItems[itemWithinView] = l.renderItem(newSelection)
302− break
303397 }
304− currentPosition += rendered.height + l.gap
305398 }
306− l.renderView()
307− return tea.Batch(cmds...)
399+ return nil
308400}
309401
310−func (l *list[T]) MoveUp(n int) tea.Cmd {
311− if l.direction == Forward {
312− l.decrementOffset(n)
313− } else {
314− l.incrementOffset(n)
402+func (l *list[T]) selectFirstItem() {
403+ inx := l.firstSelectableItemBelow(-1)
404+ if inx != ItemNotFound {
405+ l.selectedItem = l.items[inx].ID()
315406 }
316− return l.changeSelectedWhenNotVisible()
317407}
318408
319−func (l *list[T]) MoveDown(n int) tea.Cmd {
320− if l.direction == Forward {
321− l.incrementOffset(n)
322− } else {
323− l.decrementOffset(n)
409+func (l *list[T]) selectLastItem() {
410+ inx := l.firstSelectableItemAbove(len(l.items))
411+ if inx != ItemNotFound {
412+ l.selectedItem = l.items[inx].ID()
324413 }
325− return l.changeSelectedWhenNotVisible()
326414}
327415
328−func (l *list[T]) firstSelectableItemBefore(inx int) int {
416+func (l *list[T]) firstSelectableItemAbove(inx int) int {
329417 for i := inx - 1; i >= 0; i-- {
330418 if _, ok := any(l.items[i]).(layout.Focusable); ok {
331419 return i
332420 }
333421 }
334422 if inx == 0 && l.wrap {
335− return l.firstSelectableItemBefore(len(l.items))
423+ return l.firstSelectableItemAbove(len(l.items))
336424 }
337− return NotFound
425+ return ItemNotFound
338426}
339427
340−func (l *list[T]) firstSelectableItemAfter(inx int) int {
428+func (l *list[T]) firstSelectableItemBelow(inx int) int {
341429 for i := inx + 1; i < len(l.items); i++ {
342430 if _, ok := any(l.items[i]).(layout.Focusable); ok {
343431 return i
344432 }
345433 }
346434 if inx == len(l.items)-1 && l.wrap {
347− return l.firstSelectableItemAfter(-1)
435+ return l.firstSelectableItemBelow(-1)
348436 }
349− return NotFound
437+ return ItemNotFound
350438}
351439
352−// moveToSelected needs to be called after the view is rendered
353−func (l *list[T]) moveToSelected(center bool) tea.Cmd {
354− var cmds []tea.Cmd
355− if l.selectedItem == "" || !l.isReady {
440+func (l *list[T]) focusSelectedItem() tea.Cmd {
441+ if l.selectedItem == "" || !l.focused {
356442 return nil
357443 }
358− currentPosition := 0
359− start, end := l.viewPosition()
360− for _, item := range l.renderedItems {
361− if item.id == l.selectedItem {
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))
376− } else if targetOffset < l.offset {
377− cmds = append(cmds, l.MoveUp(l.offset-targetOffset))
378− }
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))
444+ var cmds []tea.Cmd
445+ for _, item := range l.items {
446+ if f, ok := any(item).(layout.Focusable); ok {
447+ if item.ID() == l.selectedItem && !f.IsFocused() {
448+ cmds = append(cmds, f.Focus())
449+ if cache, ok := l.renderedItems[item.ID()]; ok {
450+ cache.dirty = true
451+ l.renderedItems[item.ID()] = cache
391452 }
392− if currentPosition > end {
393− cmds = append(cmds, l.MoveDown(currentPosition-end))
453+ } else if item.ID() != l.selectedItem && f.IsFocused() {
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
394458 }
395459 }
396460 }
397− currentPosition += item.height + l.gap
398461 }
399462 return tea.Batch(cmds...)
400463}
401464
402−func (l *list[T]) SelectItemAbove() tea.Cmd {
403− if !l.isReady {
404− return nil
405− }
465+func (l *list[T]) blurItems() tea.Cmd {
406466 var cmds []tea.Cmd
407− for i, item := range l.items {
408− if l.selectedItem == item.ID() {
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())
417− }
418− // rerender the item
419− l.renderedItems[i] = l.renderItem(item)
420− // focus the item above
421− above := l.items[inx]
422− if focusable, ok := any(above).(layout.Focusable); ok {
423− cmds = append(cmds, focusable.Focus())
467+ for _, item := range l.items {
468+ if f, ok := any(item).(layout.Focusable); ok {
469+ if item.ID() == l.selectedItem && f.IsFocused() {
470+ cmds = append(cmds, f.Blur())
471+ if cache, ok := l.renderedItems[item.ID()]; ok {
472+ cache.dirty = true
473+ l.renderedItems[item.ID()] = cache
474+ }
424475 }
425− // rerender the item
426− l.renderedItems[inx] = l.renderItem(above)
427− l.selectedItem = above.ID()
428− break
429476 }
430477 }
431− l.renderView()
432− l.moveToSelected(false)
433478 return tea.Batch(cmds...)
434479}
435480
436−func (l *list[T]) SelectItemBelow() tea.Cmd {
437− if !l.isReady {
438− return nil
439− }
440− var cmds []tea.Cmd
441− for i, item := range l.items {
442− if l.selectedItem == item.ID() {
443− inx := l.firstSelectableItemAfter(i)
444− if inx == NotFound {
445− // no item below
446− return nil
447− }
448− // blur the current item
449− if focusable, ok := any(item).(layout.Focusable); ok {
450− cmds = append(cmds, focusable.Blur())
451− }
452− // rerender the item
453− l.renderedItems[i] = l.renderItem(item)
481+// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
482+// returns the last index
483+func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
484+ currentContentHeight := lipgloss.Height(l.rendered) - 1
485+ for i := startInx; i < len(l.items); i++ {
486+ if currentContentHeight >= l.height && limitHeight {
487+ return i
488+ }
489+ // cool way to go through the list in both directions
490+ inx := i
454491
455− // focus the item below
456− below := l.items[inx]
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
492+ if l.direction != DirectionForward {
493+ inx = (len(l.items) - 1) - i
464494 }
465− }
466495
467− l.renderView()
468− l.moveToSelected(false)
469− return tea.Batch(cmds...)
470−}
496+ item := l.items[inx]
497+ var rItem renderedItem
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
509+ }
471510
472−func (l *list[T]) GoToTop() tea.Cmd {
473− if !l.isReady {
474− return nil
511+ if l.direction == DirectionForward {
512+ l.rendered += rItem.view + strings.Repeat("\n", gap)
513+ } else {
514+ l.rendered = rItem.view + strings.Repeat("\n", gap) + l.rendered
515+ }
516+ currentContentHeight = rItem.end + 1 + l.gap
475517 }
476− l.offset = 0
477− l.direction = Forward
478− return tea.Batch(l.selectFirstItem(), l.renderForward())
518+ return len(l.items)
479519}
480520
481−func (l *list[T]) GoToBottom() tea.Cmd {
482− if !l.isReady {
483− return nil
521+func (l *list[T]) renderItem(item Item) renderedItem {
522+ view := item.View()
523+ return renderedItem{
524+ id: item.ID(),
525+ view: view,
526+ height: lipgloss.Height(view),
484527 }
485− l.offset = 0
486− l.direction = Backward
487−
488− return tea.Batch(l.selectLastItem(), l.renderBackward())
489528}
490529
491−func (l *list[T]) renderForward() tea.Cmd {
492− // TODO: figure out a way to preserve items that did not change
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− }
530+// AppendItem implements List.
531+func (l *list[T]) AppendItem(T) tea.Cmd {
532+ panic("unimplemented")
533+}
505534
506− // initial render
507− l.renderView()
535+// Blur implements List.
536+func (l *list[T]) Blur() tea.Cmd {
537+ cmd := l.blurItems()
538+ return tea.Batch(cmd, l.render())
539+}
508540
509− if currentIndex == len(l.items)-1 {
510− l.isReady = true
511− return nil
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− }
541+// DeleteItem implements List.
542+func (l *list[T]) DeleteItem(string) tea.Cmd {
543+ panic("unimplemented")
523544}
524545
525−func (l *list[T]) renderBackward() tea.Cmd {
526− // TODO: figure out a way to preserve items that did not change
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
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− }
546+// Focus implements List.
547+func (l *list[T]) Focus() tea.Cmd {
548+ l.focused = true
549+ return l.render()
554550}
555551
556−func (l *list[T]) selectFirstItem() tea.Cmd {
557− var cmd tea.Cmd
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
552+// GetSize implements List.
553+func (l *list[T]) GetSize() (int, int) {
554+ return l.width, l.height
566555}
567556
568−func (l *list[T]) selectLastItem() tea.Cmd {
569− var cmd tea.Cmd
570− inx := l.firstSelectableItemBefore(len(l.items))
571− if inx != NotFound {
572− l.selectedItem = l.items[inx].ID()
573− if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
574− cmd = focusable.Focus()
575− }
576− }
577− return cmd
557+// GoToBottom implements List.
558+func (l *list[T]) GoToBottom() tea.Cmd {
559+ l.offset = 0
560+ l.direction = DirectionBackward
561+ l.selectedItem = ""
562+ return l.render()
578563}
579564
580−func (l *list[T]) renderItems() tea.Cmd {
581− if l.height <= 0 || l.width <= 0 {
582− return nil
583− }
584− if len(l.items) == 0 {
585− return nil
586− }
565+// GoToTop implements List.
566+func (l *list[T]) GoToTop() tea.Cmd {
567+ l.offset = 0
568+ l.direction = DirectionForward
569+ l.selectedItem = ""
570+ return l.render()
571+}
587572
588− if l.selectedItem == "" {
589− if l.direction == Forward {
590− l.selectFirstItem()
591− } else {
592− l.selectLastItem()
593− }
594− }
595− if l.direction == Forward {
596− return l.renderForward()
597− }
598− return l.renderBackward()
573+// IsFocused implements List.
574+func (l *list[T]) IsFocused() bool {
575+ return l.focused
599576}
600577
601−func (l *list[T]) listHeight() int {
602− // for the moment its the same
603− return l.height
578+// Items implements List.
579+func (l *list[T]) Items() []T {
580+ return l.items
604581}
605582
606−func (l *list[T]) SetItems(items []T) tea.Cmd {
607− l.items = items
608− var cmds []tea.Cmd
609− for _, item := range l.items {
610− cmds = append(cmds, item.Init())
611− // Set height to 0 to let the item calculate its own height
612− cmds = append(cmds, item.SetSize(l.width, 0))
583+func (l *list[T]) incrementOffset(n int) {
584+ renderedHeight := lipgloss.Height(l.rendered)
585+ // no need for offset
586+ if renderedHeight <= l.height {
587+ return
588+ }
589+ maxOffset := renderedHeight - l.height
590+ n = min(n, maxOffset-l.offset)
591+ if n <= 0 {
592+ return
613593 }
594+ l.offset += n
595+}
614596
615− cmds = append(cmds, l.renderItems())
616− if l.selectedItem != "" {
617− cmds = append(cmds, l.moveToSelected(true))
597+func (l *list[T]) decrementOffset(n int) {
598+ n = min(n, l.offset)
599+ if n <= 0 {
600+ return
601+ }
602+ l.offset -= n
603+ if l.offset < 0 {
604+ l.offset = 0
618605 }
619− return tea.Batch(cmds...)
620606}
621607
622−// GetSize implements List.
623−func (l *list[T]) GetSize() (int, int) {
624− return l.width, l.height
608+// MoveDown implements List.
609+func (l *list[T]) MoveDown(n int) tea.Cmd {
610+ if l.direction == DirectionForward {
611+ l.incrementOffset(n)
612+ } else {
613+ l.decrementOffset(n)
614+ }
615+ return l.changeSelectionWhenScrolling()
625616}
626617
627−// SetSize implements List.
628−func (l *list[T]) SetSize(width int, height int) tea.Cmd {
629− l.width = width
630− l.height = height
631− var cmds []tea.Cmd
632− for _, item := range l.items {
633− cmds = append(cmds, item.SetSize(width, height))
618+// MoveUp implements List.
619+func (l *list[T]) MoveUp(n int) tea.Cmd {
620+ if l.direction == DirectionForward {
621+ l.decrementOffset(n)
622+ } else {
623+ l.incrementOffset(n)
634624 }
625+ return l.changeSelectionWhenScrolling()
626+}
635627
636− cmds = append(cmds, l.renderItems())
637− return tea.Batch(cmds...)
628+// PrependItem implements List.
629+func (l *list[T]) PrependItem(T) tea.Cmd {
630+ panic("unimplemented")
638631}
639632
640−// Blur implements List.
641−func (l *list[T]) Blur() tea.Cmd {
642− var cmd tea.Cmd
643− l.focused = false
644− for i, item := range l.items {
645− if item.ID() != l.selectedItem {
646− continue
647− }
648− if focusable, ok := any(item).(layout.Focusable); ok {
649− cmd = focusable.Blur()
650− }
651− l.renderedItems[i] = l.renderItem(item)
633+// SelectItemAbove implements List.
634+func (l *list[T]) SelectItemAbove() tea.Cmd {
635+ inx, ok := l.indexMap[l.selectedItem]
636+ if !ok {
637+ return nil
652638 }
653− l.renderView()
654− return cmd
655−}
656639
657−// Focus implements List.
658−func (l *list[T]) Focus() tea.Cmd {
659− var cmd tea.Cmd
660− l.focused = true
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()
640+ newIndex := l.firstSelectableItemAbove(inx)
641+ if newIndex == ItemNotFound {
642+ // no item above
643+ return nil
674644 }
675− return cmd
645+ item := l.items[newIndex]
646+ l.selectedItem = item.ID()
647+ return l.render()
676648}
677649
678−func (l *list[T]) SetSelected(id string) tea.Cmd {
679− if l.selectedItem == id {
650+// SelectItemBelow implements List.
651+func (l *list[T]) SelectItemBelow() tea.Cmd {
652+ inx, ok := l.indexMap[l.selectedItem]
653+ if !ok {
680654 return nil
681655 }
682− var cmds []tea.Cmd
683− for i, item := range l.items {
684− if item.ID() == l.selectedItem {
685− if focusable, ok := any(item).(layout.Focusable); ok {
686− cmds = append(cmds, focusable.Blur())
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− }
656+
657+ newIndex := l.firstSelectableItemBelow(inx)
658+ if newIndex == ItemNotFound {
659+ // no item above
660+ return nil
699661 }
700− l.selectedItem = id
701− l.renderView()
702− cmds = append(cmds, l.moveToSelected(true))
703− return tea.Batch(cmds...)
662+ item := l.items[newIndex]
663+ l.selectedItem = item.ID()
664+ return l.render()
704665}
705666
667+// SelectedItem implements List.
706668func (l *list[T]) SelectedItem() *T {
707− for _, item := range l.items {
708− if item.ID() == l.selectedItem {
709− return &item
710− }
669+ inx, ok := l.indexMap[l.selectedItem]
670+ if !ok {
671+ return nil
711672 }
712− return nil
673+ if inx > len(l.items)-1 {
674+ return nil
675+ }
676+ item := l.items[inx]
677+ return &item
713678}
714679
715−// IsFocused implements List.
716−func (l *list[T]) IsFocused() bool {
717− return l.focused
680+// SetItems implements List.
681+func (l *list[T]) SetItems(items []T) tea.Cmd {
682+ l.items = items
683+ return l.reset()
718684}
719685
720−func (l *list[T]) Items() []T {
721− return l.items
686+// SetSelected implements List.
687+func (l *list[T]) SetSelected(id string) tea.Cmd {
688+ l.selectedItem = id
689+ return l.render()
722690}
723691
724−func (l *list[T]) UpdateItem(id string, item T) {
725− // TODO: preserve offset
692+func (l *list[T]) reset() tea.Cmd {
693+ var cmds []tea.Cmd
694+ l.rendered = ""
695+ l.indexMap = make(map[string]int)
696+ l.renderedItems = make(map[string]renderedItem)
726697 for inx, item := range l.items {
727− if item.ID() == id {
728− l.items[inx] = item
729− l.renderedItems[inx] = l.renderItem(item)
730− l.renderView()
731− return
698+ l.indexMap[item.ID()] = inx
699+ if l.width > 0 && l.height > 0 {
700+ cmds = append(cmds, item.SetSize(l.width, l.height))
732701 }
733702 }
703+ cmds = append(cmds, l.render())
704+ return tea.Batch(cmds...)
734705}
735706
736−func (l *list[T]) DeleteItem(id string) {
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 {
752− // TODO: preserve offset
753− var cmd tea.Cmd
754− l.items = append([]T{item}, l.items...)
755− l.renderedItems = append([]renderedItem{l.renderItem(item)}, l.renderedItems...)
756− if len(l.items) == 1 {
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())
707+// SetSize implements List.
708+func (l *list[T]) SetSize(width int, height int) tea.Cmd {
709+ oldWidth := l.width
710+ l.width = width
711+ l.height = height
712+ if oldWidth != width {
713+ return l.reset()
762714 }
763− l.renderView()
764− return cmd
715+ return nil
765716}
766717
767−func (l *list[T]) AppendItem(item T) tea.Cmd {
768− // TODO: preserve offset
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
718+// UpdateItem implements List.
719+func (l *list[T]) UpdateItem(string, T) tea.Cmd {
720+ panic("unimplemented")
781721}
internal/tui/exp/list/list_test.go+217−489
@@ -2,7 +2,7 @@ package list
22
33import (
44 "fmt"
5− "sync"
5+ "strings"
66 "testing"
77
88 tea "github.com/charmbracelet/bubbletea/v2"
@@ -11,623 +11,344 @@ import (
1111 "github.com/charmbracelet/x/exp/golden"
1212 "github.com/google/uuid"
1313 "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
1415)
1516
16−func TestListPosition(t *testing.T) {
17+func TestList(t *testing.T) {
1718 t.Parallel()
18− type positionOffsetTest struct {
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) {
19+ t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
10820 t.Parallel()
10921 items := []Item{}
11022 for i := range 5 {
11123 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
11224 items = append(items, item)
11325 }
114− l := New(items, WithDirection(Backward), WithGap(1)).(*list[Item])
115− l.SetSize(10, 20)
116− cmd := l.Init()
117− if cmd != nil {
118− cmd()
119− }
26+ l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
27+ execCmd(l, l.Init())
12028
12129 // 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+
12345 golden.RequireEqual(t, []byte(l.View()))
12446 })
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) {
12648 t.Parallel()
12749 items := []Item{}
12850 for i := range 5 {
12951 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
13052 items = append(items, item)
13153 }
132− l := New(items, WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
133− l.SetSize(10, 20)
134− cmd := l.Init()
135− if cmd != nil {
136− cmd()
137− }
54+ l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
55+ execCmd(l, l.Init())
56+
13857 // should select the last item
139− assert.Equal(t, l.selectedItem, items[2].ID())
140− })
141− t.Run("more than height", func(t *testing.T) {
142− t.Parallel()
143− items := []Item{}
144− for i := range 10 {
145− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
146− items = append(items, item)
147− }
148− l := New(items, WithDirection(Backward))
149− l.SetSize(10, 5)
150− cmd := l.Init()
151− if cmd != nil {
152− cmd()
58+ assert.Equal(t, items[4].ID(), l.selectedItem)
59+ assert.Equal(t, 0, l.offset)
60+ require.Len(t, l.indexMap, 5)
61+ require.Len(t, l.items, 5)
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)
68+ for i := range 5 {
69+ assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
70+ assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
15371 }
15472
15573 golden.RequireEqual(t, []byte(l.View()))
15674 })
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− }
17075
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) {
17477 t.Parallel()
17578 items := []Item{}
176− for i := range 10 {
79+ for i := range 30 {
17780 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
17881 items = append(items, item)
17982 }
180− l := New(items, WithDirection(Backward))
181− l.SetSize(10, 5)
182− cmd := l.Init()
183− if cmd != nil {
184− cmd()
185− }
186−
187− l.MoveUp(1)
188− golden.RequireEqual(t, []byte(l.View()))
189− })
83+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
84+ execCmd(l, l.Init())
19085
191− t.Run("should move at max to the top", func(t *testing.T) {
192− items := []Item{}
193− for i := range 10 {
194− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
195− items = append(items, item)
196− }
197− l := New(items, WithDirection(Backward)).(*list[Item])
198− l.SetSize(10, 5)
199− cmd := l.Init()
200− if cmd != nil {
201− cmd()
86+ // should select the last 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)
97+ for i := range 30 {
98+ assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
99+ assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
202100 }
203101
204− l.MoveUp(100)
205− assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
206102 golden.RequireEqual(t, []byte(l.View()))
207103 })
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) {
209105 t.Parallel()
210106 items := []Item{}
211− for i := range 10 {
107+ for i := range 30 {
212108 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
213109 items = append(items, item)
214110 }
215− l := New(items, WithDirection(Backward))
216− l.SetSize(10, 5)
217− cmd := l.Init()
218− if cmd != nil {
219− cmd()
220− }
111+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
112+ execCmd(l, l.Init())
221113
222− l.MoveUp(-10)
223− golden.RequireEqual(t, []byte(l.View()))
224− })
225− t.Run("should move to the top", func(t *testing.T) {
226− t.Parallel()
227− items := []Item{}
228− for i := range 10 {
229− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
230− items = append(items, item)
231− }
232− l := New(items, WithDirection(Backward)).(*list[Item])
233− l.SetSize(10, 5)
234− cmd := l.Init()
235− if cmd != nil {
236− cmd()
114+ // should select the last item
115+ assert.Equal(t, items[29].ID(), l.selectedItem)
116+ assert.Equal(t, 0, l.offset)
117+ require.Len(t, l.indexMap, 30)
118+ require.Len(t, l.items, 30)
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)
125+ for i := range 30 {
126+ assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
127+ assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
237128 }
238129
239− l.GoToTop()
240− assert.Equal(t, l.direction, Forward)
241130 golden.RequireEqual(t, []byte(l.View()))
242131 })
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) {
244134 t.Parallel()
245135 items := []Item{}
246− for i := range 10 {
247− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
136+ for i := range 30 {
137+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
138+ content = strings.TrimSuffix(content, "\n")
139+ item := NewSelectableItem(content)
248140 items = append(items, item)
249141 }
250− l := New(items, WithDirection(Backward)).(*list[Item])
251− l.SetSize(10, 5)
252− cmd := l.Init()
253− if cmd != nil {
254− cmd()
255− }
142+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
143+ execCmd(l, l.Init())
256144
257− selectedInx := len(l.items) - 2
258− currentItem := items[len(l.items)-1]
259− nextItem := items[selectedInx]
260− assert.False(t, nextItem.(SelectableItem).IsFocused())
261− assert.True(t, currentItem.(SelectableItem).IsFocused())
262− cmd = l.SelectItemAbove()
263− if cmd != nil {
264− cmd()
145+ // should select the last item
146+ assert.Equal(t, items[0].ID(), l.selectedItem)
147+ assert.Equal(t, 0, l.offset)
148+ require.Len(t, l.indexMap, 30)
149+ require.Len(t, l.items, 30)
150+ require.Len(t, l.renderedItems, 30)
151+ expectedLines := 0
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
265166 }
266167
267− assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
268− assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
269−
270168 golden.RequireEqual(t, []byte(l.View()))
271169 })
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) {
273171 t.Parallel()
274172 items := []Item{}
275− for i := range 10 {
276− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
173+ for i := range 30 {
174+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
175+ content = strings.TrimSuffix(content, "\n")
176+ item := NewSelectableItem(content)
277177 items = append(items, item)
278178 }
279− l := New(items, WithDirection(Backward)).(*list[Item])
280− l.SetSize(10, 5)
281− cmd := l.Init()
282− if cmd != nil {
283− cmd()
284− }
179+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
180+ execCmd(l, l.Init())
285181
286− for range 5 {
287− cmd = l.SelectItemAbove()
288− if cmd != nil {
289− cmd()
290− }
182+ // should select the last item
183+ assert.Equal(t, items[29].ID(), l.selectedItem)
184+ assert.Equal(t, 0, l.offset)
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
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
291203 }
204+
292205 golden.RequireEqual(t, []byte(l.View()))
293206 })
294−}
295207
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) {
299209 t.Parallel()
300210 items := []Item{}
301− for i := range 5 {
302− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
211+ for i := range 30 {
212+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
213+ content = strings.TrimSuffix(content, "\n")
214+ item := NewSelectableItem(content)
303215 items = append(items, item)
304216 }
305− l := New(items, WithDirection(Forward), WithGap(1)).(*list[Item])
306− l.SetSize(10, 20)
307− cmd := l.Init()
308− if cmd != nil {
309− cmd()
310− }
217+ l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item])
218+ execCmd(l, l.Init())
311219
312220 // should select the last item
313− assert.Equal(t, l.selectedItem, items[0].ID())
221+ assert.Equal(t, items[4].ID(), l.selectedItem)
314222
315223 golden.RequireEqual(t, []byte(l.View()))
316224 })
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− }
346225
347− golden.RequireEqual(t, []byte(l.View()))
348− })
349− t.Run("more than height multi line", func(t *testing.T) {
226+ t.Run("should go to selected item and center backwards", func(t *testing.T) {
350227 t.Parallel()
351228 items := []Item{}
352− for i := range 10 {
353− item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
229+ for i := range 30 {
230+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
231+ content = strings.TrimSuffix(content, "\n")
232+ item := NewSelectableItem(content)
354233 items = append(items, item)
355234 }
356− l := New(items, WithDirection(Forward)).(*list[Item])
357− l.SetSize(10, 5)
358− cmd := l.Init()
359− if cmd != nil {
360− cmd()
361− }
235+ l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item])
236+ execCmd(l, l.Init())
362237
363− golden.RequireEqual(t, []byte(l.View()))
364− })
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− }
238+ // should select the last item
239+ assert.Equal(t, items[4].ID(), l.selectedItem)
378240
379− l.MoveDown(1)
380241 golden.RequireEqual(t, []byte(l.View()))
381242 })
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− }
395243
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) {
401245 t.Parallel()
402246 items := []Item{}
403− for i := range 10 {
404− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
247+ for i := range 30 {
248+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
249+ content = strings.TrimSuffix(content, "\n")
250+ item := NewSelectableItem(content)
405251 items = append(items, item)
406252 }
407− l := New(items, WithDirection(Forward)).(*list[Item])
408− l.SetSize(10, 5)
409− cmd := l.Init()
410− if cmd != nil {
411− cmd()
412− }
253+ l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
254+ execCmd(l, l.Init())
413255
414− l.MoveDown(-10)
415− golden.RequireEqual(t, []byte(l.View()))
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− }
256+ // should select the last item
257+ assert.Equal(t, items[10].ID(), l.selectedItem)
430258
431− l.GoToBottom()
432− assert.Equal(t, l.direction, Backward)
433259 golden.RequireEqual(t, []byte(l.View()))
434260 })
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− }
448261
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) {
465263 t.Parallel()
466264 items := []Item{}
467− for i := range 10 {
468− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
265+ for i := range 30 {
266+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
267+ content = strings.TrimSuffix(content, "\n")
268+ item := NewSelectableItem(content)
469269 items = append(items, item)
470270 }
471− l := New(items, WithDirection(Forward)).(*list[Item])
472− l.SetSize(10, 5)
473− cmd := l.Init()
474− if cmd != nil {
475− cmd()
476− }
271+ l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
272+ execCmd(l, l.Init())
273+
274+ // should select the last item
275+ assert.Equal(t, items[10].ID(), l.selectedItem)
477276
478− for range 5 {
479− cmd = l.SelectItemBelow()
480− if cmd != nil {
481− cmd()
482− }
483− }
484277 golden.RequireEqual(t, []byte(l.View()))
485278 })
486279}
487280
488−func TestListSelection(t *testing.T) {
281+func TestListMovement(t *testing.T) {
489282 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) {
491284 t.Parallel()
492285 items := []Item{}
493− items = append(items, NewSimpleItem("None Selectable"))
494− for i := range 5 {
495− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
286+ for i := range 30 {
287+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
288+ content = strings.TrimSuffix(content, "\n")
289+ item := NewSelectableItem(content)
496290 items = append(items, item)
497291 }
498− l := New(items, WithDirection(Forward)).(*list[Item])
499− l.SetSize(100, 10)
500− cmd := l.Init()
501− if cmd != nil {
502− cmd()
503− }
292+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
293+ execCmd(l, l.Init())
504294
505− assert.Equal(t, items[1].ID(), l.selectedItem)
506− golden.RequireEqual(t, []byte(l.View()))
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)
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)
295+ execCmd(l, l.MoveUp(25))
296+
297+ assert.Equal(t, 25, l.offset)
556298 golden.RequireEqual(t, []byte(l.View()))
557299 })
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) {
563301 t.Parallel()
564302 items := []Item{}
565− for i := range 100 {
566− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
303+ for i := range 30 {
304+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
305+ content = strings.TrimSuffix(content, "\n")
306+ item := NewSelectableItem(content)
567307 items = append(items, item)
568308 }
569− l := New(items, WithDirection(Forward)).(*list[Item])
570− l.SetSize(100, 10)
571− cmd := l.Init()
572− if cmd != nil {
573− cmd()
574− }
309+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
310+ execCmd(l, l.Init())
575311
576− cmd = l.SetSelected(items[52].ID())
577− if cmd != nil {
578− cmd()
579− }
312+ execCmd(l, l.MoveUp(25))
313+ execCmd(l, l.MoveDown(25))
580314
581− assert.Equal(t, items[52].ID(), l.selectedItem)
315+ assert.Equal(t, 0, l.offset)
582316 golden.RequireEqual(t, []byte(l.View()))
583317 })
584−}
585318
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) {
589320 t.Parallel()
590− items := []SelectableItem{}
591− for i := range 20 {
592− item := NewSelectableItem(fmt.Sprintf("Item %d", i))
321+ items := []Item{}
322+ for i := range 30 {
323+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
324+ content = strings.TrimSuffix(content, "\n")
325+ item := NewSelectableItem(content)
593326 items = append(items, item)
594327 }
595− l := New(items, WithDirection(Backward)).(*list[SelectableItem])
596− l.SetSize(100, 10)
597− cmd := l.Init()
598− if cmd != nil {
599− cmd()
600− }
328+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
329+ execCmd(l, l.Init())
601330
602− newItem := NewSelectableItem("New Item")
603− l.AppendItem(newItem)
331+ execCmd(l, l.MoveDown(25))
604332
605− assert.Equal(t, 21, len(l.items))
606− assert.Equal(t, 21, len(l.renderedItems))
607− assert.Equal(t, newItem.ID(), l.selectedItem)
333+ assert.Equal(t, 25, l.offset)
608334 golden.RequireEqual(t, []byte(l.View()))
609335 })
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) {
611337 t.Parallel()
612− items := []SelectableItem{}
613− for i := range 20 {
614− item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
338+ items := []Item{}
339+ for i := range 30 {
340+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
341+ content = strings.TrimSuffix(content, "\n")
342+ item := NewSelectableItem(content)
615343 items = append(items, item)
616344 }
617− l := New(items, WithDirection(Backward)).(*list[SelectableItem])
618− l.SetSize(100, 10)
619− cmd := l.Init()
620− if cmd != nil {
621− cmd()
622− }
623− l.MoveUp(1)
345+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
346+ execCmd(l, l.Init())
624347
625− newItem := NewSelectableItem("New Item")
626− l.AppendItem(newItem)
348+ execCmd(l, l.MoveDown(25))
349+ execCmd(l, l.MoveUp(25))
627350
628− assert.Equal(t, 21, len(l.items))
629− assert.Equal(t, 21, len(l.renderedItems))
630− assert.Equal(t, l.items[19].ID(), l.selectedItem)
351+ assert.Equal(t, 0, l.offset)
631352 golden.RequireEqual(t, []byte(l.View()))
632353 })
633354}
@@ -711,3 +432,10 @@ func (s *selectableItem) Focus() tea.Cmd {
711432func (s *selectableItem) IsFocused() bool {
712433 return s.focused
713434}
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