Detailed changes
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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()
+}
@@ -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.
@@ -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()
@@ -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)