@@ -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}