@@ -1,7 +1,6 @@
1package list
1package list
7 "github.com/charmbracelet/bubbles/v2/key"
6 "github.com/charmbracelet/bubbles/v2/key"
@@ -17,46 +16,50 @@ type Item interface {
17 ID() string
16 ID() string
20−type List[T Item] interface {
21+ List[T Item] interface {
21− util.Model
22+ util.Model
22− layout.Sizeable
23+ layout.Sizeable
23− layout.Focusable
24+ layout.Focusable
24− MoveUp(int) tea.Cmd
27+ MoveUp(int) tea.Cmd
25− MoveDown(int) tea.Cmd
28+ MoveDown(int) tea.Cmd
26− GoToTop() tea.Cmd
29+ GoToTop() tea.Cmd
27− GoToBottom() tea.Cmd
30+ GoToBottom() tea.Cmd
28− SelectItemAbove() tea.Cmd
31+ SelectItemAbove() tea.Cmd
29− SelectItemBelow() tea.Cmd
32+ SelectItemBelow() tea.Cmd
30− SetItems([]T) tea.Cmd
33+ SetItems([]T) tea.Cmd
31− SetSelected(string) tea.Cmd
34+ SetSelected(string) tea.Cmd
32− SelectedItem() *T
35+ SelectedItem() *T
33− Items() []T
36+ Items() []T
34− UpdateItem(string, T)
37+ UpdateItem(string, T) tea.Cmd
35− DeleteItem(string)
38+ DeleteItem(string) tea.Cmd
36− PrependItem(T) tea.Cmd
39+ PrependItem(T) tea.Cmd
37− AppendItem(T) tea.Cmd
40+ AppendItem(T) tea.Cmd
40type direction int
44type direction int
43− Forward direction = iota
47+ DirectionForward direction = iota
44− Backward
48+ DirectionBackward
48− NotFound = -1
52+ ItemNotFound = -1
49− DefaultScrollSize = 2
53+ ViewportDefaultScrollSize = 2
52−type setSelectedMsg struct {
53− selectedItemID string
56type renderedItem struct {
56type renderedItem struct {
58 view string
58 view string
59 height int
60 height int
62type confOptions struct {
65type confOptions struct {
@@ -67,16 +70,20 @@ type confOptions struct {
67 keyMap KeyMap
70 keyMap KeyMap
68 direction direction
71 direction direction
69 selectedItem string
72 selectedItem string
71type list[T Item] struct {
76type list[T Item] struct {
72 *confOptions
77 *confOptions
75− offset int
79+ offset int
81+ indexMap map[string]int
76− items []T
82+ items []T
77− renderedItems []renderedItem
84+ renderedItems map[string]renderedItem
78− rendered string
86+ rendered string
82type listOption func(*confOptions)
89type listOption func(*confOptions)
@@ -96,10 +103,17 @@ func WithGap(gap int) listOption {
99−// WithDirection sets the direction of the list.
106+// WithDirectionForward sets the direction to forward
100−func WithDirection(dir direction) listOption {
107+func WithDirectionForward() listOption {
108+ return func(l *confOptions) {
109+ l.direction = DirectionForward
113+// WithDirectionBackward sets the direction to forward
114+func WithDirectionBackward() listOption {
101 return func(l *confOptions) {
115 return func(l *confOptions) {
102− l.direction = dir
116+ l.direction = DirectionBackward
@@ -122,55 +136,60 @@ func WithWrapNavigation() listOption {
139+func WithFocus(focus bool) listOption {
140+ return func(l *confOptions) {
125func New[T Item](items []T, opts ...listOption) List[T] {
145func New[T Item](items []T, opts ...listOption) List[T] {
126 list := &list[T]{
146 list := &list[T]{
127 confOptions: &confOptions{
147 confOptions: &confOptions{
128− direction: Forward,
148+ direction: DirectionForward,
129 keyMap: DefaultKeyMap(),
149 keyMap: DefaultKeyMap(),
131− items: items,
152+ items: items,
153+ indexMap: make(map[string]int),
154+ renderedItems: map[string]renderedItem{},
133 for _, opt := range opts {
156 for _, opt := range opts {
134 opt(list.confOptions)
157 opt(list.confOptions)
160+ for inx, item := range items {
161+ list.indexMap[item.ID()] = inx
136 return list
163 return list
139// Init implements List.
166// Init implements List.
140func (l *list[T]) Init() tea.Cmd {
167func (l *list[T]) Init() tea.Cmd {
141− var cmds []tea.Cmd
168+ return l.render()
142− for _, item := range l.items {
144− cmds = append(cmds, cmd)
146− cmds = append(cmds, l.renderItems())
147− return tea.Batch(cmds...)
150// Update implements List.
171// Update implements List.
151func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
152 switch msg := msg.(type) {
173 switch msg := msg.(type) {
153− case setSelectedMsg:
154− return l, l.SetSelected(msg.selectedItemID)
155 case tea.KeyPressMsg:
174 case tea.KeyPressMsg:
156 if l.focused {
175 if l.focused {
158 case key.Matches(msg, l.keyMap.Down):
177 case key.Matches(msg, l.keyMap.Down):
159− return l, l.MoveDown(DefaultScrollSize)
178+ return l, l.MoveDown(ViewportDefaultScrollSize)
160 case key.Matches(msg, l.keyMap.Up):
179 case key.Matches(msg, l.keyMap.Up):
161− return l, l.MoveUp(DefaultScrollSize)
180+ return l, l.MoveUp(ViewportDefaultScrollSize)
162 case key.Matches(msg, l.keyMap.DownOneItem):
181 case key.Matches(msg, l.keyMap.DownOneItem):
163 return l, l.SelectItemBelow()
182 return l, l.SelectItemBelow()
164 case key.Matches(msg, l.keyMap.UpOneItem):
183 case key.Matches(msg, l.keyMap.UpOneItem):
165 return l, l.SelectItemAbove()
184 return l, l.SelectItemAbove()
166 case key.Matches(msg, l.keyMap.HalfPageDown):
185 case key.Matches(msg, l.keyMap.HalfPageDown):
167− return l, l.MoveDown(l.listHeight() / 2)
186+ return l, l.MoveDown(l.height / 2)
168 case key.Matches(msg, l.keyMap.HalfPageUp):
187 case key.Matches(msg, l.keyMap.HalfPageUp):
169− return l, l.MoveUp(l.listHeight() / 2)
188+ return l, l.MoveUp(l.height / 2)
170 case key.Matches(msg, l.keyMap.PageDown):
189 case key.Matches(msg, l.keyMap.PageDown):
171− return l, l.MoveDown(l.listHeight())
190+ return l, l.MoveDown(l.height)
172 case key.Matches(msg, l.keyMap.PageUp):
191 case key.Matches(msg, l.keyMap.PageUp):
173− return l, l.MoveUp(l.listHeight())
192+ return l, l.MoveUp(l.height)
174 case key.Matches(msg, l.keyMap.End):
193 case key.Matches(msg, l.keyMap.End):
175 return l, l.GoToBottom()
194 return l, l.GoToBottom()
176 case key.Matches(msg, l.keyMap.Home):
195 case key.Matches(msg, l.keyMap.Home):
@@ -197,585 +216,506 @@ func (l *list[T]) View() string {
197func (l *list[T]) viewPosition() (int, int) {
216func (l *list[T]) viewPosition() (int, int) {
198 start, end := 0, 0
217 start, end := 0, 0
199 renderedLines := lipgloss.Height(l.rendered) - 1
218 renderedLines := lipgloss.Height(l.rendered) - 1
200− if l.direction == Forward {
219+ if l.direction == DirectionForward {
201 start = max(0, l.offset)
220 start = max(0, l.offset)
202− end = min(l.offset+l.listHeight()-1, renderedLines)
221+ end = min(l.offset+l.height-1, renderedLines)
204− start = max(0, renderedLines-l.offset-l.listHeight()+1)
223+ start = max(0, renderedLines-l.offset-l.height+1)
205 end = max(0, renderedLines-l.offset)
224 end = max(0, renderedLines-l.offset)
207 return start, end
226 return start, end
210−func (l *list[T]) renderItem(item Item) renderedItem {
229+func (l *list[T]) recalculateItemPositions() {
211− view := item.View()
230+ currentContentHeight := 0
212− return renderedItem{
231+ for _, item := range l.items {
213− id: item.ID(),
232+ rItem, ok := l.renderedItems[item.ID()]
214− view: view,
233+ if !ok {
215− height: lipgloss.Height(view),
234+ continue
236+ rItem.start = currentContentHeight
237+ rItem.end = currentContentHeight + rItem.height - 1
238+ l.renderedItems[item.ID()] = rItem
239+ currentContentHeight = rItem.end + 1 + l.gap
219−func (l *list[T]) renderView() {
243+func (l *list[T]) render() tea.Cmd {
220− var sb strings.Builder
244+ if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
221− for i, rendered := range l.renderedItems {
245+ return nil
222− sb.WriteString(rendered.view)
246+ }
223− if i < len(l.renderedItems)-1 {
247+ l.setDefaultSelected()
224− sb.WriteString(strings.Repeat("\n", l.gap+1))
248+ focusCmd := l.focusSelectedItem()
249+ // we are not rendering the first time
250+ if l.rendered != "" {
252+ // rerender everything will mostly hit cache
253+ _ = l.renderIterator(0, false)
254+ if l.direction == DirectionBackward {
255+ l.recalculateItemPositions()
257+ // in the end scroll to the selected item
259+ l.scrollToSelection()
263+ finishIndex := l.renderIterator(0, true)
264+ // recalculate for the initial items
265+ if l.direction == DirectionBackward {
266+ l.recalculateItemPositions()
268+ renderCmd := func() tea.Msg {
270+ _ = l.renderIterator(finishIndex, false)
271+ // needed for backwards
272+ if l.direction == DirectionBackward {
273+ l.recalculateItemPositions()
275+ // in the end scroll to the selected item
277+ l.scrollToSelection()
279+ return renderedMsg{}
227− l.rendered = sb.String()
281+ return tea.Batch(focusCmd, renderCmd)
230−func (l *list[T]) incrementOffset(n int) {
284+func (l *list[T]) setDefaultSelected() {
231− if !l.isReady {
285+ if l.selectedItem == "" {
232− return
286+ if l.direction == DirectionForward {
234− renderedHeight := lipgloss.Height(l.rendered)
292+}
235− // no need for offset
293+
236− if renderedHeight <= l.listHeight() {
294+func (l *list[T]) scrollToSelection() {
295+ rItem, ok := l.renderedItems[l.selectedItem]
298+ l.setDefaultSelected()
239− maxOffset := renderedHeight - l.listHeight()
301+
240− n = min(n, maxOffset-l.offset)
302+ start, end := l.viewPosition()
241− if n <= 0 {
303+ // item bigger or equal to the viewport do nothing
304+ if rItem.start <= start && rItem.end >= end {
244− l.offset += n
307+ // item already in view do nothing
245−}
308+ if rItem.start >= start && rItem.start <= end {
247−func (l *list[T]) decrementOffset(n int) {
310+ } else if rItem.end <= end && rItem.end >= start {
314+ if rItem.height >= l.height {
315+ if l.direction == DirectionForward {
251− n = min(n, l.offset)
316+ l.offset = rItem.start
252− if n <= 0 {
317+ } else {
318+ l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
256− if l.offset < 0 {
323+ itemMiddleStart := rItem.start + rItem.height/2 + 1
257− l.offset = 0
324+ if l.direction == DirectionForward {
325+ l.offset = itemMiddleStart - l.height/2
327+ l.offset = max(0, lipgloss.Height(l.rendered)-(itemMiddleStart+l.height/2))
261−// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
331+func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
262−func (l *list[T]) changeSelectedWhenNotVisible() tea.Cmd {
332+ rItem, ok := l.renderedItems[l.selectedItem]
263− var cmds []tea.Cmd
333+ if !ok {
264 start, end := l.viewPosition()
336 start, end := l.viewPosition()
265− currentPosition := 0
337+ // item bigger than the viewport do nothing
266− itemWithinView := NotFound
338+ if rItem.start <= start && rItem.end >= end {
267− needsMove := false
339+ return nil
269− for i, item := range l.items {
341+ // item already in view do nothing
270− rendered := l.renderedItems[i]
342+ if rItem.start >= start && rItem.end <= end {
271− itemStart := currentPosition
343+ return nil
272− // we remove 1 so that we actually have the row, e.x 1 row => height 1 => start 0, end 0
344+ }
273− itemEnd := itemStart + rendered.height - 1
345+
274− if itemStart >= start && itemEnd <= end {
346+ itemMiddle := rItem.start + rItem.height/2
275− itemWithinView = i
347+
276− }
348+ if itemMiddle < start {
277− if item.ID() == l.selectedItem {
349+ // select the first item in the viewport
278− // item is completely above the viewport
350+ // the item is most likely an item coming after this item
279− if itemStart < start && itemEnd < start {
351+ inx := l.indexMap[rItem.id]
280− needsMove = true
352+ for {
353+ inx = l.firstSelectableItemBelow(inx)
354+ if inx == ItemNotFound {
282− // item is completely below the viewport
357+ item, ok := l.renderedItems[l.items[inx].ID()]
283− if itemStart > end && itemEnd > end {
358+ if !ok {
284− needsMove = true
359+ continue
287− if focusable, ok := any(item).(layout.Focusable); ok {
362+ // If the item is bigger than the viewport, select it
288− cmds = append(cmds, focusable.Blur())
363+ if item.start <= start && item.end >= end {
289− }
364+ l.selectedItem = item.id
290− l.renderedItems[i] = l.renderItem(item)
365+ return l.render()
367+ // item is in the view
368+ if item.start >= start && item.start <= end {
369+ l.selectedItem = item.id
292− return nil
370+ return l.render()
295− if itemWithinView != NotFound && needsMove {
373+ } else if itemMiddle > end {
296− newSelection := l.items[itemWithinView]
374+ // select the first item in the viewport
297− l.selectedItem = newSelection.ID()
375+ // the item is most likely an item coming after this item
298− if focusable, ok := any(newSelection).(layout.Focusable); ok {
376+ inx := l.indexMap[rItem.id]
299− cmds = append(cmds, focusable.Focus())
377+ for {
378+ inx = l.firstSelectableItemAbove(inx)
379+ if inx == ItemNotFound {
382+ item, ok := l.renderedItems[l.items[inx].ID()]
387+ // If the item is bigger than the viewport, select it
388+ if item.start <= start && item.end >= end {
389+ l.selectedItem = item.id
392+ // item is in the view
393+ if item.end >= start && item.end <= end {
394+ l.selectedItem = item.id
301− l.renderedItems[itemWithinView] = l.renderItem(newSelection)
304− currentPosition += rendered.height + l.gap
307− return tea.Batch(cmds...)
399+ return nil
310−func (l *list[T]) MoveUp(n int) tea.Cmd {
402+func (l *list[T]) selectFirstItem() {
311− if l.direction == Forward {
403+ inx := l.firstSelectableItemBelow(-1)
312− l.decrementOffset(n)
404+ if inx != ItemNotFound {
313− } else {
405+ l.selectedItem = l.items[inx].ID()
314− l.incrementOffset(n)
316− return l.changeSelectedWhenNotVisible()
319−func (l *list[T]) MoveDown(n int) tea.Cmd {
409+func (l *list[T]) selectLastItem() {
320− if l.direction == Forward {
410+ inx := l.firstSelectableItemAbove(len(l.items))
321− l.incrementOffset(n)
411+ if inx != ItemNotFound {
322− } else {
412+ l.selectedItem = l.items[inx].ID()
323− l.decrementOffset(n)
325− return l.changeSelectedWhenNotVisible()
328−func (l *list[T]) firstSelectableItemBefore(inx int) int {
416+func (l *list[T]) firstSelectableItemAbove(inx int) int {
329 for i := inx - 1; i >= 0; i-- {
417 for i := inx - 1; i >= 0; i-- {
330 if _, ok := any(l.items[i]).(layout.Focusable); ok {
418 if _, ok := any(l.items[i]).(layout.Focusable); ok {
334 if inx == 0 && l.wrap {
422 if inx == 0 && l.wrap {
335− return l.firstSelectableItemBefore(len(l.items))
423+ return l.firstSelectableItemAbove(len(l.items))
337− return NotFound
425+ return ItemNotFound
340−func (l *list[T]) firstSelectableItemAfter(inx int) int {
428+func (l *list[T]) firstSelectableItemBelow(inx int) int {
341 for i := inx + 1; i < len(l.items); i++ {
429 for i := inx + 1; i < len(l.items); i++ {
342 if _, ok := any(l.items[i]).(layout.Focusable); ok {
430 if _, ok := any(l.items[i]).(layout.Focusable); ok {
346 if inx == len(l.items)-1 && l.wrap {
434 if inx == len(l.items)-1 && l.wrap {
347− return l.firstSelectableItemAfter(-1)
435+ return l.firstSelectableItemBelow(-1)
349− return NotFound
437+ return ItemNotFound
352−// moveToSelected needs to be called after the view is rendered
353−func (l *list[T]) moveToSelected(center bool) tea.Cmd {
440+func (l *list[T]) focusSelectedItem() tea.Cmd {
355− if l.selectedItem == "" || !l.isReady {
441+ if l.selectedItem == "" || !l.focused {
356 return nil
442 return nil
358− currentPosition := 0
444+ var cmds []tea.Cmd
359− start, end := l.viewPosition()
360− for _, item := range l.renderedItems {
445+ for _, item := range l.items {
446+ if f, ok := any(item).(layout.Focusable); ok {
361− if item.id == l.selectedItem {
447+ if item.ID() == l.selectedItem && !f.IsFocused() {
362− itemStart := currentPosition
363− itemEnd := currentPosition + item.height - 1
365− if start <= itemStart && itemEnd <= end {
370− viewportCenter := l.listHeight() / 2
371− itemCenter := itemStart + item.height/2
372− targetOffset := itemCenter - viewportCenter
373− if l.direction == Forward {
374− if targetOffset > l.offset {
375− cmds = append(cmds, l.MoveDown(targetOffset-l.offset))
448+ cmds = append(cmds, f.Focus())
376− } else if targetOffset < l.offset {
449+ if cache, ok := l.renderedItems[item.ID()]; ok {
377− cmds = append(cmds, l.MoveUp(l.offset-targetOffset))
450+ cache.dirty = true
378− }
451+ l.renderedItems[item.ID()] = cache
380− renderedHeight := lipgloss.Height(l.rendered)
381− backwardTargetOffset := renderedHeight - targetOffset - l.listHeight()
382− if backwardTargetOffset > l.offset {
383− cmds = append(cmds, l.MoveUp(backwardTargetOffset-l.offset))
384− } else if backwardTargetOffset < l.offset {
385− cmds = append(cmds, l.MoveDown(l.offset-backwardTargetOffset))
389− if currentPosition < start {
390− cmds = append(cmds, l.MoveUp(start-currentPosition))
392− if currentPosition > end {
453+ } else if item.ID() != l.selectedItem && f.IsFocused() {
393− cmds = append(cmds, l.MoveDown(currentPosition-end))
454+ cmds = append(cmds, f.Blur())
455+ if cache, ok := l.renderedItems[item.ID()]; ok {
457+ l.renderedItems[item.ID()] = cache
397− currentPosition += item.height + l.gap
399 return tea.Batch(cmds...)
462 return tea.Batch(cmds...)
402−func (l *list[T]) SelectItemAbove() tea.Cmd {
465+func (l *list[T]) blurItems() tea.Cmd {
406 var cmds []tea.Cmd
466 var cmds []tea.Cmd
407− for i, item := range l.items {
467+ for _, item := range l.items {
468+ if f, ok := any(item).(layout.Focusable); ok {
408− if l.selectedItem == item.ID() {
469+ if item.ID() == l.selectedItem && f.IsFocused() {
409− inx := l.firstSelectableItemBefore(i)
410− if inx == NotFound {
414− // blur the current item
415− if focusable, ok := any(item).(layout.Focusable); ok {
416− cmds = append(cmds, focusable.Blur())
470+ cmds = append(cmds, f.Blur())
418− // rerender the item
419− l.renderedItems[i] = l.renderItem(item)
471+ if cache, ok := l.renderedItems[item.ID()]; ok {
420− // focus the item above
472+ cache.dirty = true
421− above := l.items[inx]
473+ l.renderedItems[item.ID()] = cache
422− if focusable, ok := any(above).(layout.Focusable); ok {
474+ }
423− cmds = append(cmds, focusable.Focus())
425− // rerender the item
426− l.renderedItems[inx] = l.renderItem(above)
427− l.selectedItem = above.ID()
432− l.moveToSelected(false)
433 return tea.Batch(cmds...)
478 return tea.Batch(cmds...)
436−func (l *list[T]) SelectItemBelow() tea.Cmd {
481+// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
437− if !l.isReady {
482+// returns the last index
438− return nil
483+func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
439− }
484+ currentContentHeight := lipgloss.Height(l.rendered) - 1
441− for i, item := range l.items {
485+ for i := startInx; i < len(l.items); i++ {
442− if l.selectedItem == item.ID() {
486+ if currentContentHeight >= l.height && limitHeight {
443− inx := l.firstSelectableItemAfter(i)
444− if inx == NotFound {
446− return nil
487+ return i
448− // blur the current item
489+ // cool way to go through the list in both directions
449− if focusable, ok := any(item).(layout.Focusable); ok {
490+ inx := i
450− cmds = append(cmds, focusable.Blur())
452− // rerender the item
453− l.renderedItems[i] = l.renderItem(item)
455− // focus the item below
492+ if l.direction != DirectionForward {
456− below := l.items[inx]
493+ inx = (len(l.items) - 1) - i
457− if focusable, ok := any(below).(layout.Focusable); ok {
458− cmds = append(cmds, focusable.Focus())
460− // rerender the item
461− l.renderedItems[inx] = l.renderItem(below)
462− l.selectedItem = below.ID()
467− l.renderView()
496+ item := l.items[inx]
468− l.moveToSelected(false)
497+ var rItem renderedItem
469− return tea.Batch(cmds...)
498+ if cache, ok := l.renderedItems[item.ID()]; ok && !cache.dirty {
501+ rItem = l.renderItem(item)
502+ rItem.start = currentContentHeight
503+ rItem.end = currentContentHeight + rItem.height - 1
504+ l.renderedItems[item.ID()] = rItem
507+ if inx == len(l.items)-1 {
472−func (l *list[T]) GoToTop() tea.Cmd {
511+ if l.direction == DirectionForward {
473− if !l.isReady {
512+ l.rendered += rItem.view + strings.Repeat("\n", gap)
474− return nil
513+ } else {
514+ l.rendered = rItem.view + strings.Repeat("\n", gap) + l.rendered
516+ currentContentHeight = rItem.end + 1 + l.gap
476− l.offset = 0
518+ return len(l.items)
477− l.direction = Forward
478− return tea.Batch(l.selectFirstItem(), l.renderForward())
481−func (l *list[T]) GoToBottom() tea.Cmd {
521+func (l *list[T]) renderItem(item Item) renderedItem {
482− if !l.isReady {
522+ view := item.View()
483− return nil
523+ return renderedItem{
526+ height: lipgloss.Height(view),
486− l.direction = Backward
488− return tea.Batch(l.selectLastItem(), l.renderBackward())
530+// AppendItem implements List.
491−func (l *list[T]) renderForward() tea.Cmd {
531+func (l *list[T]) AppendItem(T) tea.Cmd {
492− // TODO: figure out a way to preserve items that did not change
532+ panic("unimplemented")
493− l.renderedItems = make([]renderedItem, 0)
496− for i, item := range l.items {
498− if currentHeight-1 > l.listHeight() {
501− rendered := l.renderItem(item)
502− l.renderedItems = append(l.renderedItems, rendered)
503− currentHeight += rendered.height + l.gap
506− // initial render
535+// Blur implements List.
507− l.renderView()
536+func (l *list[T]) Blur() tea.Cmd {
537+ cmd := l.blurItems()
538+ return tea.Batch(cmd, l.render())
509− if currentIndex == len(l.items)-1 {
541+// DeleteItem implements List.
510− l.isReady = true
542+func (l *list[T]) DeleteItem(string) tea.Cmd {
511− return nil
543+ panic("unimplemented")
514− return func() tea.Msg {
515− for i := currentIndex; i < len(l.items); i++ {
516− rendered := l.renderItem(l.items[i])
517− l.renderedItems = append(l.renderedItems, rendered)
546+// Focus implements List.
525−func (l *list[T]) renderBackward() tea.Cmd {
547+func (l *list[T]) Focus() tea.Cmd {
526− // TODO: figure out a way to preserve items that did not change
548+ l.focused = true
527− l.renderedItems = make([]renderedItem, 0)
530− for i := len(l.items) - 1; i >= 0; i-- {
532− if currentHeight > l.listHeight() {
535− rendered := l.renderItem(l.items[i])
536− l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
537− currentHeight += rendered.height + l.gap
539− // initial render
549+ return l.render()
541− if currentIndex == 0 {
545− return func() tea.Msg {
546− for i := currentIndex; i >= 0; i-- {
547− rendered := l.renderItem(l.items[i])
548− l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
556−func (l *list[T]) selectFirstItem() tea.Cmd {
552+// GetSize implements List.
557− var cmd tea.Cmd
553+func (l *list[T]) GetSize() (int, int) {
558− inx := l.firstSelectableItemAfter(-1)
559− if inx != NotFound {
560− l.selectedItem = l.items[inx].ID()
561− if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
562− cmd = focusable.Focus()
565− return cmd
554+ return l.width, l.height
557+// GoToBottom implements List.
568−func (l *list[T]) selectLastItem() tea.Cmd {
558+func (l *list[T]) GoToBottom() tea.Cmd {
569− var cmd tea.Cmd
559+ l.offset = 0
570− inx := l.firstSelectableItemBefore(len(l.items))
560+ l.direction = DirectionBackward
571− if inx != NotFound {
572− l.selectedItem = l.items[inx].ID()
561+ l.selectedItem = ""
573− if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
574− cmd = focusable.Focus()
577− return cmd
562+ return l.render()
565+// GoToTop implements List.
580−func (l *list[T]) renderItems() tea.Cmd {
566+func (l *list[T]) GoToTop() tea.Cmd {
581− if l.height <= 0 || l.width <= 0 {
567+ l.offset = 0
582− return nil
568+ l.direction = DirectionForward
583− }
569+ l.selectedItem = ""
584− if len(l.items) == 0 {
585− return nil
570+ return l.render()
588− if l.selectedItem == "" {
573+// IsFocused implements List.
589− if l.direction == Forward {
574+func (l *list[T]) IsFocused() bool {
595− if l.direction == Forward {
596− return l.renderForward()
598− return l.renderBackward()
575+ return l.focused
578+// Items implements List.
601−func (l *list[T]) listHeight() int {
579+func (l *list[T]) Items() []T {
602− // for the moment its the same
603− return l.height
580+ return l.items
606−func (l *list[T]) SetItems(items []T) tea.Cmd {
583+func (l *list[T]) incrementOffset(n int) {
607− l.items = items
584+ renderedHeight := lipgloss.Height(l.rendered)
608− var cmds []tea.Cmd
585+ // no need for offset
609− for _, item := range l.items {
586+ if renderedHeight <= l.height {
610− cmds = append(cmds, item.Init())
587+ return
611− // Set height to 0 to let the item calculate its own height
588+ }
612− cmds = append(cmds, item.SetSize(l.width, 0))
589+ maxOffset := renderedHeight - l.height
590+ n = min(n, maxOffset-l.offset)
615− cmds = append(cmds, l.renderItems())
597+func (l *list[T]) decrementOffset(n int) {
616− if l.selectedItem != "" {
598+ n = min(n, l.offset)
617− cmds = append(cmds, l.moveToSelected(true))
599+ if n <= 0 {
619− return tea.Batch(cmds...)
622−// GetSize implements List.
608+// MoveDown implements List.
623−func (l *list[T]) GetSize() (int, int) {
609+func (l *list[T]) MoveDown(n int) tea.Cmd {
624− return l.width, l.height
610+ if l.direction == DirectionForward {
611+ l.incrementOffset(n)
613+ l.decrementOffset(n)
615+ return l.changeSelectionWhenScrolling()
627−// SetSize implements List.
618+// MoveUp implements List.
628−func (l *list[T]) SetSize(width int, height int) tea.Cmd {
619+func (l *list[T]) MoveUp(n int) tea.Cmd {
629− l.width = width
620+ if l.direction == DirectionForward {
630− l.height = height
621+ l.decrementOffset(n)
631− var cmds []tea.Cmd
622+ } else {
632− for _, item := range l.items {
623+ l.incrementOffset(n)
633− cmds = append(cmds, item.SetSize(width, height))
625+ return l.changeSelectionWhenScrolling()
636− cmds = append(cmds, l.renderItems())
628+// PrependItem implements List.
637− return tea.Batch(cmds...)
629+func (l *list[T]) PrependItem(T) tea.Cmd {
630+ panic("unimplemented")
640−// Blur implements List.
633+// SelectItemAbove implements List.
641−func (l *list[T]) Blur() tea.Cmd {
634+func (l *list[T]) SelectItemAbove() tea.Cmd {
644− for i, item := range l.items {
645− if item.ID() != l.selectedItem {
635+ inx, ok := l.indexMap[l.selectedItem]
646− continue
636+ if !ok {
648− if focusable, ok := any(item).(layout.Focusable); ok {
649− cmd = focusable.Blur()
651− l.renderedItems[i] = l.renderItem(item)
657−// Focus implements List.
640+ newIndex := l.firstSelectableItemAbove(inx)
658−func (l *list[T]) Focus() tea.Cmd {
641+ if newIndex == ItemNotFound {
659− var cmd tea.Cmd
642+ // no item above
660− l.focused = true
643+ return nil
661− if l.selectedItem != "" {
662− for i, item := range l.items {
663− if item.ID() != l.selectedItem {
666− if focusable, ok := any(item).(layout.Focusable); ok {
667− cmd = focusable.Focus()
669− if len(l.renderedItems) > i {
670− l.renderedItems[i] = l.renderItem(item)
645+ item := l.items[newIndex]
646+ l.selectedItem = item.ID()
675− return cmd
647+ return l.render()
650+// SelectItemBelow implements List.
678−func (l *list[T]) SetSelected(id string) tea.Cmd {
651+func (l *list[T]) SelectItemBelow() tea.Cmd {
679− if l.selectedItem == id {
652+ inx, ok := l.indexMap[l.selectedItem]
680 return nil
654 return nil
682− var cmds []tea.Cmd
656+
683− for i, item := range l.items {
657+ newIndex := l.firstSelectableItemBelow(inx)
684− if item.ID() == l.selectedItem {
658+ if newIndex == ItemNotFound {
685− if focusable, ok := any(item).(layout.Focusable); ok {
659+ // no item above
686− cmds = append(cmds, focusable.Blur())
660+ return nil
688− if len(l.renderedItems) > i {
689− l.renderedItems[i] = l.renderItem(item)
691− } else if item.ID() == id {
692− if focusable, ok := any(item).(layout.Focusable); ok {
693− cmds = append(cmds, focusable.Focus())
695− if len(l.renderedItems) > i {
696− l.renderedItems[i] = l.renderItem(item)
662+ item := l.items[newIndex]
700− l.selectedItem = id
663+ l.selectedItem = item.ID()
701− l.renderView()
664+ return l.render()
702− cmds = append(cmds, l.moveToSelected(true))
703− return tea.Batch(cmds...)
667+// SelectedItem implements List.
706func (l *list[T]) SelectedItem() *T {
668func (l *list[T]) SelectedItem() *T {
707− for _, item := range l.items {
669+ inx, ok := l.indexMap[l.selectedItem]
708− if item.ID() == l.selectedItem {
670+ if !ok {
709− return &item
671+ return nil
673+ if inx > len(l.items)-1 {
712− return nil
674+ return nil
676+ item := l.items[inx]
715−// IsFocused implements List.
680+// SetItems implements List.
716−func (l *list[T]) IsFocused() bool {
681+func (l *list[T]) SetItems(items []T) tea.Cmd {
717− return l.focused
683+ return l.reset()
720−func (l *list[T]) Items() []T {
686+// SetSelected implements List.
687+func (l *list[T]) SetSelected(id string) tea.Cmd {
721− return l.items
689+ return l.render()
724−func (l *list[T]) UpdateItem(id string, item T) {
692+func (l *list[T]) reset() tea.Cmd {
725− // TODO: preserve offset
693+ var cmds []tea.Cmd
695+ l.indexMap = make(map[string]int)
696+ l.renderedItems = make(map[string]renderedItem)
726 for inx, item := range l.items {
697 for inx, item := range l.items {
727− if item.ID() == id {
698+ l.indexMap[item.ID()] = inx
728− l.items[inx] = item
699+ if l.width > 0 && l.height > 0 {
729− l.renderedItems[inx] = l.renderItem(item)
700+ cmds = append(cmds, item.SetSize(l.width, l.height))
703+ cmds = append(cmds, l.render())
704+ return tea.Batch(cmds...)
736−func (l *list[T]) DeleteItem(id string) {
707+// SetSize implements List.
737− // TODO: preserve offset
739− for i, item := range l.items {
740− if item.ID() == id {
746− l.items = slices.Delete(l.items, inx, inx+1)
747− l.renderedItems = slices.Delete(l.renderedItems, inx, inx+1)
751−func (l *list[T]) PrependItem(item T) tea.Cmd {
708+func (l *list[T]) SetSize(width int, height int) tea.Cmd {
752− // TODO: preserve offset
709+ oldWidth := l.width
753− var cmd tea.Cmd
710+ l.width = width
754− l.items = append([]T{item}, l.items...)
711+ l.height = height
755− l.renderedItems = append([]renderedItem{l.renderItem(item)}, l.renderedItems...)
712+ if oldWidth != width {
756− if len(l.items) == 1 {
713+ return l.reset()
757− cmd = l.SetSelected(item.ID())
759− // the viewport did not move and the last item was focused
760− if l.direction == Backward && l.offset == 0 && l.selectedItem == l.items[0].ID() {
761− cmd = l.SetSelected(item.ID())
764− return cmd
715+ return nil
718+// UpdateItem implements List.
767−func (l *list[T]) AppendItem(item T) tea.Cmd {
719+func (l *list[T]) UpdateItem(string, T) tea.Cmd {
768− // TODO: preserve offset
720+ panic("unimplemented")
770− l.items = append(l.items, item)
771− l.renderedItems = append(l.renderedItems, l.renderItem(item))
772− if len(l.items) == 1 {
773− cmd = l.SetSelected(item.ID())
774− } else if l.direction == Backward && l.offset == 0 && l.selectedItem == l.items[len(l.items)-2].ID() {
775− // the viewport did not move and the last item was focused
776− cmd = l.SetSelected(item.ID())