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