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