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 positionsDirty bool // Flag to track if positions need recalculation
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 positionsDirty: true, // Mark as dirty initially
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.MoveDown(ViewportDefaultScrollSize)
312 case key.Matches(msg, l.keyMap.Up):
313 return l, l.MoveUp(ViewportDefaultScrollSize)
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 // Only calculate positions if the list is dirty
577 if l.positionsDirty {
578 l.calculateItemPositions()
579 l.positionsDirty = false
580 }
581
582 // Render only visible items
583 l.renderMu.Lock()
584 l.rendered = l.renderVirtualScrolling()
585 l.renderMu.Unlock()
586
587 // Scroll to selected item if focused and requested
588 if l.focused && scrollToSelection {
589 l.scrollToSelection()
590 }
591
592 return focusChangeCmd
593}
594
595func (l *list[T]) setDefaultSelected() {
596 if l.selectedItem == "" {
597 if l.direction == DirectionForward {
598 l.selectFirstItem()
599 } else {
600 l.selectLastItem()
601 }
602 }
603}
604
605func (l *list[T]) scrollToSelection() {
606 if l.selectedItem == "" {
607 return
608 }
609
610 inx, ok := l.indexMap.Get(l.selectedItem)
611 if !ok || inx < 0 || inx >= len(l.itemPositions) {
612 l.selectedItem = ""
613 l.setDefaultSelected()
614 return
615 }
616
617 rItem := l.itemPositions[inx]
618
619 start, end := l.viewPosition()
620
621 // item bigger or equal to the viewport - show from start
622 if rItem.height >= l.height {
623 if l.direction == DirectionForward {
624 l.offset = rItem.start
625 } else {
626 // For backward direction, we want to show the bottom of the item
627 // offset = 0 means bottom of list is visible
628 l.offset = 0
629 }
630 return
631 }
632
633 // if we are moving by item we want to move the offset so that the
634 // whole item is visible not just portions of it
635 if l.movingByItem {
636 if rItem.start >= start && rItem.end <= end {
637 // Item is fully visible, no need to scroll
638 return
639 }
640 defer func() { l.movingByItem = false }()
641 } else {
642 // item already in view do nothing
643 if rItem.start >= start && rItem.start <= end {
644 return
645 }
646 if rItem.end >= start && rItem.end <= end {
647 return
648 }
649 }
650
651 // If item is above the viewport, make it the first item
652 if rItem.start < start {
653 if l.direction == DirectionForward {
654 l.offset = rItem.start
655 } else {
656 if l.virtualHeight > 0 {
657 l.offset = l.virtualHeight - rItem.end
658 } else {
659 l.offset = 0
660 }
661 }
662 } else if rItem.end > end {
663 // If item is below the viewport, make it the last item
664 if l.direction == DirectionForward {
665 l.offset = max(0, rItem.end-l.height+1)
666 } else {
667 if l.virtualHeight > 0 {
668 l.offset = max(0, l.virtualHeight-rItem.start-l.height+1)
669 } else {
670 l.offset = 0
671 }
672 }
673 }
674}
675
676func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
677 inx, ok := l.indexMap.Get(l.selectedItem)
678 if !ok || inx < 0 || inx >= len(l.itemPositions) {
679 return nil
680 }
681
682 rItem := l.itemPositions[inx]
683 start, end := l.viewPosition()
684 // item bigger than the viewport do nothing
685 if rItem.start <= start && rItem.end >= end {
686 return nil
687 }
688 // item already in view do nothing
689 if rItem.start >= start && rItem.end <= end {
690 return nil
691 }
692
693 itemMiddle := rItem.start + rItem.height/2
694
695 if itemMiddle < start {
696 // select the first item in the viewport
697 // the item is most likely an item coming after this item
698 for {
699 inx = l.firstSelectableItemBelow(inx)
700 if inx == ItemNotFound {
701 return nil
702 }
703 item, ok := l.items.Get(inx)
704 if !ok {
705 continue
706 }
707 if inx >= len(l.itemPositions) {
708 continue
709 }
710 renderedItem := l.itemPositions[inx]
711
712 // If the item is bigger than the viewport, select it
713 if renderedItem.start <= start && renderedItem.end >= end {
714 l.selectedItem = item.ID()
715 return l.renderWithScrollToSelection(false)
716 }
717 // item is in the view
718 if renderedItem.start >= start && renderedItem.start <= end {
719 l.selectedItem = item.ID()
720 return l.renderWithScrollToSelection(false)
721 }
722 }
723 } else if itemMiddle > end {
724 // select the first item in the viewport
725 // the item is most likely an item coming after this item
726 for {
727 inx = l.firstSelectableItemAbove(inx)
728 if inx == ItemNotFound {
729 return nil
730 }
731 item, ok := l.items.Get(inx)
732 if !ok {
733 continue
734 }
735 if inx >= len(l.itemPositions) {
736 continue
737 }
738 renderedItem := l.itemPositions[inx]
739
740 // If the item is bigger than the viewport, select it
741 if renderedItem.start <= start && renderedItem.end >= end {
742 l.selectedItem = item.ID()
743 return l.renderWithScrollToSelection(false)
744 }
745 // item is in the view
746 if renderedItem.end >= start && renderedItem.end <= end {
747 l.selectedItem = item.ID()
748 return l.renderWithScrollToSelection(false)
749 }
750 }
751 }
752 return nil
753}
754
755func (l *list[T]) selectFirstItem() {
756 inx := l.firstSelectableItemBelow(-1)
757 if inx != ItemNotFound {
758 item, ok := l.items.Get(inx)
759 if ok {
760 l.selectedItem = item.ID()
761 }
762 }
763}
764
765func (l *list[T]) selectLastItem() {
766 inx := l.firstSelectableItemAbove(l.items.Len())
767 if inx != ItemNotFound {
768 item, ok := l.items.Get(inx)
769 if ok {
770 l.selectedItem = item.ID()
771 }
772 }
773}
774
775func (l *list[T]) firstSelectableItemAbove(inx int) int {
776 for i := inx - 1; i >= 0; i-- {
777 item, ok := l.items.Get(i)
778 if !ok {
779 continue
780 }
781 if _, ok := any(item).(layout.Focusable); ok {
782 return i
783 }
784 }
785 if inx == 0 && l.wrap {
786 return l.firstSelectableItemAbove(l.items.Len())
787 }
788 return ItemNotFound
789}
790
791func (l *list[T]) firstSelectableItemBelow(inx int) int {
792 itemsLen := l.items.Len()
793 for i := inx + 1; i < itemsLen; i++ {
794 item, ok := l.items.Get(i)
795 if !ok {
796 continue
797 }
798 if _, ok := any(item).(layout.Focusable); ok {
799 return i
800 }
801 }
802 if inx == itemsLen-1 && l.wrap {
803 return l.firstSelectableItemBelow(-1)
804 }
805 return ItemNotFound
806}
807
808func (l *list[T]) focusSelectedItem() tea.Cmd {
809 if l.selectedItem == "" || !l.focused {
810 return nil
811 }
812 var cmds []tea.Cmd
813 for _, item := range slices.Collect(l.items.Seq()) {
814 if f, ok := any(item).(layout.Focusable); ok {
815 if item.ID() == l.selectedItem && !f.IsFocused() {
816 cmds = append(cmds, f.Focus())
817 l.viewCache.Del(item.ID())
818 } else if item.ID() != l.selectedItem && f.IsFocused() {
819 cmds = append(cmds, f.Blur())
820 l.viewCache.Del(item.ID())
821 }
822 }
823 }
824 return tea.Batch(cmds...)
825}
826
827func (l *list[T]) blurSelectedItem() tea.Cmd {
828 if l.selectedItem == "" || l.focused {
829 return nil
830 }
831 var cmds []tea.Cmd
832 for _, item := range slices.Collect(l.items.Seq()) {
833 if f, ok := any(item).(layout.Focusable); ok {
834 if item.ID() == l.selectedItem && f.IsFocused() {
835 cmds = append(cmds, f.Blur())
836 l.viewCache.Del(item.ID())
837 }
838 }
839 }
840 return tea.Batch(cmds...)
841}
842
843// calculateItemPositions calculates and caches the position and height of all items.
844// This is O(n) but only called when the list structure changes significantly.
845func (l *list[T]) calculateItemPositions() {
846 itemsLen := l.items.Len()
847
848 // Resize positions slice if needed
849 if len(l.itemPositions) != itemsLen {
850 l.itemPositions = make([]itemPosition, itemsLen)
851 }
852
853 currentHeight := 0
854 // Always calculate positions in forward order (logical positions)
855 for i := 0; i < itemsLen; i++ {
856 item, ok := l.items.Get(i)
857 if !ok {
858 continue
859 }
860
861 // Get cached view or render new one
862 var view string
863 if cached, ok := l.viewCache.Get(item.ID()); ok {
864 view = cached
865 } else {
866 view = item.View()
867 l.viewCache.Set(item.ID(), view)
868 }
869
870 height := lipgloss.Height(view)
871
872 l.itemPositions[i] = itemPosition{
873 height: height,
874 start: currentHeight,
875 end: currentHeight + height - 1,
876 }
877
878 currentHeight += height
879 if i < itemsLen-1 {
880 currentHeight += l.gap
881 }
882 }
883
884 l.virtualHeight = currentHeight
885}
886
887// updateItemPosition updates a single item's position and adjusts subsequent items.
888// This is O(n) in worst case but only for items after the changed one.
889func (l *list[T]) updateItemPosition(index int) {
890 itemsLen := l.items.Len()
891 if index < 0 || index >= itemsLen {
892 return
893 }
894
895 item, ok := l.items.Get(index)
896 if !ok {
897 return
898 }
899
900 // Get new height
901 view := item.View()
902 l.viewCache.Set(item.ID(), view)
903 newHeight := lipgloss.Height(view)
904
905 // If height hasn't changed, no need to update
906 if index < len(l.itemPositions) && l.itemPositions[index].height == newHeight {
907 return
908 }
909
910 // Calculate starting position (from previous item or 0)
911 var startPos int
912 if index > 0 {
913 startPos = l.itemPositions[index-1].end + 1 + l.gap
914 }
915
916 // Update this item
917 oldHeight := 0
918 if index < len(l.itemPositions) {
919 oldHeight = l.itemPositions[index].height
920 }
921 heightDiff := newHeight - oldHeight
922
923 l.itemPositions[index] = itemPosition{
924 height: newHeight,
925 start: startPos,
926 end: startPos + newHeight - 1,
927 }
928
929 // Update all subsequent items' positions (shift by heightDiff)
930 for i := index + 1; i < len(l.itemPositions); i++ {
931 l.itemPositions[i].start += heightDiff
932 l.itemPositions[i].end += heightDiff
933 }
934
935 // Update total height
936 l.virtualHeight += heightDiff
937}
938
939// renderVirtualScrolling renders only the visible portion of the list.
940func (l *list[T]) renderVirtualScrolling() string {
941 if l.items.Len() == 0 {
942 return ""
943 }
944
945 // Calculate viewport bounds
946 viewStart, viewEnd := l.viewPosition()
947
948 // Check if we have any positions calculated
949 if len(l.itemPositions) == 0 {
950 // No positions calculated yet, return empty viewport
951 return ""
952 }
953
954 // Find which items are visible
955 var visibleItems []struct {
956 item T
957 pos itemPosition
958 index int
959 }
960
961 itemsLen := l.items.Len()
962 for i := 0; i < itemsLen; i++ {
963 if i >= len(l.itemPositions) {
964 continue
965 }
966
967 pos := l.itemPositions[i]
968
969 // Check if item is visible (overlaps with viewport)
970 if pos.end >= viewStart && pos.start <= viewEnd {
971 item, ok := l.items.Get(i)
972 if !ok {
973 continue
974 }
975 visibleItems = append(visibleItems, struct {
976 item T
977 pos itemPosition
978 index int
979 }{item, pos, i})
980 }
981
982 // Early exit if we've passed the viewport
983 if pos.start > viewEnd {
984 break
985 }
986 }
987
988 // Build the rendered output
989 var lines []string
990 currentLine := viewStart
991
992 for _, vis := range visibleItems {
993 // Get or render the item's view
994 var view string
995 if cached, ok := l.viewCache.Get(vis.item.ID()); ok {
996 view = cached
997 } else {
998 view = vis.item.View()
999 l.viewCache.Set(vis.item.ID(), view)
1000 }
1001
1002 itemLines := strings.Split(view, "\n")
1003
1004 // Add gap lines before item if needed (except for first item)
1005 if vis.index > 0 && currentLine < vis.pos.start {
1006 gapLines := vis.pos.start - currentLine
1007 for i := 0; i < gapLines; i++ {
1008 lines = append(lines, "")
1009 currentLine++
1010 }
1011 }
1012
1013 // Determine which lines of this item to include
1014 startLine := 0
1015 if vis.pos.start < viewStart {
1016 // Item starts before viewport, skip some lines
1017 startLine = viewStart - vis.pos.start
1018 }
1019
1020 // Add the item's visible lines
1021 for i := startLine; i < len(itemLines) && currentLine <= viewEnd; i++ {
1022 lines = append(lines, itemLines[i])
1023 currentLine++
1024 }
1025 }
1026
1027 // For content that fits entirely in viewport, don't pad with empty lines
1028 // Only pad if we have scrolled or if content is larger than viewport
1029 if l.virtualHeight > l.height || l.offset > 0 {
1030 // Fill remaining viewport with empty lines if needed
1031 for len(lines) < l.height {
1032 lines = append(lines, "")
1033 }
1034
1035 // Trim to viewport height
1036 if len(lines) > l.height {
1037 lines = lines[:l.height]
1038 }
1039 }
1040
1041 return strings.Join(lines, "\n")
1042}
1043
1044// AppendItem implements List.
1045func (l *list[T]) AppendItem(item T) tea.Cmd {
1046 var cmds []tea.Cmd
1047 cmd := item.Init()
1048 if cmd != nil {
1049 cmds = append(cmds, cmd)
1050 }
1051
1052 l.items.Append(item)
1053 l.indexMap = csync.NewMap[string, int]()
1054 for inx, item := range slices.Collect(l.items.Seq()) {
1055 l.indexMap.Set(item.ID(), inx)
1056 }
1057
1058 // Mark positions as dirty
1059 l.positionsDirty = true
1060
1061 if l.width > 0 && l.height > 0 {
1062 cmd = item.SetSize(l.width, l.height)
1063 if cmd != nil {
1064 cmds = append(cmds, cmd)
1065 }
1066 }
1067 cmd = l.render()
1068 if cmd != nil {
1069 cmds = append(cmds, cmd)
1070 }
1071 if l.direction == DirectionBackward {
1072 if l.offset == 0 {
1073 cmd = l.GoToBottom()
1074 if cmd != nil {
1075 cmds = append(cmds, cmd)
1076 }
1077 }
1078 // Note: We can't adjust offset based on item height here since positions aren't calculated yet
1079 }
1080 return tea.Sequence(cmds...)
1081}
1082
1083// Blur implements List.
1084func (l *list[T]) Blur() tea.Cmd {
1085 l.focused = false
1086 return l.render()
1087}
1088
1089// DeleteItem implements List.
1090func (l *list[T]) DeleteItem(id string) tea.Cmd {
1091 inx, ok := l.indexMap.Get(id)
1092 if !ok {
1093 return nil
1094 }
1095 l.items.Delete(inx)
1096 l.viewCache.Del(id)
1097 // Rebuild index map
1098 l.indexMap = csync.NewMap[string, int]()
1099 for inx, item := range slices.Collect(l.items.Seq()) {
1100 l.indexMap.Set(item.ID(), inx)
1101 }
1102
1103 if l.selectedItem == id {
1104 if inx > 0 {
1105 item, ok := l.items.Get(inx - 1)
1106 if ok {
1107 l.selectedItem = item.ID()
1108 } else {
1109 l.selectedItem = ""
1110 }
1111 } else {
1112 l.selectedItem = ""
1113 }
1114 }
1115 cmd := l.render()
1116 if l.rendered != "" {
1117 renderedHeight := l.virtualHeight
1118 if renderedHeight <= l.height {
1119 l.offset = 0
1120 } else {
1121 maxOffset := renderedHeight - l.height
1122 if l.offset > maxOffset {
1123 l.offset = maxOffset
1124 }
1125 }
1126 }
1127 return cmd
1128}
1129
1130// Focus implements List.
1131func (l *list[T]) Focus() tea.Cmd {
1132 l.focused = true
1133 return l.render()
1134}
1135
1136// GetSize implements List.
1137func (l *list[T]) GetSize() (int, int) {
1138 return l.width, l.height
1139}
1140
1141// GoToBottom implements List.
1142func (l *list[T]) GoToBottom() tea.Cmd {
1143 l.offset = 0
1144 l.selectedItem = ""
1145 l.direction = DirectionBackward
1146 return l.render()
1147}
1148
1149// GoToTop implements List.
1150func (l *list[T]) GoToTop() tea.Cmd {
1151 l.offset = 0
1152 l.selectedItem = ""
1153 l.direction = DirectionForward
1154 return l.render()
1155}
1156
1157// IsFocused implements List.
1158func (l *list[T]) IsFocused() bool {
1159 return l.focused
1160}
1161
1162// Items implements List.
1163func (l *list[T]) Items() []T {
1164 return slices.Collect(l.items.Seq())
1165}
1166
1167func (l *list[T]) incrementOffset(n int) {
1168 renderedHeight := l.virtualHeight
1169 // no need for offset
1170 if renderedHeight <= l.height {
1171 return
1172 }
1173 maxOffset := renderedHeight - l.height
1174 n = min(n, maxOffset-l.offset)
1175 if n <= 0 {
1176 return
1177 }
1178 l.offset += n
1179}
1180
1181func (l *list[T]) decrementOffset(n int) {
1182 n = min(n, l.offset)
1183 if n <= 0 {
1184 return
1185 }
1186 l.offset -= n
1187 if l.offset < 0 {
1188 l.offset = 0
1189 }
1190}
1191
1192// MoveDown implements List.
1193func (l *list[T]) MoveDown(n int) tea.Cmd {
1194 oldOffset := l.offset
1195 if l.direction == DirectionForward {
1196 l.incrementOffset(n)
1197 } else {
1198 l.decrementOffset(n)
1199 }
1200
1201 if oldOffset == l.offset {
1202 // no change in offset, so no need to change selection
1203 return nil
1204 }
1205 // if we are not actively selecting move the whole selection down
1206 if l.hasSelection() && !l.selectionActive {
1207 if l.selectionStartLine < l.selectionEndLine {
1208 l.selectionStartLine -= n
1209 l.selectionEndLine -= n
1210 } else {
1211 l.selectionStartLine -= n
1212 l.selectionEndLine -= n
1213 }
1214 }
1215 if l.selectionActive {
1216 if l.selectionStartLine < l.selectionEndLine {
1217 l.selectionStartLine -= n
1218 } else {
1219 l.selectionEndLine -= n
1220 }
1221 }
1222 return l.changeSelectionWhenScrolling()
1223}
1224
1225// MoveUp implements List.
1226func (l *list[T]) MoveUp(n int) tea.Cmd {
1227 oldOffset := l.offset
1228 if l.direction == DirectionForward {
1229 l.decrementOffset(n)
1230 } else {
1231 l.incrementOffset(n)
1232 }
1233
1234 if oldOffset == l.offset {
1235 // no change in offset, so no need to change selection
1236 return nil
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 // Mark positions as dirty
1273 l.positionsDirty = true
1274
1275 if l.direction == DirectionForward {
1276 if l.offset == 0 {
1277 // If we're at the top, stay at the top
1278 cmds = append(cmds, l.render())
1279 cmd := l.GoToTop()
1280 if cmd != nil {
1281 cmds = append(cmds, cmd)
1282 }
1283 } else {
1284 // Note: We need to calculate positions to adjust offset properly
1285 // This is one case where we might need to calculate immediately
1286 l.calculateItemPositions()
1287 l.positionsDirty = false
1288
1289 // Adjust offset to maintain viewport position
1290 // The prepended item is at index 0
1291 if len(l.itemPositions) > 0 {
1292 newItem := l.itemPositions[0]
1293 newLines := newItem.height
1294 if l.items.Len() > 1 {
1295 newLines += l.gap
1296 }
1297 // Increase offset to keep the same content visible
1298 if l.virtualHeight > 0 {
1299 l.offset = min(l.virtualHeight-l.height, l.offset+newLines)
1300 }
1301 }
1302 cmds = append(cmds, l.renderWithScrollToSelection(false))
1303 }
1304 } else {
1305 // For backward direction, prepending doesn't affect the offset
1306 // since offset is from the bottom
1307 cmds = append(cmds, l.render())
1308 }
1309 return tea.Batch(cmds...)
1310}
1311
1312// SelectItemAbove implements List.
1313func (l *list[T]) SelectItemAbove() tea.Cmd {
1314 inx, ok := l.indexMap.Get(l.selectedItem)
1315 if !ok {
1316 return nil
1317 }
1318
1319 newIndex := l.firstSelectableItemAbove(inx)
1320 if newIndex == ItemNotFound {
1321 // no item above
1322 return nil
1323 }
1324 var cmds []tea.Cmd
1325 if newIndex == 1 {
1326 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1327 if peakAboveIndex == ItemNotFound {
1328 // this means there is a section above move to the top
1329 cmd := l.GoToTop()
1330 if cmd != nil {
1331 cmds = append(cmds, cmd)
1332 }
1333 }
1334 }
1335 item, ok := l.items.Get(newIndex)
1336 if !ok {
1337 return nil
1338 }
1339 l.selectedItem = item.ID()
1340 l.movingByItem = true
1341 renderCmd := l.render()
1342 if renderCmd != nil {
1343 cmds = append(cmds, renderCmd)
1344 }
1345 return tea.Sequence(cmds...)
1346}
1347
1348// SelectItemBelow implements List.
1349func (l *list[T]) SelectItemBelow() tea.Cmd {
1350 inx, ok := l.indexMap.Get(l.selectedItem)
1351 if !ok {
1352 return nil
1353 }
1354
1355 newIndex := l.firstSelectableItemBelow(inx)
1356 if newIndex == ItemNotFound {
1357 // no item above
1358 return nil
1359 }
1360 item, ok := l.items.Get(newIndex)
1361 if !ok {
1362 return nil
1363 }
1364 l.selectedItem = item.ID()
1365 l.movingByItem = true
1366 return l.render()
1367}
1368
1369// SelectedItem implements List.
1370func (l *list[T]) SelectedItem() *T {
1371 inx, ok := l.indexMap.Get(l.selectedItem)
1372 if !ok {
1373 return nil
1374 }
1375 if inx > l.items.Len()-1 {
1376 return nil
1377 }
1378 item, ok := l.items.Get(inx)
1379 if !ok {
1380 return nil
1381 }
1382 return &item
1383}
1384
1385// SetItems implements List.
1386func (l *list[T]) SetItems(items []T) tea.Cmd {
1387 l.items.SetSlice(items)
1388 var cmds []tea.Cmd
1389 for inx, item := range slices.Collect(l.items.Seq()) {
1390 if i, ok := any(item).(Indexable); ok {
1391 i.SetIndex(inx)
1392 }
1393 cmds = append(cmds, item.Init())
1394 }
1395 cmds = append(cmds, l.reset(""))
1396 return tea.Batch(cmds...)
1397}
1398
1399// SetSelected implements List.
1400func (l *list[T]) SetSelected(id string) tea.Cmd {
1401 l.selectedItem = id
1402 return l.render()
1403}
1404
1405func (l *list[T]) reset(selectedItem string) tea.Cmd {
1406 var cmds []tea.Cmd
1407 l.rendered = ""
1408 l.offset = 0
1409 l.selectedItem = selectedItem
1410 l.indexMap = csync.NewMap[string, int]()
1411 l.viewCache = csync.NewMap[string, string]()
1412 l.itemPositions = nil // Will be recalculated
1413 l.virtualHeight = 0
1414 l.positionsDirty = true // Mark as dirty
1415 for inx, item := range slices.Collect(l.items.Seq()) {
1416 l.indexMap.Set(item.ID(), inx)
1417 if l.width > 0 && l.height > 0 {
1418 cmds = append(cmds, item.SetSize(l.width, l.height))
1419 }
1420 }
1421 cmds = append(cmds, l.render())
1422 return tea.Batch(cmds...)
1423}
1424
1425// SetSize implements List.
1426func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1427 oldWidth := l.width
1428 l.width = width
1429 l.height = height
1430 if oldWidth != width {
1431 cmd := l.reset(l.selectedItem)
1432 return cmd
1433 }
1434 return nil
1435}
1436
1437// UpdateItem implements List.
1438func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1439 var cmds []tea.Cmd
1440 if inx, ok := l.indexMap.Get(id); ok {
1441 // Update the item
1442 l.items.Set(inx, item)
1443
1444 // Clear cache for this item
1445 l.viewCache.Del(id)
1446
1447 // Mark positions as dirty for recalculation
1448 l.positionsDirty = true
1449
1450 // Re-render with updated positions
1451 cmd := l.renderWithScrollToSelection(false)
1452 if cmd != nil {
1453 cmds = append(cmds, cmd)
1454 }
1455
1456 cmds = append(cmds, item.Init())
1457 if l.width > 0 && l.height > 0 {
1458 cmds = append(cmds, item.SetSize(l.width, l.height))
1459 }
1460 }
1461 return tea.Sequence(cmds...)
1462}
1463
1464func (l *list[T]) hasSelection() bool {
1465 return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1466}
1467
1468// StartSelection implements List.
1469func (l *list[T]) StartSelection(col, line int) {
1470 l.selectionStartCol = col
1471 l.selectionStartLine = line
1472 l.selectionEndCol = col
1473 l.selectionEndLine = line
1474 l.selectionActive = true
1475}
1476
1477// EndSelection implements List.
1478func (l *list[T]) EndSelection(col, line int) {
1479 if !l.selectionActive {
1480 return
1481 }
1482 l.selectionEndCol = col
1483 l.selectionEndLine = line
1484}
1485
1486func (l *list[T]) SelectionStop() {
1487 l.selectionActive = false
1488}
1489
1490func (l *list[T]) SelectionClear() {
1491 l.selectionStartCol = -1
1492 l.selectionStartLine = -1
1493 l.selectionEndCol = -1
1494 l.selectionEndLine = -1
1495 l.selectionActive = false
1496}
1497
1498func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1499 lines := strings.Split(l.rendered, "\n")
1500 for i, l := range lines {
1501 lines[i] = ansi.Strip(l)
1502 }
1503
1504 if l.direction == DirectionBackward && len(lines) > l.height {
1505 line = ((len(lines) - 1) - l.height) + line + 1
1506 }
1507
1508 if l.offset > 0 {
1509 if l.direction == DirectionBackward {
1510 line -= l.offset
1511 } else {
1512 line += l.offset
1513 }
1514 }
1515
1516 if line < 0 || line >= len(lines) {
1517 return 0, 0
1518 }
1519
1520 currentLine := lines[line]
1521 gr := uniseg.NewGraphemes(currentLine)
1522 startCol = -1
1523 upTo := col
1524 for gr.Next() {
1525 if gr.IsWordBoundary() && upTo > 0 {
1526 startCol = col - upTo + 1
1527 } else if gr.IsWordBoundary() && upTo < 0 {
1528 endCol = col - upTo + 1
1529 break
1530 }
1531 if upTo == 0 && gr.Str() == " " {
1532 return 0, 0
1533 }
1534 upTo -= 1
1535 }
1536 if startCol == -1 {
1537 return 0, 0
1538 }
1539 return
1540}
1541
1542func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1543 lines := strings.Split(l.rendered, "\n")
1544 for i, l := range lines {
1545 lines[i] = ansi.Strip(l)
1546 for _, icon := range styles.SelectionIgnoreIcons {
1547 lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1548 }
1549 }
1550 if l.direction == DirectionBackward && len(lines) > l.height {
1551 line = (len(lines) - 1) - l.height + line + 1
1552 }
1553
1554 if l.offset > 0 {
1555 if l.direction == DirectionBackward {
1556 line -= l.offset
1557 } else {
1558 line += l.offset
1559 }
1560 }
1561
1562 // Ensure line is within bounds
1563 if line < 0 || line >= len(lines) {
1564 return 0, 0, false
1565 }
1566
1567 if strings.TrimSpace(lines[line]) == "" {
1568 return 0, 0, false
1569 }
1570
1571 // Find start of paragraph (search backwards for empty line or start of text)
1572 startLine = line
1573 for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1574 startLine--
1575 }
1576
1577 // Find end of paragraph (search forwards for empty line or end of text)
1578 endLine = line
1579 for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1580 endLine++
1581 }
1582
1583 // revert the line numbers if we are in backward direction
1584 if l.direction == DirectionBackward && len(lines) > l.height {
1585 startLine = startLine - (len(lines) - 1) + l.height - 1
1586 endLine = endLine - (len(lines) - 1) + l.height - 1
1587 }
1588 if l.offset > 0 {
1589 if l.direction == DirectionBackward {
1590 startLine += l.offset
1591 endLine += l.offset
1592 } else {
1593 startLine -= l.offset
1594 endLine -= l.offset
1595 }
1596 }
1597 return startLine, endLine, true
1598}
1599
1600// SelectWord selects the word at the given position.
1601func (l *list[T]) SelectWord(col, line int) {
1602 startCol, endCol := l.findWordBoundaries(col, line)
1603 l.selectionStartCol = startCol
1604 l.selectionStartLine = line
1605 l.selectionEndCol = endCol
1606 l.selectionEndLine = line
1607 l.selectionActive = false // Not actively selecting, just selected
1608}
1609
1610// SelectParagraph selects the paragraph at the given position.
1611func (l *list[T]) SelectParagraph(col, line int) {
1612 startLine, endLine, found := l.findParagraphBoundaries(line)
1613 if !found {
1614 return
1615 }
1616 l.selectionStartCol = 0
1617 l.selectionStartLine = startLine
1618 l.selectionEndCol = l.width - 1
1619 l.selectionEndLine = endLine
1620 l.selectionActive = false // Not actively selecting, just selected
1621}
1622
1623// HasSelection returns whether there is an active selection.
1624func (l *list[T]) HasSelection() bool {
1625 return l.hasSelection()
1626}
1627
1628// GetSelectedText returns the currently selected text.
1629func (l *list[T]) GetSelectedText(paddingLeft int) string {
1630 if !l.hasSelection() {
1631 return ""
1632 }
1633
1634 return l.selectionView(l.View(), true)
1635}