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