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