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