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