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