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