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