1package list
2
3import (
4 "fmt"
5 "strings"
6
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
9 "github.com/charmbracelet/crush/internal/tui/util"
10 "github.com/charmbracelet/lipgloss/v2"
11)
12
13type Item interface {
14 util.Model
15 layout.Sizeable
16 ID() string
17}
18
19type List interface {
20 util.Model
21 layout.Sizeable
22 layout.Focusable
23 SetItems(items []Item) tea.Cmd
24}
25
26type direction int
27
28const (
29 Forward direction = iota
30 Backward
31)
32
33const (
34 NotFound = -1
35)
36
37type renderedItem struct {
38 id string
39 view string
40 height int
41}
42
43type list struct {
44 width, height int
45 offset int
46 gap int
47 direction direction
48 selectedItem string
49 focused bool
50
51 items []Item
52 renderedItems []renderedItem
53 rendered string
54 isReady bool
55}
56
57type listOption func(*list)
58
59// WithItems sets the initial items for the list.
60func WithItems(items ...Item) listOption {
61 return func(l *list) {
62 l.items = items
63 }
64}
65
66// WithSize sets the size of the list.
67func WithSize(width, height int) listOption {
68 return func(l *list) {
69 l.width = width
70 l.height = height
71 }
72}
73
74// WithGap sets the gap between items in the list.
75func WithGap(gap int) listOption {
76 return func(l *list) {
77 l.gap = gap
78 }
79}
80
81// WithDirection sets the direction of the list.
82func WithDirection(dir direction) listOption {
83 return func(l *list) {
84 l.direction = dir
85 }
86}
87
88// WithSelectedItem sets the initially selected item in the list.
89func WithSelectedItem(id string) listOption {
90 return func(l *list) {
91 l.selectedItem = id
92 }
93}
94
95func New(opts ...listOption) List {
96 list := &list{
97 items: make([]Item, 0),
98 direction: Forward,
99 }
100 for _, opt := range opts {
101 opt(list)
102 }
103 return list
104}
105
106// Init implements List.
107func (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 }
114 var cmds []tea.Cmd
115 for _, item := range l.items {
116 cmd := item.Init()
117 cmds = append(cmds, cmd)
118 }
119 cmds = append(cmds, l.renderItems())
120 return tea.Batch(cmds...)
121}
122
123// Update implements List.
124func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
125 return l, nil
126}
127
128// View implements List.
129func (l *list) View() string {
130 if l.height <= 0 || l.width <= 0 {
131 return ""
132 }
133 view := l.rendered
134 lines := strings.Split(view, "\n")
135
136 start, end := l.viewPosition()
137 lines = lines[start : end+1]
138 return strings.Join(lines, "\n")
139}
140
141func (l *list) viewPosition() (int, int) {
142 start, end := 0, 0
143 renderedLines := lipgloss.Height(l.rendered) - 1
144 if l.direction == Forward {
145 start = max(0, l.offset)
146 end = min(l.offset+l.listHeight()-1, renderedLines)
147 } else {
148 start = max(0, renderedLines-l.offset-l.listHeight()+1)
149 end = max(0, renderedLines-l.offset)
150 }
151 return start, end
152}
153
154func (l *list) renderItem(item Item) renderedItem {
155 view := item.View()
156 return renderedItem{
157 id: item.ID(),
158 view: view,
159 height: lipgloss.Height(view),
160 }
161}
162
163func (l *list) renderView() {
164 var sb strings.Builder
165 for i, rendered := range l.renderedItems {
166 sb.WriteString(rendered.view)
167 if i < len(l.renderedItems)-1 {
168 sb.WriteString(strings.Repeat("\n", l.gap+1))
169 }
170 }
171 l.rendered = sb.String()
172}
173
174func (l *list) incrementOffset(n int) {
175 if !l.isReady {
176 return
177 }
178 renderedHeight := lipgloss.Height(l.rendered)
179 // no need for offset
180 if renderedHeight <= l.listHeight() {
181 return
182 }
183 maxOffset := renderedHeight - l.listHeight()
184 n = min(n, maxOffset-l.offset)
185 if n <= 0 {
186 return
187 }
188 l.offset += n
189}
190
191func (l *list) decrementOffset(n int) {
192 if !l.isReady {
193 return
194 }
195 n = min(n, l.offset)
196 if n <= 0 {
197 return
198 }
199 l.offset -= n
200 if l.offset < 0 {
201 l.offset = 0
202 }
203}
204
205// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
206func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
207 var cmds []tea.Cmd
208 start, end := l.viewPosition()
209 currentPosition := 0
210 itemWithinView := NotFound
211 needsMove := false
212
213 for i, item := range l.items {
214 rendered := l.renderedItems[i]
215 itemStart := currentPosition
216 // we remove 1 so that we actually have the row, e.x 1 row => height 1 => start 0, end 0
217 itemEnd := itemStart + rendered.height - 1
218 if itemStart >= start && itemEnd <= end {
219 itemWithinView = i
220 }
221 if item.ID() == l.selectedItem {
222 // item is completely above the viewport
223 if itemStart < start && itemEnd < start {
224 needsMove = true
225 }
226 // item is completely below the viewport
227 if itemStart > end && itemEnd > end {
228 needsMove = true
229 }
230 if needsMove {
231 if focusable, ok := item.(layout.Focusable); ok {
232 cmds = append(cmds, focusable.Blur())
233 }
234 l.renderedItems[i] = l.renderItem(item)
235 } else {
236 return nil
237 }
238 }
239 if itemWithinView != NotFound && needsMove {
240 newSelection := l.items[itemWithinView]
241 l.selectedItem = newSelection.ID()
242 if focusable, ok := newSelection.(layout.Focusable); ok {
243 cmds = append(cmds, focusable.Focus())
244 }
245 l.renderedItems[itemWithinView] = l.renderItem(newSelection)
246 break
247 }
248 currentPosition += rendered.height + l.gap
249 }
250 l.renderView()
251 return tea.Batch(cmds...)
252}
253
254func (l *list) MoveUp(n int) tea.Cmd {
255 if l.direction == Forward {
256 l.decrementOffset(n)
257 } else {
258 l.incrementOffset(n)
259 }
260 return l.changeSelectedWhenNotVisible()
261}
262
263func (l *list) MoveDown(n int) tea.Cmd {
264 if l.direction == Forward {
265 l.incrementOffset(n)
266 } else {
267 l.decrementOffset(n)
268 }
269 return l.changeSelectedWhenNotVisible()
270}
271
272func (l *list) firstSelectableItemBefore(inx int) int {
273 for i := inx - 1; i >= 0; i-- {
274 if _, ok := l.items[i].(layout.Focusable); ok {
275 return i
276 }
277 }
278 return NotFound
279}
280
281func (l *list) firstSelectableItemAfter(inx int) int {
282 for i := inx + 1; i < len(l.items); i++ {
283 if _, ok := l.items[i].(layout.Focusable); ok {
284 return i
285 }
286 }
287 return NotFound
288}
289
290func (l *list) moveToSelected() {
291 if l.selectedItem == "" || !l.isReady {
292 return
293 }
294 currentPosition := 0
295 start, end := l.viewPosition()
296 for _, item := range l.renderedItems {
297 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)
304 }
305 // we need to go down
306 if currentPosition > end {
307 l.MoveDown(currentPosition - end)
308 }
309 }
310 currentPosition += item.height + l.gap
311 }
312}
313
314func (l *list) SelectItemAbove() tea.Cmd {
315 if !l.isReady {
316 return nil
317 }
318 var cmds []tea.Cmd
319 for i, item := range l.items {
320 if l.selectedItem == item.ID() {
321 inx := l.firstSelectableItemBefore(i)
322 if inx == NotFound {
323 // no item above
324 return nil
325 }
326 // blur the current item
327 if focusable, ok := item.(layout.Focusable); ok {
328 cmds = append(cmds, focusable.Blur())
329 }
330 // rerender the item
331 l.renderedItems[i] = l.renderItem(item)
332 // focus the item above
333 above := l.items[inx]
334 if focusable, ok := above.(layout.Focusable); ok {
335 cmds = append(cmds, focusable.Focus())
336 }
337 // rerender the item
338 l.renderedItems[inx] = l.renderItem(above)
339 l.selectedItem = above.ID()
340 break
341 }
342 }
343 l.renderView()
344 l.moveToSelected()
345 return tea.Batch(cmds...)
346}
347
348func (l *list) SelectItemBelow() tea.Cmd {
349 if !l.isReady {
350 return nil
351 }
352 var cmds []tea.Cmd
353 for i, item := range l.items {
354 if l.selectedItem == item.ID() {
355 inx := l.firstSelectableItemAfter(i)
356 if inx == NotFound {
357 // no item below
358 return nil
359 }
360 // blur the current item
361 if focusable, ok := item.(layout.Focusable); ok {
362 cmds = append(cmds, focusable.Blur())
363 }
364 // rerender the item
365 l.renderedItems[i] = l.renderItem(item)
366
367 // focus the item below
368 below := l.items[inx]
369 if focusable, ok := below.(layout.Focusable); ok {
370 cmds = append(cmds, focusable.Focus())
371 }
372 // rerender the item
373 l.renderedItems[inx] = l.renderItem(below)
374 l.selectedItem = below.ID()
375 break
376 }
377 }
378
379 l.renderView()
380 l.moveToSelected()
381 return tea.Batch(cmds...)
382}
383
384func (l *list) GoToTop() tea.Cmd {
385 if !l.isReady {
386 return nil
387 }
388 l.offset = 0
389 l.direction = Forward
390 return tea.Batch(l.selectFirstItem(), l.renderForward())
391}
392
393func (l *list) GoToBottom() tea.Cmd {
394 if !l.isReady {
395 return nil
396 }
397 l.offset = 0
398 l.direction = Backward
399
400 return tea.Batch(l.selectLastItem(), l.renderBackward())
401}
402
403func (l *list) renderForward() tea.Cmd {
404 // TODO: figure out a way to preserve items that did not change
405 l.renderedItems = make([]renderedItem, 0)
406 currentHeight := 0
407 currentIndex := 0
408 for i, item := range l.items {
409 currentIndex = i
410 if currentHeight-1 > l.listHeight() {
411 break
412 }
413 rendered := l.renderItem(item)
414 l.renderedItems = append(l.renderedItems, rendered)
415 currentHeight += rendered.height + l.gap
416 }
417
418 // initial render
419 l.renderView()
420
421 if currentIndex == len(l.items)-1 {
422 l.isReady = true
423 return nil
424 }
425 // render the rest
426 return func() tea.Msg {
427 for i := currentIndex; i < len(l.items); i++ {
428 rendered := l.renderItem(l.items[i])
429 l.renderedItems = append(l.renderedItems, rendered)
430 }
431 l.renderView()
432 l.isReady = true
433 return nil
434 }
435}
436
437func (l *list) renderBackward() tea.Cmd {
438 // TODO: figure out a way to preserve items that did not change
439 l.renderedItems = make([]renderedItem, 0)
440 currentHeight := 0
441 currentIndex := 0
442 for i := len(l.items) - 1; i >= 0; i-- {
443 fmt.Printf("rendering item %d\n", i)
444 currentIndex = i
445 if currentHeight > l.listHeight() {
446 break
447 }
448 rendered := l.renderItem(l.items[i])
449 l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
450 currentHeight += rendered.height + l.gap
451 }
452 // initial render
453 l.renderView()
454 if currentIndex == 0 {
455 l.isReady = true
456 return nil
457 }
458 return func() tea.Msg {
459 for i := currentIndex; i >= 0; i-- {
460 fmt.Printf("rendering item after %d\n", i)
461 rendered := l.renderItem(l.items[i])
462 l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
463 }
464 l.renderView()
465 l.isReady = true
466 return nil
467 }
468}
469
470func (l *list) selectFirstItem() tea.Cmd {
471 var cmd tea.Cmd
472 inx := l.firstSelectableItemAfter(-1)
473 if inx != NotFound {
474 l.selectedItem = l.items[inx].ID()
475 if focusable, ok := l.items[inx].(layout.Focusable); ok {
476 cmd = focusable.Focus()
477 }
478 }
479 return cmd
480}
481
482func (l *list) selectLastItem() tea.Cmd {
483 var cmd tea.Cmd
484 inx := l.firstSelectableItemBefore(len(l.items))
485 if inx != NotFound {
486 l.selectedItem = l.items[inx].ID()
487 if focusable, ok := l.items[inx].(layout.Focusable); ok {
488 cmd = focusable.Focus()
489 }
490 }
491 return cmd
492}
493
494func (l *list) renderItems() tea.Cmd {
495 if l.height <= 0 || l.width <= 0 {
496 return nil
497 }
498 if len(l.items) == 0 {
499 return nil
500 }
501
502 if l.selectedItem == "" {
503 if l.direction == Forward {
504 l.selectFirstItem()
505 } else {
506 l.selectLastItem()
507 }
508 }
509 if l.direction == Forward {
510 return l.renderForward()
511 }
512 return l.renderBackward()
513}
514
515func (l *list) listHeight() int {
516 // for the moment its the same
517 return l.height
518}
519
520func (l *list) SetItems(items []Item) tea.Cmd {
521 l.items = items
522 var cmds []tea.Cmd
523 for _, item := range l.items {
524 cmds = append(cmds, item.Init())
525 // Set height to 0 to let the item calculate its own height
526 cmds = append(cmds, item.SetSize(l.width, 0))
527 }
528 cmds = append(cmds, l.renderItems())
529 return tea.Batch(cmds...)
530}
531
532// GetSize implements List.
533func (l *list) GetSize() (int, int) {
534 return l.width, l.height
535}
536
537// SetSize implements List.
538func (l *list) SetSize(width int, height int) tea.Cmd {
539 l.width = width
540 l.height = height
541 var cmds []tea.Cmd
542 for _, item := range l.items {
543 cmds = append(cmds, item.SetSize(width, height))
544 }
545 cmds = append(cmds, l.renderItems())
546 return tea.Batch(cmds...)
547}
548
549// Blur implements List.
550func (l *list) Blur() tea.Cmd {
551 var cmd tea.Cmd
552 l.focused = false
553 for i, item := range l.items {
554 if item.ID() != l.selectedItem {
555 continue
556 }
557 if focusable, ok := item.(layout.Focusable); ok {
558 cmd = focusable.Blur()
559 }
560 l.renderedItems[i] = l.renderItem(item)
561 }
562 l.renderView()
563 return cmd
564}
565
566// Focus implements List.
567func (l *list) Focus() tea.Cmd {
568 var cmd tea.Cmd
569 l.focused = true
570 for i, item := range l.items {
571 if item.ID() != l.selectedItem {
572 continue
573 }
574 if focusable, ok := item.(layout.Focusable); ok {
575 cmd = focusable.Focus()
576 }
577 l.renderedItems[i] = l.renderItem(item)
578 }
579 l.renderView()
580 return cmd
581}
582
583// IsFocused implements List.
584func (l *list) IsFocused() bool {
585 return l.focused
586}