feat(ui): initial lazy-loaded list implementation

Ayman Bagabas created

Change summary

internal/ui/lazylist/item.go     |    8 
internal/ui/lazylist/list.go     |  440 ++++++++++++++
internal/ui/lazylist/list.go.bak |  413 +++++++++++++
internal/ui/list/lazylist.go     | 1007 ++++++++++++++++++++++++++++++++++
internal/ui/list/simplelist.go   |  972 ++++++++++++++++++++++++++++++++
internal/ui/model/chat.go        |   28 
internal/ui/model/items.go       |   37 +
internal/ui/model/ui.go          |    6 
8 files changed, 2,894 insertions(+), 17 deletions(-)

Detailed changes

internal/ui/lazylist/item.go 🔗

@@ -0,0 +1,8 @@
+package lazylist
+
+// Item represents a single item in the lazy-loaded list.
+type Item interface {
+	// Render returns the string representation of the item for the given
+	// width.
+	Render(width int) string
+}

internal/ui/lazylist/list.go 🔗

@@ -0,0 +1,440 @@
+package lazylist
+
+import (
+	"log/slog"
+	"strings"
+)
+
+// List represents a list of items that can be lazily rendered. A list is
+// always rendered like a chat conversation where items are stacked vertically
+// from top to bottom.
+type List struct {
+	// Viewport size
+	width, height int
+
+	// Items in the list
+	items []Item
+
+	// Gap between items (0 or less means no gap)
+	gap int
+
+	// Focus and selection state
+	focused     bool
+	selectedIdx int // The current selected index -1 means no selection
+
+	// Rendered content and cache
+	renderedItems map[int]renderedItem
+
+	// offsetIdx is the index of the first visible item in the viewport.
+	offsetIdx int
+	// offsetLine is the number of lines of the item at offsetIdx that are
+	// scrolled out of view (above the viewport).
+	// It must always be >= 0.
+	offsetLine int
+
+	// Dirty tracking
+	dirtyItems map[int]struct{}
+}
+
+// renderedItem holds the rendered content and height of an item.
+type renderedItem struct {
+	content string
+	height  int
+}
+
+// NewList creates a new lazy-loaded list.
+func NewList(items ...Item) *List {
+	l := new(List)
+	l.items = items
+	l.renderedItems = make(map[int]renderedItem)
+	l.dirtyItems = make(map[int]struct{})
+	return l
+}
+
+// SetSize sets the size of the list viewport.
+func (l *List) SetSize(width, height int) {
+	if width != l.width {
+		l.renderedItems = make(map[int]renderedItem)
+	}
+	l.width = width
+	l.height = height
+	// l.normalizeOffsets()
+}
+
+// SetGap sets the gap between items.
+func (l *List) SetGap(gap int) {
+	l.gap = gap
+}
+
+// Width returns the width of the list viewport.
+func (l *List) Width() int {
+	return l.width
+}
+
+// Height returns the height of the list viewport.
+func (l *List) Height() int {
+	return l.height
+}
+
+// Len returns the number of items in the list.
+func (l *List) Len() int {
+	return len(l.items)
+}
+
+// getItem renders (if needed) and returns the item at the given index.
+func (l *List) getItem(idx int) renderedItem {
+	if idx < 0 || idx >= len(l.items) {
+		return renderedItem{}
+	}
+
+	if item, ok := l.renderedItems[idx]; ok {
+		if _, dirty := l.dirtyItems[idx]; !dirty {
+			return item
+		}
+	}
+
+	item := l.items[idx]
+	rendered := item.Render(l.width)
+	height := countLines(rendered)
+	// slog.Info("Rendered item", "idx", idx, "height", height)
+
+	ri := renderedItem{
+		content: rendered,
+		height:  height,
+	}
+
+	l.renderedItems[idx] = ri
+	delete(l.dirtyItems, idx)
+
+	return ri
+}
+
+// ScrollToIndex scrolls the list to the given item index.
+func (l *List) ScrollToIndex(index int) {
+	if index < 0 {
+		index = 0
+	}
+	if index >= len(l.items) {
+		index = len(l.items) - 1
+	}
+	l.offsetIdx = index
+	l.offsetLine = 0
+}
+
+// ScrollBy scrolls the list by the given number of lines.
+func (l *List) ScrollBy(lines int) {
+	if len(l.items) == 0 || lines == 0 {
+		return
+	}
+
+	if lines > 0 {
+		// Scroll down
+		// Calculate from the bottom how many lines needed to anchor the last
+		// item to the bottom
+		var totalLines int
+		var lastItemIdx int // the last item that can be partially visible
+		for i := len(l.items) - 1; i >= 0; i-- {
+			item := l.getItem(i)
+			totalLines += item.height
+			if l.gap > 0 && i < len(l.items)-1 {
+				totalLines += l.gap
+			}
+			if totalLines >= l.height {
+				lastItemIdx = i
+				break
+			}
+		}
+
+		// Now scroll down by lines
+		var item renderedItem
+		l.offsetLine += lines
+		for {
+			item = l.getItem(l.offsetIdx)
+			totalHeight := item.height
+			if l.gap > 0 {
+				totalHeight += l.gap
+			}
+
+			if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
+				// Valid offset
+				break
+			}
+
+			// Move to next item
+			l.offsetLine -= totalHeight
+			l.offsetIdx++
+		}
+
+		if l.offsetLine >= item.height {
+			l.offsetLine = item.height - 1
+		}
+	} else if lines < 0 {
+		// Scroll up
+		l.offsetLine += lines
+		for l.offsetLine < 0 {
+			if l.offsetIdx == 0 {
+				// Reached the top of the list
+				l.offsetLine = 0
+				break
+			}
+
+			// Move to previous item
+			l.offsetIdx--
+			item := l.getItem(l.offsetIdx)
+			totalHeight := item.height
+			if l.gap > 0 {
+				totalHeight += l.gap
+			}
+			l.offsetLine += totalHeight
+		}
+
+		item := l.getItem(l.offsetIdx)
+		if l.offsetLine >= item.height {
+			l.offsetLine = item.height - 1
+		}
+	}
+}
+
+// findVisibleItems finds the range of items that are visible in the viewport.
+// This is used for checking if selected item is in view.
+func (l *List) findVisibleItems() (startIdx, endIdx int) {
+	if len(l.items) == 0 {
+		return 0, 0
+	}
+
+	startIdx = l.offsetIdx
+	currentIdx := startIdx
+	visibleHeight := -l.offsetLine
+
+	for currentIdx < len(l.items) {
+		item := l.getItem(currentIdx)
+		visibleHeight += item.height
+		if l.gap > 0 {
+			visibleHeight += l.gap
+		}
+
+		if visibleHeight >= l.height {
+			break
+		}
+		currentIdx++
+	}
+
+	endIdx = currentIdx
+	if endIdx >= len(l.items) {
+		endIdx = len(l.items) - 1
+	}
+
+	return startIdx, endIdx
+}
+
+// Render renders the list and returns the visible lines.
+func (l *List) Render() string {
+	if len(l.items) == 0 {
+		return ""
+	}
+
+	slog.Info("Render", "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine, "width", l.width, "height", l.height)
+
+	var lines []string
+	currentIdx := l.offsetIdx
+	currentOffset := l.offsetLine
+
+	linesNeeded := l.height
+
+	for linesNeeded > 0 && currentIdx < len(l.items) {
+		item := l.getItem(currentIdx)
+		itemLines := strings.Split(item.content, "\n")
+		itemHeight := len(itemLines)
+
+		if currentOffset < itemHeight {
+			// Add visible content lines
+			lines = append(lines, itemLines[currentOffset:]...)
+
+			// Add gap if this is not the absolute last visual element (conceptually gaps are between items)
+			// But in the loop we can just add it and trim later
+			if l.gap > 0 {
+				for i := 0; i < l.gap; i++ {
+					lines = append(lines, "")
+				}
+			}
+		} else {
+			// offsetLine starts in the gap
+			gapOffset := currentOffset - itemHeight
+			gapRemaining := l.gap - gapOffset
+			if gapRemaining > 0 {
+				for i := 0; i < gapRemaining; i++ {
+					lines = append(lines, "")
+				}
+			}
+		}
+
+		linesNeeded = l.height - len(lines)
+		currentIdx++
+		currentOffset = 0 // Reset offset for subsequent items
+	}
+
+	if len(lines) > l.height {
+		lines = lines[:l.height]
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+// PrependItems prepends items to the list.
+func (l *List) PrependItems(items ...Item) {
+	l.items = append(items, l.items...)
+
+	// Shift cache
+	newCache := make(map[int]renderedItem)
+	for idx, val := range l.renderedItems {
+		newCache[idx+len(items)] = val
+	}
+	l.renderedItems = newCache
+
+	// Shift dirty items
+	newDirty := make(map[int]struct{})
+	for idx := range l.dirtyItems {
+		newDirty[idx+len(items)] = struct{}{}
+	}
+	l.dirtyItems = newDirty
+
+	// Keep view position relative to the content that was visible
+	l.offsetIdx += len(items)
+
+	// Update selection index if valid
+	if l.selectedIdx != -1 {
+		l.selectedIdx += len(items)
+	}
+}
+
+// AppendItems appends items to the list.
+func (l *List) AppendItems(items ...Item) {
+	l.items = append(l.items, items...)
+}
+
+// Focus sets the focus state of the list.
+func (l *List) Focus() {
+	l.focused = true
+}
+
+// Blur removes the focus state from the list.
+func (l *List) Blur() {
+	l.focused = false
+}
+
+// ScrollToTop scrolls the list to the top.
+func (l *List) ScrollToTop() {
+	l.offsetIdx = 0
+	l.offsetLine = 0
+}
+
+// ScrollToBottom scrolls the list to the bottom.
+func (l *List) ScrollToBottom() {
+	if len(l.items) == 0 {
+		return
+	}
+
+	// Scroll to the last item
+	var totalHeight int
+	var i int
+	for i = len(l.items) - 1; i >= 0; i-- {
+		item := l.getItem(i)
+		totalHeight += item.height
+		if l.gap > 0 && i < len(l.items)-1 {
+			totalHeight += l.gap
+		}
+		if totalHeight >= l.height {
+			l.offsetIdx = i
+			l.offsetLine = totalHeight - l.height
+			break
+		}
+	}
+	if i < 0 {
+		// All items fit in the viewport
+		l.offsetIdx = 0
+		l.offsetLine = 0
+	}
+}
+
+// ScrollToSelected scrolls the list to the selected item.
+func (l *List) ScrollToSelected() {
+	// TODO: Implement me
+}
+
+// SelectedItemInView returns whether the selected item is currently in view.
+func (l *List) SelectedItemInView() bool {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return false
+	}
+	startIdx, endIdx := l.findVisibleItems()
+	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
+}
+
+// SetSelected sets the selected item index in the list.
+func (l *List) SetSelected(index int) {
+	if index < 0 || index >= len(l.items) {
+		l.selectedIdx = -1
+	} else {
+		l.selectedIdx = index
+	}
+}
+
+// SelectPrev selects the previous item in the list.
+func (l *List) SelectPrev() {
+	if l.selectedIdx > 0 {
+		l.selectedIdx--
+	}
+}
+
+// SelectNext selects the next item in the list.
+func (l *List) SelectNext() {
+	if l.selectedIdx < len(l.items)-1 {
+		l.selectedIdx++
+	}
+}
+
+// SelectFirst selects the first item in the list.
+func (l *List) SelectFirst() {
+	if len(l.items) > 0 {
+		l.selectedIdx = 0
+	}
+}
+
+// SelectLast selects the last item in the list.
+func (l *List) SelectLast() {
+	if len(l.items) > 0 {
+		l.selectedIdx = len(l.items) - 1
+	}
+}
+
+// SelectFirstInView selects the first item currently in view.
+func (l *List) SelectFirstInView() {
+	startIdx, _ := l.findVisibleItems()
+	l.selectedIdx = startIdx
+}
+
+// SelectLastInView selects the last item currently in view.
+func (l *List) SelectLastInView() {
+	_, endIdx := l.findVisibleItems()
+	l.selectedIdx = endIdx
+}
+
+// HandleMouseDown handles mouse down events at the given line in the viewport.
+func (l *List) HandleMouseDown(x, y int) {
+}
+
+// HandleMouseUp handles mouse up events at the given line in the viewport.
+func (l *List) HandleMouseUp(x, y int) {
+}
+
+// HandleMouseDrag handles mouse drag events at the given line in the viewport.
+func (l *List) HandleMouseDrag(x, y int) {
+}
+
+// countLines counts the number of lines in a string.
+func countLines(s string) int {
+	if s == "" {
+		return 0
+	}
+	return strings.Count(s, "\n") + 1
+}

internal/ui/lazylist/list.go.bak 🔗

@@ -0,0 +1,413 @@
+package lazylist
+
+import (
+	"log/slog"
+	"strings"
+)
+
+// List represents a list of items that can be lazily rendered. A list is
+// always rendered like a chat conversation where items are stacked vertically
+// from top to bottom.
+type List struct {
+	// Viewport size
+	width, height int
+
+	// Items in the list
+	items []Item
+
+	// Gap between items (0 or less means no gap)
+	gap int
+
+	// Focus and selection state
+	focused     bool
+	selectedIdx int // The current selected index -1 means no selection
+
+	// Item positioning. If a position exists in the map, it means the item has
+	// been rendered and measured.
+	itemPositions map[int]itemPosition
+
+	// Rendered content and cache
+	lines         []string
+	renderedItems map[int]renderedItem
+	offsetIdx     int // Index of the first visible item in the viewport
+	offsetLine    int // The offset line from the start of the offsetIdx item (can be negative)
+
+	// Dirty tracking
+	dirtyItems map[int]struct{}
+}
+
+// renderedItem holds the rendered content and height of an item.
+type renderedItem struct {
+	content string
+	height  int
+}
+
+// itemPosition holds the start and end line of an item in the list.
+type itemPosition struct {
+	startLine int
+	endLine   int
+}
+
+// Height returns the height of item based on its start and end lines.
+func (ip itemPosition) Height() int {
+	return ip.endLine - ip.startLine
+}
+
+// NewList creates a new lazy-loaded list.
+func NewList(items ...Item) *List {
+	l := new(List)
+	l.items = items
+	l.itemPositions = make(map[int]itemPosition)
+	l.renderedItems = make(map[int]renderedItem)
+	l.dirtyItems = make(map[int]struct{})
+	return l
+}
+
+// SetSize sets the size of the list viewport.
+func (l *List) SetSize(width, height int) {
+	if width != l.width {
+		// Mark all rendered items as dirty if width changes because their
+		// layout may change.
+		for idx := range l.itemPositions {
+			l.dirtyItems[idx] = struct{}{}
+		}
+	}
+	l.width = width
+	l.height = height
+}
+
+// SetGap sets the gap between items.
+func (l *List) SetGap(gap int) {
+	l.gap = gap
+}
+
+// Width returns the width of the list viewport.
+func (l *List) Width() int {
+	return l.width
+}
+
+// Height returns the height of the list viewport.
+func (l *List) Height() int {
+	return l.height
+}
+
+// Len returns the number of items in the list.
+func (l *List) Len() int {
+	return len(l.items)
+}
+
+// renderItem renders the item at the given index and updates its cache and
+// position.
+func (l *List) renderItem(idx int) {
+	if idx < 0 || idx >= len(l.items) {
+		return
+	}
+
+	item := l.items[idx]
+	rendered := item.Render(l.width)
+	height := countLines(rendered)
+
+	l.renderedItems[idx] = renderedItem{
+		content: rendered,
+		height:  height,
+	}
+
+	// Calculate item position
+	var startLine int
+	if idx == 0 {
+		startLine = 0
+	} else {
+		prevPos, ok := l.itemPositions[idx-1]
+		if !ok {
+			l.renderItem(idx - 1)
+			prevPos = l.itemPositions[idx-1]
+		}
+		startLine = prevPos.endLine
+		if l.gap > 0 {
+			startLine += l.gap
+		}
+	}
+	endLine := startLine + height
+
+	l.itemPositions[idx] = itemPosition{
+		startLine: startLine,
+		endLine:   endLine,
+	}
+}
+
+// ScrollToIndex scrolls the list to the given item index.
+func (l *List) ScrollToIndex(index int) {
+	if index < 0 || index >= len(l.items) {
+		return
+	}
+	l.offsetIdx = index
+	l.offsetLine = 0
+}
+
+// ScrollBy scrolls the list by the given number of lines.
+func (l *List) ScrollBy(lines int) {
+	l.offsetLine += lines
+	if l.offsetIdx <= 0 && l.offsetLine < 0 {
+		l.offsetIdx = 0
+		l.offsetLine = 0
+		return
+	}
+
+	// Adjust offset index and line if needed
+	for l.offsetLine < 0 && l.offsetIdx > 0 {
+		// Move up to previous item
+		l.offsetIdx--
+		prevPos, ok := l.itemPositions[l.offsetIdx]
+		if !ok {
+			l.renderItem(l.offsetIdx)
+			prevPos = l.itemPositions[l.offsetIdx]
+		}
+		l.offsetLine += prevPos.Height()
+		if l.gap > 0 {
+			l.offsetLine += l.gap
+		}
+	}
+
+	for {
+		currentPos, ok := l.itemPositions[l.offsetIdx]
+		if !ok {
+			l.renderItem(l.offsetIdx)
+			currentPos = l.itemPositions[l.offsetIdx]
+		}
+		if l.offsetLine >= currentPos.Height() {
+			// Move down to next item
+			l.offsetLine -= currentPos.Height()
+			if l.gap > 0 {
+				l.offsetLine -= l.gap
+			}
+			l.offsetIdx++
+			if l.offsetIdx >= len(l.items) {
+				l.offsetIdx = len(l.items) - 1
+				l.offsetLine = currentPos.Height() - 1
+				break
+			}
+		} else {
+			break
+		}
+	}
+}
+
+// findVisibleItems finds the range of items that are visible in the viewport.
+func (l *List) findVisibleItems() (startIdx, endIdx int) {
+	startIdx = l.offsetIdx
+	endIdx = startIdx + 1
+
+	// Render items until we fill the viewport
+	visibleHeight := -l.offsetLine
+	for endIdx < len(l.items) {
+		pos, ok := l.itemPositions[endIdx-1]
+		if !ok {
+			l.renderItem(endIdx - 1)
+			pos = l.itemPositions[endIdx-1]
+		}
+		visibleHeight += pos.Height()
+		if endIdx-1 < len(l.items)-1 && l.gap > 0 {
+			visibleHeight += l.gap
+		}
+		if visibleHeight >= l.height {
+			break
+		}
+		endIdx++
+	}
+
+	if endIdx > len(l.items)-1 {
+		endIdx = len(l.items) - 1
+	}
+
+	return startIdx, endIdx
+}
+
+// renderLines renders the items between startIdx and endIdx into lines.
+func (l *List) renderLines(startIdx, endIdx int) []string {
+	var lines []string
+	for idx := startIdx; idx < endIdx+1; idx++ {
+		rendered, ok := l.renderedItems[idx]
+		if !ok {
+			l.renderItem(idx)
+			rendered = l.renderedItems[idx]
+		}
+		itemLines := strings.Split(rendered.content, "\n")
+		lines = append(lines, itemLines...)
+		if l.gap > 0 && idx < endIdx {
+			for i := 0; i < l.gap; i++ {
+				lines = append(lines, "")
+			}
+		}
+	}
+	return lines
+}
+
+// Render renders the list and returns the visible lines.
+func (l *List) Render() string {
+	viewStartIdx, viewEndIdx := l.findVisibleItems()
+	slog.Info("Render", "viewStartIdx", viewStartIdx, "viewEndIdx", viewEndIdx, "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine)
+
+	for idx := range l.dirtyItems {
+		if idx >= viewStartIdx && idx <= viewEndIdx {
+			l.renderItem(idx)
+			delete(l.dirtyItems, idx)
+		}
+	}
+
+	lines := l.renderLines(viewStartIdx, viewEndIdx)
+	for len(lines) < l.height {
+		viewStartIdx--
+		if viewStartIdx <= 0 {
+			break
+		}
+
+		lines = l.renderLines(viewStartIdx, viewEndIdx)
+	}
+
+	if len(lines) > l.height {
+		lines = lines[:l.height]
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+// PrependItems prepends items to the list.
+func (l *List) PrependItems(items ...Item) {
+	l.items = append(items, l.items...)
+	// Shift existing item positions
+	newItemPositions := make(map[int]itemPosition)
+	for idx, pos := range l.itemPositions {
+		newItemPositions[idx+len(items)] = pos
+	}
+	l.itemPositions = newItemPositions
+
+	// Mark all items as dirty
+	for idx := range l.items {
+		l.dirtyItems[idx] = struct{}{}
+	}
+
+	// Adjust offset index
+	l.offsetIdx += len(items)
+}
+
+// AppendItems appends items to the list.
+func (l *List) AppendItems(items ...Item) {
+	l.items = append(l.items, items...)
+	for idx := len(l.items) - len(items); idx < len(l.items); idx++ {
+		l.dirtyItems[idx] = struct{}{}
+	}
+}
+
+// Focus sets the focus state of the list.
+func (l *List) Focus() {
+	l.focused = true
+}
+
+// Blur removes the focus state from the list.
+func (l *List) Blur() {
+	l.focused = false
+}
+
+// ScrollToTop scrolls the list to the top.
+func (l *List) ScrollToTop() {
+	l.offsetIdx = 0
+	l.offsetLine = 0
+}
+
+// ScrollToBottom scrolls the list to the bottom.
+func (l *List) ScrollToBottom() {
+	l.offsetIdx = len(l.items) - 1
+	pos, ok := l.itemPositions[l.offsetIdx]
+	if !ok {
+		l.renderItem(l.offsetIdx)
+		pos = l.itemPositions[l.offsetIdx]
+	}
+	l.offsetLine = l.height - pos.Height()
+}
+
+// ScrollToSelected scrolls the list to the selected item.
+func (l *List) ScrollToSelected() {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return
+	}
+	l.offsetIdx = l.selectedIdx
+	l.offsetLine = 0
+}
+
+// SelectedItemInView returns whether the selected item is currently in view.
+func (l *List) SelectedItemInView() bool {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return false
+	}
+	startIdx, endIdx := l.findVisibleItems()
+	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
+}
+
+// SetSelected sets the selected item index in the list.
+func (l *List) SetSelected(index int) {
+	if index < 0 || index >= len(l.items) {
+		l.selectedIdx = -1
+	} else {
+		l.selectedIdx = index
+	}
+}
+
+// SelectPrev selects the previous item in the list.
+func (l *List) SelectPrev() {
+	if l.selectedIdx > 0 {
+		l.selectedIdx--
+	}
+}
+
+// SelectNext selects the next item in the list.
+func (l *List) SelectNext() {
+	if l.selectedIdx < len(l.items)-1 {
+		l.selectedIdx++
+	}
+}
+
+// SelectFirst selects the first item in the list.
+func (l *List) SelectFirst() {
+	if len(l.items) > 0 {
+		l.selectedIdx = 0
+	}
+}
+
+// SelectLast selects the last item in the list.
+func (l *List) SelectLast() {
+	if len(l.items) > 0 {
+		l.selectedIdx = len(l.items) - 1
+	}
+}
+
+// SelectFirstInView selects the first item currently in view.
+func (l *List) SelectFirstInView() {
+	startIdx, _ := l.findVisibleItems()
+	l.selectedIdx = startIdx
+}
+
+// SelectLastInView selects the last item currently in view.
+func (l *List) SelectLastInView() {
+	_, endIdx := l.findVisibleItems()
+	l.selectedIdx = endIdx
+}
+
+// HandleMouseDown handles mouse down events at the given line in the viewport.
+func (l *List) HandleMouseDown(x, y int) {
+}
+
+// HandleMouseUp handles mouse up events at the given line in the viewport.
+func (l *List) HandleMouseUp(x, y int) {
+}
+
+// HandleMouseDrag handles mouse drag events at the given line in the viewport.
+func (l *List) HandleMouseDrag(x, y int) {
+}
+
+// countLines counts the number of lines in a string.
+func countLines(s string) int {
+	if s == "" {
+		return 0
+	}
+	return strings.Count(s, "\n") + 1
+}

internal/ui/list/lazylist.go 🔗

@@ -0,0 +1,1007 @@
+package list
+
+import (
+	"strings"
+
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/ultraviolet/screen"
+)
+
+// LazyList is a virtual scrolling list that only renders visible items.
+// It uses height estimates to avoid expensive renders during initial layout.
+type LazyList struct {
+	// Configuration
+	width, height int
+
+	// Data
+	items []Item
+
+	// Focus & Selection
+	focused     bool
+	selectedIdx int // Currently selected item index (-1 if none)
+
+	// Item positioning - tracks measured and estimated positions
+	itemHeights []itemHeight
+	totalHeight int // Sum of all item heights (measured or estimated)
+
+	// Viewport state
+	offset int // Scroll offset in lines from top
+
+	// Rendered items cache - only visible items are rendered
+	renderedCache map[int]*renderedItemCache
+
+	// Virtual scrolling configuration
+	defaultEstimate int // Default height estimate for unmeasured items
+	overscan        int // Number of items to render outside viewport for smooth scrolling
+
+	// Dirty tracking
+	needsLayout   bool
+	dirtyItems    map[int]bool
+	dirtyViewport bool // True if we need to re-render viewport
+
+	// Mouse state
+	mouseDown     bool
+	mouseDownItem int
+	mouseDownX    int
+	mouseDownY    int
+	mouseDragItem int
+	mouseDragX    int
+	mouseDragY    int
+}
+
+// itemHeight tracks the height of an item - either measured or estimated.
+type itemHeight struct {
+	height   int
+	measured bool // true if height is actual measurement, false if estimate
+}
+
+// renderedItemCache stores a rendered item's buffer.
+type renderedItemCache struct {
+	buffer *uv.ScreenBuffer
+	height int // Actual measured height after rendering
+}
+
+// NewLazyList creates a new lazy-rendering list.
+func NewLazyList(items ...Item) *LazyList {
+	l := &LazyList{
+		items:           items,
+		itemHeights:     make([]itemHeight, len(items)),
+		renderedCache:   make(map[int]*renderedItemCache),
+		dirtyItems:      make(map[int]bool),
+		selectedIdx:     -1,
+		mouseDownItem:   -1,
+		mouseDragItem:   -1,
+		defaultEstimate: 10, // Conservative estimate: 5 lines per item
+		overscan:        5,  // Render 3 items above/below viewport
+		needsLayout:     true,
+		dirtyViewport:   true,
+	}
+
+	// Initialize all items with estimated heights
+	for i := range l.items {
+		l.itemHeights[i] = itemHeight{
+			height:   l.defaultEstimate,
+			measured: false,
+		}
+	}
+	l.calculateTotalHeight()
+
+	return l
+}
+
+// calculateTotalHeight sums all item heights (measured or estimated).
+func (l *LazyList) calculateTotalHeight() {
+	l.totalHeight = 0
+	for _, h := range l.itemHeights {
+		l.totalHeight += h.height
+	}
+}
+
+// getItemPosition returns the Y position where an item starts.
+func (l *LazyList) getItemPosition(idx int) int {
+	pos := 0
+	for i := 0; i < idx && i < len(l.itemHeights); i++ {
+		pos += l.itemHeights[i].height
+	}
+	return pos
+}
+
+// findVisibleItems returns the range of items that are visible or near the viewport.
+func (l *LazyList) findVisibleItems() (firstIdx, lastIdx int) {
+	if len(l.items) == 0 {
+		return 0, 0
+	}
+
+	viewportStart := l.offset
+	viewportEnd := l.offset + l.height
+
+	// Find first visible item
+	firstIdx = -1
+	pos := 0
+	for i := 0; i < len(l.items); i++ {
+		itemEnd := pos + l.itemHeights[i].height
+		if itemEnd > viewportStart {
+			firstIdx = i
+			break
+		}
+		pos = itemEnd
+	}
+
+	// Apply overscan above
+	firstIdx = max(0, firstIdx-l.overscan)
+
+	// Find last visible item
+	lastIdx = firstIdx
+	pos = l.getItemPosition(firstIdx)
+	for i := firstIdx; i < len(l.items); i++ {
+		if pos >= viewportEnd {
+			break
+		}
+		pos += l.itemHeights[i].height
+		lastIdx = i
+	}
+
+	// Apply overscan below
+	lastIdx = min(len(l.items)-1, lastIdx+l.overscan)
+
+	return firstIdx, lastIdx
+}
+
+// renderItem renders a single item and caches it.
+// Returns the actual measured height.
+func (l *LazyList) renderItem(idx int) int {
+	if idx < 0 || idx >= len(l.items) {
+		return 0
+	}
+
+	item := l.items[idx]
+
+	// Measure actual height
+	actualHeight := item.Height(l.width)
+
+	// Create buffer and render
+	buf := uv.NewScreenBuffer(l.width, actualHeight)
+	area := uv.Rect(0, 0, l.width, actualHeight)
+	item.Draw(&buf, area)
+
+	// Cache rendered item
+	l.renderedCache[idx] = &renderedItemCache{
+		buffer: &buf,
+		height: actualHeight,
+	}
+
+	// Update height if it was estimated or changed
+	if !l.itemHeights[idx].measured || l.itemHeights[idx].height != actualHeight {
+		oldHeight := l.itemHeights[idx].height
+		l.itemHeights[idx] = itemHeight{
+			height:   actualHeight,
+			measured: true,
+		}
+
+		// Adjust total height
+		l.totalHeight += actualHeight - oldHeight
+	}
+
+	return actualHeight
+}
+
+// Draw implements uv.Drawable.
+func (l *LazyList) Draw(scr uv.Screen, area uv.Rectangle) {
+	if area.Dx() <= 0 || area.Dy() <= 0 {
+		return
+	}
+
+	widthChanged := l.width != area.Dx()
+	heightChanged := l.height != area.Dy()
+
+	l.width = area.Dx()
+	l.height = area.Dy()
+
+	// Width changes invalidate all cached renders
+	if widthChanged {
+		l.renderedCache = make(map[int]*renderedItemCache)
+		// Mark all heights as needing remeasurement
+		for i := range l.itemHeights {
+			l.itemHeights[i].measured = false
+			l.itemHeights[i].height = l.defaultEstimate
+		}
+		l.calculateTotalHeight()
+		l.needsLayout = true
+		l.dirtyViewport = true
+	}
+
+	if heightChanged {
+		l.clampOffset()
+		l.dirtyViewport = true
+	}
+
+	if len(l.items) == 0 {
+		screen.ClearArea(scr, area)
+		return
+	}
+
+	// Find visible items based on current estimates
+	firstIdx, lastIdx := l.findVisibleItems()
+
+	// Track the first visible item's position to maintain stability
+	// Only stabilize if we're not at the top boundary
+	stabilizeIdx := -1
+	stabilizeY := 0
+	if l.offset > 0 {
+		for i := firstIdx; i <= lastIdx; i++ {
+			itemPos := l.getItemPosition(i)
+			if itemPos >= l.offset {
+				stabilizeIdx = i
+				stabilizeY = itemPos
+				break
+			}
+		}
+	}
+
+	// Track if any heights changed during rendering
+	heightsChanged := false
+
+	// Render visible items that aren't cached (measurement pass)
+	for i := firstIdx; i <= lastIdx; i++ {
+		if _, cached := l.renderedCache[i]; !cached {
+			oldHeight := l.itemHeights[i].height
+			l.renderItem(i)
+			if l.itemHeights[i].height != oldHeight {
+				heightsChanged = true
+			}
+		} else if l.dirtyItems[i] {
+			// Re-render dirty items
+			oldHeight := l.itemHeights[i].height
+			l.renderItem(i)
+			delete(l.dirtyItems, i)
+			if l.itemHeights[i].height != oldHeight {
+				heightsChanged = true
+			}
+		}
+	}
+
+	// If heights changed, adjust offset to keep stabilization point stable
+	if heightsChanged && stabilizeIdx >= 0 {
+		newStabilizeY := l.getItemPosition(stabilizeIdx)
+		offsetDelta := newStabilizeY - stabilizeY
+
+		// Adjust offset to maintain visual stability
+		l.offset += offsetDelta
+		l.clampOffset()
+
+		// Re-find visible items with adjusted positions
+		firstIdx, lastIdx = l.findVisibleItems()
+
+		// Render any newly visible items after position adjustments
+		for i := firstIdx; i <= lastIdx; i++ {
+			if _, cached := l.renderedCache[i]; !cached {
+				l.renderItem(i)
+			}
+		}
+	}
+
+	// Clear old cache entries outside visible range
+	if len(l.renderedCache) > (lastIdx-firstIdx+1)*2 {
+		l.pruneCache(firstIdx, lastIdx)
+	}
+
+	// Composite visible items into viewport with stable positions
+	l.drawViewport(scr, area, firstIdx, lastIdx)
+
+	l.dirtyViewport = false
+	l.needsLayout = false
+}
+
+// drawViewport composites visible items into the screen.
+func (l *LazyList) drawViewport(scr uv.Screen, area uv.Rectangle, firstIdx, lastIdx int) {
+	screen.ClearArea(scr, area)
+
+	itemStartY := l.getItemPosition(firstIdx)
+
+	for i := firstIdx; i <= lastIdx; i++ {
+		cached, ok := l.renderedCache[i]
+		if !ok {
+			continue
+		}
+
+		// Calculate where this item appears in viewport
+		itemY := itemStartY - l.offset
+		itemHeight := cached.height
+
+		// Skip if entirely above viewport
+		if itemY+itemHeight < 0 {
+			itemStartY += itemHeight
+			continue
+		}
+
+		// Stop if entirely below viewport
+		if itemY >= l.height {
+			break
+		}
+
+		// Calculate visible portion of item
+		srcStartY := 0
+		dstStartY := itemY
+
+		if itemY < 0 {
+			// Item starts above viewport
+			srcStartY = -itemY
+			dstStartY = 0
+		}
+
+		srcEndY := srcStartY + (l.height - dstStartY)
+		if srcEndY > itemHeight {
+			srcEndY = itemHeight
+		}
+
+		// Copy visible lines from item buffer to screen
+		buf := cached.buffer.Buffer
+		destY := area.Min.Y + dstStartY
+
+		for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
+			if srcY >= buf.Height() {
+				break
+			}
+
+			line := buf.Line(srcY)
+			destX := area.Min.X
+
+			for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
+				cell := line.At(x)
+				scr.SetCell(destX, destY, cell)
+				destX++
+			}
+			destY++
+		}
+
+		itemStartY += itemHeight
+	}
+}
+
+// pruneCache removes cached items outside the visible range.
+func (l *LazyList) pruneCache(firstIdx, lastIdx int) {
+	keepStart := max(0, firstIdx-l.overscan*2)
+	keepEnd := min(len(l.items)-1, lastIdx+l.overscan*2)
+
+	for idx := range l.renderedCache {
+		if idx < keepStart || idx > keepEnd {
+			delete(l.renderedCache, idx)
+		}
+	}
+}
+
+// clampOffset ensures scroll offset stays within valid bounds.
+func (l *LazyList) clampOffset() {
+	maxOffset := l.totalHeight - l.height
+	if maxOffset < 0 {
+		maxOffset = 0
+	}
+
+	if l.offset > maxOffset {
+		l.offset = maxOffset
+	}
+	if l.offset < 0 {
+		l.offset = 0
+	}
+}
+
+// SetItems replaces all items in the list.
+func (l *LazyList) SetItems(items []Item) {
+	l.items = items
+	l.itemHeights = make([]itemHeight, len(items))
+	l.renderedCache = make(map[int]*renderedItemCache)
+	l.dirtyItems = make(map[int]bool)
+
+	// Initialize with estimates
+	for i := range l.items {
+		l.itemHeights[i] = itemHeight{
+			height:   l.defaultEstimate,
+			measured: false,
+		}
+	}
+	l.calculateTotalHeight()
+	l.needsLayout = true
+	l.dirtyViewport = true
+}
+
+// AppendItem adds an item to the end of the list.
+func (l *LazyList) AppendItem(item Item) {
+	l.items = append(l.items, item)
+	l.itemHeights = append(l.itemHeights, itemHeight{
+		height:   l.defaultEstimate,
+		measured: false,
+	})
+	l.totalHeight += l.defaultEstimate
+	l.dirtyViewport = true
+}
+
+// PrependItem adds an item to the beginning of the list.
+func (l *LazyList) PrependItem(item Item) {
+	l.items = append([]Item{item}, l.items...)
+	l.itemHeights = append([]itemHeight{{
+		height:   l.defaultEstimate,
+		measured: false,
+	}}, l.itemHeights...)
+
+	// Shift cache indices
+	newCache := make(map[int]*renderedItemCache)
+	for idx, cached := range l.renderedCache {
+		newCache[idx+1] = cached
+	}
+	l.renderedCache = newCache
+
+	l.totalHeight += l.defaultEstimate
+	l.offset += l.defaultEstimate // Maintain scroll position
+	l.dirtyViewport = true
+}
+
+// UpdateItem replaces an item at the given index.
+func (l *LazyList) UpdateItem(idx int, item Item) {
+	if idx < 0 || idx >= len(l.items) {
+		return
+	}
+
+	l.items[idx] = item
+	delete(l.renderedCache, idx)
+	l.dirtyItems[idx] = true
+	// Keep height estimate - will remeasure on next render
+	l.dirtyViewport = true
+}
+
+// ScrollBy scrolls by the given number of lines.
+func (l *LazyList) ScrollBy(delta int) {
+	l.offset += delta
+	l.clampOffset()
+	l.dirtyViewport = true
+}
+
+// ScrollToBottom scrolls to the end of the list.
+func (l *LazyList) ScrollToBottom() {
+	l.offset = l.totalHeight - l.height
+	l.clampOffset()
+	l.dirtyViewport = true
+}
+
+// ScrollToTop scrolls to the beginning of the list.
+func (l *LazyList) ScrollToTop() {
+	l.offset = 0
+	l.dirtyViewport = true
+}
+
+// Len returns the number of items in the list.
+func (l *LazyList) Len() int {
+	return len(l.items)
+}
+
+// Focus sets the list as focused.
+func (l *LazyList) Focus() {
+	l.focused = true
+	l.focusSelectedItem()
+	l.dirtyViewport = true
+}
+
+// Blur removes focus from the list.
+func (l *LazyList) Blur() {
+	l.focused = false
+	l.blurSelectedItem()
+	l.dirtyViewport = true
+}
+
+// focusSelectedItem focuses the currently selected item if it's focusable.
+func (l *LazyList) focusSelectedItem() {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return
+	}
+
+	item := l.items[l.selectedIdx]
+	if f, ok := item.(Focusable); ok {
+		f.Focus()
+		delete(l.renderedCache, l.selectedIdx)
+		l.dirtyItems[l.selectedIdx] = true
+	}
+}
+
+// blurSelectedItem blurs the currently selected item if it's focusable.
+func (l *LazyList) blurSelectedItem() {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return
+	}
+
+	item := l.items[l.selectedIdx]
+	if f, ok := item.(Focusable); ok {
+		f.Blur()
+		delete(l.renderedCache, l.selectedIdx)
+		l.dirtyItems[l.selectedIdx] = true
+	}
+}
+
+// IsFocused returns whether the list is focused.
+func (l *LazyList) IsFocused() bool {
+	return l.focused
+}
+
+// Width returns the current viewport width.
+func (l *LazyList) Width() int {
+	return l.width
+}
+
+// Height returns the current viewport height.
+func (l *LazyList) Height() int {
+	return l.height
+}
+
+// SetSize sets the viewport size explicitly.
+// This is useful when you want to pre-configure the list size before drawing.
+func (l *LazyList) SetSize(width, height int) {
+	widthChanged := l.width != width
+	heightChanged := l.height != height
+
+	l.width = width
+	l.height = height
+
+	// Width changes invalidate all cached renders
+	if widthChanged && width > 0 {
+		l.renderedCache = make(map[int]*renderedItemCache)
+		// Mark all heights as needing remeasurement
+		for i := range l.itemHeights {
+			l.itemHeights[i].measured = false
+			l.itemHeights[i].height = l.defaultEstimate
+		}
+		l.calculateTotalHeight()
+		l.needsLayout = true
+		l.dirtyViewport = true
+	}
+
+	if heightChanged && height > 0 {
+		l.clampOffset()
+		l.dirtyViewport = true
+	}
+
+	// After cache invalidation, scroll to selected item or bottom
+	if widthChanged || heightChanged {
+		if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
+			// Scroll to selected item
+			l.ScrollToSelected()
+		} else if len(l.items) > 0 {
+			// No selection - scroll to bottom
+			l.ScrollToBottom()
+		}
+	}
+}
+
+// Selection methods
+
+// Selected returns the currently selected item index (-1 if none).
+func (l *LazyList) Selected() int {
+	return l.selectedIdx
+}
+
+// SetSelected sets the selected item by index.
+func (l *LazyList) SetSelected(idx int) {
+	if idx < -1 || idx >= len(l.items) {
+		return
+	}
+
+	if l.selectedIdx != idx {
+		prevIdx := l.selectedIdx
+		l.selectedIdx = idx
+		l.dirtyViewport = true
+
+		// Update focus states if list is focused.
+		if l.focused {
+			// Blur previously selected item.
+			if prevIdx >= 0 && prevIdx < len(l.items) {
+				if f, ok := l.items[prevIdx].(Focusable); ok {
+					f.Blur()
+					delete(l.renderedCache, prevIdx)
+					l.dirtyItems[prevIdx] = true
+				}
+			}
+
+			// Focus newly selected item.
+			if idx >= 0 && idx < len(l.items) {
+				if f, ok := l.items[idx].(Focusable); ok {
+					f.Focus()
+					delete(l.renderedCache, idx)
+					l.dirtyItems[idx] = true
+				}
+			}
+		}
+	}
+}
+
+// SelectPrev selects the previous item.
+func (l *LazyList) SelectPrev() {
+	if len(l.items) == 0 {
+		return
+	}
+
+	if l.selectedIdx <= 0 {
+		l.selectedIdx = 0
+	} else {
+		l.selectedIdx--
+	}
+
+	l.dirtyViewport = true
+}
+
+// SelectNext selects the next item.
+func (l *LazyList) SelectNext() {
+	if len(l.items) == 0 {
+		return
+	}
+
+	if l.selectedIdx < 0 {
+		l.selectedIdx = 0
+	} else if l.selectedIdx < len(l.items)-1 {
+		l.selectedIdx++
+	}
+
+	l.dirtyViewport = true
+}
+
+// SelectFirst selects the first item.
+func (l *LazyList) SelectFirst() {
+	if len(l.items) > 0 {
+		l.selectedIdx = 0
+		l.dirtyViewport = true
+	}
+}
+
+// SelectLast selects the last item.
+func (l *LazyList) SelectLast() {
+	if len(l.items) > 0 {
+		l.selectedIdx = len(l.items) - 1
+		l.dirtyViewport = true
+	}
+}
+
+// SelectFirstInView selects the first visible item in the viewport.
+func (l *LazyList) SelectFirstInView() {
+	if len(l.items) == 0 {
+		return
+	}
+
+	firstIdx, _ := l.findVisibleItems()
+	l.selectedIdx = firstIdx
+	l.dirtyViewport = true
+}
+
+// SelectLastInView selects the last visible item in the viewport.
+func (l *LazyList) SelectLastInView() {
+	if len(l.items) == 0 {
+		return
+	}
+
+	_, lastIdx := l.findVisibleItems()
+	l.selectedIdx = lastIdx
+	l.dirtyViewport = true
+}
+
+// SelectedItemInView returns whether the selected item is visible in the viewport.
+func (l *LazyList) SelectedItemInView() bool {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return false
+	}
+
+	firstIdx, lastIdx := l.findVisibleItems()
+	return l.selectedIdx >= firstIdx && l.selectedIdx <= lastIdx
+}
+
+// ScrollToSelected scrolls the viewport to ensure the selected item is visible.
+func (l *LazyList) ScrollToSelected() {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return
+	}
+
+	// Get selected item position
+	itemY := l.getItemPosition(l.selectedIdx)
+	itemHeight := l.itemHeights[l.selectedIdx].height
+
+	// Check if item is above viewport
+	if itemY < l.offset {
+		l.offset = itemY
+		l.dirtyViewport = true
+		return
+	}
+
+	// Check if item is below viewport
+	itemBottom := itemY + itemHeight
+	viewportBottom := l.offset + l.height
+
+	if itemBottom > viewportBottom {
+		// Scroll so item bottom is at viewport bottom
+		l.offset = itemBottom - l.height
+		l.clampOffset()
+		l.dirtyViewport = true
+	}
+}
+
+// Mouse interaction methods
+
+// HandleMouseDown handles mouse button down events.
+// Returns true if the event was handled.
+func (l *LazyList) HandleMouseDown(x, y int) bool {
+	if x < 0 || y < 0 || x >= l.width || y >= l.height {
+		return false
+	}
+
+	// Find which item was clicked
+	clickY := l.offset + y
+	itemIdx := l.findItemAtY(clickY)
+
+	if itemIdx < 0 {
+		return false
+	}
+
+	// Calculate item-relative Y position.
+	itemY := clickY - l.getItemPosition(itemIdx)
+
+	l.mouseDown = true
+	l.mouseDownItem = itemIdx
+	l.mouseDownX = x
+	l.mouseDownY = itemY
+	l.mouseDragItem = itemIdx
+	l.mouseDragX = x
+	l.mouseDragY = itemY
+
+	// Select the clicked item
+	l.SetSelected(itemIdx)
+
+	return true
+}
+
+// HandleMouseDrag handles mouse drag events.
+func (l *LazyList) HandleMouseDrag(x, y int) {
+	if !l.mouseDown {
+		return
+	}
+
+	// Find item under cursor
+	if y >= 0 && y < l.height {
+		dragY := l.offset + y
+		itemIdx := l.findItemAtY(dragY)
+		if itemIdx >= 0 {
+			l.mouseDragItem = itemIdx
+			// Calculate item-relative Y position.
+			l.mouseDragY = dragY - l.getItemPosition(itemIdx)
+			l.mouseDragX = x
+		}
+	}
+
+	// Update highlight if item supports it.
+	l.updateHighlight()
+}
+
+// HandleMouseUp handles mouse button up events.
+func (l *LazyList) HandleMouseUp(x, y int) {
+	if !l.mouseDown {
+		return
+	}
+
+	l.mouseDown = false
+
+	// Final highlight update.
+	l.updateHighlight()
+}
+
+// findItemAtY finds the item index at the given Y coordinate (in content space, not viewport).
+func (l *LazyList) findItemAtY(y int) int {
+	if y < 0 || len(l.items) == 0 {
+		return -1
+	}
+
+	pos := 0
+	for i := 0; i < len(l.items); i++ {
+		itemHeight := l.itemHeights[i].height
+		if y >= pos && y < pos+itemHeight {
+			return i
+		}
+		pos += itemHeight
+	}
+
+	return -1
+}
+
+// updateHighlight updates the highlight range for highlightable items.
+// Supports highlighting within a single item and respects drag direction.
+func (l *LazyList) updateHighlight() {
+	if l.mouseDownItem < 0 {
+		return
+	}
+
+	// Get start and end item indices.
+	downItemIdx := l.mouseDownItem
+	dragItemIdx := l.mouseDragItem
+
+	// Determine selection direction.
+	draggingDown := dragItemIdx > downItemIdx ||
+		(dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
+		(dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
+
+	// Determine actual start and end based on direction.
+	var startItemIdx, endItemIdx int
+	var startLine, startCol, endLine, endCol int
+
+	if draggingDown {
+		// Normal forward selection.
+		startItemIdx = downItemIdx
+		endItemIdx = dragItemIdx
+		startLine = l.mouseDownY
+		startCol = l.mouseDownX
+		endLine = l.mouseDragY
+		endCol = l.mouseDragX
+	} else {
+		// Backward selection (dragging up).
+		startItemIdx = dragItemIdx
+		endItemIdx = downItemIdx
+		startLine = l.mouseDragY
+		startCol = l.mouseDragX
+		endLine = l.mouseDownY
+		endCol = l.mouseDownX
+	}
+
+	// Clear all highlights first.
+	for i, item := range l.items {
+		if h, ok := item.(Highlightable); ok {
+			h.SetHighlight(-1, -1, -1, -1)
+			delete(l.renderedCache, i)
+			l.dirtyItems[i] = true
+		}
+	}
+
+	// Highlight all items in range.
+	for idx := startItemIdx; idx <= endItemIdx; idx++ {
+		item, ok := l.items[idx].(Highlightable)
+		if !ok {
+			continue
+		}
+
+		if idx == startItemIdx && idx == endItemIdx {
+			// Single item selection.
+			item.SetHighlight(startLine, startCol, endLine, endCol)
+		} else if idx == startItemIdx {
+			// First item - from start position to end of item.
+			itemHeight := l.itemHeights[idx].height
+			item.SetHighlight(startLine, startCol, itemHeight-1, 9999) // 9999 = end of line
+		} else if idx == endItemIdx {
+			// Last item - from start of item to end position.
+			item.SetHighlight(0, 0, endLine, endCol)
+		} else {
+			// Middle item - fully highlighted.
+			itemHeight := l.itemHeights[idx].height
+			item.SetHighlight(0, 0, itemHeight-1, 9999)
+		}
+
+		delete(l.renderedCache, idx)
+		l.dirtyItems[idx] = true
+	}
+}
+
+// ClearHighlight clears any active text highlighting.
+func (l *LazyList) ClearHighlight() {
+	for i, item := range l.items {
+		if h, ok := item.(Highlightable); ok {
+			h.SetHighlight(-1, -1, -1, -1)
+			delete(l.renderedCache, i)
+			l.dirtyItems[i] = true
+		}
+	}
+	l.mouseDownItem = -1
+	l.mouseDragItem = -1
+}
+
+// GetHighlightedText returns the plain text content of all highlighted regions
+// across items, without any styling. Returns empty string if no highlights exist.
+func (l *LazyList) GetHighlightedText() string {
+	var result strings.Builder
+
+	// Iterate through items to find highlighted ones.
+	for i, item := range l.items {
+		h, ok := item.(Highlightable)
+		if !ok {
+			continue
+		}
+
+		startLine, startCol, endLine, endCol := h.GetHighlight()
+		if startLine < 0 {
+			continue
+		}
+
+		// Ensure item is rendered so we can access its buffer.
+		if _, ok := l.renderedCache[i]; !ok {
+			l.renderItem(i)
+		}
+
+		cached := l.renderedCache[i]
+		if cached == nil || cached.buffer == nil {
+			continue
+		}
+
+		buf := cached.buffer
+		itemHeight := cached.height
+
+		// Extract text from highlighted region in item buffer.
+		for y := startLine; y <= endLine && y < itemHeight; y++ {
+			if y >= buf.Height() {
+				break
+			}
+
+			line := buf.Line(y)
+
+			// Determine column range for this line.
+			colStart := 0
+			if y == startLine {
+				colStart = startCol
+			}
+
+			colEnd := len(line)
+			if y == endLine {
+				colEnd = min(endCol, len(line))
+			}
+
+			// Track last non-empty position to trim trailing spaces.
+			lastContentX := -1
+			for x := colStart; x < colEnd && x < len(line); x++ {
+				cell := line.At(x)
+				if cell == nil || cell.IsZero() {
+					continue
+				}
+				if cell.Content != "" && cell.Content != " " {
+					lastContentX = x
+				}
+			}
+
+			// Extract text from cells, up to last content.
+			endX := colEnd
+			if lastContentX >= 0 {
+				endX = lastContentX + 1
+			}
+
+			for x := colStart; x < endX && x < len(line); x++ {
+				cell := line.At(x)
+				if cell != nil && !cell.IsZero() {
+					result.WriteString(cell.Content)
+				}
+			}
+
+			// Add newline if not the last line.
+			if y < endLine {
+				result.WriteString("\n")
+			}
+		}
+
+		// Add newline between items if this isn't the last highlighted item.
+		if i < len(l.items)-1 {
+			nextHasHighlight := false
+			for j := i + 1; j < len(l.items); j++ {
+				if h, ok := l.items[j].(Highlightable); ok {
+					s, _, _, _ := h.GetHighlight()
+					if s >= 0 {
+						nextHasHighlight = true
+						break
+					}
+				}
+			}
+			if nextHasHighlight {
+				result.WriteString("\n")
+			}
+		}
+	}
+
+	return result.String()
+}
+
+func min(a, b int) int {
+	if a < b {
+		return a
+	}
+	return b
+}
+
+func max(a, b int) int {
+	if a > b {
+		return a
+	}
+	return b
+}

internal/ui/list/simplelist.go 🔗

@@ -0,0 +1,972 @@
+package list
+
+import (
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/exp/ordered"
+)
+
+const maxGapSize = 100
+
+var newlineBuffer = strings.Repeat("\n", maxGapSize)
+
+// SimpleList is a string-based list with virtual scrolling behavior.
+// Based on exp/list but simplified for our needs.
+type SimpleList struct {
+	// Viewport dimensions.
+	width, height int
+
+	// Scroll offset (in lines from top).
+	offset int
+
+	// Items.
+	items   []Item
+	itemIDs map[string]int // ID -> index mapping
+
+	// Rendered content (all items stacked).
+	rendered       string
+	renderedHeight int   // Total height of rendered content in lines
+	lineOffsets    []int // Byte offsets for each line (for fast slicing)
+
+	// Rendered item metadata.
+	renderedItems map[string]renderedItem
+
+	// Selection.
+	selectedIdx int
+	focused     bool
+
+	// Focus tracking.
+	prevSelectedIdx int
+
+	// Mouse/highlight state.
+	mouseDown          bool
+	mouseDownItem      int
+	mouseDownX         int
+	mouseDownY         int // viewport-relative Y
+	mouseDragItem      int
+	mouseDragX         int
+	mouseDragY         int // viewport-relative Y
+	selectionStartLine int
+	selectionStartCol  int
+	selectionEndLine   int
+	selectionEndCol    int
+
+	// Configuration.
+	gap int // Gap between items in lines
+}
+
+type renderedItem struct {
+	view   string
+	height int
+	start  int // Start line in rendered content
+	end    int // End line in rendered content
+}
+
+// NewSimpleList creates a new simple list.
+func NewSimpleList(items ...Item) *SimpleList {
+	l := &SimpleList{
+		items:              items,
+		itemIDs:            make(map[string]int, len(items)),
+		renderedItems:      make(map[string]renderedItem),
+		selectedIdx:        -1,
+		prevSelectedIdx:    -1,
+		gap:                0,
+		selectionStartLine: -1,
+		selectionStartCol:  -1,
+		selectionEndLine:   -1,
+		selectionEndCol:    -1,
+	}
+
+	// Build ID map.
+	for i, item := range items {
+		if idItem, ok := item.(interface{ ID() string }); ok {
+			l.itemIDs[idItem.ID()] = i
+		}
+	}
+
+	return l
+}
+
+// Init initializes the list (Bubbletea lifecycle).
+func (l *SimpleList) Init() tea.Cmd {
+	return l.render()
+}
+
+// Update handles messages (Bubbletea lifecycle).
+func (l *SimpleList) Update(msg tea.Msg) (*SimpleList, tea.Cmd) {
+	return l, nil
+}
+
+// View returns the visible viewport (Bubbletea lifecycle).
+func (l *SimpleList) View() string {
+	if l.height <= 0 || l.width <= 0 {
+		return ""
+	}
+
+	start, end := l.viewPosition()
+	viewStart := max(0, start)
+	viewEnd := end
+
+	if viewStart > viewEnd {
+		return ""
+	}
+
+	view := l.getLines(viewStart, viewEnd)
+
+	// Apply width/height constraints.
+	view = lipgloss.NewStyle().
+		Height(l.height).
+		Width(l.width).
+		Render(view)
+
+	// Apply highlighting if active.
+	if l.hasSelection() {
+		return l.renderSelection(view)
+	}
+
+	return view
+}
+
+// viewPosition returns the start and end line indices for the viewport.
+func (l *SimpleList) viewPosition() (int, int) {
+	start := max(0, l.offset)
+	end := min(l.offset+l.height-1, l.renderedHeight-1)
+	start = min(start, end)
+	return start, end
+}
+
+// getLines returns lines [start, end] from rendered content.
+func (l *SimpleList) getLines(start, end int) string {
+	if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
+		return ""
+	}
+
+	if end >= len(l.lineOffsets) {
+		end = len(l.lineOffsets) - 1
+	}
+	if start > end {
+		return ""
+	}
+
+	startOffset := l.lineOffsets[start]
+	var endOffset int
+	if end+1 < len(l.lineOffsets) {
+		endOffset = l.lineOffsets[end+1] - 1 // Exclude newline
+	} else {
+		endOffset = len(l.rendered)
+	}
+
+	if startOffset >= len(l.rendered) {
+		return ""
+	}
+	endOffset = min(endOffset, len(l.rendered))
+
+	return l.rendered[startOffset:endOffset]
+}
+
+// render rebuilds the rendered content from all items.
+func (l *SimpleList) render() tea.Cmd {
+	if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
+		return nil
+	}
+
+	// Set default selection if none.
+	if l.selectedIdx < 0 && len(l.items) > 0 {
+		l.selectedIdx = 0
+	}
+
+	// Handle focus changes.
+	var focusCmd tea.Cmd
+	if l.focused {
+		focusCmd = l.focusSelectedItem()
+	} else {
+		focusCmd = l.blurSelectedItem()
+	}
+
+	// Render all items.
+	var b strings.Builder
+	currentLine := 0
+
+	for i, item := range l.items {
+		// Render item.
+		view := l.renderItem(item)
+		height := lipgloss.Height(view)
+
+		// Store metadata.
+		rItem := renderedItem{
+			view:   view,
+			height: height,
+			start:  currentLine,
+			end:    currentLine + height - 1,
+		}
+
+		if idItem, ok := item.(interface{ ID() string }); ok {
+			l.renderedItems[idItem.ID()] = rItem
+		}
+
+		// Append to rendered content.
+		b.WriteString(view)
+
+		// Add gap after item (except last).
+		gap := l.gap
+		if i == len(l.items)-1 {
+			gap = 0
+		}
+
+		if gap > 0 {
+			if gap <= maxGapSize {
+				b.WriteString(newlineBuffer[:gap])
+			} else {
+				b.WriteString(strings.Repeat("\n", gap))
+			}
+		}
+
+		currentLine += height + gap
+	}
+
+	l.setRendered(b.String())
+
+	// Scroll to selected item.
+	if l.focused && l.selectedIdx >= 0 {
+		l.scrollToSelection()
+	}
+
+	return focusCmd
+}
+
+// renderItem renders a single item.
+func (l *SimpleList) renderItem(item Item) string {
+	// Create a buffer for the item.
+	buf := uv.NewScreenBuffer(l.width, 1000) // Max height
+	area := uv.Rect(0, 0, l.width, 1000)
+	item.Draw(&buf, area)
+
+	// Find actual height.
+	height := l.measureBufferHeight(&buf)
+	if height == 0 {
+		height = 1
+	}
+
+	// Render to string.
+	return buf.Render()
+}
+
+// measureBufferHeight finds the actual content height in a buffer.
+func (l *SimpleList) measureBufferHeight(buf *uv.ScreenBuffer) int {
+	height := buf.Height()
+
+	// Scan from bottom up to find last non-empty line.
+	for y := height - 1; y >= 0; y-- {
+		line := buf.Line(y)
+		if l.lineHasContent(line) {
+			return y + 1
+		}
+	}
+
+	return 0
+}
+
+// lineHasContent checks if a line has any non-empty cells.
+func (l *SimpleList) lineHasContent(line uv.Line) bool {
+	for x := 0; x < len(line); x++ {
+		cell := line.At(x)
+		if cell != nil && !cell.IsZero() && cell.Content != "" && cell.Content != " " {
+			return true
+		}
+	}
+	return false
+}
+
+// setRendered updates the rendered content and caches line offsets.
+func (l *SimpleList) setRendered(rendered string) {
+	l.rendered = rendered
+	l.renderedHeight = lipgloss.Height(rendered)
+
+	// Build line offset cache.
+	if len(rendered) > 0 {
+		l.lineOffsets = make([]int, 0, l.renderedHeight)
+		l.lineOffsets = append(l.lineOffsets, 0)
+
+		offset := 0
+		for {
+			idx := strings.IndexByte(rendered[offset:], '\n')
+			if idx == -1 {
+				break
+			}
+			offset += idx + 1
+			l.lineOffsets = append(l.lineOffsets, offset)
+		}
+	} else {
+		l.lineOffsets = nil
+	}
+}
+
+// scrollToSelection scrolls to make the selected item visible.
+func (l *SimpleList) scrollToSelection() {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return
+	}
+
+	// Get selected item metadata.
+	var rItem *renderedItem
+	if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok {
+		if ri, ok := l.renderedItems[idItem.ID()]; ok {
+			rItem = &ri
+		}
+	}
+
+	if rItem == nil {
+		return
+	}
+
+	start, end := l.viewPosition()
+
+	// Already visible.
+	if rItem.start >= start && rItem.end <= end {
+		return
+	}
+
+	// Item is above viewport - scroll up.
+	if rItem.start < start {
+		l.offset = rItem.start
+		return
+	}
+
+	// Item is below viewport - scroll down.
+	if rItem.end > end {
+		l.offset = max(0, rItem.end-l.height+1)
+	}
+}
+
+// Focus/blur management.
+
+func (l *SimpleList) focusSelectedItem() tea.Cmd {
+	if l.selectedIdx < 0 || !l.focused {
+		return nil
+	}
+
+	var cmds []tea.Cmd
+
+	// Blur previous.
+	if l.prevSelectedIdx >= 0 && l.prevSelectedIdx != l.selectedIdx && l.prevSelectedIdx < len(l.items) {
+		if f, ok := l.items[l.prevSelectedIdx].(Focusable); ok && f.IsFocused() {
+			f.Blur()
+		}
+	}
+
+	// Focus current.
+	if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
+		if f, ok := l.items[l.selectedIdx].(Focusable); ok && !f.IsFocused() {
+			f.Focus()
+		}
+	}
+
+	l.prevSelectedIdx = l.selectedIdx
+	return tea.Batch(cmds...)
+}
+
+func (l *SimpleList) blurSelectedItem() tea.Cmd {
+	if l.selectedIdx < 0 || l.focused {
+		return nil
+	}
+
+	if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
+		if f, ok := l.items[l.selectedIdx].(Focusable); ok && f.IsFocused() {
+			f.Blur()
+		}
+	}
+
+	return nil
+}
+
+// Public API.
+
+// SetSize sets the viewport dimensions.
+func (l *SimpleList) SetSize(width, height int) tea.Cmd {
+	oldWidth := l.width
+	l.width = width
+	l.height = height
+
+	if oldWidth != width {
+		// Width changed - need to re-render.
+		return l.render()
+	}
+
+	return nil
+}
+
+// Width returns the viewport width.
+func (l *SimpleList) Width() int {
+	return l.width
+}
+
+// Height returns the viewport height.
+func (l *SimpleList) Height() int {
+	return l.height
+}
+
+// GetSize returns the viewport dimensions.
+func (l *SimpleList) GetSize() (int, int) {
+	return l.width, l.height
+}
+
+// Items returns all items.
+func (l *SimpleList) Items() []Item {
+	return l.items
+}
+
+// Len returns the number of items.
+func (l *SimpleList) Len() int {
+	return len(l.items)
+}
+
+// SetItems replaces all items.
+func (l *SimpleList) SetItems(items []Item) tea.Cmd {
+	l.items = items
+	l.itemIDs = make(map[string]int, len(items))
+	l.renderedItems = make(map[string]renderedItem)
+	l.selectedIdx = -1
+	l.prevSelectedIdx = -1
+	l.offset = 0
+
+	// Build ID map.
+	for i, item := range items {
+		if idItem, ok := item.(interface{ ID() string }); ok {
+			l.itemIDs[idItem.ID()] = i
+		}
+	}
+
+	return l.render()
+}
+
+// AppendItem adds an item to the end.
+func (l *SimpleList) AppendItem(item Item) tea.Cmd {
+	l.items = append(l.items, item)
+
+	if idItem, ok := item.(interface{ ID() string }); ok {
+		l.itemIDs[idItem.ID()] = len(l.items) - 1
+	}
+
+	return l.render()
+}
+
+// PrependItem adds an item to the beginning.
+func (l *SimpleList) PrependItem(item Item) tea.Cmd {
+	l.items = append([]Item{item}, l.items...)
+
+	// Rebuild ID map (indices shifted).
+	l.itemIDs = make(map[string]int, len(l.items))
+	for i, it := range l.items {
+		if idItem, ok := it.(interface{ ID() string }); ok {
+			l.itemIDs[idItem.ID()] = i
+		}
+	}
+
+	// Adjust selection.
+	if l.selectedIdx >= 0 {
+		l.selectedIdx++
+	}
+	if l.prevSelectedIdx >= 0 {
+		l.prevSelectedIdx++
+	}
+
+	return l.render()
+}
+
+// UpdateItem replaces an item at the given index.
+func (l *SimpleList) UpdateItem(idx int, item Item) tea.Cmd {
+	if idx < 0 || idx >= len(l.items) {
+		return nil
+	}
+
+	l.items[idx] = item
+
+	// Update ID map.
+	if idItem, ok := item.(interface{ ID() string }); ok {
+		l.itemIDs[idItem.ID()] = idx
+	}
+
+	return l.render()
+}
+
+// DeleteItem removes an item at the given index.
+func (l *SimpleList) DeleteItem(idx int) tea.Cmd {
+	if idx < 0 || idx >= len(l.items) {
+		return nil
+	}
+
+	l.items = append(l.items[:idx], l.items[idx+1:]...)
+
+	// Rebuild ID map (indices shifted).
+	l.itemIDs = make(map[string]int, len(l.items))
+	for i, it := range l.items {
+		if idItem, ok := it.(interface{ ID() string }); ok {
+			l.itemIDs[idItem.ID()] = i
+		}
+	}
+
+	// Adjust selection.
+	if l.selectedIdx == idx {
+		if idx > 0 {
+			l.selectedIdx = idx - 1
+		} else if len(l.items) > 0 {
+			l.selectedIdx = 0
+		} else {
+			l.selectedIdx = -1
+		}
+	} else if l.selectedIdx > idx {
+		l.selectedIdx--
+	}
+
+	if l.prevSelectedIdx == idx {
+		l.prevSelectedIdx = -1
+	} else if l.prevSelectedIdx > idx {
+		l.prevSelectedIdx--
+	}
+
+	return l.render()
+}
+
+// Focus sets the list as focused.
+func (l *SimpleList) Focus() tea.Cmd {
+	l.focused = true
+	return l.render()
+}
+
+// Blur removes focus from the list.
+func (l *SimpleList) Blur() tea.Cmd {
+	l.focused = false
+	return l.render()
+}
+
+// Focused returns whether the list is focused.
+func (l *SimpleList) Focused() bool {
+	return l.focused
+}
+
+// Selection.
+
+// Selected returns the currently selected item index.
+func (l *SimpleList) Selected() int {
+	return l.selectedIdx
+}
+
+// SelectedIndex returns the currently selected item index.
+func (l *SimpleList) SelectedIndex() int {
+	return l.selectedIdx
+}
+
+// SelectedItem returns the currently selected item.
+func (l *SimpleList) SelectedItem() Item {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return nil
+	}
+	return l.items[l.selectedIdx]
+}
+
+// SetSelected sets the selected item by index.
+func (l *SimpleList) SetSelected(idx int) tea.Cmd {
+	if idx < -1 || idx >= len(l.items) {
+		return nil
+	}
+
+	if l.selectedIdx == idx {
+		return nil
+	}
+
+	l.prevSelectedIdx = l.selectedIdx
+	l.selectedIdx = idx
+
+	return l.render()
+}
+
+// SelectFirst selects the first item.
+func (l *SimpleList) SelectFirst() tea.Cmd {
+	return l.SetSelected(0)
+}
+
+// SelectLast selects the last item.
+func (l *SimpleList) SelectLast() tea.Cmd {
+	if len(l.items) > 0 {
+		return l.SetSelected(len(l.items) - 1)
+	}
+	return nil
+}
+
+// SelectNext selects the next item.
+func (l *SimpleList) SelectNext() tea.Cmd {
+	if l.selectedIdx < len(l.items)-1 {
+		return l.SetSelected(l.selectedIdx + 1)
+	}
+	return nil
+}
+
+// SelectPrev selects the previous item.
+func (l *SimpleList) SelectPrev() tea.Cmd {
+	if l.selectedIdx > 0 {
+		return l.SetSelected(l.selectedIdx - 1)
+	}
+	return nil
+}
+
+// SelectNextWrap selects the next item (wraps to beginning).
+func (l *SimpleList) SelectNextWrap() tea.Cmd {
+	if len(l.items) == 0 {
+		return nil
+	}
+	nextIdx := (l.selectedIdx + 1) % len(l.items)
+	return l.SetSelected(nextIdx)
+}
+
+// SelectPrevWrap selects the previous item (wraps to end).
+func (l *SimpleList) SelectPrevWrap() tea.Cmd {
+	if len(l.items) == 0 {
+		return nil
+	}
+	prevIdx := (l.selectedIdx - 1 + len(l.items)) % len(l.items)
+	return l.SetSelected(prevIdx)
+}
+
+// SelectFirstInView selects the first fully visible item.
+func (l *SimpleList) SelectFirstInView() tea.Cmd {
+	if len(l.items) == 0 {
+		return nil
+	}
+
+	start, end := l.viewPosition()
+
+	for i := 0; i < len(l.items); i++ {
+		if idItem, ok := l.items[i].(interface{ ID() string }); ok {
+			if rItem, ok := l.renderedItems[idItem.ID()]; ok {
+				// Check if fully visible.
+				if rItem.start >= start && rItem.end <= end {
+					return l.SetSelected(i)
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+// SelectLastInView selects the last fully visible item.
+func (l *SimpleList) SelectLastInView() tea.Cmd {
+	if len(l.items) == 0 {
+		return nil
+	}
+
+	start, end := l.viewPosition()
+
+	for i := len(l.items) - 1; i >= 0; i-- {
+		if idItem, ok := l.items[i].(interface{ ID() string }); ok {
+			if rItem, ok := l.renderedItems[idItem.ID()]; ok {
+				// Check if fully visible.
+				if rItem.start >= start && rItem.end <= end {
+					return l.SetSelected(i)
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+// SelectedItemInView returns true if the selected item is visible.
+func (l *SimpleList) SelectedItemInView() bool {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return false
+	}
+
+	var rItem *renderedItem
+	if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok {
+		if ri, ok := l.renderedItems[idItem.ID()]; ok {
+			rItem = &ri
+		}
+	}
+
+	if rItem == nil {
+		return false
+	}
+
+	start, end := l.viewPosition()
+	return rItem.start < end && rItem.end > start
+}
+
+// Scrolling.
+
+// Offset returns the current scroll offset.
+func (l *SimpleList) Offset() int {
+	return l.offset
+}
+
+// TotalHeight returns the total height of all items.
+func (l *SimpleList) TotalHeight() int {
+	return l.renderedHeight
+}
+
+// ScrollBy scrolls by the given number of lines.
+func (l *SimpleList) ScrollBy(deltaLines int) tea.Cmd {
+	l.offset += deltaLines
+	l.clampOffset()
+	return nil
+}
+
+// ScrollToTop scrolls to the top.
+func (l *SimpleList) ScrollToTop() tea.Cmd {
+	l.offset = 0
+	return nil
+}
+
+// ScrollToBottom scrolls to the bottom.
+func (l *SimpleList) ScrollToBottom() tea.Cmd {
+	l.offset = l.renderedHeight - l.height
+	l.clampOffset()
+	return nil
+}
+
+// AtTop returns true if scrolled to the top.
+func (l *SimpleList) AtTop() bool {
+	return l.offset <= 0
+}
+
+// AtBottom returns true if scrolled to the bottom.
+func (l *SimpleList) AtBottom() bool {
+	return l.offset >= l.renderedHeight-l.height
+}
+
+// ScrollToItem scrolls to make an item visible.
+func (l *SimpleList) ScrollToItem(idx int) tea.Cmd {
+	if idx < 0 || idx >= len(l.items) {
+		return nil
+	}
+
+	var rItem *renderedItem
+	if idItem, ok := l.items[idx].(interface{ ID() string }); ok {
+		if ri, ok := l.renderedItems[idItem.ID()]; ok {
+			rItem = &ri
+		}
+	}
+
+	if rItem == nil {
+		return nil
+	}
+
+	start, end := l.viewPosition()
+
+	// Already visible.
+	if rItem.start >= start && rItem.end <= end {
+		return nil
+	}
+
+	// Above viewport.
+	if rItem.start < start {
+		l.offset = rItem.start
+		return nil
+	}
+
+	// Below viewport.
+	if rItem.end > end {
+		l.offset = rItem.end - l.height + 1
+		l.clampOffset()
+	}
+
+	return nil
+}
+
+// ScrollToSelected scrolls to the selected item.
+func (l *SimpleList) ScrollToSelected() tea.Cmd {
+	if l.selectedIdx >= 0 {
+		return l.ScrollToItem(l.selectedIdx)
+	}
+	return nil
+}
+
+func (l *SimpleList) clampOffset() {
+	maxOffset := l.renderedHeight - l.height
+	if maxOffset < 0 {
+		maxOffset = 0
+	}
+	l.offset = ordered.Clamp(l.offset, 0, maxOffset)
+}
+
+// Mouse and highlighting.
+
+// HandleMouseDown handles mouse press.
+func (l *SimpleList) HandleMouseDown(x, y int) bool {
+	if x < 0 || y < 0 || x >= l.width || y >= l.height {
+		return false
+	}
+
+	// Find item at viewport y.
+	contentY := l.offset + y
+	itemIdx := l.findItemAtLine(contentY)
+
+	if itemIdx < 0 {
+		return false
+	}
+
+	l.mouseDown = true
+	l.mouseDownItem = itemIdx
+	l.mouseDownX = x
+	l.mouseDownY = y
+	l.mouseDragItem = itemIdx
+	l.mouseDragX = x
+	l.mouseDragY = y
+
+	// Start selection.
+	l.selectionStartLine = y
+	l.selectionStartCol = x
+	l.selectionEndLine = y
+	l.selectionEndCol = x
+
+	// Select item.
+	l.SetSelected(itemIdx)
+
+	return true
+}
+
+// HandleMouseDrag handles mouse drag.
+func (l *SimpleList) HandleMouseDrag(x, y int) bool {
+	if !l.mouseDown {
+		return false
+	}
+
+	// Clamp coordinates to viewport bounds.
+	clampedX := max(0, min(x, l.width-1))
+	clampedY := max(0, min(y, l.height-1))
+
+	if clampedY >= 0 && clampedY < l.height {
+		contentY := l.offset + clampedY
+		itemIdx := l.findItemAtLine(contentY)
+		if itemIdx >= 0 {
+			l.mouseDragItem = itemIdx
+			l.mouseDragX = clampedX
+			l.mouseDragY = clampedY
+		}
+	}
+
+	// Update selection end (clamped to viewport).
+	l.selectionEndLine = clampedY
+	l.selectionEndCol = clampedX
+
+	return true
+}
+
+// HandleMouseUp handles mouse release.
+func (l *SimpleList) HandleMouseUp(x, y int) bool {
+	if !l.mouseDown {
+		return false
+	}
+
+	l.mouseDown = false
+
+	// Final selection update (clamped to viewport).
+	clampedX := max(0, min(x, l.width-1))
+	clampedY := max(0, min(y, l.height-1))
+	l.selectionEndLine = clampedY
+	l.selectionEndCol = clampedX
+
+	return true
+}
+
+// ClearHighlight clears the selection.
+func (l *SimpleList) ClearHighlight() {
+	l.selectionStartLine = -1
+	l.selectionStartCol = -1
+	l.selectionEndLine = -1
+	l.selectionEndCol = -1
+	l.mouseDown = false
+	l.mouseDownItem = -1
+	l.mouseDragItem = -1
+}
+
+// GetHighlightedText returns the selected text.
+func (l *SimpleList) GetHighlightedText() string {
+	if !l.hasSelection() {
+		return ""
+	}
+
+	return l.renderSelection(l.View())
+}
+
+func (l *SimpleList) hasSelection() bool {
+	return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
+}
+
+// renderSelection applies highlighting to the view and extracts text.
+func (l *SimpleList) renderSelection(view string) string {
+	// Create a screen buffer spanning the viewport.
+	buf := uv.NewScreenBuffer(l.width, l.height)
+	area := uv.Rect(0, 0, l.width, l.height)
+	uv.NewStyledString(view).Draw(&buf, area)
+
+	// Calculate selection bounds.
+	startLine := min(l.selectionStartLine, l.selectionEndLine)
+	endLine := max(l.selectionStartLine, l.selectionEndLine)
+	startCol := l.selectionStartCol
+	endCol := l.selectionEndCol
+
+	if l.selectionEndLine < l.selectionStartLine {
+		startCol = l.selectionEndCol
+		endCol = l.selectionStartCol
+	}
+
+	// Apply highlighting.
+	for y := startLine; y <= endLine && y < l.height; y++ {
+		if y >= buf.Height() {
+			break
+		}
+
+		line := buf.Line(y)
+
+		// Determine column range for this line.
+		colStart := 0
+		if y == startLine {
+			colStart = startCol
+		}
+
+		colEnd := len(line)
+		if y == endLine {
+			colEnd = min(endCol, len(line))
+		}
+
+		// Apply highlight style.
+		for x := colStart; x < colEnd && x < len(line); x++ {
+			cell := line.At(x)
+			if cell != nil && !cell.IsZero() {
+				cell = cell.Clone()
+				// Toggle reverse for highlight.
+				if cell.Style.Attrs&uv.AttrReverse != 0 {
+					cell.Style.Attrs &^= uv.AttrReverse
+				} else {
+					cell.Style.Attrs |= uv.AttrReverse
+				}
+				buf.SetCell(x, y, cell)
+			}
+		}
+	}
+
+	return buf.Render()
+}
+
+// findItemAtLine finds the item index at the given content line.
+func (l *SimpleList) findItemAtLine(line int) int {
+	for i := 0; i < len(l.items); i++ {
+		if idItem, ok := l.items[i].(interface{ ID() string }); ok {
+			if rItem, ok := l.renderedItems[idItem.ID()]; ok {
+				if line >= rItem.start && line <= rItem.end {
+					return i
+				}
+			}
+		}
+	}
+	return -1
+}
+
+// Render returns the view (for compatibility).
+func (l *SimpleList) Render() string {
+	return l.View()
+}

internal/ui/model/chat.go 🔗

@@ -8,6 +8,7 @@ import (
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/lazylist"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	uv "github.com/charmbracelet/ultraviolet"
@@ -196,13 +197,14 @@ func (c *ChatMessageItem) SetHighlight(startLine int, startCol int, endLine int,
 // messages.
 type Chat struct {
 	com  *common.Common
-	list *list.List
+	list *lazylist.List
 }
 
 // NewChat creates a new instance of [Chat] that handles chat interactions and
 // messages.
 func NewChat(com *common.Common) *Chat {
-	l := list.New()
+	l := lazylist.NewList()
+	l.SetGap(1)
 	return &Chat{
 		com:  com,
 		list: l,
@@ -216,7 +218,7 @@ func (m *Chat) Height() int {
 
 // Draw renders the chat UI component to the screen and the given area.
 func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
-	m.list.Draw(scr, area)
+	uv.NewStyledString(m.list.Render()).Draw(scr, area)
 }
 
 // SetSize sets the size of the chat view port.
@@ -229,25 +231,23 @@ func (m *Chat) Len() int {
 	return m.list.Len()
 }
 
-// PrependItem prepends a new item to the chat list.
-func (m *Chat) PrependItem(item list.Item) {
-	m.list.PrependItem(item)
+// PrependItems prepends new items to the chat list.
+func (m *Chat) PrependItems(items ...lazylist.Item) {
+	m.list.PrependItems(items...)
+	m.list.ScrollToIndex(0)
 }
 
 // AppendMessages appends a new message item to the chat list.
 func (m *Chat) AppendMessages(msgs ...MessageItem) {
 	for _, msg := range msgs {
-		m.AppendItem(msg)
+		m.AppendItems(msg)
 	}
 }
 
-// AppendItem appends a new item to the chat list.
-func (m *Chat) AppendItem(item list.Item) {
-	if m.Len() > 0 {
-		// Always add a spacer between messages
-		m.list.AppendItem(list.NewSpacerItem(1))
-	}
-	m.list.AppendItem(item)
+// AppendItems appends new items to the chat list.
+func (m *Chat) AppendItems(items ...lazylist.Item) {
+	m.list.AppendItems(items...)
+	m.list.ScrollToIndex(m.list.Len() - 1)
 }
 
 // Focus sets the focus state of the chat component.

internal/ui/model/items.go 🔗

@@ -13,6 +13,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/lazylist"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/crush/internal/ui/toolrender"
@@ -29,6 +30,7 @@ type MessageItem interface {
 	list.Item
 	list.Focusable
 	list.Highlightable
+	lazylist.Item
 	Identifiable
 }
 
@@ -114,6 +116,11 @@ func (m *MessageContentItem) Draw(scr uv.Screen, area uv.Rectangle) {
 	tempBuf.Draw(scr, area)
 }
 
+// Render implements lazylist.Item.
+func (m *MessageContentItem) Render(width int) string {
+	return m.render(width)
+}
+
 // render renders the content at the given width, using cache if available.
 func (m *MessageContentItem) render(width int) string {
 	// Cap width to maxWidth for markdown
@@ -228,6 +235,12 @@ func (t *ToolCallItem) Height(width int) int {
 	return height
 }
 
+// Render implements lazylist.Item.
+func (t *ToolCallItem) Render(width int) string {
+	cached := t.renderCached(width)
+	return cached.content
+}
+
 // Draw implements list.Item.
 func (t *ToolCallItem) Draw(scr uv.Screen, area uv.Rectangle) {
 	width := area.Dx()
@@ -334,6 +347,16 @@ func (a *AttachmentItem) Height(width int) int {
 	return 1
 }
 
+// Render implements lazylist.Item.
+func (a *AttachmentItem) Render(width int) string {
+	const maxFilenameWidth = 10
+	return a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
+		" %s %s ",
+		styles.DocumentIcon,
+		ansi.Truncate(a.filename, maxFilenameWidth, "..."),
+	))
+}
+
 // Draw implements list.Item.
 func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) {
 	width := area.Dx()
@@ -410,6 +433,11 @@ func (t *ThinkingItem) Height(width int) int {
 	return strings.Count(rendered, "\n") + 1
 }
 
+// Render implements lazylist.Item.
+func (t *ThinkingItem) Render(width int) string {
+	return t.render(width)
+}
+
 // Draw implements list.Item.
 func (t *ThinkingItem) Draw(scr uv.Screen, area uv.Rectangle) {
 	width := area.Dx()
@@ -522,6 +550,15 @@ func (s *SectionHeaderItem) Height(width int) int {
 	return 1
 }
 
+// Render implements lazylist.Item.
+func (s *SectionHeaderItem) Render(width int) string {
+	return s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s",
+		s.sty.Subtle.Render(styles.ModelIcon),
+		s.sty.Muted.Render(s.modelName),
+		s.sty.Subtle.Render(s.duration.String()),
+	))
+}
+
 // Draw implements list.Item.
 func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) {
 	width := area.Dx()

internal/ui/model/ui.go 🔗

@@ -172,7 +172,7 @@ func (m *UI) Init() tea.Cmd {
 	if len(allSessions) > 0 {
 		cmds = append(cmds, func() tea.Msg {
 			time.Sleep(2 * time.Second)
-			return m.loadSession(allSessions[0].ID)()
+			return m.loadSession(allSessions[1].ID)()
 		})
 	}
 	return tea.Batch(cmds...)
@@ -441,12 +441,12 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 		editor.Draw(scr, layout.editor)
 
 	case uiChat:
+		m.chat.Draw(scr, layout.main)
+
 		header := uv.NewStyledString(m.header)
 		header.Draw(scr, layout.header)
 		m.drawSidebar(scr, layout.sidebar)
 
-		m.chat.Draw(scr, layout.main)
-
 		editor := uv.NewStyledString(m.textarea.View())
 		editor.Draw(scr, layout.editor)