@@ -1,7 +1,6 @@
11package list
22
33import (
4− "slices"
54 "strings"
65
76 "github.com/charmbracelet/bubbles/v2/key"
@@ -17,46 +16,50 @@ type Item interface {
1716 ID() string
1817}
1918
20−type List[T Item] interface {
21− util.Model
22− layout.Sizeable
23− layout.Focusable
24− MoveUp(int) tea.Cmd
25− MoveDown(int) tea.Cmd
26− GoToTop() tea.Cmd
27− GoToBottom() tea.Cmd
28− SelectItemAbove() tea.Cmd
29− SelectItemBelow() tea.Cmd
30− SetItems([]T) tea.Cmd
31− SetSelected(string) tea.Cmd
32− SelectedItem() *T
33− Items() []T
34− UpdateItem(string, T)
35− DeleteItem(string)
36− PrependItem(T) tea.Cmd
37− AppendItem(T) tea.Cmd
38−}
19+type (
20+ renderedMsg struct{}
21+ List[T Item] interface {
22+ util.Model
23+ layout.Sizeable
24+ layout.Focusable
25+
26+ // Just change state
27+ MoveUp(int) tea.Cmd
28+ MoveDown(int) tea.Cmd
29+ GoToTop() tea.Cmd
30+ GoToBottom() tea.Cmd
31+ SelectItemAbove() tea.Cmd
32+ SelectItemBelow() tea.Cmd
33+ SetItems([]T) tea.Cmd
34+ SetSelected(string) tea.Cmd
35+ SelectedItem() *T
36+ Items() []T
37+ UpdateItem(string, T) tea.Cmd
38+ DeleteItem(string) tea.Cmd
39+ PrependItem(T) tea.Cmd
40+ AppendItem(T) tea.Cmd
41+ }
42+)
3943
4044type direction int
4145
4246const (
43− Forward direction = iota
44− Backward
47+ DirectionForward direction = iota
48+ DirectionBackward
4549)
4650
4751const (
48− NotFound = -1
49− DefaultScrollSize = 2
52+ ItemNotFound = -1
53+ ViewportDefaultScrollSize = 2
5054)
5155
52−type setSelectedMsg struct {
53− selectedItemID string
54−}
55−
5656type renderedItem struct {
5757 id string
5858 view string
59+ dirty bool
5960 height int
61+ start int
62+ end int
6063}
6164
6265type confOptions struct {
@@ -67,16 +70,20 @@ type confOptions struct {
6770 keyMap KeyMap
6871 direction direction
6972 selectedItem string
73+ focused bool
7074}
75+
7176type list[T Item] struct {
7277 *confOptions
7378
74− focused bool
75− offset int
76− items []T
77− renderedItems []renderedItem
78− rendered string
79− isReady bool
79+ offset int
80+
81+ indexMap map[string]int
82+ items []T
83+
84+ renderedItems map[string]renderedItem
85+
86+ rendered string
8087}
8188
8289type listOption func(*confOptions)
@@ -96,10 +103,17 @@ func WithGap(gap int) listOption {
96103 }
97104}
98105
99−// WithDirection sets the direction of the list.
100−func WithDirection(dir direction) listOption {
106+// WithDirectionForward sets the direction to forward
107+func WithDirectionForward() listOption {
108+ return func(l *confOptions) {
109+ l.direction = DirectionForward
110+ }
111+}
112+
113+// WithDirectionBackward sets the direction to forward
114+func WithDirectionBackward() listOption {
101115 return func(l *confOptions) {
102− l.direction = dir
116+ l.direction = DirectionBackward
103117 }
104118}
105119
@@ -122,55 +136,60 @@ func WithWrapNavigation() listOption {
122136 }
123137}
124138
139+func WithFocus(focus bool) listOption {
140+ return func(l *confOptions) {
141+ l.focused = focus
142+ }
143+}
144+
125145func New[T Item](items []T, opts ...listOption) List[T] {
126146 list := &list[T]{
127147 confOptions: &confOptions{
128− direction: Forward,
148+ direction: DirectionForward,
129149 keyMap: DefaultKeyMap(),
150+ focused: true,
130151 },
131− items: items,
152+ items: items,
153+ indexMap: make(map[string]int),
154+ renderedItems: map[string]renderedItem{},
132155 }
133156 for _, opt := range opts {
134157 opt(list.confOptions)
135158 }
159+
160+ for inx, item := range items {
161+ list.indexMap[item.ID()] = inx
162+ }
136163 return list
137164}
138165
139166// Init implements List.
140167func (l *list[T]) Init() tea.Cmd {
141− var cmds []tea.Cmd
142− for _, item := range l.items {
143− cmd := item.Init()
144− cmds = append(cmds, cmd)
145− }
146− cmds = append(cmds, l.renderItems())
147− return tea.Batch(cmds...)
168+ return l.render()
148169}
149170
150171// Update implements List.
151172func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
152173 switch msg := msg.(type) {
153− case setSelectedMsg:
154− return l, l.SetSelected(msg.selectedItemID)
155174 case tea.KeyPressMsg:
156175 if l.focused {
157176 switch {
158177 case key.Matches(msg, l.keyMap.Down):
159− return l, l.MoveDown(DefaultScrollSize)
178+ return l, l.MoveDown(ViewportDefaultScrollSize)
160179 case key.Matches(msg, l.keyMap.Up):
161− return l, l.MoveUp(DefaultScrollSize)
180+ return l, l.MoveUp(ViewportDefaultScrollSize)
162181 case key.Matches(msg, l.keyMap.DownOneItem):
163182 return l, l.SelectItemBelow()
164183 case key.Matches(msg, l.keyMap.UpOneItem):
165184 return l, l.SelectItemAbove()
166185 case key.Matches(msg, l.keyMap.HalfPageDown):
167− return l, l.MoveDown(l.listHeight() / 2)
186+ return l, l.MoveDown(l.height / 2)
168187 case key.Matches(msg, l.keyMap.HalfPageUp):
169− return l, l.MoveUp(l.listHeight() / 2)
188+ return l, l.MoveUp(l.height / 2)
170189 case key.Matches(msg, l.keyMap.PageDown):
171− return l, l.MoveDown(l.listHeight())
190+ return l, l.MoveDown(l.height)
172191 case key.Matches(msg, l.keyMap.PageUp):
173− return l, l.MoveUp(l.listHeight())
192+ return l, l.MoveUp(l.height)
174193 case key.Matches(msg, l.keyMap.End):
175194 return l, l.GoToBottom()
176195 case key.Matches(msg, l.keyMap.Home):
@@ -197,585 +216,506 @@ func (l *list[T]) View() string {
197216func (l *list[T]) viewPosition() (int, int) {
198217 start, end := 0, 0
199218 renderedLines := lipgloss.Height(l.rendered) - 1
200− if l.direction == Forward {
219+ if l.direction == DirectionForward {
201220 start = max(0, l.offset)
202− end = min(l.offset+l.listHeight()-1, renderedLines)
221+ end = min(l.offset+l.height-1, renderedLines)
203222 } else {
204− start = max(0, renderedLines-l.offset-l.listHeight()+1)
223+ start = max(0, renderedLines-l.offset-l.height+1)
205224 end = max(0, renderedLines-l.offset)
206225 }
207226 return start, end
208227}
209228
210−func (l *list[T]) renderItem(item Item) renderedItem {
211− view := item.View()
212− return renderedItem{
213− id: item.ID(),
214− view: view,
215− height: lipgloss.Height(view),
229+func (l *list[T]) recalculateItemPositions() {
230+ currentContentHeight := 0
231+ for _, item := range l.items {
232+ rItem, ok := l.renderedItems[item.ID()]
233+ if !ok {
234+ continue
235+ }
236+ rItem.start = currentContentHeight
237+ rItem.end = currentContentHeight + rItem.height - 1
238+ l.renderedItems[item.ID()] = rItem
239+ currentContentHeight = rItem.end + 1 + l.gap
216240 }
217241}
218242
219−func (l *list[T]) renderView() {
220− var sb strings.Builder
221− for i, rendered := range l.renderedItems {
222− sb.WriteString(rendered.view)
223− if i < len(l.renderedItems)-1 {
224− sb.WriteString(strings.Repeat("\n", l.gap+1))
243+func (l *list[T]) render() tea.Cmd {
244+ if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
245+ return nil
246+ }
247+ l.setDefaultSelected()
248+ focusCmd := l.focusSelectedItem()
249+ // we are not rendering the first time
250+ if l.rendered != "" {
251+ l.rendered = ""
252+ // rerender everything will mostly hit cache
253+ _ = l.renderIterator(0, false)
254+ if l.direction == DirectionBackward {
255+ l.recalculateItemPositions()
256+ }
257+ // in the end scroll to the selected item
258+ if l.focused {
259+ l.scrollToSelection()
260+ }
261+ return focusCmd
262+ }
263+ finishIndex := l.renderIterator(0, true)
264+ // recalculate for the initial items
265+ if l.direction == DirectionBackward {
266+ l.recalculateItemPositions()
267+ }
268+ renderCmd := func() tea.Msg {
269+ // render the rest
270+ _ = l.renderIterator(finishIndex, false)
271+ // needed for backwards
272+ if l.direction == DirectionBackward {
273+ l.recalculateItemPositions()
225274 }
275+ // in the end scroll to the selected item
276+ if l.focused {
277+ l.scrollToSelection()
278+ }
279+ return renderedMsg{}
226280 }
227− l.rendered = sb.String()
281+ return tea.Batch(focusCmd, renderCmd)
228282}
229283
230−func (l *list[T]) incrementOffset(n int) {
231− if !l.isReady {
232− return
284+func (l *list[T]) setDefaultSelected() {
285+ if l.selectedItem == "" {
286+ if l.direction == DirectionForward {
287+ l.selectFirstItem()
288+ } else {
289+ l.selectLastItem()
290+ }
233291 }
234− renderedHeight := lipgloss.Height(l.rendered)
235− // no need for offset
236− if renderedHeight <= l.listHeight() {
292+}
293+
294+func (l *list[T]) scrollToSelection() {
295+ rItem, ok := l.renderedItems[l.selectedItem]
296+ if !ok {
297+ l.selectedItem = ""
298+ l.setDefaultSelected()
237299 return
238300 }
239− maxOffset := renderedHeight - l.listHeight()
240− n = min(n, maxOffset-l.offset)
241− if n <= 0 {
301+
302+ start, end := l.viewPosition()
303+ // item bigger or equal to the viewport do nothing
304+ if rItem.start <= start && rItem.end >= end {
242305 return
243306 }
244− l.offset += n
245−}
246−
247−func (l *list[T]) decrementOffset(n int) {
248− if !l.isReady {
307+ // item already in view do nothing
308+ if rItem.start >= start && rItem.start <= end {
309+ return
310+ } else if rItem.end <= end && rItem.end >= start {
249311 return
250312 }
251− n = min(n, l.offset)
252− if n <= 0 {
313+
314+ if rItem.height >= l.height {
315+ if l.direction == DirectionForward {
316+ l.offset = rItem.start
317+ } else {
318+ l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
319+ }
253320 return
254321 }
255− l.offset -= n
256− if l.offset < 0 {
257− l.offset = 0
322+
323+ itemMiddleStart := rItem.start + rItem.height/2 + 1
324+ if l.direction == DirectionForward {
325+ l.offset = itemMiddleStart - l.height/2
326+ } else {
327+ l.offset = max(0, lipgloss.Height(l.rendered)-(itemMiddleStart+l.height/2))
258328 }
259329}
260330
261−// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
262−func (l *list[T]) changeSelectedWhenNotVisible() tea.Cmd {
263− var cmds []tea.Cmd
331+func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
332+ rItem, ok := l.renderedItems[l.selectedItem]
333+ if !ok {
334+ return nil
335+ }
264336 start, end := l.viewPosition()
265− currentPosition := 0
266− itemWithinView := NotFound
267− needsMove := false
268−
269− for i, item := range l.items {
270− rendered := l.renderedItems[i]
271− itemStart := currentPosition
272− // we remove 1 so that we actually have the row, e.x 1 row => height 1 => start 0, end 0
273− itemEnd := itemStart + rendered.height - 1
274− if itemStart >= start && itemEnd <= end {
275− itemWithinView = i
276− }
277− if item.ID() == l.selectedItem {
278− // item is completely above the viewport
279− if itemStart < start && itemEnd < start {
280− needsMove = true
337+ // item bigger than the viewport do nothing
338+ if rItem.start <= start && rItem.end >= end {
339+ return nil
340+ }
341+ // item already in view do nothing
342+ if rItem.start >= start && rItem.end <= end {
343+ return nil
344+ }
345+
346+ itemMiddle := rItem.start + rItem.height/2
347+
348+ if itemMiddle < start {
349+ // select the first item in the viewport
350+ // the item is most likely an item coming after this item
351+ inx := l.indexMap[rItem.id]
352+ for {
353+ inx = l.firstSelectableItemBelow(inx)
354+ if inx == ItemNotFound {
355+ return nil
281356 }
282− // item is completely below the viewport
283− if itemStart > end && itemEnd > end {
284− needsMove = true
357+ item, ok := l.renderedItems[l.items[inx].ID()]
358+ if !ok {
359+ continue
285360 }
286− if needsMove {
287− if focusable, ok := any(item).(layout.Focusable); ok {
288− cmds = append(cmds, focusable.Blur())
289− }
290− l.renderedItems[i] = l.renderItem(item)
291− } else {
292− return nil
361+
362+ // If the item is bigger than the viewport, select it
363+ if item.start <= start && item.end >= end {
364+ l.selectedItem = item.id
365+ return l.render()
366+ }
367+ // item is in the view
368+ if item.start >= start && item.start <= end {
369+ l.selectedItem = item.id
370+ return l.render()
293371 }
294372 }
295− if itemWithinView != NotFound && needsMove {
296− newSelection := l.items[itemWithinView]
297− l.selectedItem = newSelection.ID()
298− if focusable, ok := any(newSelection).(layout.Focusable); ok {
299− cmds = append(cmds, focusable.Focus())
373+ } else if itemMiddle > end {
374+ // select the first item in the viewport
375+ // the item is most likely an item coming after this item
376+ inx := l.indexMap[rItem.id]
377+ for {
378+ inx = l.firstSelectableItemAbove(inx)
379+ if inx == ItemNotFound {
380+ return nil
381+ }
382+ item, ok := l.renderedItems[l.items[inx].ID()]
383+ if !ok {
384+ continue
385+ }
386+
387+ // If the item is bigger than the viewport, select it
388+ if item.start <= start && item.end >= end {
389+ l.selectedItem = item.id
390+ return l.render()
391+ }
392+ // item is in the view
393+ if item.end >= start && item.end <= end {
394+ l.selectedItem = item.id
395+ return l.render()
300396 }
301− l.renderedItems[itemWithinView] = l.renderItem(newSelection)
302− break
303397 }
304− currentPosition += rendered.height + l.gap
305398 }
306− l.renderView()
307− return tea.Batch(cmds...)
399+ return nil
308400}
309401
310−func (l *list[T]) MoveUp(n int) tea.Cmd {
311− if l.direction == Forward {
312− l.decrementOffset(n)
313− } else {
314− l.incrementOffset(n)
402+func (l *list[T]) selectFirstItem() {
403+ inx := l.firstSelectableItemBelow(-1)
404+ if inx != ItemNotFound {
405+ l.selectedItem = l.items[inx].ID()
315406 }
316− return l.changeSelectedWhenNotVisible()
317407}
318408
319−func (l *list[T]) MoveDown(n int) tea.Cmd {
320− if l.direction == Forward {
321− l.incrementOffset(n)
322− } else {
323− l.decrementOffset(n)
409+func (l *list[T]) selectLastItem() {
410+ inx := l.firstSelectableItemAbove(len(l.items))
411+ if inx != ItemNotFound {
412+ l.selectedItem = l.items[inx].ID()
324413 }
325− return l.changeSelectedWhenNotVisible()
326414}
327415
328−func (l *list[T]) firstSelectableItemBefore(inx int) int {
416+func (l *list[T]) firstSelectableItemAbove(inx int) int {
329417 for i := inx - 1; i >= 0; i-- {
330418 if _, ok := any(l.items[i]).(layout.Focusable); ok {
331419 return i
332420 }
333421 }
334422 if inx == 0 && l.wrap {
335− return l.firstSelectableItemBefore(len(l.items))
423+ return l.firstSelectableItemAbove(len(l.items))
336424 }
337− return NotFound
425+ return ItemNotFound
338426}
339427
340−func (l *list[T]) firstSelectableItemAfter(inx int) int {
428+func (l *list[T]) firstSelectableItemBelow(inx int) int {
341429 for i := inx + 1; i < len(l.items); i++ {
342430 if _, ok := any(l.items[i]).(layout.Focusable); ok {
343431 return i
344432 }
345433 }
346434 if inx == len(l.items)-1 && l.wrap {
347− return l.firstSelectableItemAfter(-1)
435+ return l.firstSelectableItemBelow(-1)
348436 }
349− return NotFound
437+ return ItemNotFound
350438}
351439
352−// moveToSelected needs to be called after the view is rendered
353−func (l *list[T]) moveToSelected(center bool) tea.Cmd {
354− var cmds []tea.Cmd
355− if l.selectedItem == "" || !l.isReady {
440+func (l *list[T]) focusSelectedItem() tea.Cmd {
441+ if l.selectedItem == "" || !l.focused {
356442 return nil
357443 }
358− currentPosition := 0
359− start, end := l.viewPosition()
360− for _, item := range l.renderedItems {
361− if item.id == l.selectedItem {
362− itemStart := currentPosition
363− itemEnd := currentPosition + item.height - 1
364−
365− if start <= itemStart && itemEnd <= end {
366− return nil
367− }
368−
369− if center {
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))
376− } else if targetOffset < l.offset {
377− cmds = append(cmds, l.MoveUp(l.offset-targetOffset))
378− }
379− } else {
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))
386− }
387− }
388− } else {
389− if currentPosition < start {
390− cmds = append(cmds, l.MoveUp(start-currentPosition))
444+ var cmds []tea.Cmd
445+ for _, item := range l.items {
446+ if f, ok := any(item).(layout.Focusable); ok {
447+ if item.ID() == l.selectedItem && !f.IsFocused() {
448+ cmds = append(cmds, f.Focus())
449+ if cache, ok := l.renderedItems[item.ID()]; ok {
450+ cache.dirty = true
451+ l.renderedItems[item.ID()] = cache
391452 }
392− if currentPosition > end {
393− cmds = append(cmds, l.MoveDown(currentPosition-end))
453+ } else if item.ID() != l.selectedItem && f.IsFocused() {
454+ cmds = append(cmds, f.Blur())
455+ if cache, ok := l.renderedItems[item.ID()]; ok {
456+ cache.dirty = true
457+ l.renderedItems[item.ID()] = cache
394458 }
395459 }
396460 }
397− currentPosition += item.height + l.gap
398461 }
399462 return tea.Batch(cmds...)
400463}
401464
402−func (l *list[T]) SelectItemAbove() tea.Cmd {
403− if !l.isReady {
404− return nil
405− }
465+func (l *list[T]) blurItems() tea.Cmd {
406466 var cmds []tea.Cmd
407− for i, item := range l.items {
408− if l.selectedItem == item.ID() {
409− inx := l.firstSelectableItemBefore(i)
410− if inx == NotFound {
411− // no item above
412− return nil
413− }
414− // blur the current item
415− if focusable, ok := any(item).(layout.Focusable); ok {
416− cmds = append(cmds, focusable.Blur())
417− }
418− // rerender the item
419− l.renderedItems[i] = l.renderItem(item)
420− // focus the item above
421− above := l.items[inx]
422− if focusable, ok := any(above).(layout.Focusable); ok {
423− cmds = append(cmds, focusable.Focus())
467+ for _, item := range l.items {
468+ if f, ok := any(item).(layout.Focusable); ok {
469+ if item.ID() == l.selectedItem && f.IsFocused() {
470+ cmds = append(cmds, f.Blur())
471+ if cache, ok := l.renderedItems[item.ID()]; ok {
472+ cache.dirty = true
473+ l.renderedItems[item.ID()] = cache
474+ }
424475 }
425− // rerender the item
426− l.renderedItems[inx] = l.renderItem(above)
427− l.selectedItem = above.ID()
428− break
429476 }
430477 }
431− l.renderView()
432− l.moveToSelected(false)
433478 return tea.Batch(cmds...)
434479}
435480
436−func (l *list[T]) SelectItemBelow() tea.Cmd {
437− if !l.isReady {
438− return nil
439− }
440− var cmds []tea.Cmd
441− for i, item := range l.items {
442− if l.selectedItem == item.ID() {
443− inx := l.firstSelectableItemAfter(i)
444− if inx == NotFound {
445− // no item below
446− return nil
447− }
448− // blur the current item
449− if focusable, ok := any(item).(layout.Focusable); ok {
450− cmds = append(cmds, focusable.Blur())
451− }
452− // rerender the item
453− l.renderedItems[i] = l.renderItem(item)
481+// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
482+// returns the last index
483+func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
484+ currentContentHeight := lipgloss.Height(l.rendered) - 1
485+ for i := startInx; i < len(l.items); i++ {
486+ if currentContentHeight >= l.height && limitHeight {
487+ return i
488+ }
489+ // cool way to go through the list in both directions
490+ inx := i
454491
455− // focus the item below
456− below := l.items[inx]
457− if focusable, ok := any(below).(layout.Focusable); ok {
458− cmds = append(cmds, focusable.Focus())
459− }
460− // rerender the item
461− l.renderedItems[inx] = l.renderItem(below)
462− l.selectedItem = below.ID()
463− break
492+ if l.direction != DirectionForward {
493+ inx = (len(l.items) - 1) - i
464494 }
465− }
466495
467− l.renderView()
468− l.moveToSelected(false)
469− return tea.Batch(cmds...)
470−}
496+ item := l.items[inx]
497+ var rItem renderedItem
498+ if cache, ok := l.renderedItems[item.ID()]; ok && !cache.dirty {
499+ rItem = cache
500+ } else {
501+ rItem = l.renderItem(item)
502+ rItem.start = currentContentHeight
503+ rItem.end = currentContentHeight + rItem.height - 1
504+ l.renderedItems[item.ID()] = rItem
505+ }
506+ gap := l.gap + 1
507+ if inx == len(l.items)-1 {
508+ gap = 0
509+ }
471510
472−func (l *list[T]) GoToTop() tea.Cmd {
473− if !l.isReady {
474− return nil
511+ if l.direction == DirectionForward {
512+ l.rendered += rItem.view + strings.Repeat("\n", gap)
513+ } else {
514+ l.rendered = rItem.view + strings.Repeat("\n", gap) + l.rendered
515+ }
516+ currentContentHeight = rItem.end + 1 + l.gap
475517 }
476− l.offset = 0
477− l.direction = Forward
478− return tea.Batch(l.selectFirstItem(), l.renderForward())
518+ return len(l.items)
479519}
480520
481−func (l *list[T]) GoToBottom() tea.Cmd {
482− if !l.isReady {
483− return nil
521+func (l *list[T]) renderItem(item Item) renderedItem {
522+ view := item.View()
523+ return renderedItem{
524+ id: item.ID(),
525+ view: view,
526+ height: lipgloss.Height(view),
484527 }
485− l.offset = 0
486− l.direction = Backward
487−
488− return tea.Batch(l.selectLastItem(), l.renderBackward())
489528}
490529
491−func (l *list[T]) renderForward() tea.Cmd {
492− // TODO: figure out a way to preserve items that did not change
493− l.renderedItems = make([]renderedItem, 0)
494− currentHeight := 0
495− currentIndex := 0
496− for i, item := range l.items {
497− currentIndex = i
498− if currentHeight-1 > l.listHeight() {
499− break
500− }
501− rendered := l.renderItem(item)
502− l.renderedItems = append(l.renderedItems, rendered)
503− currentHeight += rendered.height + l.gap
504− }
530+// AppendItem implements List.
531+func (l *list[T]) AppendItem(T) tea.Cmd {
532+ panic("unimplemented")
533+}
505534
506− // initial render
507− l.renderView()
535+// Blur implements List.
536+func (l *list[T]) Blur() tea.Cmd {
537+ cmd := l.blurItems()
538+ return tea.Batch(cmd, l.render())
539+}
508540
509− if currentIndex == len(l.items)-1 {
510− l.isReady = true
511− return nil
512− }
513− // render the rest
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)
518− }
519− l.renderView()
520− l.isReady = true
521− return nil
522− }
541+// DeleteItem implements List.
542+func (l *list[T]) DeleteItem(string) tea.Cmd {
543+ panic("unimplemented")
523544}
524545
525−func (l *list[T]) renderBackward() tea.Cmd {
526− // TODO: figure out a way to preserve items that did not change
527− l.renderedItems = make([]renderedItem, 0)
528− currentHeight := 0
529− currentIndex := 0
530− for i := len(l.items) - 1; i >= 0; i-- {
531− currentIndex = i
532− if currentHeight > l.listHeight() {
533− break
534− }
535− rendered := l.renderItem(l.items[i])
536− l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
537− currentHeight += rendered.height + l.gap
538− }
539− // initial render
540− l.renderView()
541− if currentIndex == 0 {
542− l.isReady = true
543− return nil
544− }
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...)
549− }
550− l.renderView()
551− l.isReady = true
552− return nil
553− }
546+// Focus implements List.
547+func (l *list[T]) Focus() tea.Cmd {
548+ l.focused = true
549+ return l.render()
554550}
555551
556−func (l *list[T]) selectFirstItem() tea.Cmd {
557− var cmd tea.Cmd
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()
563− }
564− }
565− return cmd
552+// GetSize implements List.
553+func (l *list[T]) GetSize() (int, int) {
554+ return l.width, l.height
566555}
567556
568−func (l *list[T]) selectLastItem() tea.Cmd {
569− var cmd tea.Cmd
570− inx := l.firstSelectableItemBefore(len(l.items))
571− if inx != NotFound {
572− l.selectedItem = l.items[inx].ID()
573− if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
574− cmd = focusable.Focus()
575− }
576− }
577− return cmd
557+// GoToBottom implements List.
558+func (l *list[T]) GoToBottom() tea.Cmd {
559+ l.offset = 0
560+ l.direction = DirectionBackward
561+ l.selectedItem = ""
562+ return l.render()
578563}
579564
580−func (l *list[T]) renderItems() tea.Cmd {
581− if l.height <= 0 || l.width <= 0 {
582− return nil
583− }
584− if len(l.items) == 0 {
585− return nil
586− }
565+// GoToTop implements List.
566+func (l *list[T]) GoToTop() tea.Cmd {
567+ l.offset = 0
568+ l.direction = DirectionForward
569+ l.selectedItem = ""
570+ return l.render()
571+}
587572
588− if l.selectedItem == "" {
589− if l.direction == Forward {
590− l.selectFirstItem()
591− } else {
592− l.selectLastItem()
593− }
594− }
595− if l.direction == Forward {
596− return l.renderForward()
597− }
598− return l.renderBackward()
573+// IsFocused implements List.
574+func (l *list[T]) IsFocused() bool {
575+ return l.focused
599576}
600577
601−func (l *list[T]) listHeight() int {
602− // for the moment its the same
603− return l.height
578+// Items implements List.
579+func (l *list[T]) Items() []T {
580+ return l.items
604581}
605582
606−func (l *list[T]) SetItems(items []T) tea.Cmd {
607− l.items = items
608− var cmds []tea.Cmd
609− for _, item := range l.items {
610− cmds = append(cmds, item.Init())
611− // Set height to 0 to let the item calculate its own height
612− cmds = append(cmds, item.SetSize(l.width, 0))
583+func (l *list[T]) incrementOffset(n int) {
584+ renderedHeight := lipgloss.Height(l.rendered)
585+ // no need for offset
586+ if renderedHeight <= l.height {
587+ return
588+ }
589+ maxOffset := renderedHeight - l.height
590+ n = min(n, maxOffset-l.offset)
591+ if n <= 0 {
592+ return
613593 }
594+ l.offset += n
595+}
614596
615− cmds = append(cmds, l.renderItems())
616− if l.selectedItem != "" {
617− cmds = append(cmds, l.moveToSelected(true))
597+func (l *list[T]) decrementOffset(n int) {
598+ n = min(n, l.offset)
599+ if n <= 0 {
600+ return
601+ }
602+ l.offset -= n
603+ if l.offset < 0 {
604+ l.offset = 0
618605 }
619− return tea.Batch(cmds...)
620606}
621607
622−// GetSize implements List.
623−func (l *list[T]) GetSize() (int, int) {
624− return l.width, l.height
608+// MoveDown implements List.
609+func (l *list[T]) MoveDown(n int) tea.Cmd {
610+ if l.direction == DirectionForward {
611+ l.incrementOffset(n)
612+ } else {
613+ l.decrementOffset(n)
614+ }
615+ return l.changeSelectionWhenScrolling()
625616}
626617
627−// SetSize implements List.
628−func (l *list[T]) SetSize(width int, height int) tea.Cmd {
629− l.width = width
630− l.height = height
631− var cmds []tea.Cmd
632− for _, item := range l.items {
633− cmds = append(cmds, item.SetSize(width, height))
618+// MoveUp implements List.
619+func (l *list[T]) MoveUp(n int) tea.Cmd {
620+ if l.direction == DirectionForward {
621+ l.decrementOffset(n)
622+ } else {
623+ l.incrementOffset(n)
634624 }
625+ return l.changeSelectionWhenScrolling()
626+}
635627
636− cmds = append(cmds, l.renderItems())
637− return tea.Batch(cmds...)
628+// PrependItem implements List.
629+func (l *list[T]) PrependItem(T) tea.Cmd {
630+ panic("unimplemented")
638631}
639632
640−// Blur implements List.
641−func (l *list[T]) Blur() tea.Cmd {
642− var cmd tea.Cmd
643− l.focused = false
644− for i, item := range l.items {
645− if item.ID() != l.selectedItem {
646− continue
647− }
648− if focusable, ok := any(item).(layout.Focusable); ok {
649− cmd = focusable.Blur()
650− }
651− l.renderedItems[i] = l.renderItem(item)
633+// SelectItemAbove implements List.
634+func (l *list[T]) SelectItemAbove() tea.Cmd {
635+ inx, ok := l.indexMap[l.selectedItem]
636+ if !ok {
637+ return nil
652638 }
653− l.renderView()
654− return cmd
655−}
656639
657−// Focus implements List.
658−func (l *list[T]) Focus() tea.Cmd {
659− var cmd tea.Cmd
660− l.focused = true
661− if l.selectedItem != "" {
662− for i, item := range l.items {
663− if item.ID() != l.selectedItem {
664− continue
665− }
666− if focusable, ok := any(item).(layout.Focusable); ok {
667− cmd = focusable.Focus()
668− }
669− if len(l.renderedItems) > i {
670− l.renderedItems[i] = l.renderItem(item)
671− }
672− }
673− l.renderView()
640+ newIndex := l.firstSelectableItemAbove(inx)
641+ if newIndex == ItemNotFound {
642+ // no item above
643+ return nil
674644 }
675− return cmd
645+ item := l.items[newIndex]
646+ l.selectedItem = item.ID()
647+ return l.render()
676648}
677649
678−func (l *list[T]) SetSelected(id string) tea.Cmd {
679− if l.selectedItem == id {
650+// SelectItemBelow implements List.
651+func (l *list[T]) SelectItemBelow() tea.Cmd {
652+ inx, ok := l.indexMap[l.selectedItem]
653+ if !ok {
680654 return nil
681655 }
682− var cmds []tea.Cmd
683− for i, item := range l.items {
684− if item.ID() == l.selectedItem {
685− if focusable, ok := any(item).(layout.Focusable); ok {
686− cmds = append(cmds, focusable.Blur())
687− }
688− if len(l.renderedItems) > i {
689− l.renderedItems[i] = l.renderItem(item)
690− }
691− } else if item.ID() == id {
692− if focusable, ok := any(item).(layout.Focusable); ok {
693− cmds = append(cmds, focusable.Focus())
694− }
695− if len(l.renderedItems) > i {
696− l.renderedItems[i] = l.renderItem(item)
697− }
698− }
656+
657+ newIndex := l.firstSelectableItemBelow(inx)
658+ if newIndex == ItemNotFound {
659+ // no item above
660+ return nil
699661 }
700− l.selectedItem = id
701− l.renderView()
702− cmds = append(cmds, l.moveToSelected(true))
703− return tea.Batch(cmds...)
662+ item := l.items[newIndex]
663+ l.selectedItem = item.ID()
664+ return l.render()
704665}
705666
667+// SelectedItem implements List.
706668func (l *list[T]) SelectedItem() *T {
707− for _, item := range l.items {
708− if item.ID() == l.selectedItem {
709− return &item
710− }
669+ inx, ok := l.indexMap[l.selectedItem]
670+ if !ok {
671+ return nil
711672 }
712− return nil
673+ if inx > len(l.items)-1 {
674+ return nil
675+ }
676+ item := l.items[inx]
677+ return &item
713678}
714679
715−// IsFocused implements List.
716−func (l *list[T]) IsFocused() bool {
717− return l.focused
680+// SetItems implements List.
681+func (l *list[T]) SetItems(items []T) tea.Cmd {
682+ l.items = items
683+ return l.reset()
718684}
719685
720−func (l *list[T]) Items() []T {
721− return l.items
686+// SetSelected implements List.
687+func (l *list[T]) SetSelected(id string) tea.Cmd {
688+ l.selectedItem = id
689+ return l.render()
722690}
723691
724−func (l *list[T]) UpdateItem(id string, item T) {
725− // TODO: preserve offset
692+func (l *list[T]) reset() tea.Cmd {
693+ var cmds []tea.Cmd
694+ l.rendered = ""
695+ l.indexMap = make(map[string]int)
696+ l.renderedItems = make(map[string]renderedItem)
726697 for inx, item := range l.items {
727− if item.ID() == id {
728− l.items[inx] = item
729− l.renderedItems[inx] = l.renderItem(item)
730− l.renderView()
731− return
698+ l.indexMap[item.ID()] = inx
699+ if l.width > 0 && l.height > 0 {
700+ cmds = append(cmds, item.SetSize(l.width, l.height))
732701 }
733702 }
703+ cmds = append(cmds, l.render())
704+ return tea.Batch(cmds...)
734705}
735706
736−func (l *list[T]) DeleteItem(id string) {
737− // TODO: preserve offset
738− inx := NotFound
739− for i, item := range l.items {
740− if item.ID() == id {
741− inx = i
742− break
743− }
744− }
745−
746− l.items = slices.Delete(l.items, inx, inx+1)
747− l.renderedItems = slices.Delete(l.renderedItems, inx, inx+1)
748− l.renderView()
749−}
750−
751−func (l *list[T]) PrependItem(item T) tea.Cmd {
752− // TODO: preserve offset
753− var cmd tea.Cmd
754− l.items = append([]T{item}, l.items...)
755− l.renderedItems = append([]renderedItem{l.renderItem(item)}, l.renderedItems...)
756− if len(l.items) == 1 {
757− cmd = l.SetSelected(item.ID())
758− }
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())
707+// SetSize implements List.
708+func (l *list[T]) SetSize(width int, height int) tea.Cmd {
709+ oldWidth := l.width
710+ l.width = width
711+ l.height = height
712+ if oldWidth != width {
713+ return l.reset()
762714 }
763− l.renderView()
764− return cmd
715+ return nil
765716}
766717
767−func (l *list[T]) AppendItem(item T) tea.Cmd {
768− // TODO: preserve offset
769− var cmd tea.Cmd
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())
777− } else {
778− l.renderView()
779− }
780− return cmd
718+// UpdateItem implements List.
719+func (l *list[T]) UpdateItem(string, T) tea.Cmd {
720+ panic("unimplemented")
781721}