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