list.go

   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}