on main help sign in
chore: initial implementation
commit99bbcce22427c1eb8ba24a996e766867aa96d2ee authorKujtim Hoxha <kujtimii.h@gmail.com> dateSat 19 Jul 2025, 21:06 (1 year ago) parent4509fe7
20 files changed+1022−17
internal/tui/exp/list/list.go+458−17 internal/tui/exp/list/list_test.go+462 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+11 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_top.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+11
internal/tui/exp/list/list.go+458−17
@@ -1,41 +1,60 @@
11package list
22
33import (
4+ "strings"
5+
46 tea "github.com/charmbracelet/bubbletea/v2"
57 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
68 "github.com/charmbracelet/crush/internal/tui/util"
9+ "github.com/charmbracelet/lipgloss/v2"
710)
811
912type Item interface {
1013 util.Model
1114 layout.Sizeable
15+ ID() string
1216}
1317
1418type List interface {
1519 util.Model
20+ layout.Sizeable
21+ layout.Focusable
22+ SetItems(items []Item) tea.Cmd
23+}
24+
25+type direction int
26+
27+const (
28+ Forward direction = iota
29+ Backward
30+)
31+
32+const (
33+ NotFound = -1
34+)
35+
36+type renderedItem struct {
37+ id string
38+ view string
39+ height int
1640}
1741
1842type list struct {
1943 width, height int
44+ offset int
2045 gap int
46+ direction direction
47+ selectedItem string
48+ focused bool
2149
22− items []Item
23−
24− // Filter options
25− filterable bool
26− filterPlaceholder string
50+ items []Item
51+ renderedItems []renderedItem
52+ rendered string
53+ isReady bool
2754}
2855
2956type listOption func(*list)
3057
31−// WithFilterable enables filtering on the list.
32−func WithFilterable(placeholder string) listOption {
33− return func(l *list) {
34− l.filterable = true
35− l.filterPlaceholder = placeholder
36− }
37−}
38−
3958// WithItems sets the initial items for the list.
4059func WithItems(items ...Item) listOption {
4160 return func(l *list) {
@@ -58,9 +77,24 @@ func WithGap(gap int) listOption {
5877 }
5978}
6079
80+// WithDirection sets the direction of the list.
81+func WithDirection(dir direction) listOption {
82+ return func(l *list) {
83+ l.direction = dir
84+ }
85+}
86+
87+// WithSelectedItem sets the initially selected item in the list.
88+func WithSelectedItem(id string) listOption {
89+ return func(l *list) {
90+ l.selectedItem = id
91+ }
92+}
93+
6194func New(opts ...listOption) List {
6295 list := &list{
63− items: make([]Item, 0),
96+ items: make([]Item, 0),
97+ direction: Forward,
6498 }
6599 for _, opt := range opts {
66100 opt(list)
@@ -73,15 +107,422 @@ func (l *list) Init() tea.Cmd {
73107 if l.height <= 0 || l.width <= 0 {
74108 return nil
75109 }
76− return nil
110+ if len(l.items) == 0 {
111+ return nil
112+ }
113+ var cmds []tea.Cmd
114+ for _, item := range l.items {
115+ cmd := item.Init()
116+ cmds = append(cmds, cmd)
117+ }
118+ cmds = append(cmds, l.renderItems())
119+ return tea.Batch(cmds...)
77120}
78121
79122// Update implements List.
80123func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
81− panic("unimplemented")
124+ return l, nil
82125}
83126
84127// View implements List.
85128func (l *list) View() string {
86− panic("unimplemented")
129+ if l.height <= 0 || l.width <= 0 {
130+ return ""
131+ }
132+ view := l.rendered
133+ lines := strings.Split(view, "\n")
134+
135+ start, end := l.viewPosition(len(lines))
136+ lines = lines[start:end]
137+ return strings.Join(lines, "\n")
138+}
139+
140+func (l *list) viewPosition(total int) (int, int) {
141+ start, end := 0, 0
142+ if l.direction == Forward {
143+ start = max(0, l.offset)
144+ end = min(l.offset+l.listHeight(), total)
145+ } else {
146+ start = max(0, total-l.offset-l.listHeight())
147+ end = max(0, total-l.offset)
148+ }
149+ return start, end
150+}
151+
152+func (l *list) renderItem(item Item) renderedItem {
153+ view := item.View()
154+ return renderedItem{
155+ id: item.ID(),
156+ view: view,
157+ height: lipgloss.Height(view),
158+ }
159+}
160+
161+func (l *list) renderView() {
162+ var sb strings.Builder
163+ for i, rendered := range l.renderedItems {
164+ sb.WriteString(rendered.view)
165+ if i < len(l.renderedItems)-1 {
166+ sb.WriteString(strings.Repeat("\n", l.gap+1))
167+ }
168+ }
169+ l.rendered = sb.String()
170+}
171+
172+func (l *list) incrementOffset(n int) {
173+ if !l.isReady {
174+ return
175+ }
176+ renderedHeight := lipgloss.Height(l.rendered)
177+ // no need for offset
178+ if renderedHeight <= l.listHeight() {
179+ return
180+ }
181+ maxOffset := renderedHeight - l.listHeight()
182+ n = min(n, maxOffset-l.offset)
183+ if n <= 0 {
184+ return
185+ }
186+ l.offset += n
187+}
188+
189+func (l *list) decrementOffset(n int) {
190+ if !l.isReady {
191+ return
192+ }
193+ n = min(n, l.offset)
194+ if n <= 0 {
195+ return
196+ }
197+ l.offset -= n
198+ if l.offset < 0 {
199+ l.offset = 0
200+ }
201+}
202+
203+func (l *list) MoveUp(n int) {
204+ if l.direction == Forward {
205+ l.decrementOffset(n)
206+ } else {
207+ l.incrementOffset(n)
208+ }
209+}
210+
211+func (l *list) MoveDown(n int) {
212+ if l.direction == Forward {
213+ l.incrementOffset(n)
214+ } else {
215+ l.decrementOffset(n)
216+ }
217+}
218+
219+func (l *list) firstSelectableItemBefore(inx int) int {
220+ for i := inx - 1; i >= 0; i-- {
221+ if _, ok := l.items[i].(layout.Focusable); ok {
222+ return i
223+ }
224+ }
225+ return NotFound
226+}
227+
228+func (l *list) firstSelectableItemAfter(inx int) int {
229+ for i := inx + 1; i < len(l.items); i++ {
230+ if _, ok := l.items[i].(layout.Focusable); ok {
231+ return i
232+ }
233+ }
234+ return NotFound
235+}
236+
237+func (l *list) moveToSelected() {
238+ if l.selectedItem == "" || !l.isReady {
239+ return
240+ }
241+ currentPosition := 0
242+ start, end := l.viewPosition(lipgloss.Height(l.rendered))
243+ for _, item := range l.renderedItems {
244+ if item.id == l.selectedItem {
245+ if start <= currentPosition && currentPosition <= end {
246+ return
247+ }
248+ // we need to go up
249+ if currentPosition < start {
250+ l.MoveUp(start - currentPosition)
251+ }
252+ // we need to go down
253+ if currentPosition > end {
254+ l.MoveDown(currentPosition - end)
255+ }
256+ }
257+ currentPosition += item.height + l.gap
258+ }
259+}
260+
261+func (l *list) SelectItemAbove() tea.Cmd {
262+ if !l.isReady {
263+ return nil
264+ }
265+ var cmds []tea.Cmd
266+ for i, item := range l.items {
267+ if l.selectedItem == item.ID() {
268+ inx := l.firstSelectableItemBefore(i)
269+ if inx == NotFound {
270+ // no item above
271+ return nil
272+ }
273+ // blur the current item
274+ if focusable, ok := item.(layout.Focusable); ok {
275+ cmds = append(cmds, focusable.Blur())
276+ }
277+ // rerender the item
278+ l.renderedItems[i] = l.renderItem(item)
279+ // focus the item above
280+ above := l.items[inx]
281+ if focusable, ok := above.(layout.Focusable); ok {
282+ cmds = append(cmds, focusable.Focus())
283+ }
284+ // rerender the item
285+ l.renderedItems[inx] = l.renderItem(above)
286+ l.selectedItem = above.ID()
287+ break
288+ }
289+ }
290+ l.renderView()
291+ l.moveToSelected()
292+ return tea.Batch(cmds...)
293+}
294+
295+func (l *list) SelectItemBelow() tea.Cmd {
296+ if !l.isReady {
297+ return nil
298+ }
299+ var cmds []tea.Cmd
300+ for i, item := range l.items {
301+ if l.selectedItem == item.ID() {
302+ inx := l.firstSelectableItemAfter(i)
303+ if inx == NotFound {
304+ // no item below
305+ return nil
306+ }
307+ // blur the current item
308+ if focusable, ok := item.(layout.Focusable); ok {
309+ cmds = append(cmds, focusable.Blur())
310+ }
311+ // rerender the item
312+ l.renderedItems[i] = l.renderItem(item)
313+
314+ // focus the item below
315+ below := l.items[inx]
316+ if focusable, ok := below.(layout.Focusable); ok {
317+ cmds = append(cmds, focusable.Focus())
318+ }
319+ // rerender the item
320+ l.renderedItems[inx] = l.renderItem(below)
321+ l.selectedItem = below.ID()
322+ break
323+ }
324+ }
325+
326+ l.renderView()
327+ l.moveToSelected()
328+ return tea.Batch(cmds...)
329+}
330+
331+func (l *list) GoToTop() tea.Cmd {
332+ if !l.isReady {
333+ return nil
334+ }
335+ l.offset = 0
336+ l.direction = Forward
337+ return tea.Batch(l.selectFirstItem(), l.renderForward())
338+}
339+
340+func (l *list) GoToBottom() tea.Cmd {
341+ if !l.isReady {
342+ return nil
343+ }
344+ l.offset = 0
345+ l.direction = Backward
346+
347+ return tea.Batch(l.selectLastItem(), l.renderBackward())
348+}
349+
350+func (l *list) renderForward() tea.Cmd {
351+ // TODO: figure out a way to preserve items that did not change
352+ l.renderedItems = make([]renderedItem, 0)
353+ currentHeight := 0
354+ currentIndex := 0
355+ for i, item := range l.items {
356+ currentIndex = i
357+ if currentHeight > l.listHeight() {
358+ break
359+ }
360+ rendered := l.renderItem(item)
361+ l.renderedItems = append(l.renderedItems, rendered)
362+ currentHeight += rendered.height + l.gap
363+ }
364+
365+ // initial render
366+ l.renderView()
367+
368+ if currentIndex == len(l.items)-1 {
369+ l.isReady = true
370+ return nil
371+ }
372+ // render the rest
373+ return func() tea.Msg {
374+ for i := currentIndex; i < len(l.items); i++ {
375+ rendered := l.renderItem(l.items[i])
376+ l.renderedItems = append(l.renderedItems, rendered)
377+ }
378+ l.renderView()
379+ l.isReady = true
380+ return nil
381+ }
382+}
383+
384+func (l *list) renderBackward() tea.Cmd {
385+ // TODO: figure out a way to preserve items that did not change
386+ l.renderedItems = make([]renderedItem, 0)
387+ currentHeight := 0
388+ currentIndex := 0
389+ for i := len(l.items) - 1; i >= 0; i-- {
390+ currentIndex = i
391+ if currentHeight > l.listHeight() {
392+ break
393+ }
394+ rendered := l.renderItem(l.items[i])
395+ l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
396+ currentHeight += rendered.height + l.gap
397+ }
398+ // initial render
399+ l.renderView()
400+ if currentIndex == len(l.items)-1 {
401+ l.isReady = true
402+ return nil
403+ }
404+ return func() tea.Msg {
405+ for i := currentIndex; i >= 0; i-- {
406+ rendered := l.renderItem(l.items[i])
407+ l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
408+ }
409+ l.renderView()
410+ l.isReady = true
411+ return nil
412+ }
413+}
414+
415+func (l *list) selectFirstItem() tea.Cmd {
416+ var cmd tea.Cmd
417+ inx := l.firstSelectableItemAfter(-1)
418+ if inx != NotFound {
419+ l.selectedItem = l.items[inx].ID()
420+ if focusable, ok := l.items[inx].(layout.Focusable); ok {
421+ cmd = focusable.Focus()
422+ }
423+ }
424+ return cmd
425+}
426+
427+func (l *list) selectLastItem() tea.Cmd {
428+ var cmd tea.Cmd
429+ inx := l.firstSelectableItemBefore(len(l.items))
430+ if inx != NotFound {
431+ l.selectedItem = l.items[inx].ID()
432+ if focusable, ok := l.items[inx].(layout.Focusable); ok {
433+ cmd = focusable.Focus()
434+ }
435+ }
436+ return cmd
437+}
438+
439+func (l *list) renderItems() tea.Cmd {
440+ if l.height <= 0 || l.width <= 0 {
441+ return nil
442+ }
443+ if len(l.items) == 0 {
444+ return nil
445+ }
446+
447+ if l.selectedItem == "" {
448+ if l.direction == Forward {
449+ l.selectFirstItem()
450+ } else {
451+ l.selectLastItem()
452+ }
453+ }
454+ return l.renderBackward()
455+}
456+
457+func (l *list) listHeight() int {
458+ // for the moment its the same
459+ return l.height
460+}
461+
462+func (l *list) SetItems(items []Item) tea.Cmd {
463+ l.items = items
464+ var cmds []tea.Cmd
465+ for _, item := range l.items {
466+ cmds = append(cmds, item.Init())
467+ // Set height to 0 to let the item calculate its own height
468+ cmds = append(cmds, item.SetSize(l.width, 0))
469+ }
470+ cmds = append(cmds, l.renderItems())
471+ return tea.Batch(cmds...)
472+}
473+
474+// GetSize implements List.
475+func (l *list) GetSize() (int, int) {
476+ return l.width, l.height
477+}
478+
479+// SetSize implements List.
480+func (l *list) SetSize(width int, height int) tea.Cmd {
481+ l.width = width
482+ l.height = height
483+ var cmds []tea.Cmd
484+ for _, item := range l.items {
485+ cmds = append(cmds, item.SetSize(width, height))
486+ }
487+ cmds = append(cmds, l.renderItems())
488+ return tea.Batch(cmds...)
489+}
490+
491+// Blur implements List.
492+func (l *list) Blur() tea.Cmd {
493+ var cmd tea.Cmd
494+ l.focused = false
495+ for i, item := range l.items {
496+ if item.ID() != l.selectedItem {
497+ continue
498+ }
499+ if focusable, ok := item.(layout.Focusable); ok {
500+ cmd = focusable.Blur()
501+ }
502+ l.renderedItems[i] = l.renderItem(item)
503+ }
504+ l.renderView()
505+ return cmd
506+}
507+
508+// Focus implements List.
509+func (l *list) Focus() tea.Cmd {
510+ var cmd tea.Cmd
511+ l.focused = true
512+ for i, item := range l.items {
513+ if item.ID() != l.selectedItem {
514+ continue
515+ }
516+ if focusable, ok := item.(layout.Focusable); ok {
517+ cmd = focusable.Focus()
518+ }
519+ l.renderedItems[i] = l.renderItem(item)
520+ }
521+ l.renderView()
522+ return cmd
523+}
524+
525+// IsFocused implements List.
526+func (l *list) IsFocused() bool {
527+ return l.focused
87528}
internal/tui/exp/list/list_test.gonew file+462
@@ -0,0 +1,462 @@
1+package list
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
9+ "github.com/charmbracelet/lipgloss/v2"
10+ "github.com/charmbracelet/x/exp/golden"
11+ "github.com/google/uuid"
12+ "github.com/stretchr/testify/assert"
13+)
14+
15+func TestBackwardList(t *testing.T) {
16+ t.Run("within height", func(t *testing.T) {
17+ t.Parallel()
18+ l := New(WithDirection(Backward), WithGap(1)).(*list)
19+ l.SetSize(10, 20)
20+ items := []Item{}
21+ for i := range 5 {
22+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
23+ items = append(items, item)
24+ }
25+ cmd := l.SetItems(items)
26+ if cmd != nil {
27+ cmd()
28+ }
29+
30+ // should select the last item
31+ assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
32+
33+ golden.RequireEqual(t, []byte(l.View()))
34+ })
35+ t.Run("should not change selected item", func(t *testing.T) {
36+ t.Parallel()
37+ items := []Item{}
38+ for i := range 5 {
39+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
40+ items = append(items, item)
41+ }
42+ l := New(WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
43+ l.SetSize(10, 20)
44+ cmd := l.SetItems(items)
45+ if cmd != nil {
46+ cmd()
47+ }
48+ // should select the last item
49+ assert.Equal(t, l.selectedItem, items[2].ID())
50+ })
51+ t.Run("more than height", func(t *testing.T) {
52+ t.Parallel()
53+ l := New(WithDirection(Backward))
54+ l.SetSize(10, 5)
55+ items := []Item{}
56+ for i := range 10 {
57+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
58+ items = append(items, item)
59+ }
60+ cmd := l.SetItems(items)
61+ if cmd != nil {
62+ cmd()
63+ }
64+
65+ golden.RequireEqual(t, []byte(l.View()))
66+ })
67+ t.Run("more than height multi line", func(t *testing.T) {
68+ t.Parallel()
69+ l := New(WithDirection(Backward))
70+ l.SetSize(10, 5)
71+ items := []Item{}
72+ for i := range 10 {
73+ item := NewSimpleItem(fmt.Sprintf("Item %d\nLine2", i))
74+ items = append(items, item)
75+ }
76+ cmd := l.SetItems(items)
77+ if cmd != nil {
78+ cmd()
79+ }
80+
81+ golden.RequireEqual(t, []byte(l.View()))
82+ })
83+ t.Run("should move up", func(t *testing.T) {
84+ t.Parallel()
85+ l := New(WithDirection(Backward)).(*list)
86+ l.SetSize(10, 5)
87+ items := []Item{}
88+ for i := range 10 {
89+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
90+ items = append(items, item)
91+ }
92+ cmd := l.SetItems(items)
93+ if cmd != nil {
94+ cmd()
95+ }
96+
97+ l.MoveUp(1)
98+ golden.RequireEqual(t, []byte(l.View()))
99+ })
100+ t.Run("should move at max to the top", func(t *testing.T) {
101+ t.Parallel()
102+ l := New(WithDirection(Backward)).(*list)
103+ l.SetSize(10, 5)
104+ items := []Item{}
105+ for i := range 10 {
106+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
107+ items = append(items, item)
108+ }
109+ cmd := l.SetItems(items)
110+ if cmd != nil {
111+ cmd()
112+ }
113+
114+ l.MoveUp(100)
115+ assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
116+ golden.RequireEqual(t, []byte(l.View()))
117+ })
118+ t.Run("should do nothing with wrong move number", func(t *testing.T) {
119+ t.Parallel()
120+ l := New(WithDirection(Backward)).(*list)
121+ l.SetSize(10, 5)
122+ items := []Item{}
123+ for i := range 10 {
124+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
125+ items = append(items, item)
126+ }
127+ cmd := l.SetItems(items)
128+ if cmd != nil {
129+ cmd()
130+ }
131+
132+ l.MoveUp(-10)
133+ golden.RequireEqual(t, []byte(l.View()))
134+ })
135+ t.Run("should move to the top", func(t *testing.T) {
136+ t.Parallel()
137+ l := New(WithDirection(Backward)).(*list)
138+ l.SetSize(10, 5)
139+ items := []Item{}
140+ for i := range 10 {
141+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
142+ items = append(items, item)
143+ }
144+ cmd := l.SetItems(items)
145+ if cmd != nil {
146+ cmd()
147+ }
148+
149+ l.GoToTop()
150+ assert.Equal(t, l.direction, Forward)
151+ golden.RequireEqual(t, []byte(l.View()))
152+ })
153+ t.Run("should select the item above", func(t *testing.T) {
154+ t.Parallel()
155+ l := New(WithDirection(Backward)).(*list)
156+ l.SetSize(10, 5)
157+ items := []Item{}
158+ for i := range 10 {
159+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
160+ items = append(items, item)
161+ }
162+ cmd := l.SetItems(items)
163+ if cmd != nil {
164+ cmd()
165+ }
166+
167+ selectedInx := len(l.items) - 2
168+ currentItem := items[len(l.items)-1]
169+ nextItem := items[selectedInx]
170+ assert.False(t, nextItem.(SimpleItem).IsFocused())
171+ assert.True(t, currentItem.(SimpleItem).IsFocused())
172+ cmd = l.SelectItemAbove()
173+ if cmd != nil {
174+ cmd()
175+ }
176+
177+ assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
178+ assert.True(t, l.items[selectedInx].(SimpleItem).IsFocused())
179+
180+ golden.RequireEqual(t, []byte(l.View()))
181+ })
182+ t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
183+ t.Parallel()
184+ l := New(WithDirection(Backward)).(*list)
185+ l.SetSize(10, 5)
186+ items := []Item{}
187+ for i := range 10 {
188+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
189+ items = append(items, item)
190+ }
191+ cmd := l.SetItems(items)
192+ if cmd != nil {
193+ cmd()
194+ }
195+
196+ for range 5 {
197+ cmd = l.SelectItemAbove()
198+ if cmd != nil {
199+ cmd()
200+ }
201+ }
202+ golden.RequireEqual(t, []byte(l.View()))
203+ })
204+}
205+
206+func TestForwardList(t *testing.T) {
207+ t.Run("within height", func(t *testing.T) {
208+ t.Parallel()
209+ l := New(WithDirection(Forward), WithGap(1)).(*list)
210+ l.SetSize(10, 20)
211+ items := []Item{}
212+ for i := range 5 {
213+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
214+ items = append(items, item)
215+ }
216+ cmd := l.SetItems(items)
217+ if cmd != nil {
218+ cmd()
219+ }
220+
221+ // should select the last item
222+ assert.Equal(t, l.selectedItem, items[0].ID())
223+
224+ golden.RequireEqual(t, []byte(l.View()))
225+ })
226+ t.Run("should not change selected item", func(t *testing.T) {
227+ t.Parallel()
228+ items := []Item{}
229+ for i := range 5 {
230+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
231+ items = append(items, item)
232+ }
233+ l := New(WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
234+ l.SetSize(10, 20)
235+ cmd := l.SetItems(items)
236+ if cmd != nil {
237+ cmd()
238+ }
239+ // should select the last item
240+ assert.Equal(t, l.selectedItem, items[2].ID())
241+ })
242+ t.Run("more than height", func(t *testing.T) {
243+ t.Parallel()
244+ l := New(WithDirection(Forward))
245+ l.SetSize(10, 5)
246+ items := []Item{}
247+ for i := range 10 {
248+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
249+ items = append(items, item)
250+ }
251+ cmd := l.SetItems(items)
252+ if cmd != nil {
253+ cmd()
254+ }
255+
256+ golden.RequireEqual(t, []byte(l.View()))
257+ })
258+ t.Run("more than height multi line", func(t *testing.T) {
259+ t.Parallel()
260+ l := New(WithDirection(Forward))
261+ l.SetSize(10, 5)
262+ items := []Item{}
263+ for i := range 10 {
264+ item := NewSimpleItem(fmt.Sprintf("Item %d\nLine2", i))
265+ items = append(items, item)
266+ }
267+ cmd := l.SetItems(items)
268+ if cmd != nil {
269+ cmd()
270+ }
271+
272+ golden.RequireEqual(t, []byte(l.View()))
273+ })
274+ t.Run("should move down", func(t *testing.T) {
275+ t.Parallel()
276+ l := New(WithDirection(Forward)).(*list)
277+ l.SetSize(10, 5)
278+ items := []Item{}
279+ for i := range 10 {
280+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
281+ items = append(items, item)
282+ }
283+ cmd := l.SetItems(items)
284+ if cmd != nil {
285+ cmd()
286+ }
287+
288+ l.MoveDown(1)
289+ golden.RequireEqual(t, []byte(l.View()))
290+ })
291+ t.Run("should move at max to the top", func(t *testing.T) {
292+ t.Parallel()
293+ l := New(WithDirection(Forward)).(*list)
294+ l.SetSize(10, 5)
295+ items := []Item{}
296+ for i := range 10 {
297+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
298+ items = append(items, item)
299+ }
300+ cmd := l.SetItems(items)
301+ if cmd != nil {
302+ cmd()
303+ }
304+
305+ l.MoveDown(100)
306+ assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
307+ golden.RequireEqual(t, []byte(l.View()))
308+ })
309+ t.Run("should do nothing with wrong move number", func(t *testing.T) {
310+ t.Parallel()
311+ l := New(WithDirection(Forward)).(*list)
312+ l.SetSize(10, 5)
313+ items := []Item{}
314+ for i := range 10 {
315+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
316+ items = append(items, item)
317+ }
318+ cmd := l.SetItems(items)
319+ if cmd != nil {
320+ cmd()
321+ }
322+
323+ l.MoveDown(-10)
324+ golden.RequireEqual(t, []byte(l.View()))
325+ })
326+ t.Run("should move to the bottom", func(t *testing.T) {
327+ t.Parallel()
328+ l := New(WithDirection(Forward)).(*list)
329+ l.SetSize(10, 5)
330+ items := []Item{}
331+ for i := range 10 {
332+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
333+ items = append(items, item)
334+ }
335+ cmd := l.SetItems(items)
336+ if cmd != nil {
337+ cmd()
338+ }
339+
340+ l.GoToBottom()
341+ assert.Equal(t, l.direction, Backward)
342+ golden.RequireEqual(t, []byte(l.View()))
343+ })
344+ t.Run("should select the item below", func(t *testing.T) {
345+ t.Parallel()
346+ l := New(WithDirection(Forward)).(*list)
347+ l.SetSize(10, 5)
348+ items := []Item{}
349+ for i := range 10 {
350+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
351+ items = append(items, item)
352+ }
353+ cmd := l.SetItems(items)
354+ if cmd != nil {
355+ cmd()
356+ }
357+
358+ selectedInx := 1
359+ currentItem := items[0]
360+ nextItem := items[selectedInx]
361+ assert.False(t, nextItem.(SimpleItem).IsFocused())
362+ assert.True(t, currentItem.(SimpleItem).IsFocused())
363+ cmd = l.SelectItemBelow()
364+ if cmd != nil {
365+ cmd()
366+ }
367+
368+ assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
369+ assert.True(t, l.items[selectedInx].(SimpleItem).IsFocused())
370+
371+ golden.RequireEqual(t, []byte(l.View()))
372+ })
373+ t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
374+ t.Parallel()
375+ l := New(WithDirection(Backward)).(*list)
376+ l.SetSize(10, 5)
377+ items := []Item{}
378+ for i := range 10 {
379+ item := NewSimpleItem(fmt.Sprintf("Item %d", i))
380+ items = append(items, item)
381+ }
382+ cmd := l.SetItems(items)
383+ if cmd != nil {
384+ cmd()
385+ }
386+
387+ for range 5 {
388+ cmd = l.SelectItemBelow()
389+ if cmd != nil {
390+ cmd()
391+ }
392+ }
393+ golden.RequireEqual(t, []byte(l.View()))
394+ })
395+}
396+
397+type SimpleItem interface {
398+ Item
399+ layout.Focusable
400+}
401+
402+type simpleItem struct {
403+ width int
404+ content string
405+ id string
406+ focused bool
407+}
408+
409+func NewSimpleItem(content string) SimpleItem {
410+ return &simpleItem{
411+ width: 0,
412+ content: content,
413+ focused: false,
414+ id: uuid.NewString(),
415+ }
416+}
417+
418+func (s *simpleItem) ID() string {
419+ return s.id
420+}
421+
422+func (s *simpleItem) Init() tea.Cmd {
423+ return nil
424+}
425+
426+func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
427+ return s, nil
428+}
429+
430+func (s *simpleItem) View() string {
431+ if s.focused {
432+ return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
433+ }
434+ return lipgloss.NewStyle().Width(s.width).Render(s.content)
435+}
436+
437+func (l *simpleItem) GetSize() (int, int) {
438+ return l.width, 0
439+}
440+
441+// SetSize implements Item.
442+func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
443+ s.width = width
444+ return nil
445+}
446+
447+// Blur implements SimpleItem.
448+func (s *simpleItem) Blur() tea.Cmd {
449+ s.focused = false
450+ return nil
451+}
452+
453+// Focus implements SimpleItem.
454+func (s *simpleItem) Focus() tea.Cmd {
455+ s.focused = true
456+ return nil
457+}
458+
459+// IsFocused implements SimpleItem.
460+func (s *simpleItem) IsFocused() bool {
461+ return s.focused
462+}
internal/tui/exp/list/testdata/TestBackwardList/more_than_height.goldennew file+5
@@ -0,0 +1,5 @@
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.goldennew file+5
@@ -0,0 +1,5 @@
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.goldennew file+5
@@ -0,0 +1,5 @@
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.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/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.goldennew file+5
@@ -0,0 +1,5 @@
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.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/TestBackwardList/should_move_up.goldennew file+5
@@ -0,0 +1,5 @@
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.goldennew file+5
@@ -0,0 +1,5 @@
1+Item 5
2+Item 6
3+Item 7
4+│Item 8
5+Item 9
internal/tui/exp/list/testdata/TestBackwardList/within_height.goldennew file+11
@@ -0,0 +1,11 @@
1+Item 0
2+
3+Item 0
4+
5+Item 1
6+
7+Item 2
8+
9+Item 3
10+
11+│Item 4
internal/tui/exp/list/testdata/TestForwardList/more_than_height.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/TestForwardList/more_than_height_multi_line.goldennew file+5
@@ -0,0 +1,5 @@
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.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/TestForwardList/should_move_at_max_to_the_top.goldennew file+5
@@ -0,0 +1,5 @@
1+Item 5
2+Item 6
3+Item 7
4+Item 8
5+Item 9
internal/tui/exp/list/testdata/TestForwardList/should_move_down.goldennew file+5
@@ -0,0 +1,5 @@
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.goldennew file+5
@@ -0,0 +1,5 @@
1+Item 5
2+Item 6
3+Item 7
4+Item 8
5+│Item 9
internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.goldennew file+5
@@ -0,0 +1,5 @@
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.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/TestForwardList/within_height.goldennew file+11
@@ -0,0 +1,11 @@
1+│Item 0
2+
3+│Item 0
4+
5+Item 1
6+
7+Item 2
8+
9+Item 3
10+
11+Item 4