@@ -1,9 +1,9 @@
11package list
22
33import (
4− "fmt"
54 "strings"
65
6+ "github.com/charmbracelet/bubbles/v2/key"
77 tea "github.com/charmbracelet/bubbletea/v2"
88 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
99 "github.com/charmbracelet/crush/internal/tui/util"
@@ -16,11 +16,19 @@ type Item interface {
1616 ID() string
1717}
1818
19−type List interface {
19+type List[T Item] interface {
2020 util.Model
2121 layout.Sizeable
2222 layout.Focusable
23− SetItems(items []Item) tea.Cmd
23+ MoveUp(int) tea.Cmd
24+ MoveDown(int) tea.Cmd
25+ GoToTop() tea.Cmd
26+ GoToBottom() tea.Cmd
27+ SelectItemAbove() tea.Cmd
28+ SelectItemBelow() tea.Cmd
29+ SetItems([]T) tea.Cmd
30+ SetSelected(string) tea.Cmd
31+ SelectedItem() *T
2432}
2533
2634type direction int
@@ -31,41 +39,45 @@ const (
3139)
3240
3341const (
34− NotFound = -1
42+ NotFound = -1
43+ DefaultScrollSize = 2
3544)
3645
46+type setSelectedMsg struct {
47+ selectedItemID string
48+}
49+
3750type renderedItem struct {
3851 id string
3952 view string
4053 height int
4154}
4255
43−type list struct {
56+type confOptions struct {
4457 width, height int
45− offset int
4658 gap int
47− direction direction
48− selectedItem string
49− focused bool
59+ // if you are at the last item and go down it will wrap to the top
60+ wrap bool
61+ keyMap KeyMap
62+ direction direction
63+ selectedItem string
64+}
65+type list[T Item] struct {
66+ confOptions
5067
51− items []Item
68+ focused bool
69+ offset int
70+ items []T
5271 renderedItems []renderedItem
5372 rendered string
5473 isReady bool
5574}
5675
57−type listOption func(*list)
58−
59−// WithItems sets the initial items for the list.
60−func WithItems(items ...Item) listOption {
61− return func(l *list) {
62− l.items = items
63− }
64−}
76+type listOption func(*confOptions)
6577
6678// WithSize sets the size of the list.
6779func WithSize(width, height int) listOption {
68− return func(l *list) {
80+ return func(l *confOptions) {
6981 l.width = width
7082 l.height = height
7183 }
@@ -73,44 +85,53 @@ func WithSize(width, height int) listOption {
7385
7486// WithGap sets the gap between items in the list.
7587func WithGap(gap int) listOption {
76− return func(l *list) {
88+ return func(l *confOptions) {
7789 l.gap = gap
7890 }
7991}
8092
8193// WithDirection sets the direction of the list.
8294func WithDirection(dir direction) listOption {
83− return func(l *list) {
95+ return func(l *confOptions) {
8496 l.direction = dir
8597 }
8698}
8799
88100// WithSelectedItem sets the initially selected item in the list.
89101func WithSelectedItem(id string) listOption {
90− return func(l *list) {
102+ return func(l *confOptions) {
91103 l.selectedItem = id
92104 }
93105}
94106
95−func New(opts ...listOption) List {
96− list := &list{
97− items: make([]Item, 0),
98− direction: Forward,
107+func WithKeyMap(keyMap KeyMap) listOption {
108+ return func(l *confOptions) {
109+ l.keyMap = keyMap
110+ }
111+}
112+
113+func WithWrapNavigation() listOption {
114+ return func(l *confOptions) {
115+ l.wrap = true
116+ }
117+}
118+
119+func New[T Item](items []T, opts ...listOption) List[T] {
120+ list := &list[T]{
121+ confOptions: confOptions{
122+ direction: Forward,
123+ keyMap: DefaultKeyMap(),
124+ },
125+ items: items,
99126 }
100127 for _, opt := range opts {
101− opt(list)
128+ opt(&list.confOptions)
102129 }
103130 return list
104131}
105132
106133// Init implements List.
107−func (l *list) Init() tea.Cmd {
108− if l.height <= 0 || l.width <= 0 {
109− return nil
110− }
111− if len(l.items) == 0 {
112− return nil
113− }
134+func (l *list[T]) Init() tea.Cmd {
114135 var cmds []tea.Cmd
115136 for _, item := range l.items {
116137 cmd := item.Init()
@@ -121,12 +142,41 @@ func (l *list) Init() tea.Cmd {
121142}
122143
123144// Update implements List.
124−func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
145+func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146+ switch msg := msg.(type) {
147+ case setSelectedMsg:
148+ return l, l.SetSelected(msg.selectedItemID)
149+ case tea.KeyPressMsg:
150+ if l.focused {
151+ switch {
152+ case key.Matches(msg, l.keyMap.Down):
153+ return l, l.MoveDown(DefaultScrollSize)
154+ case key.Matches(msg, l.keyMap.Up):
155+ return l, l.MoveUp(DefaultScrollSize)
156+ case key.Matches(msg, l.keyMap.DownOneItem):
157+ return l, l.SelectItemBelow()
158+ case key.Matches(msg, l.keyMap.UpOneItem):
159+ return l, l.SelectItemAbove()
160+ case key.Matches(msg, l.keyMap.HalfPageDown):
161+ return l, l.MoveDown(l.listHeight() / 2)
162+ case key.Matches(msg, l.keyMap.HalfPageUp):
163+ return l, l.MoveUp(l.listHeight() / 2)
164+ case key.Matches(msg, l.keyMap.PageDown):
165+ return l, l.MoveDown(l.listHeight())
166+ case key.Matches(msg, l.keyMap.PageUp):
167+ return l, l.MoveUp(l.listHeight())
168+ case key.Matches(msg, l.keyMap.End):
169+ return l, l.GoToBottom()
170+ case key.Matches(msg, l.keyMap.Home):
171+ return l, l.GoToTop()
172+ }
173+ }
174+ }
125175 return l, nil
126176}
127177
128178// View implements List.
129−func (l *list) View() string {
179+func (l *list[T]) View() string {
130180 if l.height <= 0 || l.width <= 0 {
131181 return ""
132182 }
@@ -138,7 +188,7 @@ func (l *list) View() string {
138188 return strings.Join(lines, "\n")
139189}
140190
141−func (l *list) viewPosition() (int, int) {
191+func (l *list[T]) viewPosition() (int, int) {
142192 start, end := 0, 0
143193 renderedLines := lipgloss.Height(l.rendered) - 1
144194 if l.direction == Forward {
@@ -151,7 +201,7 @@ func (l *list) viewPosition() (int, int) {
151201 return start, end
152202}
153203
154−func (l *list) renderItem(item Item) renderedItem {
204+func (l *list[T]) renderItem(item Item) renderedItem {
155205 view := item.View()
156206 return renderedItem{
157207 id: item.ID(),
@@ -160,7 +210,7 @@ func (l *list) renderItem(item Item) renderedItem {
160210 }
161211}
162212
163−func (l *list) renderView() {
213+func (l *list[T]) renderView() {
164214 var sb strings.Builder
165215 for i, rendered := range l.renderedItems {
166216 sb.WriteString(rendered.view)
@@ -171,7 +221,7 @@ func (l *list) renderView() {
171221 l.rendered = sb.String()
172222}
173223
174−func (l *list) incrementOffset(n int) {
224+func (l *list[T]) incrementOffset(n int) {
175225 if !l.isReady {
176226 return
177227 }
@@ -188,7 +238,7 @@ func (l *list) incrementOffset(n int) {
188238 l.offset += n
189239}
190240
191−func (l *list) decrementOffset(n int) {
241+func (l *list[T]) decrementOffset(n int) {
192242 if !l.isReady {
193243 return
194244 }
@@ -203,7 +253,7 @@ func (l *list) decrementOffset(n int) {
203253}
204254
205255// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
206−func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
256+func (l *list[T]) changeSelectedWhenNotVisible() tea.Cmd {
207257 var cmds []tea.Cmd
208258 start, end := l.viewPosition()
209259 currentPosition := 0
@@ -228,7 +278,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
228278 needsMove = true
229279 }
230280 if needsMove {
231− if focusable, ok := item.(layout.Focusable); ok {
281+ if focusable, ok := any(item).(layout.Focusable); ok {
232282 cmds = append(cmds, focusable.Blur())
233283 }
234284 l.renderedItems[i] = l.renderItem(item)
@@ -239,7 +289,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
239289 if itemWithinView != NotFound && needsMove {
240290 newSelection := l.items[itemWithinView]
241291 l.selectedItem = newSelection.ID()
242− if focusable, ok := newSelection.(layout.Focusable); ok {
292+ if focusable, ok := any(newSelection).(layout.Focusable); ok {
243293 cmds = append(cmds, focusable.Focus())
244294 }
245295 l.renderedItems[itemWithinView] = l.renderItem(newSelection)
@@ -251,7 +301,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
251301 return tea.Batch(cmds...)
252302}
253303
254−func (l *list) MoveUp(n int) tea.Cmd {
304+func (l *list[T]) MoveUp(n int) tea.Cmd {
255305 if l.direction == Forward {
256306 l.decrementOffset(n)
257307 } else {
@@ -260,7 +310,7 @@ func (l *list) MoveUp(n int) tea.Cmd {
260310 return l.changeSelectedWhenNotVisible()
261311}
262312
263−func (l *list) MoveDown(n int) tea.Cmd {
313+func (l *list[T]) MoveDown(n int) tea.Cmd {
264314 if l.direction == Forward {
265315 l.incrementOffset(n)
266316 } else {
@@ -269,49 +319,80 @@ func (l *list) MoveDown(n int) tea.Cmd {
269319 return l.changeSelectedWhenNotVisible()
270320}
271321
272−func (l *list) firstSelectableItemBefore(inx int) int {
322+func (l *list[T]) firstSelectableItemBefore(inx int) int {
273323 for i := inx - 1; i >= 0; i-- {
274− if _, ok := l.items[i].(layout.Focusable); ok {
324+ if _, ok := any(l.items[i]).(layout.Focusable); ok {
275325 return i
276326 }
277327 }
328+ if inx == 0 && l.wrap {
329+ return l.firstSelectableItemBefore(len(l.items))
330+ }
278331 return NotFound
279332}
280333
281−func (l *list) firstSelectableItemAfter(inx int) int {
334+func (l *list[T]) firstSelectableItemAfter(inx int) int {
282335 for i := inx + 1; i < len(l.items); i++ {
283− if _, ok := l.items[i].(layout.Focusable); ok {
336+ if _, ok := any(l.items[i]).(layout.Focusable); ok {
284337 return i
285338 }
286339 }
340+ if inx == len(l.items)-1 && l.wrap {
341+ return l.firstSelectableItemAfter(-1)
342+ }
287343 return NotFound
288344}
289345
290−func (l *list) moveToSelected() {
346+func (l *list[T]) moveToSelected(center bool) tea.Cmd {
347+ var cmds []tea.Cmd
291348 if l.selectedItem == "" || !l.isReady {
292− return
349+ return nil
293350 }
294351 currentPosition := 0
295352 start, end := l.viewPosition()
296353 for _, item := range l.renderedItems {
297354 if item.id == l.selectedItem {
298− if start <= currentPosition && (currentPosition+item.height) <= end {
299− return
300− }
301− // we need to go up
302− if currentPosition < start {
303− l.MoveUp(start - currentPosition)
355+ itemStart := currentPosition
356+ itemEnd := currentPosition + item.height - 1
357+
358+ if start <= itemStart && itemEnd <= end {
359+ return nil
304360 }
305− // we need to go down
306− if currentPosition > end {
307− l.MoveDown(currentPosition - end)
361+
362+ if center {
363+ viewportCenter := l.listHeight() / 2
364+ itemCenter := itemStart + item.height/2
365+ targetOffset := itemCenter - viewportCenter
366+ if l.direction == Forward {
367+ if targetOffset > l.offset {
368+ cmds = append(cmds, l.MoveDown(targetOffset-l.offset))
369+ } else if targetOffset < l.offset {
370+ cmds = append(cmds, l.MoveUp(l.offset-targetOffset))
371+ }
372+ } else {
373+ renderedHeight := lipgloss.Height(l.rendered)
374+ backwardTargetOffset := renderedHeight - targetOffset - l.listHeight()
375+ if backwardTargetOffset > l.offset {
376+ cmds = append(cmds, l.MoveUp(backwardTargetOffset-l.offset))
377+ } else if backwardTargetOffset < l.offset {
378+ cmds = append(cmds, l.MoveDown(l.offset-backwardTargetOffset))
379+ }
380+ }
381+ } else {
382+ if currentPosition < start {
383+ cmds = append(cmds, l.MoveUp(start-currentPosition))
384+ }
385+ if currentPosition > end {
386+ cmds = append(cmds, l.MoveDown(currentPosition-end))
387+ }
308388 }
309389 }
310390 currentPosition += item.height + l.gap
311391 }
392+ return tea.Batch(cmds...)
312393}
313394
314−func (l *list) SelectItemAbove() tea.Cmd {
395+func (l *list[T]) SelectItemAbove() tea.Cmd {
315396 if !l.isReady {
316397 return nil
317398 }
@@ -324,14 +405,14 @@ func (l *list) SelectItemAbove() tea.Cmd {
324405 return nil
325406 }
326407 // blur the current item
327− if focusable, ok := item.(layout.Focusable); ok {
408+ if focusable, ok := any(item).(layout.Focusable); ok {
328409 cmds = append(cmds, focusable.Blur())
329410 }
330411 // rerender the item
331412 l.renderedItems[i] = l.renderItem(item)
332413 // focus the item above
333414 above := l.items[inx]
334− if focusable, ok := above.(layout.Focusable); ok {
415+ if focusable, ok := any(above).(layout.Focusable); ok {
335416 cmds = append(cmds, focusable.Focus())
336417 }
337418 // rerender the item
@@ -340,12 +421,12 @@ func (l *list) SelectItemAbove() tea.Cmd {
340421 break
341422 }
342423 }
424+ l.moveToSelected(false)
343425 l.renderView()
344− l.moveToSelected()
345426 return tea.Batch(cmds...)
346427}
347428
348−func (l *list) SelectItemBelow() tea.Cmd {
429+func (l *list[T]) SelectItemBelow() tea.Cmd {
349430 if !l.isReady {
350431 return nil
351432 }
@@ -358,7 +439,7 @@ func (l *list) SelectItemBelow() tea.Cmd {
358439 return nil
359440 }
360441 // blur the current item
361− if focusable, ok := item.(layout.Focusable); ok {
442+ if focusable, ok := any(item).(layout.Focusable); ok {
362443 cmds = append(cmds, focusable.Blur())
363444 }
364445 // rerender the item
@@ -366,7 +447,7 @@ func (l *list) SelectItemBelow() tea.Cmd {
366447
367448 // focus the item below
368449 below := l.items[inx]
369− if focusable, ok := below.(layout.Focusable); ok {
450+ if focusable, ok := any(below).(layout.Focusable); ok {
370451 cmds = append(cmds, focusable.Focus())
371452 }
372453 // rerender the item
@@ -376,12 +457,12 @@ func (l *list) SelectItemBelow() tea.Cmd {
376457 }
377458 }
378459
460+ l.moveToSelected(false)
379461 l.renderView()
380− l.moveToSelected()
381462 return tea.Batch(cmds...)
382463}
383464
384−func (l *list) GoToTop() tea.Cmd {
465+func (l *list[T]) GoToTop() tea.Cmd {
385466 if !l.isReady {
386467 return nil
387468 }
@@ -390,7 +471,7 @@ func (l *list) GoToTop() tea.Cmd {
390471 return tea.Batch(l.selectFirstItem(), l.renderForward())
391472}
392473
393−func (l *list) GoToBottom() tea.Cmd {
474+func (l *list[T]) GoToBottom() tea.Cmd {
394475 if !l.isReady {
395476 return nil
396477 }
@@ -400,7 +481,7 @@ func (l *list) GoToBottom() tea.Cmd {
400481 return tea.Batch(l.selectLastItem(), l.renderBackward())
401482}
402483
403−func (l *list) renderForward() tea.Cmd {
484+func (l *list[T]) renderForward() tea.Cmd {
404485 // TODO: figure out a way to preserve items that did not change
405486 l.renderedItems = make([]renderedItem, 0)
406487 currentHeight := 0
@@ -434,13 +515,12 @@ func (l *list) renderForward() tea.Cmd {
434515 }
435516}
436517
437−func (l *list) renderBackward() tea.Cmd {
518+func (l *list[T]) renderBackward() tea.Cmd {
438519 // TODO: figure out a way to preserve items that did not change
439520 l.renderedItems = make([]renderedItem, 0)
440521 currentHeight := 0
441522 currentIndex := 0
442523 for i := len(l.items) - 1; i >= 0; i-- {
443− fmt.Printf("rendering item %d\n", i)
444524 currentIndex = i
445525 if currentHeight > l.listHeight() {
446526 break
@@ -457,7 +537,6 @@ func (l *list) renderBackward() tea.Cmd {
457537 }
458538 return func() tea.Msg {
459539 for i := currentIndex; i >= 0; i-- {
460− fmt.Printf("rendering item after %d\n", i)
461540 rendered := l.renderItem(l.items[i])
462541 l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
463542 }
@@ -467,31 +546,31 @@ func (l *list) renderBackward() tea.Cmd {
467546 }
468547}
469548
470−func (l *list) selectFirstItem() tea.Cmd {
549+func (l *list[T]) selectFirstItem() tea.Cmd {
471550 var cmd tea.Cmd
472551 inx := l.firstSelectableItemAfter(-1)
473552 if inx != NotFound {
474553 l.selectedItem = l.items[inx].ID()
475− if focusable, ok := l.items[inx].(layout.Focusable); ok {
554+ if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
476555 cmd = focusable.Focus()
477556 }
478557 }
479558 return cmd
480559}
481560
482−func (l *list) selectLastItem() tea.Cmd {
561+func (l *list[T]) selectLastItem() tea.Cmd {
483562 var cmd tea.Cmd
484563 inx := l.firstSelectableItemBefore(len(l.items))
485564 if inx != NotFound {
486565 l.selectedItem = l.items[inx].ID()
487− if focusable, ok := l.items[inx].(layout.Focusable); ok {
566+ if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
488567 cmd = focusable.Focus()
489568 }
490569 }
491570 return cmd
492571}
493572
494−func (l *list) renderItems() tea.Cmd {
573+func (l *list[T]) renderItems() tea.Cmd {
495574 if l.height <= 0 || l.width <= 0 {
496575 return nil
497576 }
@@ -512,12 +591,12 @@ func (l *list) renderItems() tea.Cmd {
512591 return l.renderBackward()
513592}
514593
515−func (l *list) listHeight() int {
594+func (l *list[T]) listHeight() int {
516595 // for the moment its the same
517596 return l.height
518597}
519598
520−func (l *list) SetItems(items []Item) tea.Cmd {
599+func (l *list[T]) SetItems(items []T) tea.Cmd {
521600 l.items = items
522601 var cmds []tea.Cmd
523602 for _, item := range l.items {
@@ -525,36 +604,41 @@ func (l *list) SetItems(items []Item) tea.Cmd {
525604 // Set height to 0 to let the item calculate its own height
526605 cmds = append(cmds, item.SetSize(l.width, 0))
527606 }
607+
608+ if l.selectedItem != "" {
609+ cmds = append(cmds, l.moveToSelected(true))
610+ }
528611 cmds = append(cmds, l.renderItems())
529612 return tea.Batch(cmds...)
530613}
531614
532615// GetSize implements List.
533−func (l *list) GetSize() (int, int) {
616+func (l *list[T]) GetSize() (int, int) {
534617 return l.width, l.height
535618}
536619
537620// SetSize implements List.
538−func (l *list) SetSize(width int, height int) tea.Cmd {
621+func (l *list[T]) SetSize(width int, height int) tea.Cmd {
539622 l.width = width
540623 l.height = height
541624 var cmds []tea.Cmd
542625 for _, item := range l.items {
543626 cmds = append(cmds, item.SetSize(width, height))
544627 }
628+
545629 cmds = append(cmds, l.renderItems())
546630 return tea.Batch(cmds...)
547631}
548632
549633// Blur implements List.
550−func (l *list) Blur() tea.Cmd {
634+func (l *list[T]) Blur() tea.Cmd {
551635 var cmd tea.Cmd
552636 l.focused = false
553637 for i, item := range l.items {
554638 if item.ID() != l.selectedItem {
555639 continue
556640 }
557− if focusable, ok := item.(layout.Focusable); ok {
641+ if focusable, ok := any(item).(layout.Focusable); ok {
558642 cmd = focusable.Blur()
559643 }
560644 l.renderedItems[i] = l.renderItem(item)
@@ -564,23 +648,64 @@ func (l *list) Blur() tea.Cmd {
564648}
565649
566650// Focus implements List.
567−func (l *list) Focus() tea.Cmd {
651+func (l *list[T]) Focus() tea.Cmd {
568652 var cmd tea.Cmd
569653 l.focused = true
570− for i, item := range l.items {
571− if item.ID() != l.selectedItem {
572− continue
654+ if l.selectedItem != "" {
655+ for i, item := range l.items {
656+ if item.ID() != l.selectedItem {
657+ continue
658+ }
659+ if focusable, ok := any(item).(layout.Focusable); ok {
660+ cmd = focusable.Focus()
661+ }
662+ if len(l.renderedItems) > i {
663+ l.renderedItems[i] = l.renderItem(item)
664+ }
573665 }
574− if focusable, ok := item.(layout.Focusable); ok {
575− cmd = focusable.Focus()
666+ l.renderView()
667+ }
668+ return cmd
669+}
670+
671+func (l *list[T]) SetSelected(id string) tea.Cmd {
672+ if l.selectedItem == id {
673+ return nil
674+ }
675+ var cmds []tea.Cmd
676+ for i, item := range l.items {
677+ if item.ID() == l.selectedItem {
678+ if focusable, ok := any(item).(layout.Focusable); ok {
679+ cmds = append(cmds, focusable.Blur())
680+ }
681+ if len(l.renderedItems) > i {
682+ l.renderedItems[i] = l.renderItem(item)
683+ }
684+ } else if item.ID() == id {
685+ if focusable, ok := any(item).(layout.Focusable); ok {
686+ cmds = append(cmds, focusable.Focus())
687+ }
688+ if len(l.renderedItems) > i {
689+ l.renderedItems[i] = l.renderItem(item)
690+ }
576691 }
577− l.renderedItems[i] = l.renderItem(item)
578692 }
693+ l.selectedItem = id
694+ cmds = append(cmds, l.moveToSelected(true))
579695 l.renderView()
580− return cmd
696+ return tea.Batch(cmds...)
697+}
698+
699+func (l *list[T]) SelectedItem() *T {
700+ for _, item := range l.items {
701+ if item.ID() == l.selectedItem {
702+ return &item
703+ }
704+ }
705+ return nil
581706}
582707
583708// IsFocused implements List.
584−func (l *list) IsFocused() bool {
709+func (l *list[T]) IsFocused() bool {
585710 return l.focused
586711}