@@ -0,0 +1,625 @@
1+package list
2+
3+import (
4+ "slices"
5+ "strings"
6+ "sync"
7+
8+ "github.com/charmbracelet/bubbles/v2/help"
9+ "github.com/charmbracelet/bubbles/v2/key"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/lipgloss/v2"
12+ "github.com/opencode-ai/opencode/internal/tui/layout"
13+ "github.com/opencode-ai/opencode/internal/tui/util"
14+)
15+
16+type ListModel interface {
17+ util.Model
18+ layout.Sizeable
19+ SetItems([]util.Model) tea.Cmd
20+ AppendItem(util.Model)
21+ PrependItem(util.Model)
22+ DeleteItem(int)
23+ UpdateItem(int, util.Model)
24+ ResetView()
25+}
26+
27+type renderedItem struct {
28+ lines []string
29+ start int
30+ height int
31+}
32+type model struct {
33+ width, height, offset int
34+ finalHight int // this gets set when the last item is rendered to mark the max offset
35+ reverse bool
36+ help help.Model
37+ keymap KeyMap
38+ items []util.Model
39+ renderedItems *sync.Map // item index to rendered string
40+ needsRerender bool
41+ renderedLines []string
42+ selectedItemInx int
43+ lastRenderedInx int
44+ content string
45+ gapSize int
46+ padding []int
47+}
48+
49+type listOptions func(*model)
50+
51+func WithKeyMap(k KeyMap) listOptions {
52+ return func(m *model) {
53+ m.keymap = k
54+ }
55+}
56+
57+func WithReverse(reverse bool) listOptions {
58+ return func(m *model) {
59+ m.setReverse(reverse)
60+ }
61+}
62+
63+func WithGapSize(gapSize int) listOptions {
64+ return func(m *model) {
65+ m.gapSize = gapSize
66+ }
67+}
68+
69+func WithPadding(padding ...int) listOptions {
70+ return func(m *model) {
71+ m.padding = padding
72+ }
73+}
74+
75+func WithItems(items []util.Model) listOptions {
76+ return func(m *model) {
77+ m.items = items
78+ }
79+}
80+
81+func New(opts ...listOptions) ListModel {
82+ m := &model{
83+ help: help.New(),
84+ keymap: defaultKeymap(),
85+ items: []util.Model{},
86+ needsRerender: true,
87+ gapSize: 0,
88+ padding: []int{},
89+ selectedItemInx: -1,
90+ finalHight: -1,
91+ lastRenderedInx: -1,
92+ renderedItems: new(sync.Map),
93+ }
94+ for _, opt := range opts {
95+ opt(m)
96+ }
97+ return m
98+}
99+
100+// Init implements List.
101+func (m *model) Init() tea.Cmd {
102+ cmds := []tea.Cmd{
103+ m.SetItems(m.items),
104+ }
105+ return tea.Batch(cmds...)
106+}
107+
108+// Update implements List.
109+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
110+ switch msg := msg.(type) {
111+ case tea.KeyMsg:
112+ switch {
113+ case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
114+ if m.reverse {
115+ m.decreaseOffset(1)
116+ } else {
117+ m.increaseOffset(1)
118+ }
119+ return m, nil
120+ case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
121+ if m.reverse {
122+ m.increaseOffset(1)
123+ } else {
124+ m.decreaseOffset(1)
125+ }
126+ return m, nil
127+ case key.Matches(msg, m.keymap.DownOneItem):
128+ m.downOneItem()
129+ return m, nil
130+ case key.Matches(msg, m.keymap.UpOneItem):
131+ m.upOneItem()
132+ return m, nil
133+ case key.Matches(msg, m.keymap.HalfPageDown):
134+ if m.reverse {
135+ m.decreaseOffset(m.listHeight() / 2)
136+ } else {
137+ m.increaseOffset(m.listHeight() / 2)
138+ }
139+ return m, nil
140+ case key.Matches(msg, m.keymap.HalfPageUp):
141+ if m.reverse {
142+ m.increaseOffset(m.listHeight() / 2)
143+ } else {
144+ m.decreaseOffset(m.listHeight() / 2)
145+ }
146+ return m, nil
147+ case key.Matches(msg, m.keymap.Home):
148+ m.goToTop()
149+ return m, nil
150+ case key.Matches(msg, m.keymap.End):
151+ m.goToBottom()
152+ return m, nil
153+ }
154+ }
155+ if m.selectedItemInx > -1 {
156+ u, cmd := m.items[m.selectedItemInx].Update(msg)
157+ m.UpdateItem(m.selectedItemInx, u.(util.Model))
158+ return m, cmd
159+ }
160+
161+ return m, nil
162+}
163+
164+// View implements List.
165+func (m *model) View() string {
166+ if m.height == 0 || m.width == 0 {
167+ return ""
168+ }
169+ if m.needsRerender {
170+ m.renderVisible()
171+ }
172+ return lipgloss.NewStyle().Padding(m.padding...).Render(m.content)
173+}
174+
175+func (m *model) renderVisibleReverse() {
176+ start := 0
177+ cutoff := m.offset + m.listHeight()
178+ items := m.items
179+ if m.lastRenderedInx > -1 {
180+ items = m.items[:m.lastRenderedInx]
181+ start = len(m.renderedLines)
182+ } else {
183+ // reveresed so that it starts at the end
184+ m.lastRenderedInx = len(m.items)
185+ }
186+ realIndex := m.lastRenderedInx
187+ for i := len(items) - 1; i >= 0; i-- {
188+ realIndex--
189+ var itemLines []string
190+ cachedContent, ok := m.renderedItems.Load(realIndex)
191+ if ok {
192+ itemLines = cachedContent.(renderedItem).lines
193+ } else {
194+ itemLines = strings.Split(items[i].View(), "\n")
195+ if m.gapSize > 0 && realIndex != len(m.items)-1 {
196+ for range m.gapSize {
197+ itemLines = append(itemLines, "")
198+ }
199+ }
200+ m.renderedItems.Store(realIndex, renderedItem{
201+ lines: itemLines,
202+ start: start,
203+ height: len(itemLines),
204+ })
205+ }
206+
207+ if realIndex == 0 {
208+ m.finalHight = max(0, start+len(itemLines)-m.listHeight())
209+ }
210+ m.renderedLines = append(itemLines, m.renderedLines...)
211+ m.lastRenderedInx = realIndex
212+ // always render the next item
213+ if start > cutoff {
214+ break
215+ }
216+ start += len(itemLines)
217+ }
218+ m.needsRerender = false
219+ if m.finalHight > -1 {
220+ // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
221+ m.offset = min(m.offset, m.finalHight)
222+ }
223+ maxHeight := min(m.listHeight(), len(m.renderedLines))
224+ if m.offset < len(m.renderedLines) {
225+ end := len(m.renderedLines) - m.offset
226+ start := max(0, end-maxHeight)
227+ m.content = strings.Join(m.renderedLines[start:end], "\n")
228+ } else {
229+ m.content = ""
230+ }
231+}
232+
233+func (m *model) renderVisible() {
234+ if m.reverse {
235+ m.renderVisibleReverse()
236+ return
237+ }
238+ start := 0
239+ cutoff := m.offset + m.listHeight()
240+ items := m.items
241+ if m.lastRenderedInx > -1 {
242+ items = m.items[m.lastRenderedInx+1:]
243+ start = len(m.renderedLines)
244+ }
245+
246+ realIndex := m.lastRenderedInx
247+ for _, item := range items {
248+ realIndex++
249+
250+ var itemLines []string
251+ cachedContent, ok := m.renderedItems.Load(realIndex)
252+ if ok {
253+ itemLines = cachedContent.(renderedItem).lines
254+ } else {
255+ itemLines = strings.Split(item.View(), "\n")
256+ if m.gapSize > 0 && realIndex != len(m.items)-1 {
257+ for range m.gapSize {
258+ itemLines = append(itemLines, "")
259+ }
260+ }
261+ m.renderedItems.Store(realIndex, renderedItem{
262+ lines: itemLines,
263+ start: start,
264+ height: len(itemLines),
265+ })
266+ }
267+ // always render the next item
268+ if start > cutoff {
269+ break
270+ }
271+
272+ if realIndex == len(m.items)-1 {
273+ m.finalHight = max(0, start+len(itemLines)-m.listHeight())
274+ }
275+
276+ m.renderedLines = append(m.renderedLines, itemLines...)
277+ m.lastRenderedInx = realIndex
278+ start += len(itemLines)
279+ }
280+ m.needsRerender = false
281+ maxHeight := min(m.listHeight(), len(m.renderedLines))
282+ if m.finalHight > -1 {
283+ // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
284+ m.offset = min(m.offset, m.finalHight)
285+ }
286+ if m.offset < len(m.renderedLines) {
287+ m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
288+ } else {
289+ m.content = ""
290+ }
291+}
292+
293+func (m *model) upOneItem() tea.Cmd {
294+ var cmds []tea.Cmd
295+ if m.selectedItemInx > 0 {
296+ cmd := m.blurSelected()
297+ cmds = append(cmds, cmd)
298+ m.selectedItemInx--
299+ cmd = m.focusSelected()
300+ cmds = append(cmds, cmd)
301+ }
302+
303+ cached, ok := m.renderedItems.Load(m.selectedItemInx)
304+ if ok {
305+ // already rendered
306+ if !m.reverse {
307+ cachedItem, _ := cached.(renderedItem)
308+ // might not fit on the screen move the offset to the start of the item
309+ if cachedItem.height >= m.listHeight() {
310+ changeNeeded := m.offset - cachedItem.start
311+ m.decreaseOffset(changeNeeded)
312+ }
313+ if cachedItem.start < m.offset {
314+ changeNeeded := m.offset - cachedItem.start
315+ m.decreaseOffset(changeNeeded)
316+ }
317+ } else {
318+ cachedItem, _ := cached.(renderedItem)
319+ // might not fit on the screen move the offset to the start of the item
320+ if cachedItem.height >= m.listHeight() || cachedItem.start+cachedItem.height > m.offset+m.listHeight() {
321+ changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.offset
322+ m.increaseOffset(changeNeeded)
323+ }
324+ }
325+ }
326+ m.needsRerender = true
327+ return tea.Batch(cmds...)
328+}
329+
330+func (m *model) downOneItem() tea.Cmd {
331+ var cmds []tea.Cmd
332+ if m.selectedItemInx < len(m.items)-1 {
333+ cmd := m.blurSelected()
334+ cmds = append(cmds, cmd)
335+ m.selectedItemInx++
336+ cmd = m.focusSelected()
337+ cmds = append(cmds, cmd)
338+ }
339+ cached, ok := m.renderedItems.Load(m.selectedItemInx)
340+ if ok {
341+ // already rendered
342+ if !m.reverse {
343+ cachedItem, _ := cached.(renderedItem)
344+ // might not fit on the screen move the offset to the start of the item
345+ if cachedItem.height >= m.listHeight() {
346+ changeNeeded := cachedItem.start - m.offset
347+ m.increaseOffset(changeNeeded)
348+ } else {
349+ end := cachedItem.start + cachedItem.height
350+ if end > m.offset+m.listHeight() {
351+ changeNeeded := end - (m.offset + m.listHeight())
352+ m.increaseOffset(changeNeeded)
353+ }
354+ }
355+ } else {
356+ cachedItem, _ := cached.(renderedItem)
357+ // might not fit on the screen move the offset to the start of the item
358+ if cachedItem.height >= m.listHeight() {
359+ changeNeeded := m.offset - (cachedItem.start + cachedItem.height - m.listHeight())
360+ m.decreaseOffset(changeNeeded)
361+ } else {
362+ if cachedItem.start < m.offset {
363+ changeNeeded := m.offset - cachedItem.start
364+ m.decreaseOffset(changeNeeded)
365+ }
366+ }
367+ }
368+ }
369+
370+ m.needsRerender = true
371+ return tea.Batch(cmds...)
372+}
373+
374+func (m *model) goToBottom() tea.Cmd {
375+ var cmds []tea.Cmd
376+ m.reverse = true
377+ cmd := m.blurSelected()
378+ cmds = append(cmds, cmd)
379+ m.selectedItemInx = len(m.items) - 1
380+ cmd = m.focusSelected()
381+ cmds = append(cmds, cmd)
382+ m.ResetView()
383+ return tea.Batch(cmds...)
384+}
385+
386+func (m *model) ResetView() {
387+ m.renderedItems.Clear()
388+ m.renderedLines = []string{}
389+ m.offset = 0
390+ m.lastRenderedInx = -1
391+ m.finalHight = -1
392+ m.needsRerender = true
393+}
394+
395+func (m *model) goToTop() tea.Cmd {
396+ var cmds []tea.Cmd
397+ m.reverse = false
398+ cmd := m.blurSelected()
399+ cmds = append(cmds, cmd)
400+ m.selectedItemInx = 0
401+ cmd = m.focusSelected()
402+ cmds = append(cmds, cmd)
403+ m.ResetView()
404+ return tea.Batch(cmds...)
405+}
406+
407+func (m *model) focusSelected() tea.Cmd {
408+ if m.selectedItemInx == -1 {
409+ return nil
410+ }
411+ if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
412+ cmd := i.Focus()
413+ m.rerenderItem(m.selectedItemInx)
414+ return cmd
415+ }
416+ return nil
417+}
418+
419+func (m *model) blurSelected() tea.Cmd {
420+ if m.selectedItemInx == -1 {
421+ return nil
422+ }
423+ if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
424+ cmd := i.Blur()
425+ m.rerenderItem(m.selectedItemInx)
426+ return cmd
427+ }
428+ return nil
429+}
430+
431+func (m *model) rerenderItem(inx int) {
432+ if inx < 0 || len(m.renderedLines) == 0 {
433+ return
434+ }
435+ cached, ok := m.renderedItems.Load(inx)
436+ cachedItem, _ := cached.(renderedItem)
437+ if !ok {
438+ // No need to rerender
439+ return
440+ }
441+ rerenderedItem := m.items[inx].View()
442+ rerenderedLines := strings.Split(rerenderedItem, "\n")
443+ if m.gapSize > 0 && inx != len(m.items)-1 {
444+ for range m.gapSize {
445+ rerenderedLines = append(rerenderedLines, "")
446+ }
447+ }
448+ // check if lines are the same
449+ if slices.Equal(cachedItem.lines, rerenderedLines) {
450+ // No changes
451+ return
452+ }
453+ // check if the item is in the content
454+ start := cachedItem.start
455+ end := start + cachedItem.height
456+ totalLines := len(m.renderedLines)
457+ if m.reverse {
458+ end = totalLines - cachedItem.start
459+ start = end - cachedItem.height
460+ }
461+ if start <= totalLines && end <= totalLines {
462+ m.renderedLines = slices.Delete(m.renderedLines, start, end)
463+ m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
464+ }
465+ // TODO: if hight changed do something
466+ if cachedItem.height != len(rerenderedLines) && inx != len(m.items)-1 {
467+ panic("not handled")
468+ }
469+ m.renderedItems.Store(inx, renderedItem{
470+ lines: rerenderedLines,
471+ start: cachedItem.start,
472+ height: len(rerenderedLines),
473+ })
474+ m.needsRerender = true
475+}
476+
477+func (m *model) increaseOffset(n int) {
478+ if m.finalHight > -1 {
479+ if m.offset < m.finalHight {
480+ m.offset += n
481+ if m.offset > m.finalHight {
482+ m.offset = m.finalHight
483+ }
484+ m.needsRerender = true
485+ }
486+ } else {
487+ m.offset += n
488+ m.needsRerender = true
489+ }
490+}
491+
492+func (m *model) decreaseOffset(n int) {
493+ if m.offset > 0 {
494+ m.offset -= n
495+ if m.offset < 0 {
496+ m.offset = 0
497+ }
498+ m.needsRerender = true
499+ }
500+}
501+
502+// UpdateItem implements List.
503+func (m *model) UpdateItem(inx int, item util.Model) {
504+ m.items[inx] = item
505+ m.rerenderItem(inx)
506+ m.needsRerender = true
507+}
508+
509+// GetSize implements List.
510+func (m *model) GetSize() (int, int) {
511+ return m.width, m.height
512+}
513+
514+// SetSize implements List.
515+func (m *model) SetSize(width int, height int) tea.Cmd {
516+ if m.width == width && m.height == height {
517+ return nil
518+ }
519+ if m.height != height {
520+ m.finalHight = -1
521+ m.height = height
522+ }
523+ m.width = width
524+ m.ResetView()
525+ return m.setItemsSize()
526+}
527+
528+func (m *model) setItemsSize() tea.Cmd {
529+ var cmds []tea.Cmd
530+ width := m.width
531+ if m.padding != nil {
532+ if len(m.padding) == 1 {
533+ width -= m.padding[0] * 2
534+ } else if len(m.padding) == 2 || len(m.padding) == 3 {
535+ width -= m.padding[1] * 2
536+ } else if len(m.padding) == 4 {
537+ width -= m.padding[1] + m.padding[3]
538+ }
539+ }
540+ for _, item := range m.items {
541+ if i, ok := item.(layout.Sizeable); ok {
542+ cmd := i.SetSize(width, 0) // height is not limited
543+ cmds = append(cmds, cmd)
544+ }
545+ }
546+ return tea.Batch(cmds...)
547+}
548+
549+func (m *model) listHeight() int {
550+ height := m.height
551+ if m.padding != nil {
552+ if len(m.padding) == 1 {
553+ height -= m.padding[0] * 2
554+ } else if len(m.padding) == 2 {
555+ height -= m.padding[1] * 2
556+ } else if len(m.padding) == 3 {
557+ height -= m.padding[0] + m.padding[2]
558+ } else if len(m.padding) == 4 {
559+ height -= m.padding[0] + m.padding[2]
560+ }
561+ }
562+ return height
563+}
564+
565+// AppendItem implements List.
566+func (m *model) AppendItem(item util.Model) {
567+ m.items = append(m.items, item)
568+ m.goToBottom()
569+ m.needsRerender = true
570+}
571+
572+// DeleteItem implements List.
573+func (m *model) DeleteItem(i int) {
574+ m.items = slices.Delete(m.items, i, i+1)
575+ m.renderedItems.Delete(i)
576+ m.needsRerender = true
577+}
578+
579+// PrependItem implements List.
580+func (m *model) PrependItem(item util.Model) {
581+ m.items = append([]util.Model{item}, m.items...)
582+ // update the indices of the rendered items
583+ newRenderedItems := make(map[int]renderedItem)
584+ m.renderedItems.Range(func(key any, value any) bool {
585+ keyInt := key.(int)
586+ renderedItem := value.(renderedItem)
587+ newKey := keyInt + 1
588+ newRenderedItems[newKey] = renderedItem
589+ return false
590+ })
591+ m.renderedItems.Clear()
592+ for k, v := range newRenderedItems {
593+ m.renderedItems.Store(k, v)
594+ }
595+ m.goToTop()
596+ m.needsRerender = true
597+}
598+
599+func (m *model) setReverse(reverse bool) {
600+ if reverse {
601+ m.goToBottom()
602+ } else {
603+ m.goToTop()
604+ }
605+}
606+
607+// SetItems implements List.
608+func (m *model) SetItems(items []util.Model) tea.Cmd {
609+ m.items = items
610+ var cmds []tea.Cmd
611+ cmd := m.setItemsSize()
612+ cmds = append(cmds, cmd)
613+ if m.reverse {
614+ m.selectedItemInx = len(m.items) - 1
615+ cmd := m.focusSelected()
616+ cmds = append(cmds, cmd)
617+ } else {
618+ m.selectedItemInx = 0
619+ cmd := m.focusSelected()
620+ cmds = append(cmds, cmd)
621+ }
622+ m.needsRerender = true
623+ m.ResetView()
624+ return tea.Batch(cmds...)
625+}