list.go

   1package list
   2
   3import (
   4	"strings"
   5
   6	uv "github.com/charmbracelet/ultraviolet"
   7	"github.com/charmbracelet/ultraviolet/screen"
   8	"github.com/charmbracelet/x/exp/ordered"
   9)
  10
  11// List is a scrollable list component that implements uv.Drawable.
  12// It efficiently manages a large number of items by caching rendered content
  13// in a master buffer and extracting only the visible viewport when drawn.
  14type List struct {
  15	// Configuration
  16	width, height int
  17
  18	// Data
  19	items []Item
  20
  21	// Focus & Selection
  22	focused     bool
  23	selectedIdx int // Currently selected item index (-1 if none)
  24
  25	// Master buffer containing ALL rendered items
  26	masterBuffer *uv.ScreenBuffer
  27	totalHeight  int
  28
  29	// Item positioning in master buffer
  30	itemPositions []itemPosition
  31
  32	// Viewport state
  33	offset int // Scroll offset in lines from top
  34
  35	// Mouse state
  36	mouseDown     bool
  37	mouseDownItem int // Item index where mouse was pressed
  38	mouseDownX    int // X position in item content (character offset)
  39	mouseDownY    int // Y position in item (line offset)
  40	mouseDragItem int // Current item index being dragged over
  41	mouseDragX    int // Current X in item content
  42	mouseDragY    int // Current Y in item
  43
  44	// Dirty tracking
  45	dirty      bool
  46	dirtyItems map[int]bool
  47}
  48
  49type itemPosition struct {
  50	startLine int
  51	height    int
  52}
  53
  54// New creates a new list with the given items.
  55func New(items ...Item) *List {
  56	l := &List{
  57		items:         items,
  58		itemPositions: make([]itemPosition, len(items)),
  59		dirtyItems:    make(map[int]bool),
  60		selectedIdx:   -1,
  61		mouseDownItem: -1,
  62		mouseDragItem: -1,
  63	}
  64
  65	l.dirty = true
  66	return l
  67}
  68
  69// ensureBuilt ensures the master buffer is built.
  70// This is called by methods that need itemPositions or totalHeight.
  71func (l *List) ensureBuilt() {
  72	if l.width <= 0 || l.height <= 0 {
  73		return
  74	}
  75
  76	if l.dirty {
  77		l.rebuildMasterBuffer()
  78	} else if len(l.dirtyItems) > 0 {
  79		l.updateDirtyItems()
  80	}
  81}
  82
  83// Draw implements uv.Drawable.
  84// Draws the visible viewport of the list to the given screen buffer.
  85func (l *List) Draw(scr uv.Screen, area uv.Rectangle) {
  86	if area.Dx() <= 0 || area.Dy() <= 0 {
  87		return
  88	}
  89
  90	// Update internal dimensions if area size changed
  91	widthChanged := l.width != area.Dx()
  92	heightChanged := l.height != area.Dy()
  93
  94	l.width = area.Dx()
  95	l.height = area.Dy()
  96
  97	// Only width changes require rebuilding master buffer
  98	// Height changes only affect viewport clipping, not item rendering
  99	if widthChanged {
 100		l.dirty = true
 101	}
 102
 103	// Height changes require clamping offset to new bounds
 104	if heightChanged {
 105		l.clampOffset()
 106	}
 107
 108	if len(l.items) == 0 {
 109		screen.ClearArea(scr, area)
 110		return
 111	}
 112
 113	// Ensure buffer is built
 114	l.ensureBuilt()
 115
 116	// Draw visible portion to the target screen
 117	l.drawViewport(scr, area)
 118}
 119
 120// Render renders the visible viewport to a string.
 121// This is a convenience method that creates a temporary screen buffer,
 122// draws to it, and returns the rendered string.
 123func (l *List) Render() string {
 124	if l.width <= 0 || l.height <= 0 {
 125		return ""
 126	}
 127
 128	if len(l.items) == 0 {
 129		return ""
 130	}
 131
 132	// Ensure buffer is built
 133	l.ensureBuilt()
 134
 135	// Extract visible lines directly from master buffer
 136	return l.renderViewport()
 137}
 138
 139// renderViewport renders the visible portion of the master buffer to a string.
 140func (l *List) renderViewport() string {
 141	if l.masterBuffer == nil {
 142		return ""
 143	}
 144
 145	buf := l.masterBuffer.Buffer
 146
 147	// Calculate visible region in master buffer
 148	srcStartY := l.offset
 149	srcEndY := l.offset + l.height
 150
 151	// Clamp to actual buffer bounds
 152	if srcStartY >= len(buf.Lines) {
 153		// Beyond end of content, return empty lines
 154		emptyLine := strings.Repeat(" ", l.width)
 155		lines := make([]string, l.height)
 156		for i := range lines {
 157			lines[i] = emptyLine
 158		}
 159		return strings.Join(lines, "\n")
 160	}
 161	if srcEndY > len(buf.Lines) {
 162		srcEndY = len(buf.Lines)
 163	}
 164
 165	// Build result with proper line handling
 166	lines := make([]string, l.height)
 167	lineIdx := 0
 168
 169	// Render visible lines from buffer
 170	for y := srcStartY; y < srcEndY && lineIdx < l.height; y++ {
 171		lines[lineIdx] = buf.Lines[y].Render()
 172		lineIdx++
 173	}
 174
 175	// Pad remaining lines with spaces to maintain viewport height
 176	emptyLine := strings.Repeat(" ", l.width)
 177	for ; lineIdx < l.height; lineIdx++ {
 178		lines[lineIdx] = emptyLine
 179	}
 180
 181	return strings.Join(lines, "\n")
 182}
 183
 184// drawViewport draws the visible portion from master buffer to target screen.
 185func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) {
 186	if l.masterBuffer == nil {
 187		screen.ClearArea(scr, area)
 188		return
 189	}
 190
 191	buf := l.masterBuffer.Buffer
 192
 193	// Calculate visible region in master buffer
 194	srcStartY := l.offset
 195	srcEndY := l.offset + area.Dy()
 196
 197	// Clamp to actual buffer bounds
 198	if srcStartY >= buf.Height() {
 199		screen.ClearArea(scr, area)
 200		return
 201	}
 202	if srcEndY > buf.Height() {
 203		srcEndY = buf.Height()
 204	}
 205
 206	// Copy visible lines to target screen
 207	destY := area.Min.Y
 208	for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
 209		line := buf.Line(srcY)
 210		destX := area.Min.X
 211
 212		for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
 213			cell := line.At(x)
 214			scr.SetCell(destX, destY, cell)
 215			destX++
 216		}
 217		destY++
 218	}
 219
 220	// Clear any remaining area if content is shorter than viewport
 221	if destY < area.Max.Y {
 222		clearArea := uv.Rect(area.Min.X, destY, area.Dx(), area.Max.Y-destY)
 223		screen.ClearArea(scr, clearArea)
 224	}
 225}
 226
 227// rebuildMasterBuffer composes all items into the master buffer.
 228func (l *List) rebuildMasterBuffer() {
 229	if len(l.items) == 0 {
 230		l.totalHeight = 0
 231		l.dirty = false
 232		return
 233	}
 234
 235	// Calculate total height
 236	l.totalHeight = l.calculateTotalHeight()
 237
 238	// Create or resize master buffer
 239	if l.masterBuffer == nil || l.masterBuffer.Width() != l.width || l.masterBuffer.Height() != l.totalHeight {
 240		buf := uv.NewScreenBuffer(l.width, l.totalHeight)
 241		l.masterBuffer = &buf
 242	}
 243
 244	// Clear buffer
 245	screen.Clear(l.masterBuffer)
 246
 247	// Draw each item
 248	currentY := 0
 249	for i, item := range l.items {
 250		itemHeight := item.Height(l.width)
 251
 252		// Draw item to master buffer
 253		area := uv.Rect(0, currentY, l.width, itemHeight)
 254		item.Draw(l.masterBuffer, area)
 255
 256		// Store position
 257		l.itemPositions[i] = itemPosition{
 258			startLine: currentY,
 259			height:    itemHeight,
 260		}
 261
 262		// Advance position
 263		currentY += itemHeight
 264	}
 265
 266	l.dirty = false
 267	l.dirtyItems = make(map[int]bool)
 268}
 269
 270// updateDirtyItems efficiently updates only changed items using slice operations.
 271func (l *List) updateDirtyItems() {
 272	if len(l.dirtyItems) == 0 {
 273		return
 274	}
 275
 276	// Check if all dirty items have unchanged heights
 277	allSameHeight := true
 278	for idx := range l.dirtyItems {
 279		item := l.items[idx]
 280		pos := l.itemPositions[idx]
 281		newHeight := item.Height(l.width)
 282		if newHeight != pos.height {
 283			allSameHeight = false
 284			break
 285		}
 286	}
 287
 288	// Optimization: If all dirty items have unchanged heights, re-render in place
 289	if allSameHeight {
 290		buf := l.masterBuffer.Buffer
 291		for idx := range l.dirtyItems {
 292			item := l.items[idx]
 293			pos := l.itemPositions[idx]
 294
 295			// Clear the item's area
 296			for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ {
 297				buf.Lines[y] = uv.NewLine(l.width)
 298			}
 299
 300			// Re-render item
 301			area := uv.Rect(0, pos.startLine, l.width, pos.height)
 302			item.Draw(l.masterBuffer, area)
 303		}
 304
 305		l.dirtyItems = make(map[int]bool)
 306		return
 307	}
 308
 309	// Height changed - full rebuild
 310	l.dirty = true
 311	l.dirtyItems = make(map[int]bool)
 312	l.rebuildMasterBuffer()
 313}
 314
 315// updatePositionsBelow updates the startLine for all items below the given index.
 316func (l *List) updatePositionsBelow(fromIdx int, delta int) {
 317	for i := fromIdx + 1; i < len(l.items); i++ {
 318		pos := l.itemPositions[i]
 319		pos.startLine += delta
 320		l.itemPositions[i] = pos
 321	}
 322}
 323
 324// calculateTotalHeight calculates the total height of all items plus gaps.
 325func (l *List) calculateTotalHeight() int {
 326	if len(l.items) == 0 {
 327		return 0
 328	}
 329
 330	total := 0
 331	for _, item := range l.items {
 332		total += item.Height(l.width)
 333	}
 334	return total
 335}
 336
 337// SetSize updates the viewport size.
 338func (l *List) SetSize(width, height int) {
 339	widthChanged := l.width != width
 340	heightChanged := l.height != height
 341
 342	l.width = width
 343	l.height = height
 344
 345	// Width changes require full rebuild (items may reflow)
 346	if widthChanged {
 347		l.dirty = true
 348	}
 349
 350	// Height changes require clamping offset to new bounds
 351	if heightChanged {
 352		l.clampOffset()
 353	}
 354}
 355
 356// Height returns the current viewport height.
 357func (l *List) Height() int {
 358	return l.height
 359}
 360
 361// Width returns the current viewport width.
 362func (l *List) Width() int {
 363	return l.width
 364}
 365
 366// GetSize returns the current viewport size.
 367func (l *List) GetSize() (int, int) {
 368	return l.width, l.height
 369}
 370
 371// Len returns the number of items in the list.
 372func (l *List) Len() int {
 373	return len(l.items)
 374}
 375
 376// SetItems replaces all items in the list.
 377func (l *List) SetItems(items []Item) {
 378	l.items = items
 379	l.itemPositions = make([]itemPosition, len(items))
 380	l.dirty = true
 381}
 382
 383// Items returns all items in the list.
 384func (l *List) Items() []Item {
 385	return l.items
 386}
 387
 388// AppendItem adds an item to the end of the list. Returns true if successful.
 389func (l *List) AppendItem(item Item) bool {
 390	l.items = append(l.items, item)
 391	l.itemPositions = append(l.itemPositions, itemPosition{})
 392
 393	// If buffer not built yet, mark dirty for full rebuild
 394	if l.masterBuffer == nil || l.width <= 0 {
 395		l.dirty = true
 396		return true
 397	}
 398
 399	// Process any pending dirty items before modifying buffer structure
 400	if len(l.dirtyItems) > 0 {
 401		l.updateDirtyItems()
 402	}
 403
 404	// Efficient append: insert lines at end of buffer
 405	itemHeight := item.Height(l.width)
 406	startLine := l.totalHeight
 407
 408	// Expand buffer
 409	newLines := make([]uv.Line, itemHeight)
 410	for i := range newLines {
 411		newLines[i] = uv.NewLine(l.width)
 412	}
 413	l.masterBuffer.Buffer.Lines = append(l.masterBuffer.Buffer.Lines, newLines...)
 414
 415	// Draw new item
 416	area := uv.Rect(0, startLine, l.width, itemHeight)
 417	item.Draw(l.masterBuffer, area)
 418
 419	// Update tracking
 420	l.itemPositions[len(l.items)-1] = itemPosition{
 421		startLine: startLine,
 422		height:    itemHeight,
 423	}
 424	l.totalHeight += itemHeight
 425
 426	return true
 427}
 428
 429// PrependItem adds an item to the beginning of the list. Returns true if
 430// successful.
 431func (l *List) PrependItem(item Item) bool {
 432	l.items = append([]Item{item}, l.items...)
 433	l.itemPositions = append([]itemPosition{{}}, l.itemPositions...)
 434	if l.selectedIdx >= 0 {
 435		l.selectedIdx++
 436	}
 437
 438	// If buffer not built yet, mark dirty for full rebuild
 439	if l.masterBuffer == nil || l.width <= 0 {
 440		l.dirty = true
 441		return true
 442	}
 443
 444	// Process any pending dirty items before modifying buffer structure
 445	if len(l.dirtyItems) > 0 {
 446		l.updateDirtyItems()
 447	}
 448
 449	// Efficient prepend: insert lines at start of buffer
 450	itemHeight := item.Height(l.width)
 451
 452	// Create new lines
 453	newLines := make([]uv.Line, itemHeight)
 454	for i := range newLines {
 455		newLines[i] = uv.NewLine(l.width)
 456	}
 457
 458	// Insert at beginning
 459	buf := l.masterBuffer.Buffer
 460	buf.Lines = append(newLines, buf.Lines...)
 461
 462	// Draw new item
 463	area := uv.Rect(0, 0, l.width, itemHeight)
 464	item.Draw(l.masterBuffer, area)
 465
 466	// Update all positions (shift everything down)
 467	for i := range l.itemPositions {
 468		pos := l.itemPositions[i]
 469		pos.startLine += itemHeight
 470		l.itemPositions[i] = pos
 471	}
 472
 473	// Add position for new item at start
 474	l.itemPositions[0] = itemPosition{
 475		startLine: 0,
 476		height:    itemHeight,
 477	}
 478
 479	l.totalHeight += itemHeight
 480
 481	return true
 482}
 483
 484// UpdateItem replaces an item with the same index. Returns true if successful.
 485func (l *List) UpdateItem(idx int, item Item) bool {
 486	if idx < 0 || idx >= len(l.items) {
 487		return false
 488	}
 489	l.items[idx] = item
 490	l.dirtyItems[idx] = true
 491	return true
 492}
 493
 494// DeleteItem removes an item by index. Returns true if successful.
 495func (l *List) DeleteItem(idx int) bool {
 496	if idx < 0 || idx >= len(l.items) {
 497		return false
 498	}
 499
 500	// Get position before deleting
 501	pos := l.itemPositions[idx]
 502
 503	// Process any pending dirty items before modifying buffer structure
 504	if len(l.dirtyItems) > 0 {
 505		l.updateDirtyItems()
 506	}
 507
 508	l.items = append(l.items[:idx], l.items[idx+1:]...)
 509	l.itemPositions = append(l.itemPositions[:idx], l.itemPositions[idx+1:]...)
 510
 511	// Adjust selection
 512	if l.selectedIdx == idx {
 513		if idx > 0 {
 514			l.selectedIdx = idx - 1
 515		} else if len(l.items) > 0 {
 516			l.selectedIdx = 0
 517		} else {
 518			l.selectedIdx = -1
 519		}
 520	} else if l.selectedIdx > idx {
 521		l.selectedIdx--
 522	}
 523
 524	// If buffer not built yet, mark dirty for full rebuild
 525	if l.masterBuffer == nil {
 526		l.dirty = true
 527		return true
 528	}
 529
 530	// Efficient delete: remove lines from buffer
 531	deleteStart := pos.startLine
 532	deleteEnd := pos.startLine + pos.height
 533	buf := l.masterBuffer.Buffer
 534
 535	if deleteEnd <= len(buf.Lines) {
 536		buf.Lines = append(buf.Lines[:deleteStart], buf.Lines[deleteEnd:]...)
 537		l.totalHeight -= pos.height
 538		l.updatePositionsBelow(idx-1, -pos.height)
 539	} else {
 540		// Position data corrupt, rebuild
 541		l.dirty = true
 542	}
 543
 544	return true
 545}
 546
 547// Focus focuses the list and the selected item (if focusable).
 548func (l *List) Focus() {
 549	l.focused = true
 550	l.focusSelectedItem()
 551}
 552
 553// Blur blurs the list and the selected item (if focusable).
 554func (l *List) Blur() {
 555	l.focused = false
 556	l.blurSelectedItem()
 557}
 558
 559// Focused returns whether the list is focused.
 560func (l *List) Focused() bool {
 561	return l.focused
 562}
 563
 564// SetSelected sets the selected item by ID.
 565func (l *List) SetSelected(idx int) {
 566	if idx < 0 || idx >= len(l.items) {
 567		return
 568	}
 569	if l.selectedIdx == idx {
 570		return
 571	}
 572
 573	prevIdx := l.selectedIdx
 574	l.selectedIdx = idx
 575
 576	// Update focus states if list is focused
 577	if l.focused {
 578		if prevIdx >= 0 && prevIdx < len(l.items) {
 579			if f, ok := l.items[prevIdx].(Focusable); ok {
 580				f.Blur()
 581				l.dirtyItems[prevIdx] = true
 582			}
 583		}
 584
 585		if f, ok := l.items[idx].(Focusable); ok {
 586			f.Focus()
 587			l.dirtyItems[idx] = true
 588		}
 589	}
 590}
 591
 592// SelectFirst selects the first item in the list.
 593func (l *List) SelectFirst() {
 594	l.SetSelected(0)
 595}
 596
 597// SelectLast selects the last item in the list.
 598func (l *List) SelectLast() {
 599	l.SetSelected(len(l.items) - 1)
 600}
 601
 602// SelectNextWrap selects the next item in the list (wraps to beginning).
 603// When the list is focused, skips non-focusable items.
 604func (l *List) SelectNextWrap() {
 605	l.selectNext(true)
 606}
 607
 608// SelectNext selects the next item in the list (no wrap).
 609// When the list is focused, skips non-focusable items.
 610func (l *List) SelectNext() {
 611	l.selectNext(false)
 612}
 613
 614func (l *List) selectNext(wrap bool) {
 615	if len(l.items) == 0 {
 616		return
 617	}
 618
 619	startIdx := l.selectedIdx
 620	for i := 0; i < len(l.items); i++ {
 621		var nextIdx int
 622		if wrap {
 623			nextIdx = (startIdx + 1 + i) % len(l.items)
 624		} else {
 625			nextIdx = startIdx + 1 + i
 626			if nextIdx >= len(l.items) {
 627				return
 628			}
 629		}
 630
 631		// If list is focused and item is not focusable, skip it
 632		if l.focused {
 633			if _, ok := l.items[nextIdx].(Focusable); !ok {
 634				continue
 635			}
 636		}
 637
 638		// Select and scroll to this item
 639		l.SetSelected(nextIdx)
 640		return
 641	}
 642}
 643
 644// SelectPrevWrap selects the previous item in the list (wraps to end).
 645// When the list is focused, skips non-focusable items.
 646func (l *List) SelectPrevWrap() {
 647	l.selectPrev(true)
 648}
 649
 650// SelectPrev selects the previous item in the list (no wrap).
 651// When the list is focused, skips non-focusable items.
 652func (l *List) SelectPrev() {
 653	l.selectPrev(false)
 654}
 655
 656func (l *List) selectPrev(wrap bool) {
 657	if len(l.items) == 0 {
 658		return
 659	}
 660
 661	startIdx := l.selectedIdx
 662	for i := 0; i < len(l.items); i++ {
 663		var prevIdx int
 664		if wrap {
 665			prevIdx = (startIdx - 1 - i + len(l.items)) % len(l.items)
 666		} else {
 667			prevIdx = startIdx - 1 - i
 668			if prevIdx < 0 {
 669				return
 670			}
 671		}
 672
 673		// If list is focused and item is not focusable, skip it
 674		if l.focused {
 675			if _, ok := l.items[prevIdx].(Focusable); !ok {
 676				continue
 677			}
 678		}
 679
 680		// Select and scroll to this item
 681		l.SetSelected(prevIdx)
 682		return
 683	}
 684}
 685
 686// SelectedItem returns the currently selected item, or nil if none.
 687func (l *List) SelectedItem() Item {
 688	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
 689		return nil
 690	}
 691	return l.items[l.selectedIdx]
 692}
 693
 694// SelectedIndex returns the index of the currently selected item, or -1 if none.
 695func (l *List) SelectedIndex() int {
 696	return l.selectedIdx
 697}
 698
 699// AtBottom returns whether the viewport is scrolled to the bottom.
 700func (l *List) AtBottom() bool {
 701	l.ensureBuilt()
 702	return l.offset >= l.totalHeight-l.height
 703}
 704
 705// AtTop returns whether the viewport is scrolled to the top.
 706func (l *List) AtTop() bool {
 707	return l.offset <= 0
 708}
 709
 710// ScrollBy scrolls the viewport by the given number of lines.
 711// Positive values scroll down, negative scroll up.
 712func (l *List) ScrollBy(deltaLines int) {
 713	l.offset += deltaLines
 714	l.clampOffset()
 715}
 716
 717// ScrollToTop scrolls to the top of the list.
 718func (l *List) ScrollToTop() {
 719	l.offset = 0
 720}
 721
 722// ScrollToBottom scrolls to the bottom of the list.
 723func (l *List) ScrollToBottom() {
 724	l.ensureBuilt()
 725	if l.totalHeight > l.height {
 726		l.offset = l.totalHeight - l.height
 727	} else {
 728		l.offset = 0
 729	}
 730}
 731
 732// ScrollToItem scrolls to make the item with the given ID visible.
 733func (l *List) ScrollToItem(idx int) {
 734	l.ensureBuilt()
 735	pos := l.itemPositions[idx]
 736	itemStart := pos.startLine
 737	itemEnd := pos.startLine + pos.height
 738	viewStart := l.offset
 739	viewEnd := l.offset + l.height
 740
 741	// Check if item is already fully visible
 742	if itemStart >= viewStart && itemEnd <= viewEnd {
 743		return
 744	}
 745
 746	// Scroll to show item
 747	if itemStart < viewStart {
 748		l.offset = itemStart
 749	} else if itemEnd > viewEnd {
 750		l.offset = itemEnd - l.height
 751	}
 752
 753	l.clampOffset()
 754}
 755
 756// ScrollToSelected scrolls to make the selected item visible.
 757func (l *List) ScrollToSelected() {
 758	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
 759		return
 760	}
 761	l.ScrollToItem(l.selectedIdx)
 762}
 763
 764// Offset returns the current scroll offset.
 765func (l *List) Offset() int {
 766	return l.offset
 767}
 768
 769// TotalHeight returns the total height of all items including gaps.
 770func (l *List) TotalHeight() int {
 771	return l.totalHeight
 772}
 773
 774// SelectFirstInView selects the first item that is fully visible in the viewport.
 775func (l *List) SelectFirstInView() {
 776	l.ensureBuilt()
 777
 778	viewportStart := l.offset
 779	viewportEnd := l.offset + l.height
 780
 781	for i := range l.items {
 782		pos := l.itemPositions[i]
 783
 784		// Check if item is fully within viewport bounds
 785		if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
 786			l.SetSelected(i)
 787			return
 788		}
 789	}
 790}
 791
 792// SelectLastInView selects the last item that is fully visible in the viewport.
 793func (l *List) SelectLastInView() {
 794	l.ensureBuilt()
 795
 796	viewportStart := l.offset
 797	viewportEnd := l.offset + l.height
 798
 799	for i := len(l.items) - 1; i >= 0; i-- {
 800		pos := l.itemPositions[i]
 801
 802		// Check if item is fully within viewport bounds
 803		if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
 804			l.SetSelected(i)
 805			return
 806		}
 807	}
 808}
 809
 810// SelectedItemInView returns true if the selected item is currently visible in the viewport.
 811func (l *List) SelectedItemInView() bool {
 812	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
 813		return false
 814	}
 815
 816	// Get selected item ID and position
 817	pos := l.itemPositions[l.selectedIdx]
 818
 819	// Check if item is within viewport bounds
 820	viewportStart := l.offset
 821	viewportEnd := l.offset + l.height
 822
 823	// Item is visible if any part of it overlaps with the viewport
 824	return pos.startLine < viewportEnd && (pos.startLine+pos.height) > viewportStart
 825}
 826
 827// clampOffset ensures offset is within valid bounds.
 828func (l *List) clampOffset() {
 829	l.offset = ordered.Clamp(l.offset, 0, l.totalHeight-l.height)
 830}
 831
 832// focusSelectedItem focuses the currently selected item if it's focusable.
 833func (l *List) focusSelectedItem() {
 834	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
 835		return
 836	}
 837
 838	item := l.items[l.selectedIdx]
 839	if f, ok := item.(Focusable); ok {
 840		f.Focus()
 841		l.dirtyItems[l.selectedIdx] = true
 842	}
 843}
 844
 845// blurSelectedItem blurs the currently selected item if it's focusable.
 846func (l *List) blurSelectedItem() {
 847	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
 848		return
 849	}
 850
 851	item := l.items[l.selectedIdx]
 852	if f, ok := item.(Focusable); ok {
 853		f.Blur()
 854		l.dirtyItems[l.selectedIdx] = true
 855	}
 856}
 857
 858// HandleMouseDown handles mouse button press events.
 859// x and y are viewport-relative coordinates (0,0 = top-left of visible area).
 860// Returns true if the event was handled.
 861func (l *List) HandleMouseDown(x, y int) bool {
 862	l.ensureBuilt()
 863
 864	// Convert viewport y to master buffer y
 865	bufferY := y + l.offset
 866
 867	// Find which item was clicked
 868	itemIdx, itemY := l.findItemAtPosition(bufferY)
 869	if itemIdx < 0 {
 870		return false
 871	}
 872
 873	// Calculate x position within item content
 874	// For now, x is just the viewport x coordinate
 875	// Items can interpret this as character offset in their content
 876
 877	l.mouseDown = true
 878	l.mouseDownItem = itemIdx
 879	l.mouseDownX = x
 880	l.mouseDownY = itemY
 881	l.mouseDragItem = itemIdx
 882	l.mouseDragX = x
 883	l.mouseDragY = itemY
 884
 885	// Select the clicked item
 886	l.SetSelected(itemIdx)
 887
 888	return true
 889}
 890
 891// HandleMouseDrag handles mouse drag events during selection.
 892// x and y are viewport-relative coordinates.
 893// Returns true if the event was handled.
 894func (l *List) HandleMouseDrag(x, y int) bool {
 895	if !l.mouseDown {
 896		return false
 897	}
 898
 899	l.ensureBuilt()
 900
 901	// Convert viewport y to master buffer y
 902	bufferY := y + l.offset
 903
 904	// Find which item we're dragging over
 905	itemIdx, itemY := l.findItemAtPosition(bufferY)
 906	if itemIdx < 0 {
 907		return false
 908	}
 909
 910	l.mouseDragItem = itemIdx
 911	l.mouseDragX = x
 912	l.mouseDragY = itemY
 913
 914	// Update highlight if item supports it
 915	l.updateHighlight()
 916
 917	return true
 918}
 919
 920// HandleMouseUp handles mouse button release events.
 921// Returns true if the event was handled.
 922func (l *List) HandleMouseUp(x, y int) bool {
 923	if !l.mouseDown {
 924		return false
 925	}
 926
 927	l.mouseDown = false
 928
 929	// Final highlight update
 930	l.updateHighlight()
 931
 932	return true
 933}
 934
 935// ClearHighlight clears any active text highlighting.
 936func (l *List) ClearHighlight() {
 937	for i, item := range l.items {
 938		if h, ok := item.(Highlightable); ok {
 939			h.SetHighlight(-1, -1, -1, -1)
 940			l.dirtyItems[i] = true
 941		}
 942	}
 943	l.mouseDownItem = -1
 944	l.mouseDragItem = -1
 945}
 946
 947// findItemAtPosition finds the item at the given master buffer y coordinate.
 948// Returns the item index and the y offset within that item. It returns -1, -1
 949// if no item is found.
 950func (l *List) findItemAtPosition(bufferY int) (itemIdx int, itemY int) {
 951	if bufferY < 0 || bufferY >= l.totalHeight {
 952		return -1, -1
 953	}
 954
 955	// Linear search through items to find which one contains this y
 956	// This could be optimized with binary search if needed
 957	for i := range l.items {
 958		pos := l.itemPositions[i]
 959		if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height {
 960			return i, bufferY - pos.startLine
 961		}
 962	}
 963
 964	return -1, -1
 965}
 966
 967// updateHighlight updates the highlight range for highlightable items.
 968// Supports highlighting across multiple items and respects drag direction.
 969func (l *List) updateHighlight() {
 970	if l.mouseDownItem < 0 {
 971		return
 972	}
 973
 974	// Get start and end item indices
 975	downItemIdx := l.mouseDownItem
 976	dragItemIdx := l.mouseDragItem
 977
 978	// Determine selection direction
 979	draggingDown := dragItemIdx > downItemIdx ||
 980		(dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
 981		(dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
 982
 983	// Determine actual start and end based on direction
 984	var startItemIdx, endItemIdx int
 985	var startLine, startCol, endLine, endCol int
 986
 987	if draggingDown {
 988		// Normal forward selection
 989		startItemIdx = downItemIdx
 990		endItemIdx = dragItemIdx
 991		startLine = l.mouseDownY
 992		startCol = l.mouseDownX
 993		endLine = l.mouseDragY
 994		endCol = l.mouseDragX
 995	} else {
 996		// Backward selection (dragging up)
 997		startItemIdx = dragItemIdx
 998		endItemIdx = downItemIdx
 999		startLine = l.mouseDragY
1000		startCol = l.mouseDragX
1001		endLine = l.mouseDownY
1002		endCol = l.mouseDownX
1003	}
1004
1005	// Clear all highlights first
1006	for i, item := range l.items {
1007		if h, ok := item.(Highlightable); ok {
1008			h.SetHighlight(-1, -1, -1, -1)
1009			l.dirtyItems[i] = true
1010		}
1011	}
1012
1013	// Highlight all items in range
1014	for idx := startItemIdx; idx <= endItemIdx; idx++ {
1015		item, ok := l.items[idx].(Highlightable)
1016		if !ok {
1017			continue
1018		}
1019
1020		if idx == startItemIdx && idx == endItemIdx {
1021			// Single item selection
1022			item.SetHighlight(startLine, startCol, endLine, endCol)
1023		} else if idx == startItemIdx {
1024			// First item - from start position to end of item
1025			pos := l.itemPositions[idx]
1026			item.SetHighlight(startLine, startCol, pos.height-1, 9999) // 9999 = end of line
1027		} else if idx == endItemIdx {
1028			// Last item - from start of item to end position
1029			item.SetHighlight(0, 0, endLine, endCol)
1030		} else {
1031			// Middle item - fully highlighted
1032			pos := l.itemPositions[idx]
1033			item.SetHighlight(0, 0, pos.height-1, 9999)
1034		}
1035
1036		l.dirtyItems[idx] = true
1037	}
1038}
1039
1040// GetHighlightedText returns the plain text content of all highlighted regions
1041// across items, without any styling. Returns empty string if no highlights exist.
1042func (l *List) GetHighlightedText() string {
1043	l.ensureBuilt()
1044
1045	if l.masterBuffer == nil {
1046		return ""
1047	}
1048
1049	var result strings.Builder
1050
1051	// Iterate through items to find highlighted ones
1052	for i, item := range l.items {
1053		h, ok := item.(Highlightable)
1054		if !ok {
1055			continue
1056		}
1057
1058		startLine, startCol, endLine, endCol := h.GetHighlight()
1059		if startLine < 0 {
1060			continue
1061		}
1062
1063		pos := l.itemPositions[i]
1064
1065		// Extract text from highlighted region in master buffer
1066		for y := startLine; y <= endLine && y < pos.height; y++ {
1067			bufferY := pos.startLine + y
1068			if bufferY >= l.masterBuffer.Height() {
1069				break
1070			}
1071
1072			line := l.masterBuffer.Line(bufferY)
1073
1074			// Determine column range for this line
1075			colStart := 0
1076			if y == startLine {
1077				colStart = startCol
1078			}
1079
1080			colEnd := len(line)
1081			if y == endLine {
1082				colEnd = min(endCol, len(line))
1083			}
1084
1085			// Track last non-empty position to trim trailing spaces
1086			lastContentX := -1
1087			for x := colStart; x < colEnd && x < len(line); x++ {
1088				cell := line.At(x)
1089				if cell == nil || cell.IsZero() {
1090					continue
1091				}
1092				if cell.Content != "" && cell.Content != " " {
1093					lastContentX = x
1094				}
1095			}
1096
1097			// Extract text from cells using String() method, up to last content
1098			endX := colEnd
1099			if lastContentX >= 0 {
1100				endX = lastContentX + 1
1101			}
1102
1103			for x := colStart; x < endX && x < len(line); x++ {
1104				cell := line.At(x)
1105				if cell == nil || cell.IsZero() {
1106					continue
1107				}
1108				result.WriteString(cell.String())
1109			}
1110
1111			// Add newline between lines (but not after the last line)
1112			if y < endLine && y < pos.height-1 {
1113				result.WriteRune('\n')
1114			}
1115		}
1116
1117		// Add newline between items if there are more highlighted items
1118		if result.Len() > 0 {
1119			result.WriteRune('\n')
1120		}
1121	}
1122
1123	// Trim trailing newline if present
1124	text := result.String()
1125	return strings.TrimSuffix(text, "\n")
1126}