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