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