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 lines = lines[viewStart:viewEnd]
454 if l.resize {
455 return strings.Join(lines, "\n")
456 }
457 view = t.S().Base.
458 Height(l.height).
459 Width(l.width).
460 Render(strings.Join(lines, "\n"))
461
462 if !l.hasSelection() {
463 return view
464 }
465
466 return l.selectionView(view, false)
467}
468
469func (l *list[T]) viewPosition() (int, int) {
470 start, end := 0, 0
471 renderedLines := lipgloss.Height(l.rendered) - 1
472 if l.direction == DirectionForward {
473 start = max(0, l.offset)
474 end = min(l.offset+l.height-1, renderedLines)
475 } else {
476 start = max(0, renderedLines-l.offset-l.height+1)
477 end = max(0, renderedLines-l.offset)
478 }
479 start = min(start, end)
480 return start, end
481}
482
483func (l *list[T]) recalculateItemPositions() {
484 currentContentHeight := 0
485 for _, item := range slices.Collect(l.items.Seq()) {
486 rItem, ok := l.renderedItems.Get(item.ID())
487 if !ok {
488 continue
489 }
490 rItem.start = currentContentHeight
491 rItem.end = currentContentHeight + rItem.height - 1
492 l.renderedItems.Set(item.ID(), rItem)
493 currentContentHeight = rItem.end + 1 + l.gap
494 }
495}
496
497func (l *list[T]) render() tea.Cmd {
498 if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
499 return nil
500 }
501 l.setDefaultSelected()
502
503 var focusChangeCmd tea.Cmd
504 if l.focused {
505 focusChangeCmd = l.focusSelectedItem()
506 } else {
507 focusChangeCmd = l.blurSelectedItem()
508 }
509 // we are not rendering the first time
510 if l.rendered != "" {
511 // rerender everything will mostly hit cache
512 l.renderMu.Lock()
513 l.rendered, _ = l.renderIterator(0, false, "")
514 l.renderMu.Unlock()
515 if l.direction == DirectionBackward {
516 l.recalculateItemPositions()
517 }
518 // in the end scroll to the selected item
519 if l.focused {
520 l.scrollToSelection()
521 }
522 return focusChangeCmd
523 }
524 l.renderMu.Lock()
525 rendered, finishIndex := l.renderIterator(0, true, "")
526 l.rendered = rendered
527 l.renderMu.Unlock()
528 // recalculate for the initial items
529 if l.direction == DirectionBackward {
530 l.recalculateItemPositions()
531 }
532 renderCmd := func() tea.Msg {
533 l.offset = 0
534 // render the rest
535
536 l.renderMu.Lock()
537 l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
538 l.renderMu.Unlock()
539 // needed for backwards
540 if l.direction == DirectionBackward {
541 l.recalculateItemPositions()
542 }
543 // in the end scroll to the selected item
544 if l.focused {
545 l.scrollToSelection()
546 }
547 return nil
548 }
549 return tea.Batch(focusChangeCmd, renderCmd)
550}
551
552func (l *list[T]) setDefaultSelected() {
553 if l.selectedItem == "" {
554 if l.direction == DirectionForward {
555 l.selectFirstItem()
556 } else {
557 l.selectLastItem()
558 }
559 }
560}
561
562func (l *list[T]) scrollToSelection() {
563 rItem, ok := l.renderedItems.Get(l.selectedItem)
564 if !ok {
565 l.selectedItem = ""
566 l.setDefaultSelected()
567 return
568 }
569
570 start, end := l.viewPosition()
571 // item bigger or equal to the viewport do nothing
572 if rItem.start <= start && rItem.end >= end {
573 return
574 }
575 // if we are moving by item we want to move the offset so that the
576 // whole item is visible not just portions of it
577 if l.movingByItem {
578 if rItem.start >= start && rItem.end <= end {
579 return
580 }
581 defer func() { l.movingByItem = false }()
582 } else {
583 // item already in view do nothing
584 if rItem.start >= start && rItem.start <= end {
585 return
586 }
587 if rItem.end >= start && rItem.end <= end {
588 return
589 }
590 }
591
592 if rItem.height >= l.height {
593 if l.direction == DirectionForward {
594 l.offset = rItem.start
595 } else {
596 l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
597 }
598 return
599 }
600
601 renderedLines := lipgloss.Height(l.rendered) - 1
602
603 // If item is above the viewport, make it the first item
604 if rItem.start < start {
605 if l.direction == DirectionForward {
606 l.offset = rItem.start
607 } else {
608 l.offset = max(0, renderedLines-rItem.start-l.height+1)
609 }
610 } else if rItem.end > end {
611 // If item is below the viewport, make it the last item
612 if l.direction == DirectionForward {
613 l.offset = max(0, rItem.end-l.height+1)
614 } else {
615 l.offset = max(0, renderedLines-rItem.end)
616 }
617 }
618}
619
620func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
621 rItem, ok := l.renderedItems.Get(l.selectedItem)
622 if !ok {
623 return nil
624 }
625 start, end := l.viewPosition()
626 // item bigger than the viewport do nothing
627 if rItem.start <= start && rItem.end >= end {
628 return nil
629 }
630 // item already in view do nothing
631 if rItem.start >= start && rItem.end <= end {
632 return nil
633 }
634
635 itemMiddle := rItem.start + rItem.height/2
636
637 if itemMiddle < start {
638 // select the first item in the viewport
639 // the item is most likely an item coming after this item
640 inx, ok := l.indexMap.Get(rItem.id)
641 if !ok {
642 return nil
643 }
644 for {
645 inx = l.firstSelectableItemBelow(inx)
646 if inx == ItemNotFound {
647 return nil
648 }
649 item, ok := l.items.Get(inx)
650 if !ok {
651 continue
652 }
653 renderedItem, ok := l.renderedItems.Get(item.ID())
654 if !ok {
655 continue
656 }
657
658 // If the item is bigger than the viewport, select it
659 if renderedItem.start <= start && renderedItem.end >= end {
660 l.selectedItem = renderedItem.id
661 return l.render()
662 }
663 // item is in the view
664 if renderedItem.start >= start && renderedItem.start <= end {
665 l.selectedItem = renderedItem.id
666 return l.render()
667 }
668 }
669 } else if itemMiddle > end {
670 // select the first item in the viewport
671 // the item is most likely an item coming after this item
672 inx, ok := l.indexMap.Get(rItem.id)
673 if !ok {
674 return nil
675 }
676 for {
677 inx = l.firstSelectableItemAbove(inx)
678 if inx == ItemNotFound {
679 return nil
680 }
681 item, ok := l.items.Get(inx)
682 if !ok {
683 continue
684 }
685 renderedItem, ok := l.renderedItems.Get(item.ID())
686 if !ok {
687 continue
688 }
689
690 // If the item is bigger than the viewport, select it
691 if renderedItem.start <= start && renderedItem.end >= end {
692 l.selectedItem = renderedItem.id
693 return l.render()
694 }
695 // item is in the view
696 if renderedItem.end >= start && renderedItem.end <= end {
697 l.selectedItem = renderedItem.id
698 return l.render()
699 }
700 }
701 }
702 return nil
703}
704
705func (l *list[T]) selectFirstItem() {
706 inx := l.firstSelectableItemBelow(-1)
707 if inx != ItemNotFound {
708 item, ok := l.items.Get(inx)
709 if ok {
710 l.selectedItem = item.ID()
711 }
712 }
713}
714
715func (l *list[T]) selectLastItem() {
716 inx := l.firstSelectableItemAbove(l.items.Len())
717 if inx != ItemNotFound {
718 item, ok := l.items.Get(inx)
719 if ok {
720 l.selectedItem = item.ID()
721 }
722 }
723}
724
725func (l *list[T]) firstSelectableItemAbove(inx int) int {
726 for i := inx - 1; i >= 0; i-- {
727 item, ok := l.items.Get(i)
728 if !ok {
729 continue
730 }
731 if _, ok := any(item).(layout.Focusable); ok {
732 return i
733 }
734 }
735 if inx == 0 && l.wrap {
736 return l.firstSelectableItemAbove(l.items.Len())
737 }
738 return ItemNotFound
739}
740
741func (l *list[T]) firstSelectableItemBelow(inx int) int {
742 itemsLen := l.items.Len()
743 for i := inx + 1; i < itemsLen; i++ {
744 item, ok := l.items.Get(i)
745 if !ok {
746 continue
747 }
748 if _, ok := any(item).(layout.Focusable); ok {
749 return i
750 }
751 }
752 if inx == itemsLen-1 && l.wrap {
753 return l.firstSelectableItemBelow(-1)
754 }
755 return ItemNotFound
756}
757
758func (l *list[T]) focusSelectedItem() 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.Focus())
767 l.renderedItems.Del(item.ID())
768 } else if item.ID() != l.selectedItem && f.IsFocused() {
769 cmds = append(cmds, f.Blur())
770 l.renderedItems.Del(item.ID())
771 }
772 }
773 }
774 return tea.Batch(cmds...)
775}
776
777func (l *list[T]) blurSelectedItem() tea.Cmd {
778 if l.selectedItem == "" || l.focused {
779 return nil
780 }
781 var cmds []tea.Cmd
782 for _, item := range slices.Collect(l.items.Seq()) {
783 if f, ok := any(item).(layout.Focusable); ok {
784 if item.ID() == l.selectedItem && f.IsFocused() {
785 cmds = append(cmds, f.Blur())
786 l.renderedItems.Del(item.ID())
787 }
788 }
789 }
790 return tea.Batch(cmds...)
791}
792
793// renderFragment holds updated rendered view fragments
794type renderFragment struct {
795 view string
796 gap int
797}
798
799// renderIterator renders items starting from the specific index and limits height if limitHeight != -1
800// returns the last index and the rendered content so far
801// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
802func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
803 var fragments []renderFragment
804
805 currentContentHeight := lipgloss.Height(rendered) - 1
806 itemsLen := l.items.Len()
807 finalIndex := itemsLen
808
809 // first pass: accumulate all fragments to render until the height limit is
810 // reached
811 for i := startInx; i < itemsLen; i++ {
812 if limitHeight && currentContentHeight >= l.height {
813 finalIndex = i
814 break
815 }
816 // cool way to go through the list in both directions
817 inx := i
818
819 if l.direction != DirectionForward {
820 inx = (itemsLen - 1) - i
821 }
822
823 item, ok := l.items.Get(inx)
824 if !ok {
825 continue
826 }
827
828 var rItem renderedItem
829 if cache, ok := l.renderedItems.Get(item.ID()); ok {
830 rItem = cache
831 } else {
832 rItem = l.renderItem(item)
833 rItem.start = currentContentHeight
834 rItem.end = currentContentHeight + rItem.height - 1
835 l.renderedItems.Set(item.ID(), rItem)
836 }
837
838 gap := l.gap + 1
839 if inx == itemsLen-1 {
840 gap = 0
841 }
842
843 fragments = append(fragments, renderFragment{view: rItem.view, gap: gap})
844
845 currentContentHeight = rItem.end + 1 + l.gap
846 }
847
848 // second pass: build rendered string efficiently
849 var b strings.Builder
850 if l.direction == DirectionForward {
851 b.WriteString(rendered)
852 for _, f := range fragments {
853 b.WriteString(f.view)
854 for range f.gap {
855 b.WriteByte('\n')
856 }
857 }
858
859 return b.String(), finalIndex
860 }
861
862 // iterate backwards as fragments are in reversed order
863 for i := len(fragments) - 1; i >= 0; i-- {
864 f := fragments[i]
865 b.WriteString(f.view)
866 for range f.gap {
867 b.WriteByte('\n')
868 }
869 }
870 b.WriteString(rendered)
871
872 return b.String(), finalIndex
873}
874
875func (l *list[T]) renderItem(item Item) renderedItem {
876 view := item.View()
877 return renderedItem{
878 id: item.ID(),
879 view: view,
880 height: lipgloss.Height(view),
881 }
882}
883
884// AppendItem implements List.
885func (l *list[T]) AppendItem(item T) tea.Cmd {
886 var cmds []tea.Cmd
887 cmd := item.Init()
888 if cmd != nil {
889 cmds = append(cmds, cmd)
890 }
891
892 l.items.Append(item)
893 l.indexMap = csync.NewMap[string, int]()
894 for inx, item := range slices.Collect(l.items.Seq()) {
895 l.indexMap.Set(item.ID(), inx)
896 }
897 if l.width > 0 && l.height > 0 {
898 cmd = item.SetSize(l.width, l.height)
899 if cmd != nil {
900 cmds = append(cmds, cmd)
901 }
902 }
903 cmd = l.render()
904 if cmd != nil {
905 cmds = append(cmds, cmd)
906 }
907 if l.direction == DirectionBackward {
908 if l.offset == 0 {
909 cmd = l.GoToBottom()
910 if cmd != nil {
911 cmds = append(cmds, cmd)
912 }
913 } else {
914 newItem, ok := l.renderedItems.Get(item.ID())
915 if ok {
916 newLines := newItem.height
917 if l.items.Len() > 1 {
918 newLines += l.gap
919 }
920 l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
921 }
922 }
923 }
924 return tea.Sequence(cmds...)
925}
926
927// Blur implements List.
928func (l *list[T]) Blur() tea.Cmd {
929 l.focused = false
930 return l.render()
931}
932
933// DeleteItem implements List.
934func (l *list[T]) DeleteItem(id string) tea.Cmd {
935 inx, ok := l.indexMap.Get(id)
936 if !ok {
937 return nil
938 }
939 l.items.Delete(inx)
940 l.renderedItems.Del(id)
941 for inx, item := range slices.Collect(l.items.Seq()) {
942 l.indexMap.Set(item.ID(), inx)
943 }
944
945 if l.selectedItem == id {
946 if inx > 0 {
947 item, ok := l.items.Get(inx - 1)
948 if ok {
949 l.selectedItem = item.ID()
950 } else {
951 l.selectedItem = ""
952 }
953 } else {
954 l.selectedItem = ""
955 }
956 }
957 cmd := l.render()
958 if l.rendered != "" {
959 renderedHeight := lipgloss.Height(l.rendered)
960 if renderedHeight <= l.height {
961 l.offset = 0
962 } else {
963 maxOffset := renderedHeight - l.height
964 if l.offset > maxOffset {
965 l.offset = maxOffset
966 }
967 }
968 }
969 return cmd
970}
971
972// Focus implements List.
973func (l *list[T]) Focus() tea.Cmd {
974 l.focused = true
975 return l.render()
976}
977
978// GetSize implements List.
979func (l *list[T]) GetSize() (int, int) {
980 return l.width, l.height
981}
982
983// GoToBottom implements List.
984func (l *list[T]) GoToBottom() tea.Cmd {
985 l.offset = 0
986 l.selectedItem = ""
987 l.direction = DirectionBackward
988 return l.render()
989}
990
991// GoToTop implements List.
992func (l *list[T]) GoToTop() tea.Cmd {
993 l.offset = 0
994 l.selectedItem = ""
995 l.direction = DirectionForward
996 return l.render()
997}
998
999// IsFocused implements List.
1000func (l *list[T]) IsFocused() bool {
1001 return l.focused
1002}
1003
1004// Items implements List.
1005func (l *list[T]) Items() []T {
1006 return slices.Collect(l.items.Seq())
1007}
1008
1009func (l *list[T]) incrementOffset(n int) {
1010 renderedHeight := lipgloss.Height(l.rendered)
1011 // no need for offset
1012 if renderedHeight <= l.height {
1013 return
1014 }
1015 maxOffset := renderedHeight - l.height
1016 n = min(n, maxOffset-l.offset)
1017 if n <= 0 {
1018 return
1019 }
1020 l.offset += n
1021}
1022
1023func (l *list[T]) decrementOffset(n int) {
1024 n = min(n, l.offset)
1025 if n <= 0 {
1026 return
1027 }
1028 l.offset -= n
1029 if l.offset < 0 {
1030 l.offset = 0
1031 }
1032}
1033
1034// MoveDown implements List.
1035func (l *list[T]) MoveDown(n int) tea.Cmd {
1036 oldOffset := l.offset
1037 if l.direction == DirectionForward {
1038 l.incrementOffset(n)
1039 } else {
1040 l.decrementOffset(n)
1041 }
1042
1043 if oldOffset == l.offset {
1044 // no change in offset, so no need to change selection
1045 return nil
1046 }
1047 // if we are not actively selecting move the whole selection down
1048 if l.hasSelection() && !l.selectionActive {
1049 if l.selectionStartLine < l.selectionEndLine {
1050 l.selectionStartLine -= n
1051 l.selectionEndLine -= n
1052 } else {
1053 l.selectionStartLine -= n
1054 l.selectionEndLine -= n
1055 }
1056 }
1057 if l.selectionActive {
1058 if l.selectionStartLine < l.selectionEndLine {
1059 l.selectionStartLine -= n
1060 } else {
1061 l.selectionEndLine -= n
1062 }
1063 }
1064 return l.changeSelectionWhenScrolling()
1065}
1066
1067// MoveUp implements List.
1068func (l *list[T]) MoveUp(n int) tea.Cmd {
1069 oldOffset := l.offset
1070 if l.direction == DirectionForward {
1071 l.decrementOffset(n)
1072 } else {
1073 l.incrementOffset(n)
1074 }
1075
1076 if oldOffset == l.offset {
1077 // no change in offset, so no need to change selection
1078 return nil
1079 }
1080
1081 if l.hasSelection() && !l.selectionActive {
1082 if l.selectionStartLine > l.selectionEndLine {
1083 l.selectionStartLine += n
1084 l.selectionEndLine += n
1085 } else {
1086 l.selectionStartLine += n
1087 l.selectionEndLine += n
1088 }
1089 }
1090 if l.selectionActive {
1091 if l.selectionStartLine > l.selectionEndLine {
1092 l.selectionStartLine += n
1093 } else {
1094 l.selectionEndLine += n
1095 }
1096 }
1097 return l.changeSelectionWhenScrolling()
1098}
1099
1100// PrependItem implements List.
1101func (l *list[T]) PrependItem(item T) tea.Cmd {
1102 cmds := []tea.Cmd{
1103 item.Init(),
1104 }
1105 l.items.Prepend(item)
1106 l.indexMap = csync.NewMap[string, int]()
1107 for inx, item := range slices.Collect(l.items.Seq()) {
1108 l.indexMap.Set(item.ID(), inx)
1109 }
1110 if l.width > 0 && l.height > 0 {
1111 cmds = append(cmds, item.SetSize(l.width, l.height))
1112 }
1113 cmds = append(cmds, l.render())
1114 if l.direction == DirectionForward {
1115 if l.offset == 0 {
1116 cmd := l.GoToTop()
1117 if cmd != nil {
1118 cmds = append(cmds, cmd)
1119 }
1120 } else {
1121 newItem, ok := l.renderedItems.Get(item.ID())
1122 if ok {
1123 newLines := newItem.height
1124 if l.items.Len() > 1 {
1125 newLines += l.gap
1126 }
1127 l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
1128 }
1129 }
1130 }
1131 return tea.Batch(cmds...)
1132}
1133
1134// SelectItemAbove implements List.
1135func (l *list[T]) SelectItemAbove() tea.Cmd {
1136 inx, ok := l.indexMap.Get(l.selectedItem)
1137 if !ok {
1138 return nil
1139 }
1140
1141 newIndex := l.firstSelectableItemAbove(inx)
1142 if newIndex == ItemNotFound {
1143 // no item above
1144 return nil
1145 }
1146 var cmds []tea.Cmd
1147 if newIndex == 1 {
1148 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1149 if peakAboveIndex == ItemNotFound {
1150 // this means there is a section above move to the top
1151 cmd := l.GoToTop()
1152 if cmd != nil {
1153 cmds = append(cmds, cmd)
1154 }
1155 }
1156 }
1157 item, ok := l.items.Get(newIndex)
1158 if !ok {
1159 return nil
1160 }
1161 l.selectedItem = item.ID()
1162 l.movingByItem = true
1163 renderCmd := l.render()
1164 if renderCmd != nil {
1165 cmds = append(cmds, renderCmd)
1166 }
1167 return tea.Sequence(cmds...)
1168}
1169
1170// SelectItemBelow implements List.
1171func (l *list[T]) SelectItemBelow() tea.Cmd {
1172 inx, ok := l.indexMap.Get(l.selectedItem)
1173 if !ok {
1174 return nil
1175 }
1176
1177 newIndex := l.firstSelectableItemBelow(inx)
1178 if newIndex == ItemNotFound {
1179 // no item above
1180 return nil
1181 }
1182 item, ok := l.items.Get(newIndex)
1183 if !ok {
1184 return nil
1185 }
1186 l.selectedItem = item.ID()
1187 l.movingByItem = true
1188 return l.render()
1189}
1190
1191// SelectedItem implements List.
1192func (l *list[T]) SelectedItem() *T {
1193 inx, ok := l.indexMap.Get(l.selectedItem)
1194 if !ok {
1195 return nil
1196 }
1197 if inx > l.items.Len()-1 {
1198 return nil
1199 }
1200 item, ok := l.items.Get(inx)
1201 if !ok {
1202 return nil
1203 }
1204 return &item
1205}
1206
1207// SetItems implements List.
1208func (l *list[T]) SetItems(items []T) tea.Cmd {
1209 l.items.SetSlice(items)
1210 var cmds []tea.Cmd
1211 for inx, item := range slices.Collect(l.items.Seq()) {
1212 if i, ok := any(item).(Indexable); ok {
1213 i.SetIndex(inx)
1214 }
1215 cmds = append(cmds, item.Init())
1216 }
1217 cmds = append(cmds, l.reset(""))
1218 return tea.Batch(cmds...)
1219}
1220
1221// SetSelected implements List.
1222func (l *list[T]) SetSelected(id string) tea.Cmd {
1223 l.selectedItem = id
1224 return l.render()
1225}
1226
1227func (l *list[T]) reset(selectedItem string) tea.Cmd {
1228 var cmds []tea.Cmd
1229 l.rendered = ""
1230 l.offset = 0
1231 l.selectedItem = selectedItem
1232 l.indexMap = csync.NewMap[string, int]()
1233 l.renderedItems = csync.NewMap[string, renderedItem]()
1234 for inx, item := range slices.Collect(l.items.Seq()) {
1235 l.indexMap.Set(item.ID(), inx)
1236 if l.width > 0 && l.height > 0 {
1237 cmds = append(cmds, item.SetSize(l.width, l.height))
1238 }
1239 }
1240 cmds = append(cmds, l.render())
1241 return tea.Batch(cmds...)
1242}
1243
1244// SetSize implements List.
1245func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1246 oldWidth := l.width
1247 l.width = width
1248 l.height = height
1249 if oldWidth != width {
1250 cmd := l.reset(l.selectedItem)
1251 return cmd
1252 }
1253 return nil
1254}
1255
1256// UpdateItem implements List.
1257func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1258 var cmds []tea.Cmd
1259 if inx, ok := l.indexMap.Get(id); ok {
1260 l.items.Set(inx, item)
1261 oldItem, hasOldItem := l.renderedItems.Get(id)
1262 oldPosition := l.offset
1263 if l.direction == DirectionBackward {
1264 oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
1265 }
1266
1267 l.renderedItems.Del(id)
1268 cmd := l.render()
1269
1270 // need to check for nil because of sequence not handling nil
1271 if cmd != nil {
1272 cmds = append(cmds, cmd)
1273 }
1274 if hasOldItem && l.direction == DirectionBackward {
1275 // if we are the last item and there is no offset
1276 // make sure to go to the bottom
1277 if oldPosition < oldItem.end {
1278 newItem, ok := l.renderedItems.Get(item.ID())
1279 if ok {
1280 newLines := newItem.height - oldItem.height
1281 l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1282 }
1283 }
1284 } else if hasOldItem && l.offset > oldItem.start {
1285 newItem, ok := l.renderedItems.Get(item.ID())
1286 if ok {
1287 newLines := newItem.height - oldItem.height
1288 l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1289 }
1290 }
1291 }
1292 return tea.Sequence(cmds...)
1293}
1294
1295func (l *list[T]) hasSelection() bool {
1296 return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1297}
1298
1299// StartSelection implements List.
1300func (l *list[T]) StartSelection(col, line int) {
1301 l.selectionStartCol = col
1302 l.selectionStartLine = line
1303 l.selectionEndCol = col
1304 l.selectionEndLine = line
1305 l.selectionActive = true
1306}
1307
1308// EndSelection implements List.
1309func (l *list[T]) EndSelection(col, line int) {
1310 if !l.selectionActive {
1311 return
1312 }
1313 l.selectionEndCol = col
1314 l.selectionEndLine = line
1315}
1316
1317func (l *list[T]) SelectionStop() {
1318 l.selectionActive = false
1319}
1320
1321func (l *list[T]) SelectionClear() {
1322 l.selectionStartCol = -1
1323 l.selectionStartLine = -1
1324 l.selectionEndCol = -1
1325 l.selectionEndLine = -1
1326 l.selectionActive = false
1327}
1328
1329func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1330 lines := strings.Split(l.rendered, "\n")
1331 for i, l := range lines {
1332 lines[i] = ansi.Strip(l)
1333 }
1334
1335 if l.direction == DirectionBackward && len(lines) > l.height {
1336 line = ((len(lines) - 1) - l.height) + line + 1
1337 }
1338
1339 if l.offset > 0 {
1340 if l.direction == DirectionBackward {
1341 line -= l.offset
1342 } else {
1343 line += l.offset
1344 }
1345 }
1346
1347 if line < 0 || line >= len(lines) {
1348 return 0, 0
1349 }
1350
1351 currentLine := lines[line]
1352 gr := uniseg.NewGraphemes(currentLine)
1353 startCol = -1
1354 upTo := col
1355 for gr.Next() {
1356 if gr.IsWordBoundary() && upTo > 0 {
1357 startCol = col - upTo + 1
1358 } else if gr.IsWordBoundary() && upTo < 0 {
1359 endCol = col - upTo + 1
1360 break
1361 }
1362 if upTo == 0 && gr.Str() == " " {
1363 return 0, 0
1364 }
1365 upTo -= 1
1366 }
1367 if startCol == -1 {
1368 return 0, 0
1369 }
1370 return
1371}
1372
1373func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1374 lines := strings.Split(l.rendered, "\n")
1375 for i, l := range lines {
1376 lines[i] = ansi.Strip(l)
1377 for _, icon := range styles.SelectionIgnoreIcons {
1378 lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1379 }
1380 }
1381 if l.direction == DirectionBackward && len(lines) > l.height {
1382 line = (len(lines) - 1) - l.height + line + 1
1383 }
1384
1385 if l.offset > 0 {
1386 if l.direction == DirectionBackward {
1387 line -= l.offset
1388 } else {
1389 line += l.offset
1390 }
1391 }
1392
1393 // Ensure line is within bounds
1394 if line < 0 || line >= len(lines) {
1395 return 0, 0, false
1396 }
1397
1398 if strings.TrimSpace(lines[line]) == "" {
1399 return 0, 0, false
1400 }
1401
1402 // Find start of paragraph (search backwards for empty line or start of text)
1403 startLine = line
1404 for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1405 startLine--
1406 }
1407
1408 // Find end of paragraph (search forwards for empty line or end of text)
1409 endLine = line
1410 for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1411 endLine++
1412 }
1413
1414 // revert the line numbers if we are in backward direction
1415 if l.direction == DirectionBackward && len(lines) > l.height {
1416 startLine = startLine - (len(lines) - 1) + l.height - 1
1417 endLine = endLine - (len(lines) - 1) + l.height - 1
1418 }
1419 if l.offset > 0 {
1420 if l.direction == DirectionBackward {
1421 startLine += l.offset
1422 endLine += l.offset
1423 } else {
1424 startLine -= l.offset
1425 endLine -= l.offset
1426 }
1427 }
1428 return startLine, endLine, true
1429}
1430
1431// SelectWord selects the word at the given position.
1432func (l *list[T]) SelectWord(col, line int) {
1433 startCol, endCol := l.findWordBoundaries(col, line)
1434 l.selectionStartCol = startCol
1435 l.selectionStartLine = line
1436 l.selectionEndCol = endCol
1437 l.selectionEndLine = line
1438 l.selectionActive = false // Not actively selecting, just selected
1439}
1440
1441// SelectParagraph selects the paragraph at the given position.
1442func (l *list[T]) SelectParagraph(col, line int) {
1443 startLine, endLine, found := l.findParagraphBoundaries(line)
1444 if !found {
1445 return
1446 }
1447 l.selectionStartCol = 0
1448 l.selectionStartLine = startLine
1449 l.selectionEndCol = l.width - 1
1450 l.selectionEndLine = endLine
1451 l.selectionActive = false // Not actively selecting, just selected
1452}
1453
1454// HasSelection returns whether there is an active selection.
1455func (l *list[T]) HasSelection() bool {
1456 return l.hasSelection()
1457}
1458
1459// GetSelectedText returns the currently selected text.
1460func (l *list[T]) GetSelectedText(paddingLeft int) string {
1461 if !l.hasSelection() {
1462 return ""
1463 }
1464
1465 return l.selectionView(l.View(), true)
1466}