@@ -1,41 +1,60 @@
1package list
1package list
4 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
5 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
7 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
6 "github.com/charmbracelet/crush/internal/tui/util"
8 "github.com/charmbracelet/crush/internal/tui/util"
9+ "github.com/charmbracelet/lipgloss/v2"
9type Item interface {
12type Item interface {
10 util.Model
13 util.Model
11 layout.Sizeable
14 layout.Sizeable
14type List interface {
18type List interface {
15 util.Model
19 util.Model
22+ SetItems(items []Item) tea.Cmd
28+ Forward direction = iota
36+type renderedItem struct {
18type list struct {
42type list struct {
19 width, height int
43 width, height int
22− items []Item
50+ items []Item
23−
51+ renderedItems []renderedItem
24− // Filter options
52+ rendered string
25− filterable bool
53+ isReady bool
26− filterPlaceholder string
29type listOption func(*list)
56type listOption func(*list)
31−// WithFilterable enables filtering on the list.
32−func WithFilterable(placeholder string) listOption {
33− return func(l *list) {
35− l.filterPlaceholder = placeholder
39// WithItems sets the initial items for the list.
58// WithItems sets the initial items for the list.
40func WithItems(items ...Item) listOption {
59func WithItems(items ...Item) listOption {
41 return func(l *list) {
60 return func(l *list) {
@@ -58,9 +77,24 @@ func WithGap(gap int) listOption {
80+// WithDirection sets the direction of the list.
81+func WithDirection(dir direction) listOption {
82+ return func(l *list) {
87+// WithSelectedItem sets the initially selected item in the list.
88+func WithSelectedItem(id string) listOption {
89+ return func(l *list) {
61func New(opts ...listOption) List {
94func New(opts ...listOption) List {
62 list := &list{
95 list := &list{
63− items: make([]Item, 0),
96+ items: make([]Item, 0),
65 for _, opt := range opts {
99 for _, opt := range opts {
66 opt(list)
100 opt(list)
@@ -73,15 +107,422 @@ func (l *list) Init() tea.Cmd {
73 if l.height <= 0 || l.width <= 0 {
107 if l.height <= 0 || l.width <= 0 {
74 return nil
108 return nil
110+ if len(l.items) == 0 {
76− return nil
111+ return nil
114+ for _, item := range l.items {
116+ cmds = append(cmds, cmd)
118+ cmds = append(cmds, l.renderItems())
119+ return tea.Batch(cmds...)
79// Update implements List.
122// Update implements List.
80func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
123func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
81− panic("unimplemented")
124+ return l, nil
84// View implements List.
127// View implements List.
85func (l *list) View() string {
128func (l *list) View() string {
86− panic("unimplemented")
129+ if l.height <= 0 || l.width <= 0 {
133+ lines := strings.Split(view, "\n")
135+ start, end := l.viewPosition(len(lines))
136+ lines = lines[start:end]
137+ return strings.Join(lines, "\n")
140+func (l *list) viewPosition(total int) (int, int) {
142+ if l.direction == Forward {
143+ start = max(0, l.offset)
144+ end = min(l.offset+l.listHeight(), total)
146+ start = max(0, total-l.offset-l.listHeight())
147+ end = max(0, total-l.offset)
152+func (l *list) renderItem(item Item) renderedItem {
154+ return renderedItem{
157+ height: lipgloss.Height(view),
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))
169+ l.rendered = sb.String()
172+func (l *list) incrementOffset(n int) {
176+ renderedHeight := lipgloss.Height(l.rendered)
177+ // no need for offset
178+ if renderedHeight <= l.listHeight() {
181+ maxOffset := renderedHeight - l.listHeight()
182+ n = min(n, maxOffset-l.offset)
189+func (l *list) decrementOffset(n int) {
193+ n = min(n, l.offset)
203+func (l *list) MoveUp(n int) {
204+ if l.direction == Forward {
205+ l.decrementOffset(n)
207+ l.incrementOffset(n)
211+func (l *list) MoveDown(n int) {
212+ if l.direction == Forward {
213+ l.incrementOffset(n)
215+ l.decrementOffset(n)
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 {
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 {
237+func (l *list) moveToSelected() {
238+ if l.selectedItem == "" || !l.isReady {
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 {
249+ if currentPosition < start {
250+ l.MoveUp(start - currentPosition)
252+ // we need to go down
253+ if currentPosition > end {
254+ l.MoveDown(currentPosition - end)
257+ currentPosition += item.height + l.gap
261+func (l *list) SelectItemAbove() tea.Cmd {
266+ for i, item := range l.items {
267+ if l.selectedItem == item.ID() {
268+ inx := l.firstSelectableItemBefore(i)
269+ if inx == NotFound {
273+ // blur the current item
274+ if focusable, ok := item.(layout.Focusable); ok {
275+ cmds = append(cmds, focusable.Blur())
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())
284+ // rerender the item
285+ l.renderedItems[inx] = l.renderItem(above)
286+ l.selectedItem = above.ID()
292+ return tea.Batch(cmds...)
295+func (l *list) SelectItemBelow() tea.Cmd {
300+ for i, item := range l.items {
301+ if l.selectedItem == item.ID() {
302+ inx := l.firstSelectableItemAfter(i)
303+ if inx == NotFound {
307+ // blur the current item
308+ if focusable, ok := item.(layout.Focusable); ok {
309+ cmds = append(cmds, focusable.Blur())
311+ // rerender the item
312+ l.renderedItems[i] = l.renderItem(item)
314+ // focus the item below
315+ below := l.items[inx]
316+ if focusable, ok := below.(layout.Focusable); ok {
317+ cmds = append(cmds, focusable.Focus())
319+ // rerender the item
320+ l.renderedItems[inx] = l.renderItem(below)
321+ l.selectedItem = below.ID()
328+ return tea.Batch(cmds...)
331+func (l *list) GoToTop() tea.Cmd {
336+ l.direction = Forward
337+ return tea.Batch(l.selectFirstItem(), l.renderForward())
340+func (l *list) GoToBottom() tea.Cmd {
345+ l.direction = Backward
347+ return tea.Batch(l.selectLastItem(), l.renderBackward())
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)
355+ for i, item := range l.items {
357+ if currentHeight > l.listHeight() {
360+ rendered := l.renderItem(item)
361+ l.renderedItems = append(l.renderedItems, rendered)
362+ currentHeight += rendered.height + l.gap
368+ if currentIndex == len(l.items)-1 {
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)
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)
389+ for i := len(l.items) - 1; i >= 0; i-- {
391+ if currentHeight > l.listHeight() {
394+ rendered := l.renderItem(l.items[i])
395+ l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
396+ currentHeight += rendered.height + l.gap
400+ if currentIndex == len(l.items)-1 {
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...)
415+func (l *list) selectFirstItem() 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()
427+func (l *list) selectLastItem() 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()
439+func (l *list) renderItems() tea.Cmd {
440+ if l.height <= 0 || l.width <= 0 {
443+ if len(l.items) == 0 {
447+ if l.selectedItem == "" {
448+ if l.direction == Forward {
454+ return l.renderBackward()
457+func (l *list) listHeight() int {
458+ // for the moment its the same
462+func (l *list) SetItems(items []Item) 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))
470+ cmds = append(cmds, l.renderItems())
471+ return tea.Batch(cmds...)
474+// GetSize implements List.
475+func (l *list) GetSize() (int, int) {
476+ return l.width, l.height
479+// SetSize implements List.
480+func (l *list) SetSize(width int, height int) tea.Cmd {
484+ for _, item := range l.items {
485+ cmds = append(cmds, item.SetSize(width, height))
487+ cmds = append(cmds, l.renderItems())
488+ return tea.Batch(cmds...)
491+// Blur implements List.
492+func (l *list) Blur() tea.Cmd {
495+ for i, item := range l.items {
496+ if item.ID() != l.selectedItem {
499+ if focusable, ok := item.(layout.Focusable); ok {
500+ cmd = focusable.Blur()
502+ l.renderedItems[i] = l.renderItem(item)
508+// Focus implements List.
509+func (l *list) Focus() tea.Cmd {
512+ for i, item := range l.items {
513+ if item.ID() != l.selectedItem {
516+ if focusable, ok := item.(layout.Focusable); ok {
517+ cmd = focusable.Focus()
519+ l.renderedItems[i] = l.renderItem(item)
525+// IsFocused implements List.
526+func (l *list) IsFocused() bool {