1package list
2
3import (
4 "strings"
5
6 tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
8 "github.com/charmbracelet/crush/internal/tui/util"
9 "github.com/charmbracelet/lipgloss/v2"
10)
11
12type Item interface {
13 util.Model
14 layout.Sizeable
15 ID() string
16}
17
18type List interface {
19 util.Model
20 layout.Sizeable
21 layout.Focusable
22 SetItems(items []Item) tea.Cmd
23}
24
25type direction int
26
27const (
28 Forward direction = iota
29 Backward
30)
31
32const (
33 NotFound = -1
34)
35
36type renderedItem struct {
37 id string
38 view string
39 height int
40}
41
42type list struct {
43 width, height int
44 offset int
45 gap int
46 direction direction
47 selectedItem string
48 focused bool
49
50 items []Item
51 renderedItems []renderedItem
52 rendered string
53 isReady bool
54}
55
56type listOption func(*list)
57
58// WithItems sets the initial items for the list.
59func WithItems(items ...Item) listOption {
60 return func(l *list) {
61 l.items = items
62 }
63}
64
65// WithSize sets the size of the list.
66func WithSize(width, height int) listOption {
67 return func(l *list) {
68 l.width = width
69 l.height = height
70 }
71}
72
73// WithGap sets the gap between items in the list.
74func WithGap(gap int) listOption {
75 return func(l *list) {
76 l.gap = gap
77 }
78}
79
80// WithDirection sets the direction of the list.
81func 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.
88func WithSelectedItem(id string) listOption {
89 return func(l *list) {
90 l.selectedItem = id
91 }
92}
93
94func New(opts ...listOption) List {
95 list := &list{
96 items: make([]Item, 0),
97 direction: Forward,
98 }
99 for _, opt := range opts {
100 opt(list)
101 }
102 return list
103}
104
105// Init implements List.
106func (l *list) Init() tea.Cmd {
107 if l.height <= 0 || l.width <= 0 {
108 return nil
109 }
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...)
120}
121
122// Update implements List.
123func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
124 return l, nil
125}
126
127// View implements List.
128func (l *list) View() string {
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
140func (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
152func (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
161func (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
172func (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
189func (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
203func (l *list) MoveUp(n int) {
204 if l.direction == Forward {
205 l.decrementOffset(n)
206 } else {
207 l.incrementOffset(n)
208 }
209}
210
211func (l *list) MoveDown(n int) {
212 if l.direction == Forward {
213 l.incrementOffset(n)
214 } else {
215 l.decrementOffset(n)
216 }
217}
218
219func (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
228func (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
237func (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
261func (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
295func (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
331func (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
340func (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
350func (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
384func (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
415func (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
427func (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
439func (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
457func (l *list) listHeight() int {
458 // for the moment its the same
459 return l.height
460}
461
462func (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.
475func (l *list) GetSize() (int, int) {
476 return l.width, l.height
477}
478
479// SetSize implements List.
480func (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.
492func (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.
509func (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.
526func (l *list) IsFocused() bool {
527 return l.focused
528}