1package list
2
3import (
4 "slices"
5 "strings"
6 "sync"
7
8 "github.com/charmbracelet/bubbles/v2/key"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/crush/internal/csync"
11 "github.com/charmbracelet/crush/internal/tui/components/anim"
12 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
13 "github.com/charmbracelet/crush/internal/tui/styles"
14 "github.com/charmbracelet/crush/internal/tui/util"
15 "github.com/charmbracelet/lipgloss/v2"
16 uv "github.com/charmbracelet/ultraviolet"
17 "github.com/charmbracelet/x/ansi"
18 "github.com/rivo/uniseg"
19)
20
21type Item interface {
22 util.Model
23 layout.Sizeable
24 ID() string
25}
26
27type HasAnim interface {
28 Item
29 Spinning() bool
30}
31
32type List[T Item] interface {
33 util.Model
34 layout.Sizeable
35 layout.Focusable
36
37 // Just change state
38 MoveUp(int) tea.Cmd
39 MoveDown(int) tea.Cmd
40 GoToTop() tea.Cmd
41 GoToBottom() tea.Cmd
42 SelectItemAbove() tea.Cmd
43 SelectItemBelow() tea.Cmd
44 SetItems([]T) tea.Cmd
45 SetSelected(string) tea.Cmd
46 SelectedItem() *T
47 Items() []T
48 UpdateItem(string, T) tea.Cmd
49 DeleteItem(string) tea.Cmd
50 PrependItem(T) tea.Cmd
51 AppendItem(T) tea.Cmd
52 StartSelection(col, line int)
53 EndSelection(col, line int)
54 SelectionStop()
55 SelectionClear()
56 SelectWord(col, line int)
57 SelectParagraph(col, line int)
58 GetSelectedText(paddingLeft int) string
59 HasSelection() bool
60}
61
62type direction int
63
64const (
65 DirectionForward direction = iota
66 DirectionBackward
67)
68
69const (
70 ItemNotFound = -1
71 ViewportDefaultScrollSize = 2
72)
73
74type itemPosition struct {
75 height int
76 start int
77 end int
78}
79
80type confOptions struct {
81 width, height int
82 gap int
83 // if you are at the last item and go down it will wrap to the top
84 wrap bool
85 keyMap KeyMap
86 direction direction
87 selectedItem string
88 focused bool
89 resize bool
90 enableMouse bool
91}
92
93type list[T Item] struct {
94 *confOptions
95
96 offset int
97
98 indexMap *csync.Map[string, int]
99 items *csync.Slice[T]
100
101 // Virtual scrolling fields - using slices for O(1) index access
102 itemPositions []itemPosition // Position info for each item by index
103 virtualHeight int // Total height of all items
104 viewCache *csync.Map[string, string] // Optional cache for rendered views
105
106 renderMu sync.Mutex
107 rendered string
108
109 movingByItem bool
110 selectionStartCol int
111 selectionStartLine int
112 selectionEndCol int
113 selectionEndLine int
114
115 selectionActive bool
116}
117
118type ListOption func(*confOptions)
119
120// WithSize sets the size of the list.
121func WithSize(width, height int) ListOption {
122 return func(l *confOptions) {
123 l.width = width
124 l.height = height
125 }
126}
127
128// WithGap sets the gap between items in the list.
129func WithGap(gap int) ListOption {
130 return func(l *confOptions) {
131 l.gap = gap
132 }
133}
134
135// WithDirectionForward sets the direction to forward
136func WithDirectionForward() ListOption {
137 return func(l *confOptions) {
138 l.direction = DirectionForward
139 }
140}
141
142// WithDirectionBackward sets the direction to forward
143func WithDirectionBackward() ListOption {
144 return func(l *confOptions) {
145 l.direction = DirectionBackward
146 }
147}
148
149// WithSelectedItem sets the initially selected item in the list.
150func WithSelectedItem(id string) ListOption {
151 return func(l *confOptions) {
152 l.selectedItem = id
153 }
154}
155
156func WithKeyMap(keyMap KeyMap) ListOption {
157 return func(l *confOptions) {
158 l.keyMap = keyMap
159 }
160}
161
162func WithWrapNavigation() ListOption {
163 return func(l *confOptions) {
164 l.wrap = true
165 }
166}
167
168func WithFocus(focus bool) ListOption {
169 return func(l *confOptions) {
170 l.focused = focus
171 }
172}
173
174func WithResizeByList() ListOption {
175 return func(l *confOptions) {
176 l.resize = true
177 }
178}
179
180func WithEnableMouse() ListOption {
181 return func(l *confOptions) {
182 l.enableMouse = true
183 }
184}
185
186func New[T Item](items []T, opts ...ListOption) List[T] {
187 list := &list[T]{
188 confOptions: &confOptions{
189 direction: DirectionForward,
190 keyMap: DefaultKeyMap(),
191 focused: true,
192 },
193 items: csync.NewSliceFrom(items),
194 indexMap: csync.NewMap[string, int](),
195 itemPositions: make([]itemPosition, len(items)),
196 viewCache: csync.NewMap[string, string](),
197 selectionStartCol: -1,
198 selectionStartLine: -1,
199 selectionEndLine: -1,
200 selectionEndCol: -1,
201 }
202 for _, opt := range opts {
203 opt(list.confOptions)
204 }
205
206 for inx, item := range items {
207 if i, ok := any(item).(Indexable); ok {
208 i.SetIndex(inx)
209 }
210 list.indexMap.Set(item.ID(), inx)
211 }
212 return list
213}
214
215// Init implements List.
216func (l *list[T]) Init() tea.Cmd {
217 // Ensure we have width and height
218 if l.width <= 0 || l.height <= 0 {
219 // Can't calculate positions without dimensions
220 return nil
221 }
222
223 // Set size for all items
224 var cmds []tea.Cmd
225 for _, item := range slices.Collect(l.items.Seq()) {
226 if cmd := item.SetSize(l.width, l.height); cmd != nil {
227 cmds = append(cmds, cmd)
228 }
229 }
230
231 // Calculate positions for all items
232 l.calculateItemPositions()
233
234 // For backward lists, we need to position at the bottom after initial render
235 if l.direction == DirectionBackward && l.offset == 0 && l.items.Len() > 0 {
236 // Set offset to show the bottom of the list
237 if l.virtualHeight > l.height {
238 l.offset = 0 // In backward mode, offset 0 means bottom
239 }
240 // Select the last item if no item is selected
241 if l.selectedItem == "" {
242 l.selectLastItem()
243 }
244 }
245
246 // Scroll to the selected item for initial positioning
247 if l.focused {
248 l.scrollToSelection()
249 }
250
251 renderCmd := l.render()
252 if renderCmd != nil {
253 cmds = append(cmds, renderCmd)
254 }
255
256 return tea.Batch(cmds...)
257}
258
259// Update implements List.
260func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
261 switch msg := msg.(type) {
262 case tea.MouseWheelMsg:
263 if l.enableMouse {
264 return l.handleMouseWheel(msg)
265 }
266 return l, nil
267 case anim.StepMsg:
268 var cmds []tea.Cmd
269 for _, item := range slices.Collect(l.items.Seq()) {
270 if i, ok := any(item).(HasAnim); ok && i.Spinning() {
271 updated, cmd := i.Update(msg)
272 cmds = append(cmds, cmd)
273 if u, ok := updated.(T); ok {
274 cmds = append(cmds, l.UpdateItem(u.ID(), u))
275 }
276 }
277 }
278 return l, tea.Batch(cmds...)
279 case tea.KeyPressMsg:
280 if l.focused {
281 switch {
282 case key.Matches(msg, l.keyMap.Down):
283 return l, l.MoveDown(ViewportDefaultScrollSize)
284 case key.Matches(msg, l.keyMap.Up):
285 return l, l.MoveUp(ViewportDefaultScrollSize)
286 case key.Matches(msg, l.keyMap.DownOneItem):
287 return l, l.SelectItemBelow()
288 case key.Matches(msg, l.keyMap.UpOneItem):
289 return l, l.SelectItemAbove()
290 case key.Matches(msg, l.keyMap.HalfPageDown):
291 return l, l.MoveDown(l.height / 2)
292 case key.Matches(msg, l.keyMap.HalfPageUp):
293 return l, l.MoveUp(l.height / 2)
294 case key.Matches(msg, l.keyMap.PageDown):
295 return l, l.MoveDown(l.height)
296 case key.Matches(msg, l.keyMap.PageUp):
297 return l, l.MoveUp(l.height)
298 case key.Matches(msg, l.keyMap.End):
299 return l, l.GoToBottom()
300 case key.Matches(msg, l.keyMap.Home):
301 return l, l.GoToTop()
302 }
303 s := l.SelectedItem()
304 if s == nil {
305 return l, nil
306 }
307 item := *s
308 var cmds []tea.Cmd
309 updated, cmd := item.Update(msg)
310 cmds = append(cmds, cmd)
311 if u, ok := updated.(T); ok {
312 cmds = append(cmds, l.UpdateItem(u.ID(), u))
313 }
314 return l, tea.Batch(cmds...)
315 }
316 }
317 return l, nil
318}
319
320func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
321 var cmd tea.Cmd
322 switch msg.Button {
323 case tea.MouseWheelDown:
324 cmd = l.MoveDown(ViewportDefaultScrollSize)
325 case tea.MouseWheelUp:
326 cmd = l.MoveUp(ViewportDefaultScrollSize)
327 }
328 return l, cmd
329}
330
331// selectionView renders the highlighted selection in the view and returns it
332// as a string. If textOnly is true, it won't render any styles.
333func (l *list[T]) selectionView(view string, textOnly bool) string {
334 t := styles.CurrentTheme()
335 area := uv.Rect(0, 0, l.width, l.height)
336 scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
337 uv.NewStyledString(view).Draw(scr, area)
338
339 selArea := uv.Rectangle{
340 Min: uv.Pos(l.selectionStartCol, l.selectionStartLine),
341 Max: uv.Pos(l.selectionEndCol, l.selectionEndLine),
342 }
343 selArea = selArea.Canon()
344
345 specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
346 for _, icon := range styles.SelectionIgnoreIcons {
347 specialChars[icon] = true
348 }
349
350 isNonWhitespace := func(r rune) bool {
351 return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
352 }
353
354 type selectionBounds struct {
355 startX, endX int
356 inSelection bool
357 }
358 lineSelections := make([]selectionBounds, scr.Height())
359
360 for y := range scr.Height() {
361 bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
362
363 if y >= selArea.Min.Y && y <= selArea.Max.Y {
364 bounds.inSelection = true
365 if selArea.Min.Y == selArea.Max.Y {
366 // Single line selection
367 bounds.startX = selArea.Min.X
368 bounds.endX = selArea.Max.X
369 } else if y == selArea.Min.Y {
370 // First line of multi-line selection
371 bounds.startX = selArea.Min.X
372 bounds.endX = scr.Width()
373 } else if y == selArea.Max.Y {
374 // Last line of multi-line selection
375 bounds.startX = 0
376 bounds.endX = selArea.Max.X
377 } else {
378 // Middle lines
379 bounds.startX = 0
380 bounds.endX = scr.Width()
381 }
382 }
383 lineSelections[y] = bounds
384 }
385
386 type lineBounds struct {
387 start, end int
388 }
389 lineTextBounds := make([]lineBounds, scr.Height())
390
391 // First pass: find text bounds for lines that have selections
392 for y := range scr.Height() {
393 bounds := lineBounds{start: -1, end: -1}
394
395 // Only process lines that might have selections
396 if lineSelections[y].inSelection {
397 for x := range scr.Width() {
398 cell := scr.CellAt(x, y)
399 if cell == nil {
400 continue
401 }
402
403 cellStr := cell.String()
404 if len(cellStr) == 0 {
405 continue
406 }
407
408 char := rune(cellStr[0])
409 isSpecial := specialChars[cellStr]
410
411 if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
412 if bounds.start == -1 {
413 bounds.start = x
414 }
415 bounds.end = x + 1 // Position after last character
416 }
417 }
418 }
419 lineTextBounds[y] = bounds
420 }
421
422 var selectedText strings.Builder
423
424 // Second pass: apply selection highlighting
425 for y := range scr.Height() {
426 selBounds := lineSelections[y]
427 if !selBounds.inSelection {
428 continue
429 }
430
431 textBounds := lineTextBounds[y]
432 if textBounds.start < 0 {
433 if textOnly {
434 // We don't want to get rid of all empty lines in text-only mode
435 selectedText.WriteByte('\n')
436 }
437
438 continue // No text on this line
439 }
440
441 // Only scan within the intersection of text bounds and selection bounds
442 scanStart := max(textBounds.start, selBounds.startX)
443 scanEnd := min(textBounds.end, selBounds.endX)
444
445 for x := scanStart; x < scanEnd; x++ {
446 cell := scr.CellAt(x, y)
447 if cell == nil {
448 continue
449 }
450
451 cellStr := cell.String()
452 if len(cellStr) > 0 && !specialChars[cellStr] {
453 if textOnly {
454 // Collect selected text without styles
455 selectedText.WriteString(cell.String())
456 continue
457 }
458
459 // Text selection styling, which is a Lip Gloss style. We must
460 // extract the values to use in a UV style, below.
461 ts := t.TextSelection
462
463 cell = cell.Clone()
464 cell.Style = cell.Style.Background(ts.GetBackground()).Foreground(ts.GetForeground())
465 scr.SetCell(x, y, cell)
466 }
467 }
468
469 if textOnly {
470 // Make sure we add a newline after each line of selected text
471 selectedText.WriteByte('\n')
472 }
473 }
474
475 if textOnly {
476 return strings.TrimSpace(selectedText.String())
477 }
478
479 return scr.Render()
480}
481
482// View implements List.
483func (l *list[T]) View() string {
484 if l.height <= 0 || l.width <= 0 {
485 return ""
486 }
487 t := styles.CurrentTheme()
488
489 // With virtual scrolling, rendered already contains only visible content
490 view := l.rendered
491
492 if l.resize {
493 return view
494 }
495
496 view = t.S().Base.
497 Height(l.height).
498 Width(l.width).
499 Render(view)
500
501 if !l.hasSelection() {
502 return view
503 }
504
505 return l.selectionView(view, false)
506}
507
508func (l *list[T]) viewPosition() (int, int) {
509 // View position in the virtual space
510 start, end := 0, 0
511 if l.direction == DirectionForward {
512 start = l.offset
513 if l.virtualHeight > 0 {
514 end = min(l.offset+l.height-1, l.virtualHeight-1)
515 } else {
516 end = l.offset + l.height - 1
517 }
518 } else {
519 // For backward direction
520 if l.virtualHeight > 0 {
521 end = l.virtualHeight - l.offset - 1
522 start = max(0, end - l.height + 1)
523 } else {
524 end = 0
525 start = 0
526 }
527 }
528 return start, end
529}
530
531func (l *list[T]) render() tea.Cmd {
532 return l.renderWithScrollToSelection(true)
533}
534
535func (l *list[T]) renderWithScrollToSelection(scrollToSelection bool) tea.Cmd {
536 if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
537 return nil
538 }
539 l.setDefaultSelected()
540
541 var focusChangeCmd tea.Cmd
542 if l.focused {
543 focusChangeCmd = l.focusSelectedItem()
544 } else {
545 focusChangeCmd = l.blurSelectedItem()
546 }
547
548 // Calculate all item positions and total height
549 l.calculateItemPositions()
550
551 // Render only visible items
552 l.renderMu.Lock()
553 l.rendered = l.renderVirtualScrolling()
554 l.renderMu.Unlock()
555
556 // Scroll to selected item if focused and requested
557 if l.focused && scrollToSelection {
558 l.scrollToSelection()
559 }
560
561 return focusChangeCmd
562}
563
564func (l *list[T]) setDefaultSelected() {
565 if l.selectedItem == "" {
566 if l.direction == DirectionForward {
567 l.selectFirstItem()
568 } else {
569 l.selectLastItem()
570 }
571 }
572}
573
574func (l *list[T]) scrollToSelection() {
575 if l.selectedItem == "" {
576 return
577 }
578
579 inx, ok := l.indexMap.Get(l.selectedItem)
580 if !ok || inx < 0 || inx >= len(l.itemPositions) {
581 l.selectedItem = ""
582 l.setDefaultSelected()
583 return
584 }
585
586 rItem := l.itemPositions[inx]
587
588 start, end := l.viewPosition()
589
590 // item bigger or equal to the viewport - show from start
591 if rItem.height >= l.height {
592 if l.direction == DirectionForward {
593 l.offset = rItem.start
594 } else {
595 // For backward direction, we want to show the bottom of the item
596 // offset = 0 means bottom of list is visible
597 l.offset = 0
598 }
599 return
600 }
601
602 // if we are moving by item we want to move the offset so that the
603 // whole item is visible not just portions of it
604 if l.movingByItem {
605 if rItem.start >= start && rItem.end <= end {
606 // Item is fully visible, no need to scroll
607 return
608 }
609 defer func() { l.movingByItem = false }()
610 } else {
611 // item already in view do nothing
612 if rItem.start >= start && rItem.start <= end {
613 return
614 }
615 if rItem.end >= start && rItem.end <= end {
616 return
617 }
618 }
619
620 // If item is above the viewport, make it the first item
621 if rItem.start < start {
622 if l.direction == DirectionForward {
623 l.offset = rItem.start
624 } else {
625 if l.virtualHeight > 0 {
626 l.offset = l.virtualHeight - rItem.end
627 } else {
628 l.offset = 0
629 }
630 }
631 } else if rItem.end > end {
632 // If item is below the viewport, make it the last item
633 if l.direction == DirectionForward {
634 l.offset = max(0, rItem.end - l.height + 1)
635 } else {
636 if l.virtualHeight > 0 {
637 l.offset = max(0, l.virtualHeight - rItem.start - l.height + 1)
638 } else {
639 l.offset = 0
640 }
641 }
642 }
643}
644
645func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
646 inx, ok := l.indexMap.Get(l.selectedItem)
647 if !ok || inx < 0 || inx >= len(l.itemPositions) {
648 return nil
649 }
650
651 rItem := l.itemPositions[inx]
652 start, end := l.viewPosition()
653 // item bigger than the viewport do nothing
654 if rItem.start <= start && rItem.end >= end {
655 return nil
656 }
657 // item already in view do nothing
658 if rItem.start >= start && rItem.end <= end {
659 return nil
660 }
661
662 itemMiddle := rItem.start + rItem.height/2
663
664 if itemMiddle < start {
665 // select the first item in the viewport
666 // the item is most likely an item coming after this item
667 for {
668 inx = l.firstSelectableItemBelow(inx)
669 if inx == ItemNotFound {
670 return nil
671 }
672 item, ok := l.items.Get(inx)
673 if !ok {
674 continue
675 }
676 if inx >= len(l.itemPositions) {
677 continue
678 }
679 renderedItem := l.itemPositions[inx]
680
681 // If the item is bigger than the viewport, select it
682 if renderedItem.start <= start && renderedItem.end >= end {
683 l.selectedItem = item.ID()
684 return l.renderWithScrollToSelection(false)
685 }
686 // item is in the view
687 if renderedItem.start >= start && renderedItem.start <= end {
688 l.selectedItem = item.ID()
689 return l.renderWithScrollToSelection(false)
690 }
691 }
692 } else if itemMiddle > end {
693 // select the first item in the viewport
694 // the item is most likely an item coming after this item
695 for {
696 inx = l.firstSelectableItemAbove(inx)
697 if inx == ItemNotFound {
698 return nil
699 }
700 item, ok := l.items.Get(inx)
701 if !ok {
702 continue
703 }
704 if inx >= len(l.itemPositions) {
705 continue
706 }
707 renderedItem := l.itemPositions[inx]
708
709 // If the item is bigger than the viewport, select it
710 if renderedItem.start <= start && renderedItem.end >= end {
711 l.selectedItem = item.ID()
712 return l.renderWithScrollToSelection(false)
713 }
714 // item is in the view
715 if renderedItem.end >= start && renderedItem.end <= end {
716 l.selectedItem = item.ID()
717 return l.renderWithScrollToSelection(false)
718 }
719 }
720 }
721 return nil
722}
723
724func (l *list[T]) selectFirstItem() {
725 inx := l.firstSelectableItemBelow(-1)
726 if inx != ItemNotFound {
727 item, ok := l.items.Get(inx)
728 if ok {
729 l.selectedItem = item.ID()
730 }
731 }
732}
733
734func (l *list[T]) selectLastItem() {
735 inx := l.firstSelectableItemAbove(l.items.Len())
736 if inx != ItemNotFound {
737 item, ok := l.items.Get(inx)
738 if ok {
739 l.selectedItem = item.ID()
740 }
741 }
742}
743
744func (l *list[T]) firstSelectableItemAbove(inx int) int {
745 for i := inx - 1; i >= 0; i-- {
746 item, ok := l.items.Get(i)
747 if !ok {
748 continue
749 }
750 if _, ok := any(item).(layout.Focusable); ok {
751 return i
752 }
753 }
754 if inx == 0 && l.wrap {
755 return l.firstSelectableItemAbove(l.items.Len())
756 }
757 return ItemNotFound
758}
759
760func (l *list[T]) firstSelectableItemBelow(inx int) int {
761 itemsLen := l.items.Len()
762 for i := inx + 1; i < itemsLen; i++ {
763 item, ok := l.items.Get(i)
764 if !ok {
765 continue
766 }
767 if _, ok := any(item).(layout.Focusable); ok {
768 return i
769 }
770 }
771 if inx == itemsLen-1 && l.wrap {
772 return l.firstSelectableItemBelow(-1)
773 }
774 return ItemNotFound
775}
776
777func (l *list[T]) focusSelectedItem() tea.Cmd {
778 if l.selectedItem == "" || !l.focused {
779 return nil
780 }
781 var cmds []tea.Cmd
782 for _, item := range slices.Collect(l.items.Seq()) {
783 if f, ok := any(item).(layout.Focusable); ok {
784 if item.ID() == l.selectedItem && !f.IsFocused() {
785 cmds = append(cmds, f.Focus())
786 l.viewCache.Del(item.ID())
787 } else if item.ID() != l.selectedItem && f.IsFocused() {
788 cmds = append(cmds, f.Blur())
789 l.viewCache.Del(item.ID())
790 }
791 }
792 }
793 return tea.Batch(cmds...)
794}
795
796func (l *list[T]) blurSelectedItem() tea.Cmd {
797 if l.selectedItem == "" || l.focused {
798 return nil
799 }
800 var cmds []tea.Cmd
801 for _, item := range slices.Collect(l.items.Seq()) {
802 if f, ok := any(item).(layout.Focusable); ok {
803 if item.ID() == l.selectedItem && f.IsFocused() {
804 cmds = append(cmds, f.Blur())
805 l.viewCache.Del(item.ID())
806 }
807 }
808 }
809 return tea.Batch(cmds...)
810}
811
812
813
814// calculateItemPositions calculates and caches the position and height of all items.
815// This is O(n) but only called when the list structure changes significantly.
816func (l *list[T]) calculateItemPositions() {
817 itemsLen := l.items.Len()
818
819 // Resize positions slice if needed
820 if len(l.itemPositions) != itemsLen {
821 l.itemPositions = make([]itemPosition, itemsLen)
822 }
823
824 currentHeight := 0
825 // Always calculate positions in forward order (logical positions)
826 for i := 0; i < itemsLen; i++ {
827 item, ok := l.items.Get(i)
828 if !ok {
829 continue
830 }
831
832 // Get cached view or render new one
833 var view string
834 if cached, ok := l.viewCache.Get(item.ID()); ok {
835 view = cached
836 } else {
837 view = item.View()
838 l.viewCache.Set(item.ID(), view)
839 }
840
841 height := lipgloss.Height(view)
842
843 l.itemPositions[i] = itemPosition{
844 height: height,
845 start: currentHeight,
846 end: currentHeight + height - 1,
847 }
848
849 currentHeight += height
850 if i < itemsLen-1 {
851 currentHeight += l.gap
852 }
853 }
854
855 l.virtualHeight = currentHeight
856}
857
858// updateItemPosition updates a single item's position and adjusts subsequent items.
859// This is O(n) in worst case but only for items after the changed one.
860func (l *list[T]) updateItemPosition(index int) {
861 itemsLen := l.items.Len()
862 if index < 0 || index >= itemsLen {
863 return
864 }
865
866 item, ok := l.items.Get(index)
867 if !ok {
868 return
869 }
870
871 // Get new height
872 view := item.View()
873 l.viewCache.Set(item.ID(), view)
874 newHeight := lipgloss.Height(view)
875
876 // If height hasn't changed, no need to update
877 if index < len(l.itemPositions) && l.itemPositions[index].height == newHeight {
878 return
879 }
880
881 // Calculate starting position (from previous item or 0)
882 var startPos int
883 if index > 0 {
884 startPos = l.itemPositions[index-1].end + 1 + l.gap
885 }
886
887 // Update this item
888 oldHeight := 0
889 if index < len(l.itemPositions) {
890 oldHeight = l.itemPositions[index].height
891 }
892 heightDiff := newHeight - oldHeight
893
894 l.itemPositions[index] = itemPosition{
895 height: newHeight,
896 start: startPos,
897 end: startPos + newHeight - 1,
898 }
899
900 // Update all subsequent items' positions (shift by heightDiff)
901 for i := index + 1; i < len(l.itemPositions); i++ {
902 l.itemPositions[i].start += heightDiff
903 l.itemPositions[i].end += heightDiff
904 }
905
906 // Update total height
907 l.virtualHeight += heightDiff
908}
909
910// renderVirtualScrolling renders only the visible portion of the list.
911func (l *list[T]) renderVirtualScrolling() string {
912 if l.items.Len() == 0 {
913 return ""
914 }
915
916 // Calculate viewport bounds
917 viewStart, viewEnd := l.viewPosition()
918
919 // Check if we have any positions calculated
920 if len(l.itemPositions) == 0 {
921 // No positions calculated yet, return empty viewport
922 return ""
923 }
924
925 // Find which items are visible
926 var visibleItems []struct {
927 item T
928 pos itemPosition
929 index int
930 }
931
932 itemsLen := l.items.Len()
933 for i := 0; i < itemsLen; i++ {
934 if i >= len(l.itemPositions) {
935 continue
936 }
937
938 pos := l.itemPositions[i]
939
940 // Check if item is visible (overlaps with viewport)
941 if pos.end >= viewStart && pos.start <= viewEnd {
942 item, ok := l.items.Get(i)
943 if !ok {
944 continue
945 }
946 visibleItems = append(visibleItems, struct {
947 item T
948 pos itemPosition
949 index int
950 }{item, pos, i})
951 }
952
953 // Early exit if we've passed the viewport
954 if pos.start > viewEnd {
955 break
956 }
957 }
958
959 // Build the rendered output
960 var lines []string
961 currentLine := viewStart
962
963 for _, vis := range visibleItems {
964 // Get or render the item's view
965 var view string
966 if cached, ok := l.viewCache.Get(vis.item.ID()); ok {
967 view = cached
968 } else {
969 view = vis.item.View()
970 l.viewCache.Set(vis.item.ID(), view)
971 }
972
973 itemLines := strings.Split(view, "\n")
974
975 // Add gap lines before item if needed (except for first item)
976 if vis.index > 0 && currentLine < vis.pos.start {
977 gapLines := vis.pos.start - currentLine
978 for i := 0; i < gapLines; i++ {
979 lines = append(lines, "")
980 currentLine++
981 }
982 }
983
984 // Determine which lines of this item to include
985 startLine := 0
986 if vis.pos.start < viewStart {
987 // Item starts before viewport, skip some lines
988 startLine = viewStart - vis.pos.start
989 }
990
991 // Add the item's visible lines
992 for i := startLine; i < len(itemLines) && currentLine <= viewEnd; i++ {
993 lines = append(lines, itemLines[i])
994 currentLine++
995 }
996 }
997
998 // For content that fits entirely in viewport, don't pad with empty lines
999 // Only pad if we have scrolled or if content is larger than viewport
1000 if l.virtualHeight > l.height || l.offset > 0 {
1001 // Fill remaining viewport with empty lines if needed
1002 for len(lines) < l.height {
1003 lines = append(lines, "")
1004 }
1005
1006 // Trim to viewport height
1007 if len(lines) > l.height {
1008 lines = lines[:l.height]
1009 }
1010 }
1011
1012 return strings.Join(lines, "\n")
1013}
1014
1015
1016
1017// AppendItem implements List.
1018func (l *list[T]) AppendItem(item T) tea.Cmd {
1019 var cmds []tea.Cmd
1020 cmd := item.Init()
1021 if cmd != nil {
1022 cmds = append(cmds, cmd)
1023 }
1024
1025 l.items.Append(item)
1026 l.indexMap = csync.NewMap[string, int]()
1027 for inx, item := range slices.Collect(l.items.Seq()) {
1028 l.indexMap.Set(item.ID(), inx)
1029 }
1030 if l.width > 0 && l.height > 0 {
1031 cmd = item.SetSize(l.width, l.height)
1032 if cmd != nil {
1033 cmds = append(cmds, cmd)
1034 }
1035 }
1036 cmd = l.render()
1037 if cmd != nil {
1038 cmds = append(cmds, cmd)
1039 }
1040 if l.direction == DirectionBackward {
1041 if l.offset == 0 {
1042 cmd = l.GoToBottom()
1043 if cmd != nil {
1044 cmds = append(cmds, cmd)
1045 }
1046 } else {
1047 // Get the new item's position to adjust offset
1048 newInx := l.items.Len() - 1
1049 if newInx < len(l.itemPositions) {
1050 newItem := l.itemPositions[newInx]
1051 newLines := newItem.height
1052 if l.items.Len() > 1 {
1053 newLines += l.gap
1054 }
1055 if l.virtualHeight > 0 {
1056 l.offset = min(l.virtualHeight-1, l.offset+newLines)
1057 }
1058 }
1059 }
1060 }
1061 return tea.Sequence(cmds...)
1062}
1063
1064// Blur implements List.
1065func (l *list[T]) Blur() tea.Cmd {
1066 l.focused = false
1067 return l.render()
1068}
1069
1070// DeleteItem implements List.
1071func (l *list[T]) DeleteItem(id string) tea.Cmd {
1072 inx, ok := l.indexMap.Get(id)
1073 if !ok {
1074 return nil
1075 }
1076 l.items.Delete(inx)
1077 l.viewCache.Del(id)
1078 // Rebuild index map
1079 l.indexMap = csync.NewMap[string, int]()
1080 for inx, item := range slices.Collect(l.items.Seq()) {
1081 l.indexMap.Set(item.ID(), inx)
1082 }
1083
1084 if l.selectedItem == id {
1085 if inx > 0 {
1086 item, ok := l.items.Get(inx - 1)
1087 if ok {
1088 l.selectedItem = item.ID()
1089 } else {
1090 l.selectedItem = ""
1091 }
1092 } else {
1093 l.selectedItem = ""
1094 }
1095 }
1096 cmd := l.render()
1097 if l.rendered != "" {
1098 renderedHeight := l.virtualHeight
1099 if renderedHeight <= l.height {
1100 l.offset = 0
1101 } else {
1102 maxOffset := renderedHeight - l.height
1103 if l.offset > maxOffset {
1104 l.offset = maxOffset
1105 }
1106 }
1107 }
1108 return cmd
1109}
1110
1111// Focus implements List.
1112func (l *list[T]) Focus() tea.Cmd {
1113 l.focused = true
1114 return l.render()
1115}
1116
1117// GetSize implements List.
1118func (l *list[T]) GetSize() (int, int) {
1119 return l.width, l.height
1120}
1121
1122// GoToBottom implements List.
1123func (l *list[T]) GoToBottom() tea.Cmd {
1124 l.offset = 0
1125 l.selectedItem = ""
1126 l.direction = DirectionBackward
1127 return l.render()
1128}
1129
1130// GoToTop implements List.
1131func (l *list[T]) GoToTop() tea.Cmd {
1132 l.offset = 0
1133 l.selectedItem = ""
1134 l.direction = DirectionForward
1135 return l.render()
1136}
1137
1138// IsFocused implements List.
1139func (l *list[T]) IsFocused() bool {
1140 return l.focused
1141}
1142
1143// Items implements List.
1144func (l *list[T]) Items() []T {
1145 return slices.Collect(l.items.Seq())
1146}
1147
1148func (l *list[T]) incrementOffset(n int) {
1149 renderedHeight := l.virtualHeight
1150 // no need for offset
1151 if renderedHeight <= l.height {
1152 return
1153 }
1154 maxOffset := renderedHeight - l.height
1155 n = min(n, maxOffset-l.offset)
1156 if n <= 0 {
1157 return
1158 }
1159 l.offset += n
1160}
1161
1162func (l *list[T]) decrementOffset(n int) {
1163 n = min(n, l.offset)
1164 if n <= 0 {
1165 return
1166 }
1167 l.offset -= n
1168 if l.offset < 0 {
1169 l.offset = 0
1170 }
1171}
1172
1173// MoveDown implements List.
1174func (l *list[T]) MoveDown(n int) tea.Cmd {
1175 oldOffset := l.offset
1176 if l.direction == DirectionForward {
1177 l.incrementOffset(n)
1178 } else {
1179 l.decrementOffset(n)
1180 }
1181
1182 if oldOffset == l.offset {
1183 // no change in offset, so no need to change selection
1184 return nil
1185 }
1186 // if we are not actively selecting move the whole selection down
1187 if l.hasSelection() && !l.selectionActive {
1188 if l.selectionStartLine < l.selectionEndLine {
1189 l.selectionStartLine -= n
1190 l.selectionEndLine -= n
1191 } else {
1192 l.selectionStartLine -= n
1193 l.selectionEndLine -= n
1194 }
1195 }
1196 if l.selectionActive {
1197 if l.selectionStartLine < l.selectionEndLine {
1198 l.selectionStartLine -= n
1199 } else {
1200 l.selectionEndLine -= n
1201 }
1202 }
1203 return l.changeSelectionWhenScrolling()
1204}
1205
1206// MoveUp implements List.
1207func (l *list[T]) MoveUp(n int) tea.Cmd {
1208 oldOffset := l.offset
1209 if l.direction == DirectionForward {
1210 l.decrementOffset(n)
1211 } else {
1212 l.incrementOffset(n)
1213 }
1214
1215 if oldOffset == l.offset {
1216 // no change in offset, so no need to change selection
1217 return nil
1218 }
1219
1220 if l.hasSelection() && !l.selectionActive {
1221 if l.selectionStartLine > l.selectionEndLine {
1222 l.selectionStartLine += n
1223 l.selectionEndLine += n
1224 } else {
1225 l.selectionStartLine += n
1226 l.selectionEndLine += n
1227 }
1228 }
1229 if l.selectionActive {
1230 if l.selectionStartLine > l.selectionEndLine {
1231 l.selectionStartLine += n
1232 } else {
1233 l.selectionEndLine += n
1234 }
1235 }
1236 return l.changeSelectionWhenScrolling()
1237}
1238
1239// PrependItem implements List.
1240func (l *list[T]) PrependItem(item T) tea.Cmd {
1241 cmds := []tea.Cmd{
1242 item.Init(),
1243 }
1244 l.items.Prepend(item)
1245 l.indexMap = csync.NewMap[string, int]()
1246 for inx, item := range slices.Collect(l.items.Seq()) {
1247 l.indexMap.Set(item.ID(), inx)
1248 }
1249 if l.width > 0 && l.height > 0 {
1250 cmds = append(cmds, item.SetSize(l.width, l.height))
1251 }
1252
1253 // Recalculate positions after prepending
1254 l.calculateItemPositions()
1255
1256 if l.direction == DirectionForward {
1257 if l.offset == 0 {
1258 // If we're at the top, stay at the top
1259 cmds = append(cmds, l.render())
1260 cmd := l.GoToTop()
1261 if cmd != nil {
1262 cmds = append(cmds, cmd)
1263 }
1264 } else {
1265 // Adjust offset to maintain viewport position
1266 // The prepended item is at index 0
1267 if len(l.itemPositions) > 0 {
1268 newItem := l.itemPositions[0]
1269 newLines := newItem.height
1270 if l.items.Len() > 1 {
1271 newLines += l.gap
1272 }
1273 // Increase offset to keep the same content visible
1274 if l.virtualHeight > 0 {
1275 l.offset = min(l.virtualHeight-l.height, l.offset+newLines)
1276 }
1277 }
1278 cmds = append(cmds, l.renderWithScrollToSelection(false))
1279 }
1280 } else {
1281 // For backward direction, prepending doesn't affect the offset
1282 // since offset is from the bottom
1283 cmds = append(cmds, l.render())
1284 }
1285 return tea.Batch(cmds...)
1286}
1287
1288// SelectItemAbove implements List.
1289func (l *list[T]) SelectItemAbove() tea.Cmd {
1290 inx, ok := l.indexMap.Get(l.selectedItem)
1291 if !ok {
1292 return nil
1293 }
1294
1295 newIndex := l.firstSelectableItemAbove(inx)
1296 if newIndex == ItemNotFound {
1297 // no item above
1298 return nil
1299 }
1300 var cmds []tea.Cmd
1301 if newIndex == 1 {
1302 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1303 if peakAboveIndex == ItemNotFound {
1304 // this means there is a section above move to the top
1305 cmd := l.GoToTop()
1306 if cmd != nil {
1307 cmds = append(cmds, cmd)
1308 }
1309 }
1310 }
1311 item, ok := l.items.Get(newIndex)
1312 if !ok {
1313 return nil
1314 }
1315 l.selectedItem = item.ID()
1316 l.movingByItem = true
1317 renderCmd := l.render()
1318 if renderCmd != nil {
1319 cmds = append(cmds, renderCmd)
1320 }
1321 return tea.Sequence(cmds...)
1322}
1323
1324// SelectItemBelow implements List.
1325func (l *list[T]) SelectItemBelow() tea.Cmd {
1326 inx, ok := l.indexMap.Get(l.selectedItem)
1327 if !ok {
1328 return nil
1329 }
1330
1331 newIndex := l.firstSelectableItemBelow(inx)
1332 if newIndex == ItemNotFound {
1333 // no item above
1334 return nil
1335 }
1336 item, ok := l.items.Get(newIndex)
1337 if !ok {
1338 return nil
1339 }
1340 l.selectedItem = item.ID()
1341 l.movingByItem = true
1342 return l.render()
1343}
1344
1345// SelectedItem implements List.
1346func (l *list[T]) SelectedItem() *T {
1347 inx, ok := l.indexMap.Get(l.selectedItem)
1348 if !ok {
1349 return nil
1350 }
1351 if inx > l.items.Len()-1 {
1352 return nil
1353 }
1354 item, ok := l.items.Get(inx)
1355 if !ok {
1356 return nil
1357 }
1358 return &item
1359}
1360
1361// SetItems implements List.
1362func (l *list[T]) SetItems(items []T) tea.Cmd {
1363 l.items.SetSlice(items)
1364 var cmds []tea.Cmd
1365 for inx, item := range slices.Collect(l.items.Seq()) {
1366 if i, ok := any(item).(Indexable); ok {
1367 i.SetIndex(inx)
1368 }
1369 cmds = append(cmds, item.Init())
1370 }
1371 cmds = append(cmds, l.reset(""))
1372 return tea.Batch(cmds...)
1373}
1374
1375// SetSelected implements List.
1376func (l *list[T]) SetSelected(id string) tea.Cmd {
1377 l.selectedItem = id
1378 return l.render()
1379}
1380
1381func (l *list[T]) reset(selectedItem string) tea.Cmd {
1382 var cmds []tea.Cmd
1383 l.rendered = ""
1384 l.offset = 0
1385 l.selectedItem = selectedItem
1386 l.indexMap = csync.NewMap[string, int]()
1387 l.viewCache = csync.NewMap[string, string]()
1388 l.itemPositions = nil // Will be recalculated
1389 l.virtualHeight = 0
1390 for inx, item := range slices.Collect(l.items.Seq()) {
1391 l.indexMap.Set(item.ID(), inx)
1392 if l.width > 0 && l.height > 0 {
1393 cmds = append(cmds, item.SetSize(l.width, l.height))
1394 }
1395 }
1396 cmds = append(cmds, l.render())
1397 return tea.Batch(cmds...)
1398}
1399
1400// SetSize implements List.
1401func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1402 oldWidth := l.width
1403 l.width = width
1404 l.height = height
1405 if oldWidth != width {
1406 cmd := l.reset(l.selectedItem)
1407 return cmd
1408 }
1409 return nil
1410}
1411
1412// UpdateItem implements List.
1413func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1414 var cmds []tea.Cmd
1415 if inx, ok := l.indexMap.Get(id); ok {
1416 // Store old item position info before update
1417 var oldItemPos itemPosition
1418 hasOldItem := false
1419 if inx < len(l.itemPositions) {
1420 oldItemPos = l.itemPositions[inx]
1421 hasOldItem = true
1422 }
1423
1424 // Update the item
1425 l.items.Set(inx, item)
1426
1427 // Clear cache for this item
1428 l.viewCache.Del(id)
1429
1430 // Recalculate positions to get new height
1431 l.calculateItemPositions()
1432
1433 // Adjust offset if item height changed and it's outside the viewport
1434 if hasOldItem && inx < len(l.itemPositions) {
1435 newItemPos := l.itemPositions[inx]
1436 heightDiff := newItemPos.height - oldItemPos.height
1437
1438 if heightDiff != 0 {
1439 // Get current viewport position
1440 viewStart, viewEnd := l.viewPosition()
1441
1442 if l.direction == DirectionForward {
1443 // Item is above viewport if its end is before viewport start
1444 if oldItemPos.end < viewStart {
1445 // Adjust offset to maintain viewport content
1446 l.offset = max(0, l.offset + heightDiff)
1447 }
1448 } else {
1449 // For backward direction:
1450 // Check if item is outside the current viewport
1451 // Item is completely below viewport if its start is after viewport end
1452 if oldItemPos.start > viewEnd {
1453 // Item below viewport increased height, increase offset to maintain view
1454 l.offset = max(0, l.offset + heightDiff)
1455 } else if oldItemPos.end < viewStart {
1456 // Item is completely above viewport
1457 // No offset adjustment needed for items above in backward direction
1458 // because they don't affect the view from bottom
1459 }
1460 }
1461 }
1462 }
1463
1464 // Re-render with updated positions and offset
1465 cmd := l.renderWithScrollToSelection(false)
1466 if cmd != nil {
1467 cmds = append(cmds, cmd)
1468 }
1469
1470 cmds = append(cmds, item.Init())
1471 if l.width > 0 && l.height > 0 {
1472 cmds = append(cmds, item.SetSize(l.width, l.height))
1473 }
1474 }
1475 return tea.Sequence(cmds...)
1476}
1477
1478func (l *list[T]) hasSelection() bool {
1479 return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1480}
1481
1482// StartSelection implements List.
1483func (l *list[T]) StartSelection(col, line int) {
1484 l.selectionStartCol = col
1485 l.selectionStartLine = line
1486 l.selectionEndCol = col
1487 l.selectionEndLine = line
1488 l.selectionActive = true
1489}
1490
1491// EndSelection implements List.
1492func (l *list[T]) EndSelection(col, line int) {
1493 if !l.selectionActive {
1494 return
1495 }
1496 l.selectionEndCol = col
1497 l.selectionEndLine = line
1498}
1499
1500func (l *list[T]) SelectionStop() {
1501 l.selectionActive = false
1502}
1503
1504func (l *list[T]) SelectionClear() {
1505 l.selectionStartCol = -1
1506 l.selectionStartLine = -1
1507 l.selectionEndCol = -1
1508 l.selectionEndLine = -1
1509 l.selectionActive = false
1510}
1511
1512func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1513 lines := strings.Split(l.rendered, "\n")
1514 for i, l := range lines {
1515 lines[i] = ansi.Strip(l)
1516 }
1517
1518 if l.direction == DirectionBackward && len(lines) > l.height {
1519 line = ((len(lines) - 1) - l.height) + line + 1
1520 }
1521
1522 if l.offset > 0 {
1523 if l.direction == DirectionBackward {
1524 line -= l.offset
1525 } else {
1526 line += l.offset
1527 }
1528 }
1529
1530 if line < 0 || line >= len(lines) {
1531 return 0, 0
1532 }
1533
1534 currentLine := lines[line]
1535 gr := uniseg.NewGraphemes(currentLine)
1536 startCol = -1
1537 upTo := col
1538 for gr.Next() {
1539 if gr.IsWordBoundary() && upTo > 0 {
1540 startCol = col - upTo + 1
1541 } else if gr.IsWordBoundary() && upTo < 0 {
1542 endCol = col - upTo + 1
1543 break
1544 }
1545 if upTo == 0 && gr.Str() == " " {
1546 return 0, 0
1547 }
1548 upTo -= 1
1549 }
1550 if startCol == -1 {
1551 return 0, 0
1552 }
1553 return
1554}
1555
1556func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1557 lines := strings.Split(l.rendered, "\n")
1558 for i, l := range lines {
1559 lines[i] = ansi.Strip(l)
1560 for _, icon := range styles.SelectionIgnoreIcons {
1561 lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1562 }
1563 }
1564 if l.direction == DirectionBackward && len(lines) > l.height {
1565 line = (len(lines) - 1) - l.height + line + 1
1566 }
1567
1568 if l.offset > 0 {
1569 if l.direction == DirectionBackward {
1570 line -= l.offset
1571 } else {
1572 line += l.offset
1573 }
1574 }
1575
1576 // Ensure line is within bounds
1577 if line < 0 || line >= len(lines) {
1578 return 0, 0, false
1579 }
1580
1581 if strings.TrimSpace(lines[line]) == "" {
1582 return 0, 0, false
1583 }
1584
1585 // Find start of paragraph (search backwards for empty line or start of text)
1586 startLine = line
1587 for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1588 startLine--
1589 }
1590
1591 // Find end of paragraph (search forwards for empty line or end of text)
1592 endLine = line
1593 for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1594 endLine++
1595 }
1596
1597 // revert the line numbers if we are in backward direction
1598 if l.direction == DirectionBackward && len(lines) > l.height {
1599 startLine = startLine - (len(lines) - 1) + l.height - 1
1600 endLine = endLine - (len(lines) - 1) + l.height - 1
1601 }
1602 if l.offset > 0 {
1603 if l.direction == DirectionBackward {
1604 startLine += l.offset
1605 endLine += l.offset
1606 } else {
1607 startLine -= l.offset
1608 endLine -= l.offset
1609 }
1610 }
1611 return startLine, endLine, true
1612}
1613
1614// SelectWord selects the word at the given position.
1615func (l *list[T]) SelectWord(col, line int) {
1616 startCol, endCol := l.findWordBoundaries(col, line)
1617 l.selectionStartCol = startCol
1618 l.selectionStartLine = line
1619 l.selectionEndCol = endCol
1620 l.selectionEndLine = line
1621 l.selectionActive = false // Not actively selecting, just selected
1622}
1623
1624// SelectParagraph selects the paragraph at the given position.
1625func (l *list[T]) SelectParagraph(col, line int) {
1626 startLine, endLine, found := l.findParagraphBoundaries(line)
1627 if !found {
1628 return
1629 }
1630 l.selectionStartCol = 0
1631 l.selectionStartLine = startLine
1632 l.selectionEndCol = l.width - 1
1633 l.selectionEndLine = endLine
1634 l.selectionActive = false // Not actively selecting, just selected
1635}
1636
1637// HasSelection returns whether there is an active selection.
1638func (l *list[T]) HasSelection() bool {
1639 return l.hasSelection()
1640}
1641
1642// GetSelectedText returns the currently selected text.
1643func (l *list[T]) GetSelectedText(paddingLeft int) string {
1644 if !l.hasSelection() {
1645 return ""
1646 }
1647
1648 return l.selectionView(l.View(), true)
1649}