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