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