list.go

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