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