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 "github.com/charmbracelet/crush/internal/tui/components/anim"
11 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
12 "github.com/charmbracelet/crush/internal/tui/styles"
13 "github.com/charmbracelet/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 unfocusableCount := 0
868 for i := inx - 1; i >= 0; i-- {
869 if i < 0 || i >= len(l.items) {
870 continue
871 }
872
873 item := l.items[i]
874 if _, ok := any(item).(layout.Focusable); ok {
875 return i
876 }
877 unfocusableCount++
878 }
879 if unfocusableCount == inx && l.wrap {
880 return l.firstSelectableItemAbove(len(l.items))
881 }
882 return ItemNotFound
883}
884
885func (l *list[T]) firstSelectableItemBelow(inx int) int {
886 unfocusableCount := 0
887 itemsLen := len(l.items)
888 for i := inx + 1; i < itemsLen; i++ {
889 if i < 0 || i >= len(l.items) {
890 continue
891 }
892
893 item := l.items[i]
894 if _, ok := any(item).(layout.Focusable); ok {
895 return i
896 }
897 unfocusableCount++
898 }
899 if unfocusableCount == itemsLen-inx-1 && l.wrap {
900 return l.firstSelectableItemBelow(-1)
901 }
902 return ItemNotFound
903}
904
905func (l *list[T]) focusSelectedItem() tea.Cmd {
906 if l.selectedItemIdx < 0 || !l.focused {
907 return nil
908 }
909 // Pre-allocate with expected capacity
910 cmds := make([]tea.Cmd, 0, 2)
911
912 // Blur the previously selected item if it's different
913 if l.prevSelectedItemIdx >= 0 && l.prevSelectedItemIdx != l.selectedItemIdx && l.prevSelectedItemIdx < len(l.items) {
914 prevItem := l.items[l.prevSelectedItemIdx]
915 if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() {
916 cmds = append(cmds, f.Blur())
917 // Mark cache as needing update, but don't delete yet
918 // This allows the render to potentially reuse it
919 delete(l.renderedItems, prevItem.ID())
920 }
921 }
922
923 // Focus the currently selected item
924 if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
925 item := l.items[l.selectedItemIdx]
926 if f, ok := any(item).(layout.Focusable); ok && !f.IsFocused() {
927 cmds = append(cmds, f.Focus())
928 // Mark for re-render
929 delete(l.renderedItems, item.ID())
930 }
931 }
932
933 l.prevSelectedItemIdx = l.selectedItemIdx
934 return tea.Batch(cmds...)
935}
936
937func (l *list[T]) blurSelectedItem() tea.Cmd {
938 if l.selectedItemIdx < 0 || l.focused {
939 return nil
940 }
941
942 // Blur the currently selected item
943 if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
944 item := l.items[l.selectedItemIdx]
945 if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() {
946 delete(l.renderedItems, item.ID())
947 return f.Blur()
948 }
949 }
950
951 return nil
952}
953
954// renderFragment holds updated rendered view fragments
955type renderFragment struct {
956 view string
957 gap int
958}
959
960// renderIterator renders items starting from the specific index and limits height if limitHeight != -1
961// returns the last index and the rendered content so far
962// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
963func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
964 // Pre-allocate fragments with expected capacity
965 itemsLen := len(l.items)
966 expectedFragments := itemsLen - startInx
967 if limitHeight && l.height > 0 {
968 expectedFragments = min(expectedFragments, l.height)
969 }
970 fragments := make([]renderFragment, 0, expectedFragments)
971
972 currentContentHeight := lipgloss.Height(rendered) - 1
973 finalIndex := itemsLen
974
975 // first pass: accumulate all fragments to render until the height limit is
976 // reached
977 for i := startInx; i < itemsLen; i++ {
978 if limitHeight && currentContentHeight >= l.height {
979 finalIndex = i
980 break
981 }
982 // cool way to go through the list in both directions
983 inx := i
984
985 if l.direction != DirectionForward {
986 inx = (itemsLen - 1) - i
987 }
988
989 if inx < 0 || inx >= len(l.items) {
990 continue
991 }
992
993 item := l.items[inx]
994
995 var rItem renderedItem
996 if cache, ok := l.renderedItems[item.ID()]; ok {
997 rItem = cache
998 } else {
999 rItem = l.renderItem(item)
1000 rItem.start = currentContentHeight
1001 rItem.end = currentContentHeight + rItem.height - 1
1002 l.renderedItems[item.ID()] = rItem
1003 }
1004
1005 gap := l.gap + 1
1006 if inx == itemsLen-1 {
1007 gap = 0
1008 }
1009
1010 fragments = append(fragments, renderFragment{view: rItem.view, gap: gap})
1011
1012 currentContentHeight = rItem.end + 1 + l.gap
1013 }
1014
1015 // second pass: build rendered string efficiently
1016 var b strings.Builder
1017
1018 // Pre-size the builder to reduce allocations
1019 estimatedSize := len(rendered)
1020 for _, f := range fragments {
1021 estimatedSize += len(f.view) + f.gap
1022 }
1023 b.Grow(estimatedSize)
1024
1025 if l.direction == DirectionForward {
1026 b.WriteString(rendered)
1027 for i := range fragments {
1028 f := &fragments[i]
1029 b.WriteString(f.view)
1030 // Optimized gap writing using pre-allocated buffer
1031 if f.gap > 0 {
1032 if f.gap <= maxGapSize {
1033 b.WriteString(newlineBuffer[:f.gap])
1034 } else {
1035 b.WriteString(strings.Repeat("\n", f.gap))
1036 }
1037 }
1038 }
1039
1040 return b.String(), finalIndex
1041 }
1042
1043 // iterate backwards as fragments are in reversed order
1044 for i := len(fragments) - 1; i >= 0; i-- {
1045 f := &fragments[i]
1046 b.WriteString(f.view)
1047 // Optimized gap writing using pre-allocated buffer
1048 if f.gap > 0 {
1049 if f.gap <= maxGapSize {
1050 b.WriteString(newlineBuffer[:f.gap])
1051 } else {
1052 b.WriteString(strings.Repeat("\n", f.gap))
1053 }
1054 }
1055 }
1056 b.WriteString(rendered)
1057
1058 return b.String(), finalIndex
1059}
1060
1061func (l *list[T]) renderItem(item Item) renderedItem {
1062 view := item.View()
1063 return renderedItem{
1064 view: view,
1065 height: lipgloss.Height(view),
1066 }
1067}
1068
1069// AppendItem implements List.
1070func (l *list[T]) AppendItem(item T) tea.Cmd {
1071 // Pre-allocate with expected capacity
1072 cmds := make([]tea.Cmd, 0, 4)
1073 cmd := item.Init()
1074 if cmd != nil {
1075 cmds = append(cmds, cmd)
1076 }
1077
1078 newIndex := len(l.items)
1079 l.items = append(l.items, item)
1080 l.indexMap[item.ID()] = newIndex
1081
1082 if l.width > 0 && l.height > 0 {
1083 cmd = item.SetSize(l.width, l.height)
1084 if cmd != nil {
1085 cmds = append(cmds, cmd)
1086 }
1087 }
1088 cmd = l.render()
1089 if cmd != nil {
1090 cmds = append(cmds, cmd)
1091 }
1092 if l.direction == DirectionBackward {
1093 if l.offset == 0 {
1094 cmd = l.GoToBottom()
1095 if cmd != nil {
1096 cmds = append(cmds, cmd)
1097 }
1098 } else {
1099 newItem, ok := l.renderedItems[item.ID()]
1100 if ok {
1101 newLines := newItem.height
1102 if len(l.items) > 1 {
1103 newLines += l.gap
1104 }
1105 l.offset = min(l.renderedHeight-1, l.offset+newLines)
1106 }
1107 }
1108 }
1109 return tea.Sequence(cmds...)
1110}
1111
1112// Blur implements List.
1113func (l *list[T]) Blur() tea.Cmd {
1114 l.focused = false
1115 return l.render()
1116}
1117
1118// DeleteItem implements List.
1119func (l *list[T]) DeleteItem(id string) tea.Cmd {
1120 inx, ok := l.indexMap[id]
1121 if !ok {
1122 return nil
1123 }
1124 l.items = append(l.items[:inx], l.items[inx+1:]...)
1125 delete(l.renderedItems, id)
1126 delete(l.indexMap, id)
1127
1128 // Only update indices for items after the deleted one
1129 itemsLen := len(l.items)
1130 for i := inx; i < itemsLen; i++ {
1131 if i >= 0 && i < len(l.items) {
1132 item := l.items[i]
1133 l.indexMap[item.ID()] = i
1134 }
1135 }
1136
1137 // Adjust selectedItemIdx if the deleted item was selected or before it
1138 if l.selectedItemIdx == inx {
1139 // Deleted item was selected, select the previous item if possible
1140 if inx > 0 {
1141 l.selectedItemIdx = inx - 1
1142 } else {
1143 l.selectedItemIdx = -1
1144 }
1145 } else if l.selectedItemIdx > inx {
1146 // Selected item is after the deleted one, shift index down
1147 l.selectedItemIdx--
1148 }
1149 cmd := l.render()
1150 if l.rendered != "" {
1151 if l.renderedHeight <= l.height {
1152 l.offset = 0
1153 } else {
1154 maxOffset := l.renderedHeight - l.height
1155 if l.offset > maxOffset {
1156 l.offset = maxOffset
1157 }
1158 }
1159 }
1160 return cmd
1161}
1162
1163// Focus implements List.
1164func (l *list[T]) Focus() tea.Cmd {
1165 l.focused = true
1166 return l.render()
1167}
1168
1169// GetSize implements List.
1170func (l *list[T]) GetSize() (int, int) {
1171 return l.width, l.height
1172}
1173
1174// GoToBottom implements List.
1175func (l *list[T]) GoToBottom() tea.Cmd {
1176 l.offset = 0
1177 l.selectedItemIdx = -1
1178 l.direction = DirectionBackward
1179 return l.render()
1180}
1181
1182// GoToTop implements List.
1183func (l *list[T]) GoToTop() tea.Cmd {
1184 l.offset = 0
1185 l.selectedItemIdx = -1
1186 l.direction = DirectionForward
1187 return l.render()
1188}
1189
1190// IsFocused implements List.
1191func (l *list[T]) IsFocused() bool {
1192 return l.focused
1193}
1194
1195// Items implements List.
1196func (l *list[T]) Items() []T {
1197 itemsLen := len(l.items)
1198 result := make([]T, 0, itemsLen)
1199 for i := range itemsLen {
1200 if i >= 0 && i < len(l.items) {
1201 item := l.items[i]
1202 result = append(result, item)
1203 }
1204 }
1205 return result
1206}
1207
1208func (l *list[T]) incrementOffset(n int) {
1209 // no need for offset
1210 if l.renderedHeight <= l.height {
1211 return
1212 }
1213 maxOffset := l.renderedHeight - l.height
1214 n = min(n, maxOffset-l.offset)
1215 if n <= 0 {
1216 return
1217 }
1218 l.offset += n
1219 l.cachedViewDirty = true
1220}
1221
1222func (l *list[T]) decrementOffset(n int) {
1223 n = min(n, l.offset)
1224 if n <= 0 {
1225 return
1226 }
1227 l.offset -= n
1228 if l.offset < 0 {
1229 l.offset = 0
1230 }
1231 l.cachedViewDirty = true
1232}
1233
1234// MoveDown implements List.
1235func (l *list[T]) MoveDown(n int) tea.Cmd {
1236 oldOffset := l.offset
1237 if l.direction == DirectionForward {
1238 l.incrementOffset(n)
1239 } else {
1240 l.decrementOffset(n)
1241 }
1242
1243 if oldOffset == l.offset {
1244 // no change in offset, so no need to change selection
1245 return nil
1246 }
1247 // if we are not actively selecting move the whole selection down
1248 if l.hasSelection() && !l.selectionActive {
1249 if l.selectionStartLine < l.selectionEndLine {
1250 l.selectionStartLine -= n
1251 l.selectionEndLine -= n
1252 } else {
1253 l.selectionStartLine -= n
1254 l.selectionEndLine -= n
1255 }
1256 }
1257 if l.selectionActive {
1258 if l.selectionStartLine < l.selectionEndLine {
1259 l.selectionStartLine -= n
1260 } else {
1261 l.selectionEndLine -= n
1262 }
1263 }
1264 return l.changeSelectionWhenScrolling()
1265}
1266
1267// MoveUp implements List.
1268func (l *list[T]) MoveUp(n int) tea.Cmd {
1269 oldOffset := l.offset
1270 if l.direction == DirectionForward {
1271 l.decrementOffset(n)
1272 } else {
1273 l.incrementOffset(n)
1274 }
1275
1276 if oldOffset == l.offset {
1277 // no change in offset, so no need to change selection
1278 return nil
1279 }
1280
1281 if l.hasSelection() && !l.selectionActive {
1282 if l.selectionStartLine > l.selectionEndLine {
1283 l.selectionStartLine += n
1284 l.selectionEndLine += n
1285 } else {
1286 l.selectionStartLine += n
1287 l.selectionEndLine += n
1288 }
1289 }
1290 if l.selectionActive {
1291 if l.selectionStartLine > l.selectionEndLine {
1292 l.selectionStartLine += n
1293 } else {
1294 l.selectionEndLine += n
1295 }
1296 }
1297 return l.changeSelectionWhenScrolling()
1298}
1299
1300// PrependItem implements List.
1301func (l *list[T]) PrependItem(item T) tea.Cmd {
1302 // Pre-allocate with expected capacity
1303 cmds := make([]tea.Cmd, 0, 4)
1304 cmds = append(cmds, item.Init())
1305
1306 l.items = append([]T{item}, l.items...)
1307
1308 // Shift selectedItemIdx since all items moved down by 1
1309 if l.selectedItemIdx >= 0 {
1310 l.selectedItemIdx++
1311 }
1312
1313 // Update index map incrementally: shift all existing indices up by 1
1314 // This is more efficient than rebuilding from scratch
1315 newIndexMap := make(map[string]int, len(l.indexMap)+1)
1316 for id, idx := range l.indexMap {
1317 newIndexMap[id] = idx + 1 // All existing items shift down by 1
1318 }
1319 newIndexMap[item.ID()] = 0 // New item is at index 0
1320 l.indexMap = newIndexMap
1321
1322 if l.width > 0 && l.height > 0 {
1323 cmds = append(cmds, item.SetSize(l.width, l.height))
1324 }
1325 cmds = append(cmds, l.render())
1326 if l.direction == DirectionForward {
1327 if l.offset == 0 {
1328 cmd := l.GoToTop()
1329 if cmd != nil {
1330 cmds = append(cmds, cmd)
1331 }
1332 } else {
1333 newItem, ok := l.renderedItems[item.ID()]
1334 if ok {
1335 newLines := newItem.height
1336 if len(l.items) > 1 {
1337 newLines += l.gap
1338 }
1339 l.offset = min(l.renderedHeight-1, l.offset+newLines)
1340 }
1341 }
1342 }
1343 return tea.Batch(cmds...)
1344}
1345
1346// SelectItemAbove implements List.
1347func (l *list[T]) SelectItemAbove() tea.Cmd {
1348 if l.selectedItemIdx < 0 {
1349 return nil
1350 }
1351
1352 newIndex := l.firstSelectableItemAbove(l.selectedItemIdx)
1353 if newIndex == ItemNotFound {
1354 // no item above
1355 return nil
1356 }
1357 // Pre-allocate with expected capacity
1358 cmds := make([]tea.Cmd, 0, 2)
1359 if newIndex > l.selectedItemIdx && l.selectedItemIdx > 0 && l.offset > 0 {
1360 // this means there is a section above and not showing on the top, move to the top
1361 newIndex = l.selectedItemIdx
1362 cmd := l.GoToTop()
1363 if cmd != nil {
1364 cmds = append(cmds, cmd)
1365 }
1366 }
1367 if newIndex == 1 {
1368 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1369 if peakAboveIndex == ItemNotFound {
1370 // this means there is a section above move to the top
1371 cmd := l.GoToTop()
1372 if cmd != nil {
1373 cmds = append(cmds, cmd)
1374 }
1375 }
1376 }
1377 if newIndex < 0 || newIndex >= len(l.items) {
1378 return nil
1379 }
1380 l.prevSelectedItemIdx = l.selectedItemIdx
1381 l.selectedItemIdx = newIndex
1382 l.movingByItem = true
1383 renderCmd := l.render()
1384 if renderCmd != nil {
1385 cmds = append(cmds, renderCmd)
1386 }
1387 return tea.Sequence(cmds...)
1388}
1389
1390// SelectItemBelow implements List.
1391func (l *list[T]) SelectItemBelow() tea.Cmd {
1392 if l.selectedItemIdx < 0 {
1393 return nil
1394 }
1395
1396 newIndex := l.firstSelectableItemBelow(l.selectedItemIdx)
1397 if newIndex == ItemNotFound {
1398 // no item below
1399 return nil
1400 }
1401 if newIndex < 0 || newIndex >= len(l.items) {
1402 return nil
1403 }
1404 if newIndex < l.selectedItemIdx {
1405 // reset offset when wrap to the top to show the top section if it exists
1406 l.offset = 0
1407 }
1408 l.prevSelectedItemIdx = l.selectedItemIdx
1409 l.selectedItemIdx = newIndex
1410 l.movingByItem = true
1411 return l.render()
1412}
1413
1414// SelectedItem implements List.
1415func (l *list[T]) SelectedItem() *T {
1416 if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
1417 return nil
1418 }
1419 item := l.items[l.selectedItemIdx]
1420 return &item
1421}
1422
1423// SetItems implements List.
1424func (l *list[T]) SetItems(items []T) tea.Cmd {
1425 l.items = items
1426 var cmds []tea.Cmd
1427 for inx, item := range items {
1428 if i, ok := any(item).(Indexable); ok {
1429 i.SetIndex(inx)
1430 }
1431 cmds = append(cmds, item.Init())
1432 }
1433 cmds = append(cmds, l.reset(""))
1434 return tea.Batch(cmds...)
1435}
1436
1437// SetSelected implements List.
1438func (l *list[T]) SetSelected(id string) tea.Cmd {
1439 l.prevSelectedItemIdx = l.selectedItemIdx
1440 if idx, ok := l.indexMap[id]; ok {
1441 l.selectedItemIdx = idx
1442 } else {
1443 l.selectedItemIdx = -1
1444 }
1445 return l.render()
1446}
1447
1448func (l *list[T]) reset(selectedItemID string) tea.Cmd {
1449 var cmds []tea.Cmd
1450 l.rendered = ""
1451 l.renderedHeight = 0
1452 l.offset = 0
1453 l.indexMap = make(map[string]int)
1454 l.renderedItems = make(map[string]renderedItem)
1455 itemsLen := len(l.items)
1456 for i := range itemsLen {
1457 if i < 0 || i >= len(l.items) {
1458 continue
1459 }
1460
1461 item := l.items[i]
1462 l.indexMap[item.ID()] = i
1463 if l.width > 0 && l.height > 0 {
1464 cmds = append(cmds, item.SetSize(l.width, l.height))
1465 }
1466 }
1467 // Convert selectedItemID to index after rebuilding indexMap
1468 if selectedItemID != "" {
1469 if idx, ok := l.indexMap[selectedItemID]; ok {
1470 l.selectedItemIdx = idx
1471 } else {
1472 l.selectedItemIdx = -1
1473 }
1474 } else {
1475 l.selectedItemIdx = -1
1476 }
1477 cmds = append(cmds, l.render())
1478 return tea.Batch(cmds...)
1479}
1480
1481// SetSize implements List.
1482func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1483 oldWidth := l.width
1484 l.width = width
1485 l.height = height
1486 if oldWidth != width {
1487 // Get current selected item ID before reset
1488 selectedID := ""
1489 if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
1490 item := l.items[l.selectedItemIdx]
1491 selectedID = item.ID()
1492 }
1493 cmd := l.reset(selectedID)
1494 return cmd
1495 }
1496 return nil
1497}
1498
1499// UpdateItem implements List.
1500func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1501 // Pre-allocate with expected capacity
1502 cmds := make([]tea.Cmd, 0, 1)
1503 if inx, ok := l.indexMap[id]; ok {
1504 l.items[inx] = item
1505 oldItem, hasOldItem := l.renderedItems[id]
1506 oldPosition := l.offset
1507 if l.direction == DirectionBackward {
1508 oldPosition = (l.renderedHeight - 1) - l.offset
1509 }
1510
1511 delete(l.renderedItems, id)
1512 cmd := l.render()
1513
1514 // need to check for nil because of sequence not handling nil
1515 if cmd != nil {
1516 cmds = append(cmds, cmd)
1517 }
1518 if hasOldItem && l.direction == DirectionBackward {
1519 // if we are the last item and there is no offset
1520 // make sure to go to the bottom
1521 if oldPosition < oldItem.end {
1522 newItem, ok := l.renderedItems[item.ID()]
1523 if ok {
1524 newLines := newItem.height - oldItem.height
1525 l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
1526 }
1527 }
1528 } else if hasOldItem && l.offset > oldItem.start {
1529 newItem, ok := l.renderedItems[item.ID()]
1530 if ok {
1531 newLines := newItem.height - oldItem.height
1532 l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
1533 }
1534 }
1535 }
1536 return tea.Sequence(cmds...)
1537}
1538
1539func (l *list[T]) hasSelection() bool {
1540 return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1541}
1542
1543// StartSelection implements List.
1544func (l *list[T]) StartSelection(col, line int) {
1545 l.selectionStartCol = col
1546 l.selectionStartLine = line
1547 l.selectionEndCol = col
1548 l.selectionEndLine = line
1549 l.selectionActive = true
1550}
1551
1552// EndSelection implements List.
1553func (l *list[T]) EndSelection(col, line int) {
1554 if !l.selectionActive {
1555 return
1556 }
1557 l.selectionEndCol = col
1558 l.selectionEndLine = line
1559}
1560
1561func (l *list[T]) SelectionStop() {
1562 l.selectionActive = false
1563}
1564
1565func (l *list[T]) SelectionClear() {
1566 l.selectionStartCol = -1
1567 l.selectionStartLine = -1
1568 l.selectionEndCol = -1
1569 l.selectionEndLine = -1
1570 l.selectionActive = false
1571}
1572
1573func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1574 numLines := l.lineCount()
1575
1576 if l.direction == DirectionBackward && numLines > l.height {
1577 line = ((numLines - 1) - l.height) + line + 1
1578 }
1579
1580 if l.offset > 0 {
1581 if l.direction == DirectionBackward {
1582 line -= l.offset
1583 } else {
1584 line += l.offset
1585 }
1586 }
1587
1588 if line < 0 || line >= numLines {
1589 return 0, 0
1590 }
1591
1592 currentLine := ansi.Strip(l.getLine(line))
1593 gr := uniseg.NewGraphemes(currentLine)
1594 startCol = -1
1595 upTo := col
1596 for gr.Next() {
1597 if gr.IsWordBoundary() && upTo > 0 {
1598 startCol = col - upTo + 1
1599 } else if gr.IsWordBoundary() && upTo < 0 {
1600 endCol = col - upTo + 1
1601 break
1602 }
1603 if upTo == 0 && gr.Str() == " " {
1604 return 0, 0
1605 }
1606 upTo -= 1
1607 }
1608 if startCol == -1 {
1609 return 0, 0
1610 }
1611 return startCol, endCol
1612}
1613
1614func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1615 // Helper function to get a line with ANSI stripped and icons replaced
1616 getCleanLine := func(index int) string {
1617 rawLine := l.getLine(index)
1618 cleanLine := ansi.Strip(rawLine)
1619 for _, icon := range styles.SelectionIgnoreIcons {
1620 cleanLine = strings.ReplaceAll(cleanLine, icon, " ")
1621 }
1622 return cleanLine
1623 }
1624
1625 numLines := l.lineCount()
1626 if l.direction == DirectionBackward && numLines > l.height {
1627 line = (numLines - 1) - l.height + line + 1
1628 }
1629
1630 if l.offset > 0 {
1631 if l.direction == DirectionBackward {
1632 line -= l.offset
1633 } else {
1634 line += l.offset
1635 }
1636 }
1637
1638 // Ensure line is within bounds
1639 if line < 0 || line >= numLines {
1640 return 0, 0, false
1641 }
1642
1643 if strings.TrimSpace(getCleanLine(line)) == "" {
1644 return 0, 0, false
1645 }
1646
1647 // Find start of paragraph (search backwards for empty line or start of text)
1648 startLine = line
1649 for startLine > 0 && strings.TrimSpace(getCleanLine(startLine-1)) != "" {
1650 startLine--
1651 }
1652
1653 // Find end of paragraph (search forwards for empty line or end of text)
1654 endLine = line
1655 for endLine < numLines-1 && strings.TrimSpace(getCleanLine(endLine+1)) != "" {
1656 endLine++
1657 }
1658
1659 // revert the line numbers if we are in backward direction
1660 if l.direction == DirectionBackward && numLines > l.height {
1661 startLine = startLine - (numLines - 1) + l.height - 1
1662 endLine = endLine - (numLines - 1) + l.height - 1
1663 }
1664 if l.offset > 0 {
1665 if l.direction == DirectionBackward {
1666 startLine += l.offset
1667 endLine += l.offset
1668 } else {
1669 startLine -= l.offset
1670 endLine -= l.offset
1671 }
1672 }
1673 return startLine, endLine, true
1674}
1675
1676// SelectWord selects the word at the given position.
1677func (l *list[T]) SelectWord(col, line int) {
1678 startCol, endCol := l.findWordBoundaries(col, line)
1679 l.selectionStartCol = startCol
1680 l.selectionStartLine = line
1681 l.selectionEndCol = endCol
1682 l.selectionEndLine = line
1683 l.selectionActive = false // Not actively selecting, just selected
1684}
1685
1686// SelectParagraph selects the paragraph at the given position.
1687func (l *list[T]) SelectParagraph(col, line int) {
1688 startLine, endLine, found := l.findParagraphBoundaries(line)
1689 if !found {
1690 return
1691 }
1692 l.selectionStartCol = 0
1693 l.selectionStartLine = startLine
1694 l.selectionEndCol = l.width - 1
1695 l.selectionEndLine = endLine
1696 l.selectionActive = false // Not actively selecting, just selected
1697}
1698
1699// HasSelection returns whether there is an active selection.
1700func (l *list[T]) HasSelection() bool {
1701 return l.hasSelection()
1702}
1703
1704// GetSelectedText returns the currently selected text.
1705func (l *list[T]) GetSelectedText(paddingLeft int) string {
1706 if !l.hasSelection() {
1707 return ""
1708 }
1709
1710 return l.selectionView(l.View(), true)
1711}