1package list
2
3import (
4 "strings"
5 "sync"
6
7 "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/crush/internal/tui/components/anim"
10 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
11 "github.com/charmbracelet/crush/internal/tui/styles"
12 "github.com/charmbracelet/crush/internal/tui/util"
13 "github.com/charmbracelet/lipgloss/v2"
14 uv "github.com/charmbracelet/ultraviolet"
15 "github.com/charmbracelet/x/ansi"
16 "github.com/charmbracelet/x/exp/ordered"
17 "github.com/rivo/uniseg"
18)
19
20const maxGapSize = 100
21
22var newlineBuffer = strings.Repeat("\n", maxGapSize)
23
24var (
25 specialCharsMap map[string]struct{}
26 specialCharsOnce sync.Once
27)
28
29func getSpecialCharsMap() map[string]struct{} {
30 specialCharsOnce.Do(func() {
31 specialCharsMap = make(map[string]struct{}, len(styles.SelectionIgnoreIcons))
32 for _, icon := range styles.SelectionIgnoreIcons {
33 specialCharsMap[icon] = struct{}{}
34 }
35 })
36 return specialCharsMap
37}
38
39type Item interface {
40 util.Model
41 layout.Sizeable
42 ID() string
43}
44
45type HasAnim interface {
46 Item
47 Spinning() bool
48}
49
50type List[T Item] interface {
51 util.Model
52 layout.Sizeable
53 layout.Focusable
54
55 MoveUp(int) tea.Cmd
56 MoveDown(int) tea.Cmd
57 GoToTop() tea.Cmd
58 GoToBottom() tea.Cmd
59 SelectItemAbove() tea.Cmd
60 SelectItemBelow() tea.Cmd
61 SetItems([]T) tea.Cmd
62 SetSelected(string) tea.Cmd
63 SelectedItem() *T
64 Items() []T
65 UpdateItem(string, T) tea.Cmd
66 DeleteItem(string) tea.Cmd
67 PrependItem(T) tea.Cmd
68 AppendItem(T) tea.Cmd
69 StartSelection(col, line int)
70 EndSelection(col, line int)
71 SelectionStop()
72 SelectionClear()
73 SelectWord(col, line int)
74 SelectParagraph(col, line int)
75 GetSelectedText(paddingLeft int) string
76 HasSelection() bool
77}
78
79type direction int
80
81const (
82 DirectionForward direction = iota
83 DirectionBackward
84)
85
86const (
87 ItemNotFound = -1
88 ViewportDefaultScrollSize = 5
89)
90
91type renderedItem struct {
92 view string
93 height int
94 start int
95 end int
96}
97
98type confOptions struct {
99 width, height int
100 gap int
101 wrap bool
102 keyMap KeyMap
103 direction direction
104 selectedItemIdx int // Index of selected item (-1 if none)
105 selectedItemID string // Temporary storage for WithSelectedItem (resolved in New())
106 focused bool
107 resize bool
108 enableMouse bool
109}
110
111type list[T Item] struct {
112 *confOptions
113
114 offset int
115
116 indexMap map[string]int
117 items []T
118 renderedItems map[string]renderedItem
119
120 rendered string
121 renderedHeight int // cached height of rendered content
122 lineOffsets []int // cached byte offsets for each line (for fast slicing)
123
124 cachedView string
125 cachedViewOffset int
126 cachedViewDirty bool
127
128 movingByItem bool
129 prevSelectedItemIdx int // Index of previously selected item (-1 if none)
130 selectionStartCol int
131 selectionStartLine int
132 selectionEndCol int
133 selectionEndLine int
134
135 selectionActive bool
136}
137
138type ListOption func(*confOptions)
139
140// WithSize sets the size of the list.
141func WithSize(width, height int) ListOption {
142 return func(l *confOptions) {
143 l.width = width
144 l.height = height
145 }
146}
147
148// WithGap sets the gap between items in the list.
149func WithGap(gap int) ListOption {
150 return func(l *confOptions) {
151 l.gap = gap
152 }
153}
154
155// WithDirectionForward sets the direction to forward
156func WithDirectionForward() ListOption {
157 return func(l *confOptions) {
158 l.direction = DirectionForward
159 }
160}
161
162// WithDirectionBackward sets the direction to forward
163func WithDirectionBackward() ListOption {
164 return func(l *confOptions) {
165 l.direction = DirectionBackward
166 }
167}
168
169// WithSelectedItem sets the initially selected item in the list.
170func WithSelectedItem(id string) ListOption {
171 return func(l *confOptions) {
172 l.selectedItemID = id // Will be resolved to index in New()
173 }
174}
175
176func WithKeyMap(keyMap KeyMap) ListOption {
177 return func(l *confOptions) {
178 l.keyMap = keyMap
179 }
180}
181
182func WithWrapNavigation() ListOption {
183 return func(l *confOptions) {
184 l.wrap = true
185 }
186}
187
188func WithFocus(focus bool) ListOption {
189 return func(l *confOptions) {
190 l.focused = focus
191 }
192}
193
194func WithResizeByList() ListOption {
195 return func(l *confOptions) {
196 l.resize = true
197 }
198}
199
200func WithEnableMouse() ListOption {
201 return func(l *confOptions) {
202 l.enableMouse = true
203 }
204}
205
206func New[T Item](items []T, opts ...ListOption) List[T] {
207 list := &list[T]{
208 confOptions: &confOptions{
209 direction: DirectionForward,
210 keyMap: DefaultKeyMap(),
211 focused: true,
212 selectedItemIdx: -1,
213 },
214 items: items,
215 indexMap: make(map[string]int, len(items)),
216 renderedItems: make(map[string]renderedItem),
217 prevSelectedItemIdx: -1,
218 selectionStartCol: -1,
219 selectionStartLine: -1,
220 selectionEndLine: -1,
221 selectionEndCol: -1,
222 }
223 for _, opt := range opts {
224 opt(list.confOptions)
225 }
226
227 for inx, item := range items {
228 if i, ok := any(item).(Indexable); ok {
229 i.SetIndex(inx)
230 }
231 list.indexMap[item.ID()] = inx
232 }
233
234 // Resolve selectedItemID to selectedItemIdx if specified
235 if list.selectedItemID != "" {
236 if idx, ok := list.indexMap[list.selectedItemID]; ok {
237 list.selectedItemIdx = idx
238 }
239 list.selectedItemID = "" // Clear temporary storage
240 }
241
242 return list
243}
244
245// Init implements List.
246func (l *list[T]) Init() tea.Cmd {
247 return l.render()
248}
249
250// Update implements List.
251func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
252 switch msg := msg.(type) {
253 case tea.MouseWheelMsg:
254 if l.enableMouse {
255 return l.handleMouseWheel(msg)
256 }
257 return l, nil
258 case anim.StepMsg:
259 // Fast path: if no items, skip processing
260 if len(l.items) == 0 {
261 return l, nil
262 }
263
264 // Fast path: check if ANY items are actually spinning before processing
265 if !l.hasSpinningItems() {
266 return l, nil
267 }
268
269 var cmds []tea.Cmd
270 itemsLen := len(l.items)
271 for i := range itemsLen {
272 if i >= len(l.items) {
273 continue
274 }
275 item := l.items[i]
276 if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
277 updated, cmd := animItem.Update(msg)
278 cmds = append(cmds, cmd)
279 if u, ok := updated.(T); ok {
280 cmds = append(cmds, l.UpdateItem(u.ID(), u))
281 }
282 }
283 }
284 return l, tea.Batch(cmds...)
285 case tea.KeyPressMsg:
286 if l.focused {
287 switch {
288 case key.Matches(msg, l.keyMap.Down):
289 return l, l.MoveDown(ViewportDefaultScrollSize)
290 case key.Matches(msg, l.keyMap.Up):
291 return l, l.MoveUp(ViewportDefaultScrollSize)
292 case key.Matches(msg, l.keyMap.DownOneItem):
293 return l, l.SelectItemBelow()
294 case key.Matches(msg, l.keyMap.UpOneItem):
295 return l, l.SelectItemAbove()
296 case key.Matches(msg, l.keyMap.HalfPageDown):
297 return l, l.MoveDown(l.height / 2)
298 case key.Matches(msg, l.keyMap.HalfPageUp):
299 return l, l.MoveUp(l.height / 2)
300 case key.Matches(msg, l.keyMap.PageDown):
301 return l, l.MoveDown(l.height)
302 case key.Matches(msg, l.keyMap.PageUp):
303 return l, l.MoveUp(l.height)
304 case key.Matches(msg, l.keyMap.End):
305 return l, l.GoToBottom()
306 case key.Matches(msg, l.keyMap.Home):
307 return l, l.GoToTop()
308 }
309 s := l.SelectedItem()
310 if s == nil {
311 return l, nil
312 }
313 item := *s
314 var cmds []tea.Cmd
315 updated, cmd := item.Update(msg)
316 cmds = append(cmds, cmd)
317 if u, ok := updated.(T); ok {
318 cmds = append(cmds, l.UpdateItem(u.ID(), u))
319 }
320 return l, tea.Batch(cmds...)
321 }
322 }
323 return l, nil
324}
325
326func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd) {
327 var cmd tea.Cmd
328 switch msg.Button {
329 case tea.MouseWheelDown:
330 cmd = l.MoveDown(ViewportDefaultScrollSize)
331 case tea.MouseWheelUp:
332 cmd = l.MoveUp(ViewportDefaultScrollSize)
333 }
334 return l, cmd
335}
336
337func (l *list[T]) hasSpinningItems() bool {
338 for i := range l.items {
339 item := l.items[i]
340 if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
341 return true
342 }
343 }
344 return false
345}
346
347func (l *list[T]) selectionView(view string, textOnly bool) string {
348 t := styles.CurrentTheme()
349 area := uv.Rect(0, 0, l.width, l.height)
350 scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
351 uv.NewStyledString(view).Draw(scr, area)
352
353 selArea := uv.Rectangle{
354 Min: uv.Pos(l.selectionStartCol, l.selectionStartLine),
355 Max: uv.Pos(l.selectionEndCol, l.selectionEndLine),
356 }
357 selArea = selArea.Canon()
358
359 specialChars := getSpecialCharsMap()
360
361 isNonWhitespace := func(r rune) bool {
362 return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
363 }
364
365 type selectionBounds struct {
366 startX, endX int
367 inSelection bool
368 }
369 lineSelections := make([]selectionBounds, scr.Height())
370
371 for y := range scr.Height() {
372 bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
373
374 if y >= selArea.Min.Y && y <= selArea.Max.Y {
375 bounds.inSelection = true
376 if selArea.Min.Y == selArea.Max.Y {
377 // Single line selection
378 bounds.startX = selArea.Min.X
379 bounds.endX = selArea.Max.X
380 } else if y == selArea.Min.Y {
381 // First line of multi-line selection
382 bounds.startX = selArea.Min.X
383 bounds.endX = scr.Width()
384 } else if y == selArea.Max.Y {
385 // Last line of multi-line selection
386 bounds.startX = 0
387 bounds.endX = selArea.Max.X
388 } else {
389 // Middle lines
390 bounds.startX = 0
391 bounds.endX = scr.Width()
392 }
393 }
394 lineSelections[y] = bounds
395 }
396
397 type lineBounds struct {
398 start, end int
399 }
400 lineTextBounds := make([]lineBounds, scr.Height())
401
402 // First pass: find text bounds for lines that have selections
403 for y := range scr.Height() {
404 bounds := lineBounds{start: -1, end: -1}
405
406 // Only process lines that might have selections
407 if lineSelections[y].inSelection {
408 for x := range scr.Width() {
409 cell := scr.CellAt(x, y)
410 if cell == nil {
411 continue
412 }
413
414 cellStr := cell.String()
415 if len(cellStr) == 0 {
416 continue
417 }
418
419 char := rune(cellStr[0])
420 _, isSpecial := specialChars[cellStr]
421
422 if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
423 if bounds.start == -1 {
424 bounds.start = x
425 }
426 bounds.end = x + 1 // Position after last character
427 }
428 }
429 }
430 lineTextBounds[y] = bounds
431 }
432
433 var selectedText strings.Builder
434
435 // Second pass: apply selection highlighting
436 for y := range scr.Height() {
437 selBounds := lineSelections[y]
438 if !selBounds.inSelection {
439 continue
440 }
441
442 textBounds := lineTextBounds[y]
443 if textBounds.start < 0 {
444 if textOnly {
445 // We don't want to get rid of all empty lines in text-only mode
446 selectedText.WriteByte('\n')
447 }
448
449 continue // No text on this line
450 }
451
452 // Only scan within the intersection of text bounds and selection bounds
453 scanStart := max(textBounds.start, selBounds.startX)
454 scanEnd := min(textBounds.end, selBounds.endX)
455
456 for x := scanStart; x < scanEnd; x++ {
457 cell := scr.CellAt(x, y)
458 if cell == nil {
459 continue
460 }
461
462 cellStr := cell.String()
463 if len(cellStr) > 0 {
464 if _, isSpecial := specialChars[cellStr]; isSpecial {
465 continue
466 }
467 if textOnly {
468 // Collect selected text without styles
469 selectedText.WriteString(cell.String())
470 continue
471 }
472
473 // Text selection styling, which is a Lip Gloss style. We must
474 // extract the values to use in a UV style, below.
475 ts := t.TextSelection
476
477 cell = cell.Clone()
478 cell.Style = cell.Style.Background(ts.GetBackground()).Foreground(ts.GetForeground())
479 scr.SetCell(x, y, cell)
480 }
481 }
482
483 if textOnly {
484 // Make sure we add a newline after each line of selected text
485 selectedText.WriteByte('\n')
486 }
487 }
488
489 if textOnly {
490 return strings.TrimSpace(selectedText.String())
491 }
492
493 return scr.Render()
494}
495
496func (l *list[T]) View() string {
497 if l.height <= 0 || l.width <= 0 {
498 return ""
499 }
500
501 if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
502 return l.cachedView
503 }
504
505 t := styles.CurrentTheme()
506
507 start, end := l.viewPosition()
508 viewStart := max(0, start)
509 viewEnd := end
510
511 if viewStart > viewEnd {
512 return ""
513 }
514
515 view := l.getLines(viewStart, viewEnd)
516
517 if l.resize {
518 return view
519 }
520
521 view = t.S().Base.
522 Height(l.height).
523 Width(l.width).
524 Render(view)
525
526 if !l.hasSelection() {
527 l.cachedView = view
528 l.cachedViewOffset = l.offset
529 l.cachedViewDirty = false
530 return view
531 }
532
533 return l.selectionView(view, false)
534}
535
536func (l *list[T]) viewPosition() (int, int) {
537 start, end := 0, 0
538 renderedLines := l.renderedHeight - 1
539 if l.direction == DirectionForward {
540 start = max(0, l.offset)
541 end = min(l.offset+l.height-1, renderedLines)
542 } else {
543 start = max(0, renderedLines-l.offset-l.height+1)
544 end = max(0, renderedLines-l.offset)
545 }
546 start = min(start, end)
547 return start, end
548}
549
550func (l *list[T]) setRendered(rendered string) {
551 l.rendered = rendered
552 l.renderedHeight = lipgloss.Height(rendered)
553 l.cachedViewDirty = true // Mark view cache as dirty
554
555 if len(rendered) > 0 {
556 l.lineOffsets = make([]int, 0, l.renderedHeight)
557 l.lineOffsets = append(l.lineOffsets, 0)
558
559 offset := 0
560 for {
561 idx := strings.IndexByte(rendered[offset:], '\n')
562 if idx == -1 {
563 break
564 }
565 offset += idx + 1
566 l.lineOffsets = append(l.lineOffsets, offset)
567 }
568 } else {
569 l.lineOffsets = nil
570 }
571}
572
573func (l *list[T]) getLines(start, end int) string {
574 if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
575 return ""
576 }
577
578 if end >= len(l.lineOffsets) {
579 end = len(l.lineOffsets) - 1
580 }
581 if start > end {
582 return ""
583 }
584
585 startOffset := l.lineOffsets[start]
586 var endOffset int
587 if end+1 < len(l.lineOffsets) {
588 endOffset = l.lineOffsets[end+1] - 1
589 } else {
590 endOffset = len(l.rendered)
591 }
592
593 if startOffset >= len(l.rendered) {
594 return ""
595 }
596 endOffset = min(endOffset, len(l.rendered))
597
598 return l.rendered[startOffset:endOffset]
599}
600
601// getLine returns a single line from the rendered content using lineOffsets.
602// This avoids allocating a new string for each line like strings.Split does.
603func (l *list[T]) getLine(index int) string {
604 if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) {
605 return ""
606 }
607
608 startOffset := l.lineOffsets[index]
609 var endOffset int
610 if index+1 < len(l.lineOffsets) {
611 endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline
612 } else {
613 endOffset = len(l.rendered)
614 }
615
616 if startOffset >= len(l.rendered) {
617 return ""
618 }
619 endOffset = min(endOffset, len(l.rendered))
620
621 return l.rendered[startOffset:endOffset]
622}
623
624// lineCount returns the number of lines in the rendered content.
625func (l *list[T]) lineCount() int {
626 return len(l.lineOffsets)
627}
628
629func (l *list[T]) recalculateItemPositions() {
630 l.recalculateItemPositionsFrom(0)
631}
632
633func (l *list[T]) recalculateItemPositionsFrom(startIdx int) {
634 var currentContentHeight int
635
636 if startIdx > 0 && startIdx <= len(l.items) {
637 prevItem := l.items[startIdx-1]
638 if rItem, ok := l.renderedItems[prevItem.ID()]; ok {
639 currentContentHeight = rItem.end + 1 + l.gap
640 }
641 }
642
643 for i := startIdx; i < len(l.items); i++ {
644 item := l.items[i]
645 rItem, ok := l.renderedItems[item.ID()]
646 if !ok {
647 continue
648 }
649 rItem.start = currentContentHeight
650 rItem.end = currentContentHeight + rItem.height - 1
651 l.renderedItems[item.ID()] = rItem
652 currentContentHeight = rItem.end + 1 + l.gap
653 }
654}
655
656func (l *list[T]) render() tea.Cmd {
657 if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
658 return nil
659 }
660 l.setDefaultSelected()
661
662 var focusChangeCmd tea.Cmd
663 if l.focused {
664 focusChangeCmd = l.focusSelectedItem()
665 } else {
666 focusChangeCmd = l.blurSelectedItem()
667 }
668 if l.rendered != "" {
669 rendered, _ := l.renderIterator(0, false, "")
670 l.setRendered(rendered)
671 if l.direction == DirectionBackward {
672 l.recalculateItemPositions()
673 }
674 if l.focused {
675 l.scrollToSelection()
676 }
677 return focusChangeCmd
678 }
679 rendered, finishIndex := l.renderIterator(0, true, "")
680 l.setRendered(rendered)
681 if l.direction == DirectionBackward {
682 l.recalculateItemPositions()
683 }
684
685 l.offset = 0
686 rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
687 l.setRendered(rendered)
688 if l.direction == DirectionBackward {
689 l.recalculateItemPositions()
690 }
691 if l.focused {
692 l.scrollToSelection()
693 }
694
695 return focusChangeCmd
696}
697
698func (l *list[T]) setDefaultSelected() {
699 if l.selectedItemIdx < 0 {
700 if l.direction == DirectionForward {
701 l.selectFirstItem()
702 } else {
703 l.selectLastItem()
704 }
705 }
706}
707
708func (l *list[T]) scrollToSelection() {
709 if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
710 l.selectedItemIdx = -1
711 l.setDefaultSelected()
712 return
713 }
714 item := l.items[l.selectedItemIdx]
715 rItem, ok := l.renderedItems[item.ID()]
716 if !ok {
717 l.selectedItemIdx = -1
718 l.setDefaultSelected()
719 return
720 }
721
722 start, end := l.viewPosition()
723 if rItem.start <= start && rItem.end >= end {
724 return
725 }
726 if l.movingByItem {
727 if rItem.start >= start && rItem.end <= end {
728 return
729 }
730 defer func() { l.movingByItem = false }()
731 } else {
732 if rItem.start >= start && rItem.start <= end {
733 return
734 }
735 if rItem.end >= start && rItem.end <= end {
736 return
737 }
738 }
739
740 if rItem.height >= l.height {
741 if l.direction == DirectionForward {
742 l.offset = rItem.start
743 } else {
744 l.offset = max(0, l.renderedHeight-(rItem.start+l.height))
745 }
746 return
747 }
748
749 renderedLines := l.renderedHeight - 1
750
751 if rItem.start < start {
752 if l.direction == DirectionForward {
753 l.offset = rItem.start
754 } else {
755 l.offset = max(0, renderedLines-rItem.start-l.height+1)
756 }
757 } else if rItem.end > end {
758 if l.direction == DirectionForward {
759 l.offset = max(0, rItem.end-l.height+1)
760 } else {
761 l.offset = max(0, renderedLines-rItem.end)
762 }
763 }
764}
765
766func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
767 if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
768 return nil
769 }
770 item := l.items[l.selectedItemIdx]
771 rItem, ok := l.renderedItems[item.ID()]
772 if !ok {
773 return nil
774 }
775 start, end := l.viewPosition()
776 // item bigger than the viewport do nothing
777 if rItem.start <= start && rItem.end >= end {
778 return nil
779 }
780 // item already in view do nothing
781 if rItem.start >= start && rItem.end <= end {
782 return nil
783 }
784
785 itemMiddle := rItem.start + rItem.height/2
786
787 if itemMiddle < start {
788 // select the first item in the viewport
789 // the item is most likely an item coming after this item
790 inx := l.selectedItemIdx
791 for {
792 inx = l.firstSelectableItemBelow(inx)
793 if inx == ItemNotFound {
794 return nil
795 }
796 if inx < 0 || inx >= len(l.items) {
797 continue
798 }
799
800 item := l.items[inx]
801 renderedItem, ok := l.renderedItems[item.ID()]
802 if !ok {
803 continue
804 }
805
806 // If the item is bigger than the viewport, select it
807 if renderedItem.start <= start && renderedItem.end >= end {
808 l.selectedItemIdx = inx
809 return l.render()
810 }
811 // item is in the view
812 if renderedItem.start >= start && renderedItem.start <= end {
813 l.selectedItemIdx = inx
814 return l.render()
815 }
816 }
817 } else if itemMiddle > end {
818 // select the first item in the viewport
819 // the item is most likely an item coming after this item
820 inx := l.selectedItemIdx
821 for {
822 inx = l.firstSelectableItemAbove(inx)
823 if inx == ItemNotFound {
824 return nil
825 }
826 if inx < 0 || inx >= len(l.items) {
827 continue
828 }
829
830 item := l.items[inx]
831 renderedItem, ok := l.renderedItems[item.ID()]
832 if !ok {
833 continue
834 }
835
836 // If the item is bigger than the viewport, select it
837 if renderedItem.start <= start && renderedItem.end >= end {
838 l.selectedItemIdx = inx
839 return l.render()
840 }
841 // item is in the view
842 if renderedItem.end >= start && renderedItem.end <= end {
843 l.selectedItemIdx = inx
844 return l.render()
845 }
846 }
847 }
848 return nil
849}
850
851func (l *list[T]) selectFirstItem() {
852 inx := l.firstSelectableItemBelow(-1)
853 if inx != ItemNotFound {
854 l.selectedItemIdx = inx
855 }
856}
857
858func (l *list[T]) selectLastItem() {
859 inx := l.firstSelectableItemAbove(len(l.items))
860 if inx != ItemNotFound {
861 l.selectedItemIdx = inx
862 }
863}
864
865func (l *list[T]) firstSelectableItemAbove(inx int) int {
866 for i := inx - 1; i >= 0; i-- {
867 if i < 0 || i >= len(l.items) {
868 continue
869 }
870
871 item := l.items[i]
872 if _, ok := any(item).(layout.Focusable); ok {
873 return i
874 }
875 }
876 if inx == 0 && l.wrap {
877 return l.firstSelectableItemAbove(len(l.items))
878 }
879 return ItemNotFound
880}
881
882func (l *list[T]) firstSelectableItemBelow(inx int) int {
883 itemsLen := len(l.items)
884 for i := inx + 1; i < itemsLen; i++ {
885 if i < 0 || i >= len(l.items) {
886 continue
887 }
888
889 item := l.items[i]
890 if _, ok := any(item).(layout.Focusable); ok {
891 return i
892 }
893 }
894 if inx == itemsLen-1 && l.wrap {
895 return l.firstSelectableItemBelow(-1)
896 }
897 return ItemNotFound
898}
899
900func (l *list[T]) focusSelectedItem() tea.Cmd {
901 if l.selectedItemIdx < 0 || !l.focused {
902 return nil
903 }
904 // Pre-allocate with expected capacity
905 cmds := make([]tea.Cmd, 0, 2)
906
907 // Blur the previously selected item if it's different
908 if l.prevSelectedItemIdx >= 0 && l.prevSelectedItemIdx != l.selectedItemIdx && l.prevSelectedItemIdx < len(l.items) {
909 prevItem := l.items[l.prevSelectedItemIdx]
910 if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() {
911 cmds = append(cmds, f.Blur())
912 // Mark cache as needing update, but don't delete yet
913 // This allows the render to potentially reuse it
914 delete(l.renderedItems, prevItem.ID())
915 }
916 }
917
918 // Focus the currently selected item
919 if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
920 item := l.items[l.selectedItemIdx]
921 if f, ok := any(item).(layout.Focusable); ok && !f.IsFocused() {
922 cmds = append(cmds, f.Focus())
923 // Mark for re-render
924 delete(l.renderedItems, item.ID())
925 }
926 }
927
928 l.prevSelectedItemIdx = l.selectedItemIdx
929 return tea.Batch(cmds...)
930}
931
932func (l *list[T]) blurSelectedItem() tea.Cmd {
933 if l.selectedItemIdx < 0 || l.focused {
934 return nil
935 }
936
937 // Blur the currently selected item
938 if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
939 item := l.items[l.selectedItemIdx]
940 if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() {
941 delete(l.renderedItems, item.ID())
942 return f.Blur()
943 }
944 }
945
946 return nil
947}
948
949// renderFragment holds updated rendered view fragments
950type renderFragment struct {
951 view string
952 gap int
953}
954
955// renderIterator renders items starting from the specific index and limits height if limitHeight != -1
956// returns the last index and the rendered content so far
957// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
958func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
959 // Pre-allocate fragments with expected capacity
960 itemsLen := len(l.items)
961 expectedFragments := itemsLen - startInx
962 if limitHeight && l.height > 0 {
963 expectedFragments = min(expectedFragments, l.height)
964 }
965 fragments := make([]renderFragment, 0, expectedFragments)
966
967 currentContentHeight := lipgloss.Height(rendered) - 1
968 finalIndex := itemsLen
969
970 // first pass: accumulate all fragments to render until the height limit is
971 // reached
972 for i := startInx; i < itemsLen; i++ {
973 if limitHeight && currentContentHeight >= l.height {
974 finalIndex = i
975 break
976 }
977 // cool way to go through the list in both directions
978 inx := i
979
980 if l.direction != DirectionForward {
981 inx = (itemsLen - 1) - i
982 }
983
984 if inx < 0 || inx >= len(l.items) {
985 continue
986 }
987
988 item := l.items[inx]
989
990 var rItem renderedItem
991 if cache, ok := l.renderedItems[item.ID()]; ok {
992 rItem = cache
993 } else {
994 rItem = l.renderItem(item)
995 rItem.start = currentContentHeight
996 rItem.end = currentContentHeight + rItem.height - 1
997 l.renderedItems[item.ID()] = rItem
998 }
999
1000 gap := l.gap + 1
1001 if inx == itemsLen-1 {
1002 gap = 0
1003 }
1004
1005 fragments = append(fragments, renderFragment{view: rItem.view, gap: gap})
1006
1007 currentContentHeight = rItem.end + 1 + l.gap
1008 }
1009
1010 // second pass: build rendered string efficiently
1011 var b strings.Builder
1012
1013 // Pre-size the builder to reduce allocations
1014 estimatedSize := len(rendered)
1015 for _, f := range fragments {
1016 estimatedSize += len(f.view) + f.gap
1017 }
1018 b.Grow(estimatedSize)
1019
1020 if l.direction == DirectionForward {
1021 b.WriteString(rendered)
1022 for i := range fragments {
1023 f := &fragments[i]
1024 b.WriteString(f.view)
1025 // Optimized gap writing using pre-allocated buffer
1026 if f.gap > 0 {
1027 if f.gap <= maxGapSize {
1028 b.WriteString(newlineBuffer[:f.gap])
1029 } else {
1030 b.WriteString(strings.Repeat("\n", f.gap))
1031 }
1032 }
1033 }
1034
1035 return b.String(), finalIndex
1036 }
1037
1038 // iterate backwards as fragments are in reversed order
1039 for i := len(fragments) - 1; i >= 0; i-- {
1040 f := &fragments[i]
1041 b.WriteString(f.view)
1042 // Optimized gap writing using pre-allocated buffer
1043 if f.gap > 0 {
1044 if f.gap <= maxGapSize {
1045 b.WriteString(newlineBuffer[:f.gap])
1046 } else {
1047 b.WriteString(strings.Repeat("\n", f.gap))
1048 }
1049 }
1050 }
1051 b.WriteString(rendered)
1052
1053 return b.String(), finalIndex
1054}
1055
1056func (l *list[T]) renderItem(item Item) renderedItem {
1057 view := item.View()
1058 return renderedItem{
1059 view: view,
1060 height: lipgloss.Height(view),
1061 }
1062}
1063
1064// AppendItem implements List.
1065func (l *list[T]) AppendItem(item T) tea.Cmd {
1066 // Pre-allocate with expected capacity
1067 cmds := make([]tea.Cmd, 0, 4)
1068 cmd := item.Init()
1069 if cmd != nil {
1070 cmds = append(cmds, cmd)
1071 }
1072
1073 newIndex := len(l.items)
1074 l.items = append(l.items, item)
1075 l.indexMap[item.ID()] = newIndex
1076
1077 if l.width > 0 && l.height > 0 {
1078 cmd = item.SetSize(l.width, l.height)
1079 if cmd != nil {
1080 cmds = append(cmds, cmd)
1081 }
1082 }
1083 cmd = l.render()
1084 if cmd != nil {
1085 cmds = append(cmds, cmd)
1086 }
1087 if l.direction == DirectionBackward {
1088 if l.offset == 0 {
1089 cmd = l.GoToBottom()
1090 if cmd != nil {
1091 cmds = append(cmds, cmd)
1092 }
1093 } else {
1094 newItem, ok := l.renderedItems[item.ID()]
1095 if ok {
1096 newLines := newItem.height
1097 if len(l.items) > 1 {
1098 newLines += l.gap
1099 }
1100 l.offset = min(l.renderedHeight-1, l.offset+newLines)
1101 }
1102 }
1103 }
1104 return tea.Sequence(cmds...)
1105}
1106
1107// Blur implements List.
1108func (l *list[T]) Blur() tea.Cmd {
1109 l.focused = false
1110 return l.render()
1111}
1112
1113// DeleteItem implements List.
1114func (l *list[T]) DeleteItem(id string) tea.Cmd {
1115 inx, ok := l.indexMap[id]
1116 if !ok {
1117 return nil
1118 }
1119 l.items = append(l.items[:inx], l.items[inx+1:]...)
1120 delete(l.renderedItems, id)
1121 delete(l.indexMap, id)
1122
1123 // Only update indices for items after the deleted one
1124 itemsLen := len(l.items)
1125 for i := inx; i < itemsLen; i++ {
1126 if i >= 0 && i < len(l.items) {
1127 item := l.items[i]
1128 l.indexMap[item.ID()] = i
1129 }
1130 }
1131
1132 // Adjust selectedItemIdx if the deleted item was selected or before it
1133 if l.selectedItemIdx == inx {
1134 // Deleted item was selected, select the previous item if possible
1135 if inx > 0 {
1136 l.selectedItemIdx = inx - 1
1137 } else {
1138 l.selectedItemIdx = -1
1139 }
1140 } else if l.selectedItemIdx > inx {
1141 // Selected item is after the deleted one, shift index down
1142 l.selectedItemIdx--
1143 }
1144 cmd := l.render()
1145 if l.rendered != "" {
1146 if l.renderedHeight <= l.height {
1147 l.offset = 0
1148 } else {
1149 maxOffset := l.renderedHeight - l.height
1150 if l.offset > maxOffset {
1151 l.offset = maxOffset
1152 }
1153 }
1154 }
1155 return cmd
1156}
1157
1158// Focus implements List.
1159func (l *list[T]) Focus() tea.Cmd {
1160 l.focused = true
1161 return l.render()
1162}
1163
1164// GetSize implements List.
1165func (l *list[T]) GetSize() (int, int) {
1166 return l.width, l.height
1167}
1168
1169// GoToBottom implements List.
1170func (l *list[T]) GoToBottom() tea.Cmd {
1171 l.offset = 0
1172 l.selectedItemIdx = -1
1173 l.direction = DirectionBackward
1174 return l.render()
1175}
1176
1177// GoToTop implements List.
1178func (l *list[T]) GoToTop() tea.Cmd {
1179 l.offset = 0
1180 l.selectedItemIdx = -1
1181 l.direction = DirectionForward
1182 return l.render()
1183}
1184
1185// IsFocused implements List.
1186func (l *list[T]) IsFocused() bool {
1187 return l.focused
1188}
1189
1190// Items implements List.
1191func (l *list[T]) Items() []T {
1192 itemsLen := len(l.items)
1193 result := make([]T, 0, itemsLen)
1194 for i := range itemsLen {
1195 if i >= 0 && i < len(l.items) {
1196 item := l.items[i]
1197 result = append(result, item)
1198 }
1199 }
1200 return result
1201}
1202
1203func (l *list[T]) incrementOffset(n int) {
1204 // no need for offset
1205 if l.renderedHeight <= l.height {
1206 return
1207 }
1208 maxOffset := l.renderedHeight - l.height
1209 n = min(n, maxOffset-l.offset)
1210 if n <= 0 {
1211 return
1212 }
1213 l.offset += n
1214 l.cachedViewDirty = true
1215}
1216
1217func (l *list[T]) decrementOffset(n int) {
1218 n = min(n, l.offset)
1219 if n <= 0 {
1220 return
1221 }
1222 l.offset -= n
1223 if l.offset < 0 {
1224 l.offset = 0
1225 }
1226 l.cachedViewDirty = true
1227}
1228
1229// MoveDown implements List.
1230func (l *list[T]) MoveDown(n int) tea.Cmd {
1231 oldOffset := l.offset
1232 if l.direction == DirectionForward {
1233 l.incrementOffset(n)
1234 } else {
1235 l.decrementOffset(n)
1236 }
1237
1238 if oldOffset == l.offset {
1239 // no change in offset, so no need to change selection
1240 return nil
1241 }
1242 // if we are not actively selecting move the whole selection down
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// MoveUp implements List.
1263func (l *list[T]) MoveUp(n int) tea.Cmd {
1264 oldOffset := l.offset
1265 if l.direction == DirectionForward {
1266 l.decrementOffset(n)
1267 } else {
1268 l.incrementOffset(n)
1269 }
1270
1271 if oldOffset == l.offset {
1272 // no change in offset, so no need to change selection
1273 return nil
1274 }
1275
1276 if l.hasSelection() && !l.selectionActive {
1277 if l.selectionStartLine > l.selectionEndLine {
1278 l.selectionStartLine += n
1279 l.selectionEndLine += n
1280 } else {
1281 l.selectionStartLine += n
1282 l.selectionEndLine += n
1283 }
1284 }
1285 if l.selectionActive {
1286 if l.selectionStartLine > l.selectionEndLine {
1287 l.selectionStartLine += n
1288 } else {
1289 l.selectionEndLine += n
1290 }
1291 }
1292 return l.changeSelectionWhenScrolling()
1293}
1294
1295// PrependItem implements List.
1296func (l *list[T]) PrependItem(item T) tea.Cmd {
1297 // Pre-allocate with expected capacity
1298 cmds := make([]tea.Cmd, 0, 4)
1299 cmds = append(cmds, item.Init())
1300
1301 l.items = append([]T{item}, l.items...)
1302
1303 // Shift selectedItemIdx since all items moved down by 1
1304 if l.selectedItemIdx >= 0 {
1305 l.selectedItemIdx++
1306 }
1307
1308 // Update index map incrementally: shift all existing indices up by 1
1309 // This is more efficient than rebuilding from scratch
1310 newIndexMap := make(map[string]int, len(l.indexMap)+1)
1311 for id, idx := range l.indexMap {
1312 newIndexMap[id] = idx + 1 // All existing items shift down by 1
1313 }
1314 newIndexMap[item.ID()] = 0 // New item is at index 0
1315 l.indexMap = newIndexMap
1316
1317 if l.width > 0 && l.height > 0 {
1318 cmds = append(cmds, item.SetSize(l.width, l.height))
1319 }
1320 cmds = append(cmds, l.render())
1321 if l.direction == DirectionForward {
1322 if l.offset == 0 {
1323 cmd := l.GoToTop()
1324 if cmd != nil {
1325 cmds = append(cmds, cmd)
1326 }
1327 } else {
1328 newItem, ok := l.renderedItems[item.ID()]
1329 if ok {
1330 newLines := newItem.height
1331 if len(l.items) > 1 {
1332 newLines += l.gap
1333 }
1334 l.offset = min(l.renderedHeight-1, l.offset+newLines)
1335 }
1336 }
1337 }
1338 return tea.Batch(cmds...)
1339}
1340
1341// SelectItemAbove implements List.
1342func (l *list[T]) SelectItemAbove() tea.Cmd {
1343 if l.selectedItemIdx < 0 {
1344 return nil
1345 }
1346
1347 newIndex := l.firstSelectableItemAbove(l.selectedItemIdx)
1348 if newIndex == ItemNotFound {
1349 // no item above
1350 return nil
1351 }
1352 // Pre-allocate with expected capacity
1353 cmds := make([]tea.Cmd, 0, 2)
1354 if newIndex == 1 {
1355 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1356 if peakAboveIndex == ItemNotFound {
1357 // this means there is a section above move to the top
1358 cmd := l.GoToTop()
1359 if cmd != nil {
1360 cmds = append(cmds, cmd)
1361 }
1362 }
1363 }
1364 if newIndex < 0 || newIndex >= len(l.items) {
1365 return nil
1366 }
1367 l.prevSelectedItemIdx = l.selectedItemIdx
1368 l.selectedItemIdx = newIndex
1369 l.movingByItem = true
1370 renderCmd := l.render()
1371 if renderCmd != nil {
1372 cmds = append(cmds, renderCmd)
1373 }
1374 return tea.Sequence(cmds...)
1375}
1376
1377// SelectItemBelow implements List.
1378func (l *list[T]) SelectItemBelow() tea.Cmd {
1379 if l.selectedItemIdx < 0 {
1380 return nil
1381 }
1382
1383 newIndex := l.firstSelectableItemBelow(l.selectedItemIdx)
1384 if newIndex == ItemNotFound {
1385 // no item above
1386 return nil
1387 }
1388 if newIndex < 0 || newIndex >= len(l.items) {
1389 return nil
1390 }
1391 l.prevSelectedItemIdx = l.selectedItemIdx
1392 l.selectedItemIdx = newIndex
1393 l.movingByItem = true
1394 return l.render()
1395}
1396
1397// SelectedItem implements List.
1398func (l *list[T]) SelectedItem() *T {
1399 if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
1400 return nil
1401 }
1402 item := l.items[l.selectedItemIdx]
1403 return &item
1404}
1405
1406// SetItems implements List.
1407func (l *list[T]) SetItems(items []T) tea.Cmd {
1408 l.items = items
1409 var cmds []tea.Cmd
1410 for inx, item := range items {
1411 if i, ok := any(item).(Indexable); ok {
1412 i.SetIndex(inx)
1413 }
1414 cmds = append(cmds, item.Init())
1415 }
1416 cmds = append(cmds, l.reset(""))
1417 return tea.Batch(cmds...)
1418}
1419
1420// SetSelected implements List.
1421func (l *list[T]) SetSelected(id string) tea.Cmd {
1422 l.prevSelectedItemIdx = l.selectedItemIdx
1423 if idx, ok := l.indexMap[id]; ok {
1424 l.selectedItemIdx = idx
1425 } else {
1426 l.selectedItemIdx = -1
1427 }
1428 return l.render()
1429}
1430
1431func (l *list[T]) reset(selectedItemID string) tea.Cmd {
1432 var cmds []tea.Cmd
1433 l.rendered = ""
1434 l.renderedHeight = 0
1435 l.offset = 0
1436 l.indexMap = make(map[string]int)
1437 l.renderedItems = make(map[string]renderedItem)
1438 itemsLen := len(l.items)
1439 for i := range itemsLen {
1440 if i < 0 || i >= len(l.items) {
1441 continue
1442 }
1443
1444 item := l.items[i]
1445 l.indexMap[item.ID()] = i
1446 if l.width > 0 && l.height > 0 {
1447 cmds = append(cmds, item.SetSize(l.width, l.height))
1448 }
1449 }
1450 // Convert selectedItemID to index after rebuilding indexMap
1451 if selectedItemID != "" {
1452 if idx, ok := l.indexMap[selectedItemID]; ok {
1453 l.selectedItemIdx = idx
1454 } else {
1455 l.selectedItemIdx = -1
1456 }
1457 } else {
1458 l.selectedItemIdx = -1
1459 }
1460 cmds = append(cmds, l.render())
1461 return tea.Batch(cmds...)
1462}
1463
1464// SetSize implements List.
1465func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1466 oldWidth := l.width
1467 l.width = width
1468 l.height = height
1469 if oldWidth != width {
1470 // Get current selected item ID before reset
1471 selectedID := ""
1472 if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
1473 item := l.items[l.selectedItemIdx]
1474 selectedID = item.ID()
1475 }
1476 cmd := l.reset(selectedID)
1477 return cmd
1478 }
1479 return nil
1480}
1481
1482// UpdateItem implements List.
1483func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1484 // Pre-allocate with expected capacity
1485 cmds := make([]tea.Cmd, 0, 1)
1486 if inx, ok := l.indexMap[id]; ok {
1487 l.items[inx] = item
1488 oldItem, hasOldItem := l.renderedItems[id]
1489 oldPosition := l.offset
1490 if l.direction == DirectionBackward {
1491 oldPosition = (l.renderedHeight - 1) - l.offset
1492 }
1493
1494 delete(l.renderedItems, id)
1495 cmd := l.render()
1496
1497 // need to check for nil because of sequence not handling nil
1498 if cmd != nil {
1499 cmds = append(cmds, cmd)
1500 }
1501 if hasOldItem && l.direction == DirectionBackward {
1502 // if we are the last item and there is no offset
1503 // make sure to go to the bottom
1504 if oldPosition < oldItem.end {
1505 newItem, ok := l.renderedItems[item.ID()]
1506 if ok {
1507 newLines := newItem.height - oldItem.height
1508 l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
1509 }
1510 }
1511 } else if hasOldItem && l.offset > oldItem.start {
1512 newItem, ok := l.renderedItems[item.ID()]
1513 if ok {
1514 newLines := newItem.height - oldItem.height
1515 l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
1516 }
1517 }
1518 }
1519 return tea.Sequence(cmds...)
1520}
1521
1522func (l *list[T]) hasSelection() bool {
1523 return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1524}
1525
1526// StartSelection implements List.
1527func (l *list[T]) StartSelection(col, line int) {
1528 l.selectionStartCol = col
1529 l.selectionStartLine = line
1530 l.selectionEndCol = col
1531 l.selectionEndLine = line
1532 l.selectionActive = true
1533}
1534
1535// EndSelection implements List.
1536func (l *list[T]) EndSelection(col, line int) {
1537 if !l.selectionActive {
1538 return
1539 }
1540 l.selectionEndCol = col
1541 l.selectionEndLine = line
1542}
1543
1544func (l *list[T]) SelectionStop() {
1545 l.selectionActive = false
1546}
1547
1548func (l *list[T]) SelectionClear() {
1549 l.selectionStartCol = -1
1550 l.selectionStartLine = -1
1551 l.selectionEndCol = -1
1552 l.selectionEndLine = -1
1553 l.selectionActive = false
1554}
1555
1556func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1557 numLines := l.lineCount()
1558
1559 if l.direction == DirectionBackward && numLines > l.height {
1560 line = ((numLines - 1) - l.height) + line + 1
1561 }
1562
1563 if l.offset > 0 {
1564 if l.direction == DirectionBackward {
1565 line -= l.offset
1566 } else {
1567 line += l.offset
1568 }
1569 }
1570
1571 if line < 0 || line >= numLines {
1572 return 0, 0
1573 }
1574
1575 currentLine := ansi.Strip(l.getLine(line))
1576 gr := uniseg.NewGraphemes(currentLine)
1577 startCol = -1
1578 upTo := col
1579 for gr.Next() {
1580 if gr.IsWordBoundary() && upTo > 0 {
1581 startCol = col - upTo + 1
1582 } else if gr.IsWordBoundary() && upTo < 0 {
1583 endCol = col - upTo + 1
1584 break
1585 }
1586 if upTo == 0 && gr.Str() == " " {
1587 return 0, 0
1588 }
1589 upTo -= 1
1590 }
1591 if startCol == -1 {
1592 return 0, 0
1593 }
1594 return startCol, endCol
1595}
1596
1597func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1598 // Helper function to get a line with ANSI stripped and icons replaced
1599 getCleanLine := func(index int) string {
1600 rawLine := l.getLine(index)
1601 cleanLine := ansi.Strip(rawLine)
1602 for _, icon := range styles.SelectionIgnoreIcons {
1603 cleanLine = strings.ReplaceAll(cleanLine, icon, " ")
1604 }
1605 return cleanLine
1606 }
1607
1608 numLines := l.lineCount()
1609 if l.direction == DirectionBackward && numLines > l.height {
1610 line = (numLines - 1) - l.height + line + 1
1611 }
1612
1613 if l.offset > 0 {
1614 if l.direction == DirectionBackward {
1615 line -= l.offset
1616 } else {
1617 line += l.offset
1618 }
1619 }
1620
1621 // Ensure line is within bounds
1622 if line < 0 || line >= numLines {
1623 return 0, 0, false
1624 }
1625
1626 if strings.TrimSpace(getCleanLine(line)) == "" {
1627 return 0, 0, false
1628 }
1629
1630 // Find start of paragraph (search backwards for empty line or start of text)
1631 startLine = line
1632 for startLine > 0 && strings.TrimSpace(getCleanLine(startLine-1)) != "" {
1633 startLine--
1634 }
1635
1636 // Find end of paragraph (search forwards for empty line or end of text)
1637 endLine = line
1638 for endLine < numLines-1 && strings.TrimSpace(getCleanLine(endLine+1)) != "" {
1639 endLine++
1640 }
1641
1642 // revert the line numbers if we are in backward direction
1643 if l.direction == DirectionBackward && numLines > l.height {
1644 startLine = startLine - (numLines - 1) + l.height - 1
1645 endLine = endLine - (numLines - 1) + l.height - 1
1646 }
1647 if l.offset > 0 {
1648 if l.direction == DirectionBackward {
1649 startLine += l.offset
1650 endLine += l.offset
1651 } else {
1652 startLine -= l.offset
1653 endLine -= l.offset
1654 }
1655 }
1656 return startLine, endLine, true
1657}
1658
1659// SelectWord selects the word at the given position.
1660func (l *list[T]) SelectWord(col, line int) {
1661 startCol, endCol := l.findWordBoundaries(col, line)
1662 l.selectionStartCol = startCol
1663 l.selectionStartLine = line
1664 l.selectionEndCol = endCol
1665 l.selectionEndLine = line
1666 l.selectionActive = false // Not actively selecting, just selected
1667}
1668
1669// SelectParagraph selects the paragraph at the given position.
1670func (l *list[T]) SelectParagraph(col, line int) {
1671 startLine, endLine, found := l.findParagraphBoundaries(line)
1672 if !found {
1673 return
1674 }
1675 l.selectionStartCol = 0
1676 l.selectionStartLine = startLine
1677 l.selectionEndCol = l.width - 1
1678 l.selectionEndLine = endLine
1679 l.selectionActive = false // Not actively selecting, just selected
1680}
1681
1682// HasSelection returns whether there is an active selection.
1683func (l *list[T]) HasSelection() bool {
1684 return l.hasSelection()
1685}
1686
1687// GetSelectedText returns the currently selected text.
1688func (l *list[T]) GetSelectedText(paddingLeft int) string {
1689 if !l.hasSelection() {
1690 return ""
1691 }
1692
1693 return l.selectionView(l.View(), true)
1694}