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