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 SelectedItemIndex() int
48 Items() []T
49 UpdateItem(string, T) tea.Cmd
50 DeleteItem(string) tea.Cmd
51 PrependItem(T) tea.Cmd
52 AppendItem(T) tea.Cmd
53 StartSelection(col, line int)
54 EndSelection(col, line int)
55 SelectionStop()
56 SelectionClear()
57 SelectWord(col, line int)
58 SelectParagraph(col, line int)
59 GetSelectedText(paddingLeft int) string
60 HasSelection() bool
61}
62
63type direction int
64
65const (
66 DirectionForward direction = iota
67 DirectionBackward
68)
69
70const (
71 ItemNotFound = -1
72 ViewportDefaultScrollSize = 2
73)
74
75type itemPosition struct {
76 height int
77 start int
78 end int
79}
80
81type confOptions struct {
82 width, height int
83 gap int
84 // if you are at the last item and go down it will wrap to the top
85 wrap bool
86 keyMap KeyMap
87 direction direction
88 selectedIndex int
89 focused bool
90 resize bool
91 enableMouse bool
92}
93
94type list[T Item] struct {
95 *confOptions
96
97 offset int
98
99 indexMap *csync.Map[string, int]
100 items *csync.Slice[T]
101
102 itemPositions []itemPosition
103 virtualHeight int
104 viewCache *csync.Map[string, string]
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 by ID.
151// This will be converted to an index when the list is created.
152func WithSelectedItem(id string) ListOption {
153 return func(l *confOptions) {
154 // Store temporarily, will be converted to index in New()
155 l.selectedIndex = -1 // Will be resolved later
156 }
157}
158
159// WithSelectedIndex sets the initially selected item in the list by index.
160func WithSelectedIndex(index int) ListOption {
161 return func(l *confOptions) {
162 l.selectedIndex = index
163 }
164}
165
166func WithKeyMap(keyMap KeyMap) ListOption {
167 return func(l *confOptions) {
168 l.keyMap = keyMap
169 }
170}
171
172func WithWrapNavigation() ListOption {
173 return func(l *confOptions) {
174 l.wrap = true
175 }
176}
177
178func WithFocus(focus bool) ListOption {
179 return func(l *confOptions) {
180 l.focused = focus
181 }
182}
183
184func WithResizeByList() ListOption {
185 return func(l *confOptions) {
186 l.resize = true
187 }
188}
189
190func WithEnableMouse() ListOption {
191 return func(l *confOptions) {
192 l.enableMouse = true
193 }
194}
195
196func New[T Item](items []T, opts ...ListOption) List[T] {
197 list := &list[T]{
198 confOptions: &confOptions{
199 direction: DirectionForward,
200 keyMap: DefaultKeyMap(),
201 focused: true,
202 selectedIndex: -1, // Initialize to -1 to indicate no selection
203 },
204 items: csync.NewSliceFrom(items),
205 indexMap: csync.NewMap[string, int](),
206 itemPositions: make([]itemPosition, len(items)),
207 viewCache: csync.NewMap[string, string](),
208 shouldCalculateItemPositions: true,
209 selectionStartCol: -1,
210 selectionStartLine: -1,
211 selectionEndLine: -1,
212 selectionEndCol: -1,
213 }
214 for _, opt := range opts {
215 opt(list.confOptions)
216 }
217
218 for inx, item := range items {
219 if i, ok := any(item).(Indexable); ok {
220 i.SetIndex(inx)
221 }
222 list.indexMap.Set(item.ID(), inx)
223 }
224 return list
225}
226
227// Init implements List.
228func (l *list[T]) Init() tea.Cmd {
229 // Ensure we have width and height
230 if l.width <= 0 || l.height <= 0 {
231 // Can't calculate positions without dimensions
232 return nil
233 }
234
235 // Set size for all items
236 var cmds []tea.Cmd
237 for _, item := range slices.Collect(l.items.Seq()) {
238 if cmd := item.SetSize(l.width, l.height); cmd != nil {
239 cmds = append(cmds, cmd)
240 }
241 }
242
243 // Calculate positions for all items
244 l.calculateItemPositions()
245
246 // Select initial item based on direction
247 if l.selectedIndex < 0 && l.items.Len() > 0 {
248 if l.direction == DirectionForward {
249 l.selectFirstItem()
250 } else {
251 l.selectLastItem()
252 }
253 }
254
255 // For backward lists, we need to position at the bottom after initial render
256 if l.direction == DirectionBackward && l.offset == 0 && l.items.Len() > 0 {
257 // Set offset to show the bottom of the list
258 if l.virtualHeight > l.height {
259 l.offset = 0 // In backward mode, offset 0 means bottom
260 }
261 }
262
263 // Scroll to the selected item for initial positioning
264 if l.focused {
265 l.scrollToSelection()
266 }
267
268 renderCmd := l.render()
269 if renderCmd != nil {
270 cmds = append(cmds, renderCmd)
271 }
272
273 return tea.Batch(cmds...)
274}
275
276// Update implements List.
277func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
278 switch msg := msg.(type) {
279 case tea.MouseWheelMsg:
280 if l.enableMouse {
281 return l.handleMouseWheel(msg)
282 }
283 return l, nil
284 case anim.StepMsg:
285 // Only update animations for visible items to avoid unnecessary renders
286 viewStart, viewEnd := l.viewPosition()
287 var needsRender bool
288 var cmds []tea.Cmd
289
290 for inx, item := range slices.Collect(l.items.Seq()) {
291 if i, ok := any(item).(HasAnim); ok && i.Spinning() {
292 // Check if item is visible
293 isVisible := false
294 if inx < len(l.itemPositions) {
295 pos := l.itemPositions[inx]
296 isVisible = pos.end >= viewStart && pos.start <= viewEnd
297 }
298
299 // Always update the animation state
300 updated, cmd := i.Update(msg)
301 cmds = append(cmds, cmd)
302
303 // Only trigger render if the spinning item is visible
304 if isVisible {
305 needsRender = true
306 // Clear the cache for this item so it re-renders
307 if u, ok := updated.(T); ok {
308 l.viewCache.Del(u.ID())
309 }
310 }
311 }
312 }
313
314 // Only re-render if we have visible spinning items
315 if needsRender {
316 l.renderMu.Lock()
317 l.rendered = l.renderVirtualScrolling()
318 l.renderMu.Unlock()
319 }
320
321 return l, tea.Batch(cmds...)
322 case tea.KeyPressMsg:
323 if l.focused {
324 switch {
325 case key.Matches(msg, l.keyMap.Down):
326 return l, l.SelectItemBelow()
327 case key.Matches(msg, l.keyMap.Up):
328 return l, l.SelectItemAbove()
329 case key.Matches(msg, l.keyMap.DownOneItem):
330 return l, l.SelectItemBelow()
331 case key.Matches(msg, l.keyMap.UpOneItem):
332 return l, l.SelectItemAbove()
333 case key.Matches(msg, l.keyMap.HalfPageDown):
334 return l, l.MoveDown(l.height / 2)
335 case key.Matches(msg, l.keyMap.HalfPageUp):
336 return l, l.MoveUp(l.height / 2)
337 case key.Matches(msg, l.keyMap.PageDown):
338 return l, l.MoveDown(l.height)
339 case key.Matches(msg, l.keyMap.PageUp):
340 return l, l.MoveUp(l.height)
341 case key.Matches(msg, l.keyMap.End):
342 return l, l.GoToBottom()
343 case key.Matches(msg, l.keyMap.Home):
344 return l, l.GoToTop()
345 }
346 s := l.SelectedItem()
347 if s == nil {
348 return l, nil
349 }
350 item := *s
351 var cmds []tea.Cmd
352 updated, cmd := item.Update(msg)
353 cmds = append(cmds, cmd)
354 if u, ok := updated.(T); ok {
355 cmds = append(cmds, l.UpdateItem(u.ID(), u))
356 }
357 return l, tea.Batch(cmds...)
358 }
359 }
360 return l, nil
361}
362
363func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
364 var cmd tea.Cmd
365 switch msg.Button {
366 case tea.MouseWheelDown:
367 cmd = l.MoveDown(ViewportDefaultScrollSize)
368 case tea.MouseWheelUp:
369 cmd = l.MoveUp(ViewportDefaultScrollSize)
370 }
371 return l, cmd
372}
373
374// selectionView renders the highlighted selection in the view and returns it
375// as a string. If textOnly is true, it won't render any styles.
376func (l *list[T]) selectionView(view string, textOnly bool) string {
377 t := styles.CurrentTheme()
378 area := uv.Rect(0, 0, l.width, l.height)
379 scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
380 uv.NewStyledString(view).Draw(scr, area)
381
382 selArea := uv.Rectangle{
383 Min: uv.Pos(l.selectionStartCol, l.selectionStartLine),
384 Max: uv.Pos(l.selectionEndCol, l.selectionEndLine),
385 }
386 selArea = selArea.Canon()
387
388 specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
389 for _, icon := range styles.SelectionIgnoreIcons {
390 specialChars[icon] = true
391 }
392
393 isNonWhitespace := func(r rune) bool {
394 return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
395 }
396
397 type selectionBounds struct {
398 startX, endX int
399 inSelection bool
400 }
401 lineSelections := make([]selectionBounds, scr.Height())
402
403 for y := range scr.Height() {
404 bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
405
406 if y >= selArea.Min.Y && y <= selArea.Max.Y {
407 bounds.inSelection = true
408 if selArea.Min.Y == selArea.Max.Y {
409 // Single line selection
410 bounds.startX = selArea.Min.X
411 bounds.endX = selArea.Max.X
412 } else if y == selArea.Min.Y {
413 // First line of multi-line selection
414 bounds.startX = selArea.Min.X
415 bounds.endX = scr.Width()
416 } else if y == selArea.Max.Y {
417 // Last line of multi-line selection
418 bounds.startX = 0
419 bounds.endX = selArea.Max.X
420 } else {
421 // Middle lines
422 bounds.startX = 0
423 bounds.endX = scr.Width()
424 }
425 }
426 lineSelections[y] = bounds
427 }
428
429 type lineBounds struct {
430 start, end int
431 }
432 lineTextBounds := make([]lineBounds, scr.Height())
433
434 // First pass: find text bounds for lines that have selections
435 for y := range scr.Height() {
436 bounds := lineBounds{start: -1, end: -1}
437
438 // Only process lines that might have selections
439 if lineSelections[y].inSelection {
440 for x := range scr.Width() {
441 cell := scr.CellAt(x, y)
442 if cell == nil {
443 continue
444 }
445
446 cellStr := cell.String()
447 if len(cellStr) == 0 {
448 continue
449 }
450
451 char := rune(cellStr[0])
452 isSpecial := specialChars[cellStr]
453
454 if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
455 if bounds.start == -1 {
456 bounds.start = x
457 }
458 bounds.end = x + 1 // Position after last character
459 }
460 }
461 }
462 lineTextBounds[y] = bounds
463 }
464
465 var selectedText strings.Builder
466
467 // Second pass: apply selection highlighting
468 for y := range scr.Height() {
469 selBounds := lineSelections[y]
470 if !selBounds.inSelection {
471 continue
472 }
473
474 textBounds := lineTextBounds[y]
475 if textBounds.start < 0 {
476 if textOnly {
477 // We don't want to get rid of all empty lines in text-only mode
478 selectedText.WriteByte('\n')
479 }
480
481 continue // No text on this line
482 }
483
484 // Only scan within the intersection of text bounds and selection bounds
485 scanStart := max(textBounds.start, selBounds.startX)
486 scanEnd := min(textBounds.end, selBounds.endX)
487
488 for x := scanStart; x < scanEnd; x++ {
489 cell := scr.CellAt(x, y)
490 if cell == nil {
491 continue
492 }
493
494 cellStr := cell.String()
495 if len(cellStr) > 0 && !specialChars[cellStr] {
496 if textOnly {
497 // Collect selected text without styles
498 selectedText.WriteString(cell.String())
499 continue
500 }
501
502 // Text selection styling, which is a Lip Gloss style. We must
503 // extract the values to use in a UV style, below.
504 ts := t.TextSelection
505
506 cell = cell.Clone()
507 cell.Style = cell.Style.Background(ts.GetBackground()).Foreground(ts.GetForeground())
508 scr.SetCell(x, y, cell)
509 }
510 }
511
512 if textOnly {
513 // Make sure we add a newline after each line of selected text
514 selectedText.WriteByte('\n')
515 }
516 }
517
518 if textOnly {
519 return strings.TrimSpace(selectedText.String())
520 }
521
522 return scr.Render()
523}
524
525// View implements List.
526func (l *list[T]) View() string {
527 if l.height <= 0 || l.width <= 0 {
528 return ""
529 }
530 t := styles.CurrentTheme()
531
532 // With virtual scrolling, rendered already contains only visible content
533 view := l.rendered
534
535 if l.resize {
536 return view
537 }
538
539 view = t.S().Base.
540 Height(l.height).
541 Width(l.width).
542 Render(view)
543
544 if !l.hasSelection() {
545 return view
546 }
547
548 return l.selectionView(view, false)
549}
550
551func (l *list[T]) viewPosition() (int, int) {
552 // View position in the virtual space
553 start, end := 0, 0
554 if l.direction == DirectionForward {
555 start = l.offset
556 if l.virtualHeight > 0 {
557 end = min(l.offset+l.height-1, l.virtualHeight-1)
558 } else {
559 end = l.offset + l.height - 1
560 }
561 } else {
562 // For backward direction
563 if l.virtualHeight > 0 {
564 end = l.virtualHeight - l.offset - 1
565 start = max(0, end-l.height+1)
566 } else {
567 end = 0
568 start = 0
569 }
570 }
571 return start, end
572}
573
574func (l *list[T]) render() tea.Cmd {
575 return l.renderWithScrollToSelection(true)
576}
577
578func (l *list[T]) renderWithScrollToSelection(scrollToSelection bool) tea.Cmd {
579 if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
580 return nil
581 }
582 l.setDefaultSelected()
583
584 var focusChangeCmd tea.Cmd
585 if l.focused {
586 focusChangeCmd = l.focusSelectedItem()
587 } else {
588 focusChangeCmd = l.blurSelectedItem()
589 }
590
591 if l.shouldCalculateItemPositions {
592 l.calculateItemPositions()
593 l.shouldCalculateItemPositions = false
594 }
595
596 // Scroll to selected item BEFORE rendering if focused and requested
597 if l.focused && scrollToSelection {
598 l.scrollToSelection()
599 }
600
601 // Render only visible items
602 l.renderMu.Lock()
603 l.rendered = l.renderVirtualScrolling()
604 l.renderMu.Unlock()
605
606 return focusChangeCmd
607}
608
609func (l *list[T]) setDefaultSelected() {
610 if l.selectedIndex < 0 {
611 if l.direction == DirectionForward {
612 l.selectFirstItem()
613 } else {
614 l.selectLastItem()
615 }
616 }
617}
618
619func (l *list[T]) scrollToSelection() {
620 if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
621 return
622 }
623
624 inx := l.selectedIndex
625 if inx < 0 || inx >= len(l.itemPositions) {
626 l.selectedIndex = -1
627 l.setDefaultSelected()
628 return
629 }
630
631 rItem := l.itemPositions[inx]
632
633 start, end := l.viewPosition()
634
635 // item bigger or equal to the viewport - show from start
636 if rItem.height >= l.height {
637 if l.direction == DirectionForward {
638 l.offset = rItem.start
639 } else {
640 // For backward direction, we want to show the bottom of the item
641 // offset = 0 means bottom of list is visible
642 l.offset = 0
643 }
644 return
645 }
646
647 // if we are moving by item we want to move the offset so that the
648 // whole item is visible not just portions of it
649 if l.movingByItem {
650 if rItem.start >= start && rItem.end <= end {
651 // Item is fully visible, no need to scroll
652 return
653 }
654 defer func() { l.movingByItem = false }()
655 } else {
656 // item already in view do nothing
657 if rItem.start >= start && rItem.start <= end {
658 return
659 }
660 if rItem.end >= start && rItem.end <= end {
661 return
662 }
663 }
664
665 // If item is above the viewport, make it the first item
666 if rItem.start < start {
667 if l.direction == DirectionForward {
668 l.offset = rItem.start
669 } else {
670 if l.virtualHeight > 0 {
671 l.offset = l.virtualHeight - rItem.end
672 } else {
673 l.offset = 0
674 }
675 }
676 } else if rItem.end > end {
677 // If item is below the viewport, make it the last item
678 if l.direction == DirectionForward {
679 l.offset = max(0, rItem.end-l.height+1)
680 } else {
681 if l.virtualHeight > 0 {
682 l.offset = max(0, l.virtualHeight-rItem.start-l.height+1)
683 } else {
684 l.offset = 0
685 }
686 }
687 }
688}
689
690func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
691 if l.selectedIndex < 0 || l.selectedIndex >= len(l.itemPositions) {
692 return nil
693 }
694
695 rItem := l.itemPositions[l.selectedIndex]
696 start, end := l.viewPosition()
697 // item bigger than the viewport do nothing
698 if rItem.start <= start && rItem.end >= end {
699 return nil
700 }
701 // item already in view do nothing
702 if rItem.start >= start && rItem.end <= end {
703 return nil
704 }
705
706 itemMiddle := rItem.start + rItem.height/2
707
708 if itemMiddle < start {
709 // select the first item in the viewport
710 // the item is most likely an item coming after this item
711 for {
712 inx := l.firstSelectableItemBelow(l.selectedIndex)
713 if inx == ItemNotFound {
714 return nil
715 }
716 if inx >= len(l.itemPositions) {
717 continue
718 }
719 renderedItem := l.itemPositions[inx]
720
721 // If the item is bigger than the viewport, select it
722 if renderedItem.start <= start && renderedItem.end >= end {
723 l.selectedIndex = inx
724 return l.renderWithScrollToSelection(false)
725 }
726 // item is in the view
727 if renderedItem.start >= start && renderedItem.start <= end {
728 l.selectedIndex = inx
729 return l.renderWithScrollToSelection(false)
730 }
731 }
732 } else if itemMiddle > end {
733 // select the first item in the viewport
734 // the item is most likely an item coming after this item
735 for {
736 inx := l.firstSelectableItemAbove(l.selectedIndex)
737 if inx == ItemNotFound {
738 return nil
739 }
740 if inx >= len(l.itemPositions) {
741 continue
742 }
743 renderedItem := l.itemPositions[inx]
744
745 // If the item is bigger than the viewport, select it
746 if renderedItem.start <= start && renderedItem.end >= end {
747 l.selectedIndex = inx
748 return l.renderWithScrollToSelection(false)
749 }
750 // item is in the view
751 if renderedItem.end >= start && renderedItem.end <= end {
752 l.selectedIndex = inx
753 return l.renderWithScrollToSelection(false)
754 }
755 }
756 }
757 return nil
758}
759
760func (l *list[T]) selectFirstItem() {
761 inx := l.firstSelectableItemBelow(-1)
762 if inx != ItemNotFound {
763 l.selectedIndex = inx
764 }
765}
766
767func (l *list[T]) selectLastItem() {
768 inx := l.firstSelectableItemAbove(l.items.Len())
769 if inx != ItemNotFound {
770 l.selectedIndex = inx
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.selectedIndex < 0 || !l.focused {
809 return nil
810 }
811 var cmds []tea.Cmd
812 for inx, item := range slices.Collect(l.items.Seq()) {
813 if f, ok := any(item).(layout.Focusable); ok {
814 if inx == l.selectedIndex && !f.IsFocused() {
815 cmds = append(cmds, f.Focus())
816 l.viewCache.Del(item.ID())
817 } else if inx != l.selectedIndex && 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.selectedIndex < 0 || l.focused {
828 return nil
829 }
830 var cmds []tea.Cmd
831 for inx, item := range slices.Collect(l.items.Seq()) {
832 if f, ok := any(item).(layout.Focusable); ok {
833 if inx == l.selectedIndex && 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
1094 // Check if we're deleting the selected item
1095 if l.selectedIndex == inx {
1096 // Adjust selection
1097 if inx > 0 {
1098 l.selectedIndex = inx - 1
1099 } else if l.items.Len() > 1 {
1100 l.selectedIndex = 0 // Will be valid after deletion
1101 } else {
1102 l.selectedIndex = -1 // No items left
1103 }
1104 } else if l.selectedIndex > inx {
1105 // Adjust index if selected item is after deleted item
1106 l.selectedIndex--
1107 }
1108
1109 l.items.Delete(inx)
1110 l.viewCache.Del(id)
1111 // Rebuild index map
1112 l.indexMap = csync.NewMap[string, int]()
1113 for inx, item := range slices.Collect(l.items.Seq()) {
1114 l.indexMap.Set(item.ID(), inx)
1115 }
1116
1117 cmd := l.render()
1118 if l.rendered != "" {
1119 renderedHeight := l.virtualHeight
1120 if renderedHeight <= l.height {
1121 l.offset = 0
1122 } else {
1123 maxOffset := renderedHeight - l.height
1124 if l.offset > maxOffset {
1125 l.offset = maxOffset
1126 }
1127 }
1128 }
1129 return cmd
1130}
1131
1132// Focus implements List.
1133func (l *list[T]) Focus() tea.Cmd {
1134 l.focused = true
1135 return l.render()
1136}
1137
1138// GetSize implements List.
1139func (l *list[T]) GetSize() (int, int) {
1140 return l.width, l.height
1141}
1142
1143// GoToBottom implements List.
1144func (l *list[T]) GoToBottom() tea.Cmd {
1145 l.offset = 0
1146 l.selectedIndex = -1
1147 l.direction = DirectionBackward
1148 return l.render()
1149}
1150
1151// GoToTop implements List.
1152func (l *list[T]) GoToTop() tea.Cmd {
1153 l.offset = 0
1154 l.selectedIndex = -1
1155 l.direction = DirectionForward
1156 return l.render()
1157}
1158
1159// IsFocused implements List.
1160func (l *list[T]) IsFocused() bool {
1161 return l.focused
1162}
1163
1164// Items implements List.
1165func (l *list[T]) Items() []T {
1166 return slices.Collect(l.items.Seq())
1167}
1168
1169func (l *list[T]) incrementOffset(n int) {
1170 renderedHeight := l.virtualHeight
1171 // no need for offset
1172 if renderedHeight <= l.height {
1173 return
1174 }
1175 maxOffset := renderedHeight - l.height
1176 n = min(n, maxOffset-l.offset)
1177 if n <= 0 {
1178 return
1179 }
1180 l.offset += n
1181}
1182
1183func (l *list[T]) decrementOffset(n int) {
1184 n = min(n, l.offset)
1185 if n <= 0 {
1186 return
1187 }
1188 l.offset -= n
1189 if l.offset < 0 {
1190 l.offset = 0
1191 }
1192}
1193
1194// MoveDown implements List.
1195func (l *list[T]) MoveDown(n int) tea.Cmd {
1196 oldOffset := l.offset
1197 if l.direction == DirectionForward {
1198 l.incrementOffset(n)
1199 } else {
1200 l.decrementOffset(n)
1201 }
1202
1203 if oldOffset == l.offset {
1204 // Even if offset didn't change, we might need to change selection
1205 // if we're at the edge of the scrollable area
1206 return l.changeSelectionWhenScrolling()
1207 }
1208 // if we are not actively selecting move the whole selection down
1209 if l.hasSelection() && !l.selectionActive {
1210 if l.selectionStartLine < l.selectionEndLine {
1211 l.selectionStartLine -= n
1212 l.selectionEndLine -= n
1213 } else {
1214 l.selectionStartLine -= n
1215 l.selectionEndLine -= n
1216 }
1217 }
1218 if l.selectionActive {
1219 if l.selectionStartLine < l.selectionEndLine {
1220 l.selectionStartLine -= n
1221 } else {
1222 l.selectionEndLine -= n
1223 }
1224 }
1225 return l.changeSelectionWhenScrolling()
1226}
1227
1228// MoveUp implements List.
1229func (l *list[T]) MoveUp(n int) tea.Cmd {
1230 oldOffset := l.offset
1231 if l.direction == DirectionForward {
1232 l.decrementOffset(n)
1233 } else {
1234 l.incrementOffset(n)
1235 }
1236
1237 if oldOffset == l.offset {
1238 // Even if offset didn't change, we might need to change selection
1239 // if we're at the edge of the scrollable area
1240 return l.changeSelectionWhenScrolling()
1241 }
1242
1243 if l.hasSelection() && !l.selectionActive {
1244 if l.selectionStartLine > l.selectionEndLine {
1245 l.selectionStartLine += n
1246 l.selectionEndLine += n
1247 } else {
1248 l.selectionStartLine += n
1249 l.selectionEndLine += n
1250 }
1251 }
1252 if l.selectionActive {
1253 if l.selectionStartLine > l.selectionEndLine {
1254 l.selectionStartLine += n
1255 } else {
1256 l.selectionEndLine += n
1257 }
1258 }
1259 return l.changeSelectionWhenScrolling()
1260}
1261
1262// PrependItem implements List.
1263func (l *list[T]) PrependItem(item T) tea.Cmd {
1264 cmds := []tea.Cmd{
1265 item.Init(),
1266 }
1267 l.items.Prepend(item)
1268 l.indexMap = csync.NewMap[string, int]()
1269 for inx, item := range slices.Collect(l.items.Seq()) {
1270 l.indexMap.Set(item.ID(), inx)
1271 }
1272 if l.width > 0 && l.height > 0 {
1273 cmds = append(cmds, item.SetSize(l.width, l.height))
1274 }
1275
1276 l.shouldCalculateItemPositions = true
1277
1278 if l.direction == DirectionForward {
1279 if l.offset == 0 {
1280 // If we're at the top, stay at the top
1281 cmds = append(cmds, l.render())
1282 cmd := l.GoToTop()
1283 if cmd != nil {
1284 cmds = append(cmds, cmd)
1285 }
1286 } else {
1287 // Note: We need to calculate positions to adjust offset properly
1288 // This is one case where we might need to calculate immediately
1289 l.calculateItemPositions()
1290 l.shouldCalculateItemPositions = false
1291
1292 // Adjust offset to maintain viewport position
1293 // The prepended item is at index 0
1294 if len(l.itemPositions) > 0 {
1295 newItem := l.itemPositions[0]
1296 newLines := newItem.height
1297 if l.items.Len() > 1 {
1298 newLines += l.gap
1299 }
1300 // Increase offset to keep the same content visible
1301 if l.virtualHeight > 0 {
1302 l.offset = min(l.virtualHeight-l.height, l.offset+newLines)
1303 }
1304 }
1305 cmds = append(cmds, l.renderWithScrollToSelection(false))
1306 }
1307 } else {
1308 // For backward direction, prepending doesn't affect the offset
1309 // since offset is from the bottom
1310 cmds = append(cmds, l.render())
1311 }
1312
1313 // Adjust selected index since we prepended
1314 if l.selectedIndex >= 0 {
1315 l.selectedIndex++
1316 }
1317
1318 return tea.Batch(cmds...)
1319}
1320
1321// SelectItemAbove implements List.
1322func (l *list[T]) SelectItemAbove() tea.Cmd {
1323 if l.selectedIndex < 0 {
1324 return nil
1325 }
1326
1327 newIndex := l.firstSelectableItemAbove(l.selectedIndex)
1328 if newIndex == ItemNotFound {
1329 // no item above
1330 return nil
1331 }
1332 var cmds []tea.Cmd
1333 if newIndex == 1 {
1334 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1335 if peakAboveIndex == ItemNotFound {
1336 // this means there is a section above move to the top
1337 cmd := l.GoToTop()
1338 if cmd != nil {
1339 cmds = append(cmds, cmd)
1340 }
1341 }
1342 }
1343 l.selectedIndex = newIndex
1344 l.movingByItem = true
1345 renderCmd := l.render()
1346 if renderCmd != nil {
1347 cmds = append(cmds, renderCmd)
1348 }
1349 return tea.Sequence(cmds...)
1350}
1351
1352// SelectItemBelow implements List.
1353func (l *list[T]) SelectItemBelow() tea.Cmd {
1354 if l.selectedIndex < 0 {
1355 return nil
1356 }
1357
1358 newIndex := l.firstSelectableItemBelow(l.selectedIndex)
1359 if newIndex == ItemNotFound {
1360 // no item below
1361 return nil
1362 }
1363 l.selectedIndex = newIndex
1364 l.movingByItem = true
1365 return l.render()
1366}
1367
1368// SelectedItem implements List.
1369func (l *list[T]) SelectedItem() *T {
1370 if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
1371 return nil
1372 }
1373 item, ok := l.items.Get(l.selectedIndex)
1374 if !ok {
1375 return nil
1376 }
1377 return &item
1378}
1379
1380// SelectedItemID returns the ID of the currently selected item (for testing).
1381func (l *list[T]) SelectedItemID() string {
1382 if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
1383 return ""
1384 }
1385 item, ok := l.items.Get(l.selectedIndex)
1386 if !ok {
1387 return ""
1388 }
1389 return item.ID()
1390}
1391
1392// SelectedItemIndex returns the index of the currently selected item.
1393// Returns -1 if no item is selected.
1394func (l *list[T]) SelectedItemIndex() int {
1395 return l.selectedIndex
1396}
1397
1398// SetItems implements List.
1399func (l *list[T]) SetItems(items []T) tea.Cmd {
1400 l.items.SetSlice(items)
1401 var cmds []tea.Cmd
1402 for inx, item := range slices.Collect(l.items.Seq()) {
1403 if i, ok := any(item).(Indexable); ok {
1404 i.SetIndex(inx)
1405 }
1406 cmds = append(cmds, item.Init())
1407 }
1408 cmds = append(cmds, l.reset(""))
1409 return tea.Batch(cmds...)
1410}
1411
1412// SetSelected implements List.
1413func (l *list[T]) SetSelected(id string) tea.Cmd {
1414 inx, ok := l.indexMap.Get(id)
1415 if ok {
1416 l.selectedIndex = inx
1417 } else {
1418 l.selectedIndex = -1
1419 }
1420 return l.render()
1421}
1422
1423func (l *list[T]) reset(selectedItemID string) tea.Cmd {
1424 var cmds []tea.Cmd
1425 l.rendered = ""
1426 l.offset = 0
1427
1428 // Convert ID to index if provided
1429 if selectedItemID != "" {
1430 if inx, ok := l.indexMap.Get(selectedItemID); ok {
1431 l.selectedIndex = inx
1432 } else {
1433 l.selectedIndex = -1
1434 }
1435 } else {
1436 l.selectedIndex = -1
1437 }
1438
1439 l.indexMap = csync.NewMap[string, int]()
1440 l.viewCache = csync.NewMap[string, string]()
1441 l.itemPositions = nil // Will be recalculated
1442 l.virtualHeight = 0
1443 l.shouldCalculateItemPositions = true
1444 for inx, item := range slices.Collect(l.items.Seq()) {
1445 l.indexMap.Set(item.ID(), inx)
1446 if l.width > 0 && l.height > 0 {
1447 cmds = append(cmds, item.SetSize(l.width, l.height))
1448 }
1449 }
1450 cmds = append(cmds, l.render())
1451 return tea.Batch(cmds...)
1452}
1453
1454// SetSize implements List.
1455func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1456 oldWidth := l.width
1457 l.width = width
1458 l.height = height
1459 if oldWidth != width {
1460 // Get current selected item ID to preserve selection
1461 var selectedID string
1462 if l.selectedIndex >= 0 && l.selectedIndex < l.items.Len() {
1463 if item, ok := l.items.Get(l.selectedIndex); ok {
1464 selectedID = item.ID()
1465 }
1466 }
1467 cmd := l.reset(selectedID)
1468 return cmd
1469 }
1470 return nil
1471}
1472
1473// UpdateItem implements List.
1474func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1475 var cmds []tea.Cmd
1476 if inx, ok := l.indexMap.Get(id); ok {
1477 // Update the item
1478 l.items.Set(inx, item)
1479
1480 // Clear cache for this item
1481 l.viewCache.Del(id)
1482
1483 // Mark positions as dirty for recalculation
1484 l.shouldCalculateItemPositions = true
1485
1486 // Re-render with updated positions
1487 cmd := l.renderWithScrollToSelection(false)
1488 if cmd != nil {
1489 cmds = append(cmds, cmd)
1490 }
1491
1492 cmds = append(cmds, item.Init())
1493 if l.width > 0 && l.height > 0 {
1494 cmds = append(cmds, item.SetSize(l.width, l.height))
1495 }
1496 }
1497 return tea.Sequence(cmds...)
1498}
1499
1500func (l *list[T]) hasSelection() bool {
1501 return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1502}
1503
1504// StartSelection implements List.
1505func (l *list[T]) StartSelection(col, line int) {
1506 l.selectionStartCol = col
1507 l.selectionStartLine = line
1508 l.selectionEndCol = col
1509 l.selectionEndLine = line
1510 l.selectionActive = true
1511}
1512
1513// EndSelection implements List.
1514func (l *list[T]) EndSelection(col, line int) {
1515 if !l.selectionActive {
1516 return
1517 }
1518 l.selectionEndCol = col
1519 l.selectionEndLine = line
1520}
1521
1522func (l *list[T]) SelectionStop() {
1523 l.selectionActive = false
1524}
1525
1526func (l *list[T]) SelectionClear() {
1527 l.selectionStartCol = -1
1528 l.selectionStartLine = -1
1529 l.selectionEndCol = -1
1530 l.selectionEndLine = -1
1531 l.selectionActive = false
1532}
1533
1534func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1535 lines := strings.Split(l.rendered, "\n")
1536 for i, l := range lines {
1537 lines[i] = ansi.Strip(l)
1538 }
1539
1540 if l.direction == DirectionBackward && len(lines) > l.height {
1541 line = ((len(lines) - 1) - l.height) + line + 1
1542 }
1543
1544 if l.offset > 0 {
1545 if l.direction == DirectionBackward {
1546 line -= l.offset
1547 } else {
1548 line += l.offset
1549 }
1550 }
1551
1552 if line < 0 || line >= len(lines) {
1553 return 0, 0
1554 }
1555
1556 currentLine := lines[line]
1557 gr := uniseg.NewGraphemes(currentLine)
1558 startCol = -1
1559 upTo := col
1560 for gr.Next() {
1561 if gr.IsWordBoundary() && upTo > 0 {
1562 startCol = col - upTo + 1
1563 } else if gr.IsWordBoundary() && upTo < 0 {
1564 endCol = col - upTo + 1
1565 break
1566 }
1567 if upTo == 0 && gr.Str() == " " {
1568 return 0, 0
1569 }
1570 upTo -= 1
1571 }
1572 if startCol == -1 {
1573 return 0, 0
1574 }
1575 return
1576}
1577
1578func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1579 lines := strings.Split(l.rendered, "\n")
1580 for i, l := range lines {
1581 lines[i] = ansi.Strip(l)
1582 for _, icon := range styles.SelectionIgnoreIcons {
1583 lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1584 }
1585 }
1586 if l.direction == DirectionBackward && len(lines) > l.height {
1587 line = (len(lines) - 1) - l.height + line + 1
1588 }
1589
1590 if l.offset > 0 {
1591 if l.direction == DirectionBackward {
1592 line -= l.offset
1593 } else {
1594 line += l.offset
1595 }
1596 }
1597
1598 // Ensure line is within bounds
1599 if line < 0 || line >= len(lines) {
1600 return 0, 0, false
1601 }
1602
1603 if strings.TrimSpace(lines[line]) == "" {
1604 return 0, 0, false
1605 }
1606
1607 // Find start of paragraph (search backwards for empty line or start of text)
1608 startLine = line
1609 for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1610 startLine--
1611 }
1612
1613 // Find end of paragraph (search forwards for empty line or end of text)
1614 endLine = line
1615 for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1616 endLine++
1617 }
1618
1619 // revert the line numbers if we are in backward direction
1620 if l.direction == DirectionBackward && len(lines) > l.height {
1621 startLine = startLine - (len(lines) - 1) + l.height - 1
1622 endLine = endLine - (len(lines) - 1) + l.height - 1
1623 }
1624 if l.offset > 0 {
1625 if l.direction == DirectionBackward {
1626 startLine += l.offset
1627 endLine += l.offset
1628 } else {
1629 startLine -= l.offset
1630 endLine -= l.offset
1631 }
1632 }
1633 return startLine, endLine, true
1634}
1635
1636// SelectWord selects the word at the given position.
1637func (l *list[T]) SelectWord(col, line int) {
1638 startCol, endCol := l.findWordBoundaries(col, line)
1639 l.selectionStartCol = startCol
1640 l.selectionStartLine = line
1641 l.selectionEndCol = endCol
1642 l.selectionEndLine = line
1643 l.selectionActive = false // Not actively selecting, just selected
1644}
1645
1646// SelectParagraph selects the paragraph at the given position.
1647func (l *list[T]) SelectParagraph(col, line int) {
1648 startLine, endLine, found := l.findParagraphBoundaries(line)
1649 if !found {
1650 return
1651 }
1652 l.selectionStartCol = 0
1653 l.selectionStartLine = startLine
1654 l.selectionEndCol = l.width - 1
1655 l.selectionEndLine = endLine
1656 l.selectionActive = false // Not actively selecting, just selected
1657}
1658
1659// HasSelection returns whether there is an active selection.
1660func (l *list[T]) HasSelection() bool {
1661 return l.hasSelection()
1662}
1663
1664// GetSelectedText returns the currently selected text.
1665func (l *list[T]) GetSelectedText(paddingLeft int) string {
1666 if !l.hasSelection() {
1667 return ""
1668 }
1669
1670 return l.selectionView(l.View(), true)
1671}