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 selectedIndex int // Changed from string to int for index-based selection
88 focused bool
89 resize bool
90 enableMouse bool
91}
92
93type list[T Item] struct {
94 *confOptions
95
96 offset int
97
98 indexMap *csync.Map[string, int]
99 items *csync.Slice[T]
100
101 // Virtual scrolling fields - using slices for O(1) index access
102 itemPositions []itemPosition // Position info for each item by index
103 virtualHeight int // Total height of all items
104 viewCache *csync.Map[string, string] // Optional cache for rendered views
105 shouldCalculateItemPositions bool
106
107 renderMu sync.Mutex
108 rendered string
109
110 movingByItem bool
111 selectionStartCol int
112 selectionStartLine int
113 selectionEndCol int
114 selectionEndLine int
115
116 selectionActive bool
117}
118
119type ListOption func(*confOptions)
120
121// WithSize sets the size of the list.
122func WithSize(width, height int) ListOption {
123 return func(l *confOptions) {
124 l.width = width
125 l.height = height
126 }
127}
128
129// WithGap sets the gap between items in the list.
130func WithGap(gap int) ListOption {
131 return func(l *confOptions) {
132 l.gap = gap
133 }
134}
135
136// WithDirectionForward sets the direction to forward
137func WithDirectionForward() ListOption {
138 return func(l *confOptions) {
139 l.direction = DirectionForward
140 }
141}
142
143// WithDirectionBackward sets the direction to forward
144func WithDirectionBackward() ListOption {
145 return func(l *confOptions) {
146 l.direction = DirectionBackward
147 }
148}
149
150// WithSelectedItem sets the initially selected item in the list 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// SetItems implements List.
1393func (l *list[T]) SetItems(items []T) tea.Cmd {
1394 l.items.SetSlice(items)
1395 var cmds []tea.Cmd
1396 for inx, item := range slices.Collect(l.items.Seq()) {
1397 if i, ok := any(item).(Indexable); ok {
1398 i.SetIndex(inx)
1399 }
1400 cmds = append(cmds, item.Init())
1401 }
1402 cmds = append(cmds, l.reset(""))
1403 return tea.Batch(cmds...)
1404}
1405
1406// SetSelected implements List.
1407func (l *list[T]) SetSelected(id string) tea.Cmd {
1408 inx, ok := l.indexMap.Get(id)
1409 if ok {
1410 l.selectedIndex = inx
1411 } else {
1412 l.selectedIndex = -1
1413 }
1414 return l.render()
1415}
1416
1417func (l *list[T]) reset(selectedItemID string) tea.Cmd {
1418 var cmds []tea.Cmd
1419 l.rendered = ""
1420 l.offset = 0
1421
1422 // Convert ID to index if provided
1423 if selectedItemID != "" {
1424 if inx, ok := l.indexMap.Get(selectedItemID); ok {
1425 l.selectedIndex = inx
1426 } else {
1427 l.selectedIndex = -1
1428 }
1429 } else {
1430 l.selectedIndex = -1
1431 }
1432
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 l.shouldCalculateItemPositions = true
1438 for inx, item := range slices.Collect(l.items.Seq()) {
1439 l.indexMap.Set(item.ID(), inx)
1440 if l.width > 0 && l.height > 0 {
1441 cmds = append(cmds, item.SetSize(l.width, l.height))
1442 }
1443 }
1444 cmds = append(cmds, l.render())
1445 return tea.Batch(cmds...)
1446}
1447
1448// SetSize implements List.
1449func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1450 oldWidth := l.width
1451 l.width = width
1452 l.height = height
1453 if oldWidth != width {
1454 // Get current selected item ID to preserve selection
1455 var selectedID string
1456 if l.selectedIndex >= 0 && l.selectedIndex < l.items.Len() {
1457 if item, ok := l.items.Get(l.selectedIndex); ok {
1458 selectedID = item.ID()
1459 }
1460 }
1461 cmd := l.reset(selectedID)
1462 return cmd
1463 }
1464 return nil
1465}
1466
1467// UpdateItem implements List.
1468func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1469 var cmds []tea.Cmd
1470 if inx, ok := l.indexMap.Get(id); ok {
1471 // Update the item
1472 l.items.Set(inx, item)
1473
1474 // Clear cache for this item
1475 l.viewCache.Del(id)
1476
1477 // Mark positions as dirty for recalculation
1478 l.shouldCalculateItemPositions = true
1479
1480 // Re-render with updated positions
1481 cmd := l.renderWithScrollToSelection(false)
1482 if cmd != nil {
1483 cmds = append(cmds, cmd)
1484 }
1485
1486 cmds = append(cmds, item.Init())
1487 if l.width > 0 && l.height > 0 {
1488 cmds = append(cmds, item.SetSize(l.width, l.height))
1489 }
1490 }
1491 return tea.Sequence(cmds...)
1492}
1493
1494func (l *list[T]) hasSelection() bool {
1495 return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1496}
1497
1498// StartSelection implements List.
1499func (l *list[T]) StartSelection(col, line int) {
1500 l.selectionStartCol = col
1501 l.selectionStartLine = line
1502 l.selectionEndCol = col
1503 l.selectionEndLine = line
1504 l.selectionActive = true
1505}
1506
1507// EndSelection implements List.
1508func (l *list[T]) EndSelection(col, line int) {
1509 if !l.selectionActive {
1510 return
1511 }
1512 l.selectionEndCol = col
1513 l.selectionEndLine = line
1514}
1515
1516func (l *list[T]) SelectionStop() {
1517 l.selectionActive = false
1518}
1519
1520func (l *list[T]) SelectionClear() {
1521 l.selectionStartCol = -1
1522 l.selectionStartLine = -1
1523 l.selectionEndCol = -1
1524 l.selectionEndLine = -1
1525 l.selectionActive = false
1526}
1527
1528func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1529 lines := strings.Split(l.rendered, "\n")
1530 for i, l := range lines {
1531 lines[i] = ansi.Strip(l)
1532 }
1533
1534 if l.direction == DirectionBackward && len(lines) > l.height {
1535 line = ((len(lines) - 1) - l.height) + line + 1
1536 }
1537
1538 if l.offset > 0 {
1539 if l.direction == DirectionBackward {
1540 line -= l.offset
1541 } else {
1542 line += l.offset
1543 }
1544 }
1545
1546 if line < 0 || line >= len(lines) {
1547 return 0, 0
1548 }
1549
1550 currentLine := lines[line]
1551 gr := uniseg.NewGraphemes(currentLine)
1552 startCol = -1
1553 upTo := col
1554 for gr.Next() {
1555 if gr.IsWordBoundary() && upTo > 0 {
1556 startCol = col - upTo + 1
1557 } else if gr.IsWordBoundary() && upTo < 0 {
1558 endCol = col - upTo + 1
1559 break
1560 }
1561 if upTo == 0 && gr.Str() == " " {
1562 return 0, 0
1563 }
1564 upTo -= 1
1565 }
1566 if startCol == -1 {
1567 return 0, 0
1568 }
1569 return
1570}
1571
1572func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1573 lines := strings.Split(l.rendered, "\n")
1574 for i, l := range lines {
1575 lines[i] = ansi.Strip(l)
1576 for _, icon := range styles.SelectionIgnoreIcons {
1577 lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1578 }
1579 }
1580 if l.direction == DirectionBackward && len(lines) > l.height {
1581 line = (len(lines) - 1) - l.height + line + 1
1582 }
1583
1584 if l.offset > 0 {
1585 if l.direction == DirectionBackward {
1586 line -= l.offset
1587 } else {
1588 line += l.offset
1589 }
1590 }
1591
1592 // Ensure line is within bounds
1593 if line < 0 || line >= len(lines) {
1594 return 0, 0, false
1595 }
1596
1597 if strings.TrimSpace(lines[line]) == "" {
1598 return 0, 0, false
1599 }
1600
1601 // Find start of paragraph (search backwards for empty line or start of text)
1602 startLine = line
1603 for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1604 startLine--
1605 }
1606
1607 // Find end of paragraph (search forwards for empty line or end of text)
1608 endLine = line
1609 for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1610 endLine++
1611 }
1612
1613 // revert the line numbers if we are in backward direction
1614 if l.direction == DirectionBackward && len(lines) > l.height {
1615 startLine = startLine - (len(lines) - 1) + l.height - 1
1616 endLine = endLine - (len(lines) - 1) + l.height - 1
1617 }
1618 if l.offset > 0 {
1619 if l.direction == DirectionBackward {
1620 startLine += l.offset
1621 endLine += l.offset
1622 } else {
1623 startLine -= l.offset
1624 endLine -= l.offset
1625 }
1626 }
1627 return startLine, endLine, true
1628}
1629
1630// SelectWord selects the word at the given position.
1631func (l *list[T]) SelectWord(col, line int) {
1632 startCol, endCol := l.findWordBoundaries(col, line)
1633 l.selectionStartCol = startCol
1634 l.selectionStartLine = line
1635 l.selectionEndCol = endCol
1636 l.selectionEndLine = line
1637 l.selectionActive = false // Not actively selecting, just selected
1638}
1639
1640// SelectParagraph selects the paragraph at the given position.
1641func (l *list[T]) SelectParagraph(col, line int) {
1642 startLine, endLine, found := l.findParagraphBoundaries(line)
1643 if !found {
1644 return
1645 }
1646 l.selectionStartCol = 0
1647 l.selectionStartLine = startLine
1648 l.selectionEndCol = l.width - 1
1649 l.selectionEndLine = endLine
1650 l.selectionActive = false // Not actively selecting, just selected
1651}
1652
1653// HasSelection returns whether there is an active selection.
1654func (l *list[T]) HasSelection() bool {
1655 return l.hasSelection()
1656}
1657
1658// GetSelectedText returns the currently selected text.
1659func (l *list[T]) GetSelectedText(paddingLeft int) string {
1660 if !l.hasSelection() {
1661 return ""
1662 }
1663
1664 return l.selectionView(l.View(), true)
1665}