diff --git a/internal/ui/lazylist/item.go b/internal/ui/lazylist/item.go new file mode 100644 index 0000000000000000000000000000000000000000..dc39451ea5259a06d68b9dc45e78fdc00a2dac24 --- /dev/null +++ b/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 +} diff --git a/internal/ui/lazylist/list.go b/internal/ui/lazylist/list.go new file mode 100644 index 0000000000000000000000000000000000000000..70862f80b5efd6e3379c01676c1211b0b32221e0 --- /dev/null +++ b/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 +} diff --git a/internal/ui/lazylist/list.go.bak b/internal/ui/lazylist/list.go.bak new file mode 100644 index 0000000000000000000000000000000000000000..9aec6442e1ace230cc660b8d1cca6bf9b685c845 --- /dev/null +++ b/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 +} diff --git a/internal/ui/list/lazylist.go b/internal/ui/list/lazylist.go new file mode 100644 index 0000000000000000000000000000000000000000..58ce3bf3eff9869f220af3574fc920865090e172 --- /dev/null +++ b/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 +} diff --git a/internal/ui/list/simplelist.go b/internal/ui/list/simplelist.go new file mode 100644 index 0000000000000000000000000000000000000000..10cb0912d42a8d25f2ea635ff69ea41653bbdbce --- /dev/null +++ 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() +} diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 9b3f4967f374380b46bfcc813136597c3812c26f..8872081b01794c5a3acad1d0057c6b4d49fea8da 100644 --- a/internal/ui/model/chat.go +++ b/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. diff --git a/internal/ui/model/items.go b/internal/ui/model/items.go index 7df9f8c052400da3e9198513dcd37bc4d67d41ec..48bf79d2f84c0000cd0c8c7e07b9da44401997fc 100644 --- a/internal/ui/model/items.go +++ b/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() diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 09c3155dfd597ab55175f0ac079ce03e69494b5b..5e24004d84a56d8c07bad623e59c7c2df6cdb31b 100644 --- a/internal/ui/model/ui.go +++ b/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)