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