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 := l.selectionArea(false)
354 specialChars := getSpecialCharsMap()
355 selStyle := uv.Style{
356 Bg: t.TextSelection.GetBackground(),
357 Fg: t.TextSelection.GetForeground(),
358 }
359
360 isNonWhitespace := func(r rune) bool {
361 return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
362 }
363
364 type selectionBounds struct {
365 startX, endX int
366 inSelection bool
367 }
368 lineSelections := make([]selectionBounds, scr.Height())
369
370 for y := range scr.Height() {
371 bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
372
373 if y >= selArea.Min.Y && y < selArea.Max.Y {
374 bounds.inSelection = true
375 if selArea.Min.Y == selArea.Max.Y-1 {
376 // Single line selection
377 bounds.startX = selArea.Min.X
378 bounds.endX = selArea.Max.X
379 } else if y == selArea.Min.Y {
380 // First line of multi-line selection
381 bounds.startX = selArea.Min.X
382 bounds.endX = scr.Width()
383 } else if y == selArea.Max.Y-1 {
384 // Last line of multi-line selection
385 bounds.startX = 0
386 bounds.endX = selArea.Max.X
387 } else {
388 // Middle lines
389 bounds.startX = 0
390 bounds.endX = scr.Width()
391 }
392 }
393 lineSelections[y] = bounds
394 }
395
396 type lineBounds struct {
397 start, end int
398 }
399 lineTextBounds := make([]lineBounds, scr.Height())
400
401 // First pass: find text bounds for lines that have selections
402 for y := range scr.Height() {
403 bounds := lineBounds{start: -1, end: -1}
404
405 // Only process lines that might have selections
406 if lineSelections[y].inSelection {
407 for x := range scr.Width() {
408 cell := scr.CellAt(x, y)
409 if cell == nil {
410 continue
411 }
412
413 cellStr := cell.String()
414 if len(cellStr) == 0 {
415 continue
416 }
417
418 char := rune(cellStr[0])
419 _, isSpecial := specialChars[cellStr]
420
421 if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
422 if bounds.start == -1 {
423 bounds.start = x
424 }
425 bounds.end = x + 1 // Position after last character
426 }
427 }
428 }
429 lineTextBounds[y] = bounds
430 }
431
432 var selectedText strings.Builder
433
434 // Second pass: apply selection highlighting
435 for y := range scr.Height() {
436 selBounds := lineSelections[y]
437 if !selBounds.inSelection {
438 continue
439 }
440
441 textBounds := lineTextBounds[y]
442 if textBounds.start < 0 {
443 if textOnly {
444 // We don't want to get rid of all empty lines in text-only mode
445 selectedText.WriteByte('\n')
446 }
447
448 continue // No text on this line
449 }
450
451 // Only scan within the intersection of text bounds and selection bounds
452 scanStart := max(textBounds.start, selBounds.startX)
453 scanEnd := min(textBounds.end, selBounds.endX)
454
455 for x := scanStart; x < scanEnd; x++ {
456 cell := scr.CellAt(x, y)
457 if cell == nil {
458 continue
459 }
460
461 cellStr := cell.String()
462 if len(cellStr) > 0 {
463 if _, isSpecial := specialChars[cellStr]; isSpecial {
464 continue
465 }
466 if textOnly {
467 // Collect selected text without styles
468 selectedText.WriteString(cell.String())
469 continue
470 }
471
472 cell = cell.Clone()
473 cell.Style.Bg = selStyle.Bg
474 cell.Style.Fg = selStyle.Fg
475 scr.SetCell(x, y, cell)
476 }
477 }
478
479 if textOnly {
480 // Make sure we add a newline after each line of selected text
481 selectedText.WriteByte('\n')
482 }
483 }
484
485 if textOnly {
486 return strings.TrimSpace(selectedText.String())
487 }
488
489 return scr.Render()
490}
491
492func (l *list[T]) View() string {
493 if l.height <= 0 || l.width <= 0 {
494 return ""
495 }
496
497 if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
498 return l.cachedView
499 }
500
501 t := styles.CurrentTheme()
502
503 start, end := l.viewPosition()
504 viewStart := max(0, start)
505 viewEnd := end
506
507 if viewStart > viewEnd {
508 return ""
509 }
510
511 view := l.getLines(viewStart, viewEnd)
512
513 if l.resize {
514 return view
515 }
516
517 view = t.S().Base.
518 Height(l.height).
519 Width(l.width).
520 Render(view)
521
522 if !l.hasSelection() {
523 l.cachedView = view
524 l.cachedViewOffset = l.offset
525 l.cachedViewDirty = false
526 return view
527 }
528
529 return l.selectionView(view, false)
530}
531
532func (l *list[T]) viewPosition() (int, int) {
533 start, end := 0, 0
534 renderedLines := l.renderedHeight - 1
535 if l.direction == DirectionForward {
536 start = max(0, l.offset)
537 end = min(l.offset+l.height-1, renderedLines)
538 } else {
539 start = max(0, renderedLines-l.offset-l.height+1)
540 end = max(0, renderedLines-l.offset)
541 }
542 start = min(start, end)
543 return start, end
544}
545
546func (l *list[T]) setRendered(rendered string) {
547 l.rendered = rendered
548 l.renderedHeight = lipgloss.Height(rendered)
549 l.cachedViewDirty = true // Mark view cache as dirty
550
551 if len(rendered) > 0 {
552 l.lineOffsets = make([]int, 0, l.renderedHeight)
553 l.lineOffsets = append(l.lineOffsets, 0)
554
555 offset := 0
556 for {
557 idx := strings.IndexByte(rendered[offset:], '\n')
558 if idx == -1 {
559 break
560 }
561 offset += idx + 1
562 l.lineOffsets = append(l.lineOffsets, offset)
563 }
564 } else {
565 l.lineOffsets = nil
566 }
567}
568
569func (l *list[T]) getLines(start, end int) string {
570 if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
571 return ""
572 }
573
574 if end >= len(l.lineOffsets) {
575 end = len(l.lineOffsets) - 1
576 }
577 if start > end {
578 return ""
579 }
580
581 startOffset := l.lineOffsets[start]
582 var endOffset int
583 if end+1 < len(l.lineOffsets) {
584 endOffset = l.lineOffsets[end+1] - 1
585 } else {
586 endOffset = len(l.rendered)
587 }
588
589 if startOffset >= len(l.rendered) {
590 return ""
591 }
592 endOffset = min(endOffset, len(l.rendered))
593
594 return l.rendered[startOffset:endOffset]
595}
596
597// getLine returns a single line from the rendered content using lineOffsets.
598// This avoids allocating a new string for each line like strings.Split does.
599func (l *list[T]) getLine(index int) string {
600 if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) {
601 return ""
602 }
603
604 startOffset := l.lineOffsets[index]
605 var endOffset int
606 if index+1 < len(l.lineOffsets) {
607 endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline
608 } else {
609 endOffset = len(l.rendered)
610 }
611
612 if startOffset >= len(l.rendered) {
613 return ""
614 }
615 endOffset = min(endOffset, len(l.rendered))
616
617 return l.rendered[startOffset:endOffset]
618}
619
620// lineCount returns the number of lines in the rendered content.
621func (l *list[T]) lineCount() int {
622 return len(l.lineOffsets)
623}
624
625func (l *list[T]) recalculateItemPositions() {
626 l.recalculateItemPositionsFrom(0)
627}
628
629func (l *list[T]) recalculateItemPositionsFrom(startIdx int) {
630 var currentContentHeight int
631
632 if startIdx > 0 && startIdx <= len(l.items) {
633 prevItem := l.items[startIdx-1]
634 if rItem, ok := l.renderedItems[prevItem.ID()]; ok {
635 currentContentHeight = rItem.end + 1 + l.gap
636 }
637 }
638
639 for i := startIdx; i < len(l.items); i++ {
640 item := l.items[i]
641 rItem, ok := l.renderedItems[item.ID()]
642 if !ok {
643 continue
644 }
645 rItem.start = currentContentHeight
646 rItem.end = currentContentHeight + rItem.height - 1
647 l.renderedItems[item.ID()] = rItem
648 currentContentHeight = rItem.end + 1 + l.gap
649 }
650}
651
652func (l *list[T]) render() tea.Cmd {
653 if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
654 return nil
655 }
656 l.setDefaultSelected()
657
658 var focusChangeCmd tea.Cmd
659 if l.focused {
660 focusChangeCmd = l.focusSelectedItem()
661 } else {
662 focusChangeCmd = l.blurSelectedItem()
663 }
664 if l.rendered != "" {
665 rendered, _ := l.renderIterator(0, false, "")
666 l.setRendered(rendered)
667 if l.direction == DirectionBackward {
668 l.recalculateItemPositions()
669 }
670 if l.focused {
671 l.scrollToSelection()
672 }
673 return focusChangeCmd
674 }
675 rendered, finishIndex := l.renderIterator(0, true, "")
676 l.setRendered(rendered)
677 if l.direction == DirectionBackward {
678 l.recalculateItemPositions()
679 }
680
681 l.offset = 0
682 rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
683 l.setRendered(rendered)
684 if l.direction == DirectionBackward {
685 l.recalculateItemPositions()
686 }
687 if l.focused {
688 l.scrollToSelection()
689 }
690
691 return focusChangeCmd
692}
693
694func (l *list[T]) setDefaultSelected() {
695 if l.selectedItemIdx < 0 {
696 if l.direction == DirectionForward {
697 l.selectFirstItem()
698 } else {
699 l.selectLastItem()
700 }
701 }
702}
703
704func (l *list[T]) scrollToSelection() {
705 if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
706 l.selectedItemIdx = -1
707 l.setDefaultSelected()
708 return
709 }
710 item := l.items[l.selectedItemIdx]
711 rItem, ok := l.renderedItems[item.ID()]
712 if !ok {
713 l.selectedItemIdx = -1
714 l.setDefaultSelected()
715 return
716 }
717
718 start, end := l.viewPosition()
719 if rItem.start <= start && rItem.end >= end {
720 return
721 }
722 if l.movingByItem {
723 if rItem.start >= start && rItem.end <= end {
724 return
725 }
726 defer func() { l.movingByItem = false }()
727 } else {
728 if rItem.start >= start && rItem.start <= end {
729 return
730 }
731 if rItem.end >= start && rItem.end <= end {
732 return
733 }
734 }
735
736 if rItem.height >= l.height {
737 if l.direction == DirectionForward {
738 l.offset = rItem.start
739 } else {
740 l.offset = max(0, l.renderedHeight-(rItem.start+l.height))
741 }
742 return
743 }
744
745 renderedLines := l.renderedHeight - 1
746
747 if rItem.start < start {
748 if l.direction == DirectionForward {
749 l.offset = rItem.start
750 } else {
751 l.offset = max(0, renderedLines-rItem.start-l.height+1)
752 }
753 } else if rItem.end > end {
754 if l.direction == DirectionForward {
755 l.offset = max(0, rItem.end-l.height+1)
756 } else {
757 l.offset = max(0, renderedLines-rItem.end)
758 }
759 }
760}
761
762func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
763 if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
764 return nil
765 }
766 item := l.items[l.selectedItemIdx]
767 rItem, ok := l.renderedItems[item.ID()]
768 if !ok {
769 return nil
770 }
771 start, end := l.viewPosition()
772 // item bigger than the viewport do nothing
773 if rItem.start <= start && rItem.end >= end {
774 return nil
775 }
776 // item already in view do nothing
777 if rItem.start >= start && rItem.end <= end {
778 return nil
779 }
780
781 itemMiddle := rItem.start + rItem.height/2
782
783 if itemMiddle < start {
784 // select the first item in the viewport
785 // the item is most likely an item coming after this item
786 inx := l.selectedItemIdx
787 for {
788 inx = l.firstSelectableItemBelow(inx)
789 if inx == ItemNotFound {
790 return nil
791 }
792 if inx < 0 || inx >= len(l.items) {
793 continue
794 }
795
796 item := l.items[inx]
797 renderedItem, ok := l.renderedItems[item.ID()]
798 if !ok {
799 continue
800 }
801
802 // If the item is bigger than the viewport, select it
803 if renderedItem.start <= start && renderedItem.end >= end {
804 l.selectedItemIdx = inx
805 return l.render()
806 }
807 // item is in the view
808 if renderedItem.start >= start && renderedItem.start <= end {
809 l.selectedItemIdx = inx
810 return l.render()
811 }
812 }
813 } else if itemMiddle > end {
814 // select the first item in the viewport
815 // the item is most likely an item coming after this item
816 inx := l.selectedItemIdx
817 for {
818 inx = l.firstSelectableItemAbove(inx)
819 if inx == ItemNotFound {
820 return nil
821 }
822 if inx < 0 || inx >= len(l.items) {
823 continue
824 }
825
826 item := l.items[inx]
827 renderedItem, ok := l.renderedItems[item.ID()]
828 if !ok {
829 continue
830 }
831
832 // If the item is bigger than the viewport, select it
833 if renderedItem.start <= start && renderedItem.end >= end {
834 l.selectedItemIdx = inx
835 return l.render()
836 }
837 // item is in the view
838 if renderedItem.end >= start && renderedItem.end <= end {
839 l.selectedItemIdx = inx
840 return l.render()
841 }
842 }
843 }
844 return nil
845}
846
847func (l *list[T]) selectFirstItem() {
848 inx := l.firstSelectableItemBelow(-1)
849 if inx != ItemNotFound {
850 l.selectedItemIdx = inx
851 }
852}
853
854func (l *list[T]) selectLastItem() {
855 inx := l.firstSelectableItemAbove(len(l.items))
856 if inx != ItemNotFound {
857 l.selectedItemIdx = inx
858 }
859}
860
861func (l *list[T]) firstSelectableItemAbove(inx int) int {
862 unfocusableCount := 0
863 for i := inx - 1; i >= 0; i-- {
864 if i < 0 || i >= len(l.items) {
865 continue
866 }
867
868 item := l.items[i]
869 if _, ok := any(item).(layout.Focusable); ok {
870 return i
871 }
872 unfocusableCount++
873 }
874 if unfocusableCount == inx && l.wrap {
875 return l.firstSelectableItemAbove(len(l.items))
876 }
877 return ItemNotFound
878}
879
880func (l *list[T]) firstSelectableItemBelow(inx int) int {
881 unfocusableCount := 0
882 itemsLen := len(l.items)
883 for i := inx + 1; i < itemsLen; i++ {
884 if i < 0 || i >= len(l.items) {
885 continue
886 }
887
888 item := l.items[i]
889 if _, ok := any(item).(layout.Focusable); ok {
890 return i
891 }
892 unfocusableCount++
893 }
894 if unfocusableCount == itemsLen-inx-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 > l.selectedItemIdx && l.selectedItemIdx > 0 && l.offset > 0 {
1355 // this means there is a section above and not showing on the top, move to the top
1356 newIndex = l.selectedItemIdx
1357 cmd := l.GoToTop()
1358 if cmd != nil {
1359 cmds = append(cmds, cmd)
1360 }
1361 }
1362 if newIndex == 1 {
1363 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1364 if peakAboveIndex == ItemNotFound {
1365 // this means there is a section above move to the top
1366 cmd := l.GoToTop()
1367 if cmd != nil {
1368 cmds = append(cmds, cmd)
1369 }
1370 }
1371 }
1372 if newIndex < 0 || newIndex >= len(l.items) {
1373 return nil
1374 }
1375 l.prevSelectedItemIdx = l.selectedItemIdx
1376 l.selectedItemIdx = newIndex
1377 l.movingByItem = true
1378 renderCmd := l.render()
1379 if renderCmd != nil {
1380 cmds = append(cmds, renderCmd)
1381 }
1382 return tea.Sequence(cmds...)
1383}
1384
1385// SelectItemBelow implements List.
1386func (l *list[T]) SelectItemBelow() tea.Cmd {
1387 if l.selectedItemIdx < 0 {
1388 return nil
1389 }
1390
1391 newIndex := l.firstSelectableItemBelow(l.selectedItemIdx)
1392 if newIndex == ItemNotFound {
1393 // no item below
1394 return nil
1395 }
1396 if newIndex < 0 || newIndex >= len(l.items) {
1397 return nil
1398 }
1399 if newIndex < l.selectedItemIdx {
1400 // reset offset when wrap to the top to show the top section if it exists
1401 l.offset = 0
1402 }
1403 l.prevSelectedItemIdx = l.selectedItemIdx
1404 l.selectedItemIdx = newIndex
1405 l.movingByItem = true
1406 return l.render()
1407}
1408
1409// SelectedItem implements List.
1410func (l *list[T]) SelectedItem() *T {
1411 if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
1412 return nil
1413 }
1414 item := l.items[l.selectedItemIdx]
1415 return &item
1416}
1417
1418// SetItems implements List.
1419func (l *list[T]) SetItems(items []T) tea.Cmd {
1420 l.items = items
1421 var cmds []tea.Cmd
1422 for inx, item := range items {
1423 if i, ok := any(item).(Indexable); ok {
1424 i.SetIndex(inx)
1425 }
1426 cmds = append(cmds, item.Init())
1427 }
1428 cmds = append(cmds, l.reset(""))
1429 return tea.Batch(cmds...)
1430}
1431
1432// SetSelected implements List.
1433func (l *list[T]) SetSelected(id string) tea.Cmd {
1434 l.prevSelectedItemIdx = l.selectedItemIdx
1435 if idx, ok := l.indexMap[id]; ok {
1436 l.selectedItemIdx = idx
1437 } else {
1438 l.selectedItemIdx = -1
1439 }
1440 return l.render()
1441}
1442
1443func (l *list[T]) reset(selectedItemID string) tea.Cmd {
1444 var cmds []tea.Cmd
1445 l.rendered = ""
1446 l.renderedHeight = 0
1447 l.offset = 0
1448 l.indexMap = make(map[string]int)
1449 l.renderedItems = make(map[string]renderedItem)
1450 itemsLen := len(l.items)
1451 for i := range itemsLen {
1452 if i < 0 || i >= len(l.items) {
1453 continue
1454 }
1455
1456 item := l.items[i]
1457 l.indexMap[item.ID()] = i
1458 if l.width > 0 && l.height > 0 {
1459 cmds = append(cmds, item.SetSize(l.width, l.height))
1460 }
1461 }
1462 // Convert selectedItemID to index after rebuilding indexMap
1463 if selectedItemID != "" {
1464 if idx, ok := l.indexMap[selectedItemID]; ok {
1465 l.selectedItemIdx = idx
1466 } else {
1467 l.selectedItemIdx = -1
1468 }
1469 } else {
1470 l.selectedItemIdx = -1
1471 }
1472 cmds = append(cmds, l.render())
1473 return tea.Batch(cmds...)
1474}
1475
1476// SetSize implements List.
1477func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1478 oldWidth := l.width
1479 oldHeight := l.height
1480 l.width = width
1481 l.height = height
1482 // Invalidate cache if height changed
1483 if oldHeight != height {
1484 l.cachedViewDirty = true
1485 }
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
1704func (l *list[T]) selectionArea(absolute bool) uv.Rectangle {
1705 var startY int
1706 if absolute {
1707 startY, _ = l.viewPosition()
1708 }
1709 selArea := uv.Rectangle{
1710 Min: uv.Pos(l.selectionStartCol, l.selectionStartLine+startY),
1711 Max: uv.Pos(l.selectionEndCol, l.selectionEndLine+startY),
1712 }
1713 selArea = selArea.Canon()
1714 selArea.Max.Y++ // make max Y exclusive
1715 return selArea
1716}
1717
1718// GetSelectedText returns the currently selected text.
1719func (l *list[T]) GetSelectedText(paddingLeft int) string {
1720 if !l.hasSelection() {
1721 return ""
1722 }
1723
1724 selArea := l.selectionArea(true)
1725 if selArea.Empty() {
1726 return ""
1727 }
1728
1729 selectionHeight := selArea.Dy()
1730
1731 tempBuf := uv.NewScreenBuffer(l.width, selectionHeight)
1732 tempBufArea := tempBuf.Bounds()
1733 renderedLines := l.getLines(selArea.Min.Y, selArea.Max.Y)
1734 styled := uv.NewStyledString(renderedLines)
1735 styled.Draw(tempBuf, tempBufArea)
1736
1737 // XXX: Left padding assumes the list component is rendered with absolute
1738 // positioning. The chat component has a left margin of 1 and items in the
1739 // list have a border of 1 plus a padding of 1. The paddingLeft parameter
1740 // assumes this total left padding of 3 and we should fix that.
1741 leftBorder := paddingLeft - 1
1742
1743 var b strings.Builder
1744 for y := tempBufArea.Min.Y; y < tempBufArea.Max.Y; y++ {
1745 var pending strings.Builder
1746 for x := tempBufArea.Min.X + leftBorder; x < tempBufArea.Max.X; {
1747 cell := tempBuf.CellAt(x, y)
1748 if cell == nil || cell.IsZero() {
1749 x++
1750 continue
1751 }
1752 if y == 0 && x < selArea.Min.X {
1753 x++
1754 continue
1755 }
1756 if y == selectionHeight-1 && x > selArea.Max.X-1 {
1757 break
1758 }
1759 if cell.Width == 1 && cell.Content == " " {
1760 pending.WriteString(cell.Content)
1761 x++
1762 continue
1763 }
1764 b.WriteString(pending.String())
1765 pending.Reset()
1766 b.WriteString(cell.Content)
1767 x += cell.Width
1768 }
1769 if y < tempBufArea.Max.Y-1 {
1770 b.WriteByte('\n')
1771 }
1772 }
1773
1774 return b.String()
1775}