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.items.Len() == 0 {
700 return nil
701 }
702 if len(l.itemPositions) != l.items.Len() || l.shouldCalculateItemPositions {
703 l.calculateItemPositions()
704 l.shouldCalculateItemPositions = false
705 }
706 if l.selectedIndex < 0 || l.selectedIndex >= len(l.itemPositions) {
707 return nil
708 }
709
710 rItem := l.itemPositions[l.selectedIndex]
711 start, end := l.viewPosition()
712 // item bigger than the viewport do nothing
713 if rItem.start <= start && rItem.end >= end {
714 return nil
715 }
716 // item already in view do nothing
717 if rItem.start >= start && rItem.end <= end {
718 return nil
719 }
720
721 itemMiddle := rItem.start + rItem.height/2
722
723 if itemMiddle < start {
724 inx := l.firstSelectableItemBelow(l.selectedIndex)
725 if inx == ItemNotFound {
726 return nil
727 }
728 if inx >= len(l.itemPositions) {
729 if len(l.itemPositions) != l.items.Len() || l.shouldCalculateItemPositions {
730 l.calculateItemPositions()
731 l.shouldCalculateItemPositions = false
732 if inx >= len(l.itemPositions) {
733 return nil
734 }
735 } else {
736 return nil
737 }
738 }
739 renderedItem := l.itemPositions[inx]
740 if renderedItem.start <= start && renderedItem.end >= end {
741 l.selectedIndex = inx
742 return l.renderWithScrollToSelection(false)
743 }
744 if renderedItem.start >= start && renderedItem.start <= end {
745 l.selectedIndex = inx
746 return l.renderWithScrollToSelection(false)
747 }
748 return nil
749 } else if itemMiddle > end {
750 inx := l.firstSelectableItemAbove(l.selectedIndex)
751 if inx == ItemNotFound {
752 return nil
753 }
754 if inx >= len(l.itemPositions) {
755 if len(l.itemPositions) != l.items.Len() || l.shouldCalculateItemPositions {
756 l.calculateItemPositions()
757 l.shouldCalculateItemPositions = false
758 if inx >= len(l.itemPositions) {
759 return nil
760 }
761 } else {
762 return nil
763 }
764 }
765 renderedItem := l.itemPositions[inx]
766 if renderedItem.start <= start && renderedItem.end >= end {
767 l.selectedIndex = inx
768 return l.renderWithScrollToSelection(false)
769 }
770 if renderedItem.end >= start && renderedItem.end <= end {
771 l.selectedIndex = inx
772 return l.renderWithScrollToSelection(false)
773 }
774 return nil
775 }
776 return nil
777}
778
779func (l *list[T]) selectFirstItem() {
780 inx := l.firstSelectableItemBelow(-1)
781 if inx != ItemNotFound {
782 l.selectedIndex = inx
783 }
784}
785
786func (l *list[T]) selectLastItem() {
787 inx := l.firstSelectableItemAbove(l.items.Len())
788 if inx != ItemNotFound {
789 l.selectedIndex = inx
790 }
791}
792
793func (l *list[T]) firstSelectableItemAbove(inx int) int {
794 for i := inx - 1; i >= 0; i-- {
795 item, ok := l.items.Get(i)
796 if !ok {
797 continue
798 }
799 if _, ok := any(item).(layout.Focusable); ok {
800 return i
801 }
802 }
803 if inx == 0 && l.wrap {
804 return l.firstSelectableItemAbove(l.items.Len())
805 }
806 return ItemNotFound
807}
808
809func (l *list[T]) firstSelectableItemBelow(inx int) int {
810 itemsLen := l.items.Len()
811 for i := inx + 1; i < itemsLen; i++ {
812 item, ok := l.items.Get(i)
813 if !ok {
814 continue
815 }
816 if _, ok := any(item).(layout.Focusable); ok {
817 return i
818 }
819 }
820 if inx == itemsLen-1 && l.wrap {
821 return l.firstSelectableItemBelow(-1)
822 }
823 return ItemNotFound
824}
825
826func (l *list[T]) focusSelectedItem() tea.Cmd {
827 if l.selectedIndex < 0 || !l.focused {
828 return nil
829 }
830 var cmds []tea.Cmd
831 for inx, item := range slices.Collect(l.items.Seq()) {
832 if f, ok := any(item).(layout.Focusable); ok {
833 if inx == l.selectedIndex && !f.IsFocused() {
834 cmds = append(cmds, f.Focus())
835 l.viewCache.Del(item.ID())
836 } else if inx != l.selectedIndex && f.IsFocused() {
837 cmds = append(cmds, f.Blur())
838 l.viewCache.Del(item.ID())
839 }
840 }
841 }
842 return tea.Batch(cmds...)
843}
844
845func (l *list[T]) blurSelectedItem() tea.Cmd {
846 if l.selectedIndex < 0 || l.focused {
847 return nil
848 }
849 var cmds []tea.Cmd
850 for inx, item := range slices.Collect(l.items.Seq()) {
851 if f, ok := any(item).(layout.Focusable); ok {
852 if inx == l.selectedIndex && f.IsFocused() {
853 cmds = append(cmds, f.Blur())
854 l.viewCache.Del(item.ID())
855 }
856 }
857 }
858 return tea.Batch(cmds...)
859}
860
861// calculateItemPositions calculates and caches the position and height of all items.
862// This is O(n) but only called when the list structure changes significantly.
863func (l *list[T]) calculateItemPositions() {
864 itemsLen := l.items.Len()
865
866 // Resize positions slice if needed
867 if len(l.itemPositions) != itemsLen {
868 l.itemPositions = make([]itemPosition, itemsLen)
869 }
870
871 currentHeight := 0
872 // Always calculate positions in forward order (logical positions)
873 for i := range itemsLen {
874 item, ok := l.items.Get(i)
875 if !ok {
876 continue
877 }
878
879 // Get cached view or render new one
880 var view string
881 if cached, ok := l.viewCache.Get(item.ID()); ok {
882 view = cached
883 } else {
884 view = item.View()
885 l.viewCache.Set(item.ID(), view)
886 }
887
888 height := lipgloss.Height(view)
889
890 l.itemPositions[i] = itemPosition{
891 height: height,
892 start: currentHeight,
893 end: currentHeight + height - 1,
894 }
895
896 currentHeight += height
897 if i < itemsLen-1 {
898 currentHeight += l.gap
899 }
900 }
901
902 l.virtualHeight = currentHeight
903}
904
905// renderVirtualScrolling renders only the visible portion of the list.
906func (l *list[T]) renderVirtualScrolling() string {
907 if l.items.Len() == 0 {
908 return ""
909 }
910
911 // Calculate viewport bounds
912 viewStart, viewEnd := l.viewPosition()
913
914 // Check if we have any positions calculated
915 if len(l.itemPositions) == 0 {
916 // No positions calculated yet, return empty viewport
917 return ""
918 }
919
920 // Find which items are visible
921 var visibleItems []struct {
922 item T
923 pos itemPosition
924 index int
925 }
926
927 itemsLen := l.items.Len()
928 for i := range itemsLen {
929 if i >= len(l.itemPositions) {
930 continue
931 }
932
933 pos := l.itemPositions[i]
934
935 // Check if item is visible (overlaps with viewport)
936 if pos.end >= viewStart && pos.start <= viewEnd {
937 item, ok := l.items.Get(i)
938 if !ok {
939 continue
940 }
941 visibleItems = append(visibleItems, struct {
942 item T
943 pos itemPosition
944 index int
945 }{item, pos, i})
946 } else {
947 // Item is not visible
948 }
949
950 // Early exit if we've passed the viewport
951 if pos.start > viewEnd {
952 break
953 }
954 }
955
956 // Build the rendered output
957 var lines []string
958 currentLine := viewStart
959
960 for idx, vis := range visibleItems {
961 // Get or render the item's view
962 var view string
963 if cached, ok := l.viewCache.Get(vis.item.ID()); ok {
964 view = cached
965 } else {
966 view = vis.item.View()
967 l.viewCache.Set(vis.item.ID(), view)
968 }
969
970 itemLines := strings.Split(view, "\n")
971
972 // Add gap lines before item if needed (except for first visible item)
973 if idx > 0 && currentLine < vis.pos.start {
974 gapLines := vis.pos.start - currentLine
975 for range gapLines {
976 lines = append(lines, "")
977 }
978 currentLine = vis.pos.start
979 }
980
981 // Determine which lines of this item to include
982 startLine := 0
983 if vis.pos.start < viewStart {
984 // Item starts before viewport, skip some lines
985 startLine = viewStart - vis.pos.start
986 }
987
988 // Add the item's visible lines
989 linesAdded := 0
990 maxLinesToAdd := len(itemLines) - startLine
991 for i := 0; i < maxLinesToAdd && len(lines) < l.height; i++ {
992 lines = append(lines, itemLines[startLine+i])
993 linesAdded++
994 }
995
996 // Update currentLine to track our position in virtual space
997 if vis.pos.start < viewStart {
998 // Item started before viewport, we're now at viewStart + linesAdded
999 currentLine = viewStart + linesAdded
1000 } else {
1001 // Normal case: we're at the item's start position + lines added
1002 currentLine = vis.pos.start + linesAdded
1003 }
1004 }
1005
1006 // For content that fits entirely in viewport, don't pad with empty lines
1007 // Only pad if we have scrolled or if content is larger than viewport
1008
1009 if l.virtualHeight > l.height || l.offset > 0 {
1010 // Fill remaining viewport with empty lines if needed
1011 initialLen := len(lines)
1012 for len(lines) < l.height {
1013 lines = append(lines, "")
1014 }
1015 if len(lines) > initialLen {
1016 // Added padding lines
1017 // TODO: ?
1018 }
1019
1020 // Trim to viewport height
1021 if len(lines) > l.height {
1022 lines = lines[:l.height]
1023 }
1024 }
1025
1026 result := strings.Join(lines, "\n")
1027 resultHeight := lipgloss.Height(result)
1028 if resultHeight < l.height && len(visibleItems) > 0 {
1029 // Warning: rendered fewer lines than viewport
1030 // TODO: ?
1031 }
1032 return result
1033}
1034
1035// AppendItem implements List.
1036func (l *list[T]) AppendItem(item T) tea.Cmd {
1037 var cmds []tea.Cmd
1038 cmd := item.Init()
1039 if cmd != nil {
1040 cmds = append(cmds, cmd)
1041 }
1042
1043 l.items.Append(item)
1044 l.indexMap = csync.NewMap[string, int]()
1045 for inx, item := range slices.Collect(l.items.Seq()) {
1046 l.indexMap.Set(item.ID(), inx)
1047 }
1048
1049 l.shouldCalculateItemPositions = true
1050
1051 if l.width > 0 && l.height > 0 {
1052 cmd = item.SetSize(l.width, l.height)
1053 if cmd != nil {
1054 cmds = append(cmds, cmd)
1055 }
1056 }
1057 cmd = l.render()
1058 if cmd != nil {
1059 cmds = append(cmds, cmd)
1060 }
1061 if l.direction == DirectionBackward {
1062 if l.offset == 0 {
1063 cmd = l.GoToBottom()
1064 if cmd != nil {
1065 cmds = append(cmds, cmd)
1066 }
1067 }
1068 // Note: We can't adjust offset based on item height here since positions aren't calculated yet
1069 }
1070 return tea.Sequence(cmds...)
1071}
1072
1073// Blur implements List.
1074func (l *list[T]) Blur() tea.Cmd {
1075 l.focused = false
1076 return l.render()
1077}
1078
1079// DeleteItem implements List.
1080func (l *list[T]) DeleteItem(id string) tea.Cmd {
1081 inx, ok := l.indexMap.Get(id)
1082 if !ok {
1083 return nil
1084 }
1085
1086 // Check if we're deleting the selected item
1087 if l.selectedIndex == inx {
1088 // Adjust selection
1089 if inx > 0 {
1090 l.selectedIndex = inx - 1
1091 } else if l.items.Len() > 1 {
1092 l.selectedIndex = 0 // Will be valid after deletion
1093 } else {
1094 l.selectedIndex = -1 // No items left
1095 }
1096 } else if l.selectedIndex > inx {
1097 // Adjust index if selected item is after deleted item
1098 l.selectedIndex--
1099 }
1100
1101 l.items.Delete(inx)
1102 l.viewCache.Del(id)
1103 // Rebuild index map
1104 l.indexMap = csync.NewMap[string, int]()
1105 for inx, item := range slices.Collect(l.items.Seq()) {
1106 l.indexMap.Set(item.ID(), inx)
1107 }
1108
1109 cmd := l.render()
1110 if l.rendered != "" {
1111 renderedHeight := l.virtualHeight
1112 if renderedHeight <= l.height {
1113 l.offset = 0
1114 } else {
1115 maxOffset := renderedHeight - l.height
1116 if l.offset > maxOffset {
1117 l.offset = maxOffset
1118 }
1119 }
1120 }
1121 return cmd
1122}
1123
1124// Focus implements List.
1125func (l *list[T]) Focus() tea.Cmd {
1126 l.focused = true
1127 return l.render()
1128}
1129
1130// GetSize implements List.
1131func (l *list[T]) GetSize() (int, int) {
1132 return l.width, l.height
1133}
1134
1135// GoToBottom implements List.
1136func (l *list[T]) GoToBottom() tea.Cmd {
1137 l.offset = 0
1138 l.selectedIndex = -1
1139 l.direction = DirectionBackward
1140 return l.render()
1141}
1142
1143// GoToTop implements List.
1144func (l *list[T]) GoToTop() tea.Cmd {
1145 l.offset = 0
1146 l.selectedIndex = -1
1147 l.direction = DirectionForward
1148 return l.render()
1149}
1150
1151// IsFocused implements List.
1152func (l *list[T]) IsFocused() bool {
1153 return l.focused
1154}
1155
1156// Items implements List.
1157func (l *list[T]) Items() []T {
1158 return slices.Collect(l.items.Seq())
1159}
1160
1161func (l *list[T]) incrementOffset(n int) {
1162 renderedHeight := l.virtualHeight
1163 // no need for offset
1164 if renderedHeight <= l.height {
1165 return
1166 }
1167 maxOffset := renderedHeight - l.height
1168 n = min(n, maxOffset-l.offset)
1169 if n <= 0 {
1170 return
1171 }
1172 l.offset += n
1173}
1174
1175func (l *list[T]) decrementOffset(n int) {
1176 n = min(n, l.offset)
1177 if n <= 0 {
1178 return
1179 }
1180 l.offset -= n
1181 if l.offset < 0 {
1182 l.offset = 0
1183 }
1184}
1185
1186// MoveDown implements List.
1187func (l *list[T]) MoveDown(n int) tea.Cmd {
1188 oldOffset := l.offset
1189 if l.direction == DirectionForward {
1190 l.incrementOffset(n)
1191 } else {
1192 l.decrementOffset(n)
1193 }
1194
1195 // Re-render after scrolling
1196 if oldOffset != l.offset {
1197 l.renderMu.Lock()
1198 l.rendered = l.renderVirtualScrolling()
1199 l.renderMu.Unlock()
1200 }
1201
1202 if oldOffset == l.offset {
1203 // Even if offset didn't change, we might need to change selection
1204 // if we're at the edge of the scrollable area
1205 return l.changeSelectionWhenScrolling()
1206 }
1207 // if we are not actively selecting move the whole selection down
1208 if l.hasSelection() && !l.selectionActive {
1209 if l.selectionStartLine < l.selectionEndLine {
1210 l.selectionStartLine -= n
1211 l.selectionEndLine -= n
1212 } else {
1213 l.selectionStartLine -= n
1214 l.selectionEndLine -= n
1215 }
1216 }
1217 if l.selectionActive {
1218 if l.selectionStartLine < l.selectionEndLine {
1219 l.selectionStartLine -= n
1220 } else {
1221 l.selectionEndLine -= n
1222 }
1223 }
1224 return l.changeSelectionWhenScrolling()
1225}
1226
1227// MoveUp implements List.
1228func (l *list[T]) MoveUp(n int) tea.Cmd {
1229 oldOffset := l.offset
1230 if l.direction == DirectionForward {
1231 l.decrementOffset(n)
1232 } else {
1233 l.incrementOffset(n)
1234 }
1235
1236 // Re-render after scrolling
1237 if oldOffset != l.offset {
1238 l.renderMu.Lock()
1239 l.rendered = l.renderVirtualScrolling()
1240 l.renderMu.Unlock()
1241 }
1242
1243 if oldOffset == l.offset {
1244 // Even if offset didn't change, we might need to change selection
1245 // if we're at the edge of the scrollable area
1246 return l.changeSelectionWhenScrolling()
1247 }
1248
1249 if l.hasSelection() && !l.selectionActive {
1250 if l.selectionStartLine > l.selectionEndLine {
1251 l.selectionStartLine += n
1252 l.selectionEndLine += n
1253 } else {
1254 l.selectionStartLine += n
1255 l.selectionEndLine += n
1256 }
1257 }
1258 if l.selectionActive {
1259 if l.selectionStartLine > l.selectionEndLine {
1260 l.selectionStartLine += n
1261 } else {
1262 l.selectionEndLine += n
1263 }
1264 }
1265 return l.changeSelectionWhenScrolling()
1266}
1267
1268// PrependItem implements List.
1269func (l *list[T]) PrependItem(item T) tea.Cmd {
1270 cmds := []tea.Cmd{
1271 item.Init(),
1272 }
1273 l.items.Prepend(item)
1274 l.indexMap = csync.NewMap[string, int]()
1275 for inx, item := range slices.Collect(l.items.Seq()) {
1276 l.indexMap.Set(item.ID(), inx)
1277 }
1278 if l.width > 0 && l.height > 0 {
1279 cmds = append(cmds, item.SetSize(l.width, l.height))
1280 }
1281
1282 l.shouldCalculateItemPositions = true
1283
1284 if l.direction == DirectionForward {
1285 if l.offset == 0 {
1286 // If we're at the top, stay at the top
1287 cmds = append(cmds, l.render())
1288 cmd := l.GoToTop()
1289 if cmd != nil {
1290 cmds = append(cmds, cmd)
1291 }
1292 } else {
1293 // Note: We need to calculate positions to adjust offset properly
1294 // This is one case where we might need to calculate immediately
1295 l.calculateItemPositions()
1296 l.shouldCalculateItemPositions = false
1297
1298 // Adjust offset to maintain viewport position
1299 // The prepended item is at index 0
1300 if len(l.itemPositions) > 0 {
1301 newItem := l.itemPositions[0]
1302 newLines := newItem.height
1303 if l.items.Len() > 1 {
1304 newLines += l.gap
1305 }
1306 // Increase offset to keep the same content visible
1307 if l.virtualHeight > 0 {
1308 l.offset = min(l.virtualHeight-l.height, l.offset+newLines)
1309 }
1310 }
1311 cmds = append(cmds, l.renderWithScrollToSelection(false))
1312 }
1313 } else {
1314 // For backward direction, prepending doesn't affect the offset
1315 // since offset is from the bottom
1316 cmds = append(cmds, l.render())
1317 }
1318
1319 // Adjust selected index since we prepended
1320 if l.selectedIndex >= 0 {
1321 l.selectedIndex++
1322 }
1323
1324 return tea.Batch(cmds...)
1325}
1326
1327// SelectItemAbove implements List.
1328func (l *list[T]) SelectItemAbove() tea.Cmd {
1329 if l.selectedIndex < 0 {
1330 return nil
1331 }
1332
1333 newIndex := l.firstSelectableItemAbove(l.selectedIndex)
1334 if newIndex == ItemNotFound {
1335 // no item above
1336 return nil
1337 }
1338 var cmds []tea.Cmd
1339 if newIndex == 1 {
1340 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1341 if peakAboveIndex == ItemNotFound {
1342 // this means there is a section above move to the top
1343 cmd := l.GoToTop()
1344 if cmd != nil {
1345 cmds = append(cmds, cmd)
1346 }
1347 }
1348 }
1349 l.selectedIndex = newIndex
1350 l.movingByItem = true
1351 renderCmd := l.render()
1352 if renderCmd != nil {
1353 cmds = append(cmds, renderCmd)
1354 }
1355 return tea.Sequence(cmds...)
1356}
1357
1358// SelectItemBelow implements List.
1359func (l *list[T]) SelectItemBelow() tea.Cmd {
1360 if l.selectedIndex < 0 {
1361 return nil
1362 }
1363
1364 newIndex := l.firstSelectableItemBelow(l.selectedIndex)
1365 if newIndex == ItemNotFound {
1366 // no item below
1367 return nil
1368 }
1369 l.selectedIndex = newIndex
1370 l.movingByItem = true
1371 return l.render()
1372}
1373
1374// SelectedItem implements List.
1375func (l *list[T]) SelectedItem() *T {
1376 if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
1377 return nil
1378 }
1379 item, ok := l.items.Get(l.selectedIndex)
1380 if !ok {
1381 return nil
1382 }
1383 return &item
1384}
1385
1386// SelectedItemID returns the ID of the currently selected item (for testing).
1387func (l *list[T]) SelectedItemID() string {
1388 if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
1389 return ""
1390 }
1391 item, ok := l.items.Get(l.selectedIndex)
1392 if !ok {
1393 return ""
1394 }
1395 return item.ID()
1396}
1397
1398// SelectedItemIndex returns the index of the currently selected item.
1399// Returns -1 if no item is selected.
1400func (l *list[T]) SelectedItemIndex() int {
1401 return l.selectedIndex
1402}
1403
1404// SetItems implements List.
1405func (l *list[T]) SetItems(items []T) tea.Cmd {
1406 l.items.SetSlice(items)
1407 var cmds []tea.Cmd
1408 for inx, item := range slices.Collect(l.items.Seq()) {
1409 if i, ok := any(item).(Indexable); ok {
1410 i.SetIndex(inx)
1411 }
1412 cmds = append(cmds, item.Init())
1413 }
1414 cmds = append(cmds, l.reset(""))
1415 return tea.Batch(cmds...)
1416}
1417
1418// SetSelected implements List.
1419func (l *list[T]) SetSelected(id string) tea.Cmd {
1420 inx, ok := l.indexMap.Get(id)
1421 if ok {
1422 l.selectedIndex = inx
1423 } else {
1424 l.selectedIndex = -1
1425 }
1426 return l.render()
1427}
1428
1429func (l *list[T]) reset(selectedItemID string) tea.Cmd {
1430 var cmds []tea.Cmd
1431 l.rendered = ""
1432 l.offset = 0
1433
1434 // Convert ID to index if provided
1435 if selectedItemID != "" {
1436 if inx, ok := l.indexMap.Get(selectedItemID); ok {
1437 l.selectedIndex = inx
1438 } else {
1439 l.selectedIndex = -1
1440 }
1441 } else {
1442 l.selectedIndex = -1
1443 }
1444
1445 l.indexMap = csync.NewMap[string, int]()
1446 l.viewCache = csync.NewMap[string, string]()
1447 l.itemPositions = nil // Will be recalculated
1448 l.virtualHeight = 0
1449 l.shouldCalculateItemPositions = true
1450 for inx, item := range slices.Collect(l.items.Seq()) {
1451 l.indexMap.Set(item.ID(), inx)
1452 if l.width > 0 && l.height > 0 {
1453 cmds = append(cmds, item.SetSize(l.width, l.height))
1454 }
1455 }
1456 cmds = append(cmds, l.render())
1457 return tea.Batch(cmds...)
1458}
1459
1460// SetSize implements List.
1461func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1462 oldWidth, oldHeight := l.width, l.height
1463 l.width, l.height = width, height
1464 if oldWidth != width || oldHeight != height {
1465 l.renderMu.Lock()
1466 l.rendered = l.renderVirtualScrolling()
1467 l.renderMu.Unlock()
1468 }
1469 if oldWidth != width {
1470 // Get current selected item ID to preserve selection
1471 var selectedID string
1472 if l.selectedIndex >= 0 && l.selectedIndex < l.items.Len() {
1473 if item, ok := l.items.Get(l.selectedIndex); ok {
1474 selectedID = item.ID()
1475 }
1476 }
1477 cmd := l.reset(selectedID)
1478 return cmd
1479 }
1480 return nil
1481}
1482
1483// UpdateItem implements List.
1484func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1485 var cmds []tea.Cmd
1486 if inx, ok := l.indexMap.Get(id); ok {
1487 // Update the item
1488 l.items.Set(inx, item)
1489
1490 // Clear cache for this item
1491 l.viewCache.Del(id)
1492
1493 // Mark positions as dirty for recalculation
1494 l.shouldCalculateItemPositions = true
1495
1496 // Re-render with updated positions
1497 cmd := l.renderWithScrollToSelection(false)
1498 if cmd != nil {
1499 cmds = append(cmds, cmd)
1500 }
1501
1502 cmds = append(cmds, item.Init())
1503 if l.width > 0 && l.height > 0 {
1504 cmds = append(cmds, item.SetSize(l.width, l.height))
1505 }
1506 }
1507 return tea.Sequence(cmds...)
1508}
1509
1510func (l *list[T]) hasSelection() bool {
1511 return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1512}
1513
1514// StartSelection implements List.
1515func (l *list[T]) StartSelection(col, line int) {
1516 l.selectionStartCol = col
1517 l.selectionStartLine = line
1518 l.selectionEndCol = col
1519 l.selectionEndLine = line
1520 l.selectionActive = true
1521}
1522
1523// EndSelection implements List.
1524func (l *list[T]) EndSelection(col, line int) {
1525 if !l.selectionActive {
1526 return
1527 }
1528 l.selectionEndCol = col
1529 l.selectionEndLine = line
1530}
1531
1532func (l *list[T]) SelectionStop() {
1533 l.selectionActive = false
1534}
1535
1536func (l *list[T]) SelectionClear() {
1537 l.selectionStartCol = -1
1538 l.selectionStartLine = -1
1539 l.selectionEndCol = -1
1540 l.selectionEndLine = -1
1541 l.selectionActive = false
1542}
1543
1544func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1545 lines := strings.Split(l.rendered, "\n")
1546 for i, l := range lines {
1547 lines[i] = ansi.Strip(l)
1548 }
1549
1550 if l.direction == DirectionBackward && len(lines) > l.height {
1551 line = ((len(lines) - 1) - l.height) + line + 1
1552 }
1553
1554 if l.offset > 0 {
1555 if l.direction == DirectionBackward {
1556 line -= l.offset
1557 } else {
1558 line += l.offset
1559 }
1560 }
1561
1562 if line < 0 || line >= len(lines) {
1563 return 0, 0
1564 }
1565
1566 currentLine := lines[line]
1567 gr := uniseg.NewGraphemes(currentLine)
1568 startCol = -1
1569 upTo := col
1570 for gr.Next() {
1571 if gr.IsWordBoundary() && upTo > 0 {
1572 startCol = col - upTo + 1
1573 } else if gr.IsWordBoundary() && upTo < 0 {
1574 endCol = col - upTo + 1
1575 break
1576 }
1577 if upTo == 0 && gr.Str() == " " {
1578 return 0, 0
1579 }
1580 upTo -= 1
1581 }
1582 if startCol == -1 {
1583 return 0, 0
1584 }
1585 return startCol, endCol
1586}
1587
1588func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1589 lines := strings.Split(l.rendered, "\n")
1590 for i, l := range lines {
1591 lines[i] = ansi.Strip(l)
1592 for _, icon := range styles.SelectionIgnoreIcons {
1593 lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1594 }
1595 }
1596 if l.direction == DirectionBackward && len(lines) > l.height {
1597 line = (len(lines) - 1) - l.height + line + 1
1598 }
1599
1600 if l.offset > 0 {
1601 if l.direction == DirectionBackward {
1602 line -= l.offset
1603 } else {
1604 line += l.offset
1605 }
1606 }
1607
1608 // Ensure line is within bounds
1609 if line < 0 || line >= len(lines) {
1610 return 0, 0, false
1611 }
1612
1613 if strings.TrimSpace(lines[line]) == "" {
1614 return 0, 0, false
1615 }
1616
1617 // Find start of paragraph (search backwards for empty line or start of text)
1618 startLine = line
1619 for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1620 startLine--
1621 }
1622
1623 // Find end of paragraph (search forwards for empty line or end of text)
1624 endLine = line
1625 for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1626 endLine++
1627 }
1628
1629 // revert the line numbers if we are in backward direction
1630 if l.direction == DirectionBackward && len(lines) > l.height {
1631 startLine = startLine - (len(lines) - 1) + l.height - 1
1632 endLine = endLine - (len(lines) - 1) + l.height - 1
1633 }
1634 if l.offset > 0 {
1635 if l.direction == DirectionBackward {
1636 startLine += l.offset
1637 endLine += l.offset
1638 } else {
1639 startLine -= l.offset
1640 endLine -= l.offset
1641 }
1642 }
1643 return startLine, endLine, true
1644}
1645
1646// SelectWord selects the word at the given position.
1647func (l *list[T]) SelectWord(col, line int) {
1648 startCol, endCol := l.findWordBoundaries(col, line)
1649 l.selectionStartCol = startCol
1650 l.selectionStartLine = line
1651 l.selectionEndCol = endCol
1652 l.selectionEndLine = line
1653 l.selectionActive = false // Not actively selecting, just selected
1654}
1655
1656// SelectParagraph selects the paragraph at the given position.
1657func (l *list[T]) SelectParagraph(col, line int) {
1658 startLine, endLine, found := l.findParagraphBoundaries(line)
1659 if !found {
1660 return
1661 }
1662 l.selectionStartCol = 0
1663 l.selectionStartLine = startLine
1664 l.selectionEndCol = l.width - 1
1665 l.selectionEndLine = endLine
1666 l.selectionActive = false // Not actively selecting, just selected
1667}
1668
1669// HasSelection returns whether there is an active selection.
1670func (l *list[T]) HasSelection() bool {
1671 return l.hasSelection()
1672}
1673
1674// GetSelectedText returns the currently selected text.
1675func (l *list[T]) GetSelectedText(paddingLeft int) string {
1676 if !l.hasSelection() {
1677 return ""
1678 }
1679
1680 return l.selectionView(l.View(), true)
1681}