diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 7c47e754bb900b87822d16eb430c44f658b8602b..08c66a722aeaf1f733868583b902c28a50338532 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -529,6 +529,10 @@ func (l *list[T]) viewPosition() (int, int) { } func (l *list[T]) render() tea.Cmd { + return l.renderWithScrollToSelection(true) +} + +func (l *list[T]) renderWithScrollToSelection(scrollToSelection bool) tea.Cmd { if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 { return nil } @@ -549,8 +553,8 @@ func (l *list[T]) render() tea.Cmd { l.rendered = l.renderVirtualScrolling() l.renderMu.Unlock() - // Scroll to selected item if focused - if l.focused { + // Scroll to selected item if focused and requested + if l.focused && scrollToSelection { l.scrollToSelection() } @@ -677,12 +681,12 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { // If the item is bigger than the viewport, select it if renderedItem.start <= start && renderedItem.end >= end { l.selectedItem = item.ID() - return l.render() + return l.renderWithScrollToSelection(false) } // item is in the view if renderedItem.start >= start && renderedItem.start <= end { l.selectedItem = item.ID() - return l.render() + return l.renderWithScrollToSelection(false) } } } else if itemMiddle > end { @@ -705,12 +709,12 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { // If the item is bigger than the viewport, select it if renderedItem.start <= start && renderedItem.end >= end { l.selectedItem = item.ID() - return l.render() + return l.renderWithScrollToSelection(false) } // item is in the view if renderedItem.end >= start && renderedItem.end <= end { l.selectedItem = item.ID() - return l.render() + return l.renderWithScrollToSelection(false) } } } @@ -912,24 +916,10 @@ func (l *list[T]) renderVirtualScrolling() string { // Calculate viewport bounds viewStart, viewEnd := l.viewPosition() - // Debug: Check if viewport is valid - if viewEnd < viewStart { - // Return empty viewport - var lines []string - for i := 0; i < l.height; i++ { - lines = append(lines, "") - } - return strings.Join(lines, "\n") - } - // Check if we have any positions calculated if len(l.itemPositions) == 0 { - // No items have been calculated yet, return empty - var lines []string - for i := 0; i < l.height; i++ { - lines = append(lines, "") - } - return strings.Join(lines, "\n") + // No positions calculated yet, return empty viewport + return "" } // Find which items are visible @@ -941,13 +931,7 @@ func (l *list[T]) renderVirtualScrolling() string { itemsLen := l.items.Len() for i := 0; i < itemsLen; i++ { - item, ok := l.items.Get(i) - if !ok { - continue - } - if i >= len(l.itemPositions) { - // Item not yet calculated, skip it continue } @@ -955,6 +939,10 @@ func (l *list[T]) renderVirtualScrolling() string { // Check if item is visible (overlaps with viewport) if pos.end >= viewStart && pos.start <= viewEnd { + item, ok := l.items.Get(i) + if !ok { + continue + } visibleItems = append(visibleItems, struct { item T pos itemPosition @@ -968,106 +956,60 @@ func (l *list[T]) renderVirtualScrolling() string { } } - if len(visibleItems) == 0 { - // No visible items found - this shouldn't happen if viewport is valid - // Return empty lines to maintain height - var lines []string - for i := 0; i < l.height; i++ { - lines = append(lines, "") - } - return strings.Join(lines, "\n") - } - - // Render visible items - var b strings.Builder + // Build the rendered output + var lines []string currentLine := viewStart - // Handle first visible item - firstVisible := visibleItems[0] - if firstVisible.pos.start < viewStart { - // We're starting mid-item, render partial - if cached, ok := l.viewCache.Get(firstVisible.item.ID()); ok && cached != "" { - lines := strings.Split(cached, "\n") - skipLines := viewStart - firstVisible.pos.start - if skipLines >= 0 && skipLines < len(lines) { - for i := skipLines; i < len(lines) && currentLine <= viewEnd; i++ { - if b.Len() > 0 { - b.WriteByte('\n') - } - b.WriteString(lines[i]) - currentLine++ - } - } - } - } else if firstVisible.pos.start > viewStart { - // Add empty lines before first item - for currentLine < firstVisible.pos.start && currentLine <= viewEnd { - if b.Len() > 0 { - b.WriteByte('\n') - } - currentLine++ - } - } - - // Render fully visible items - for i, vis := range visibleItems { - if currentLine > viewEnd { - break - } - - // Skip first item if we already rendered it partially - if i == 0 && firstVisible.pos.start < viewStart { - // Update currentLine to where we left off after partial rendering - currentLine = viewStart + (firstVisible.pos.end - firstVisible.pos.start + 1) - (viewStart - firstVisible.pos.start) - continue - } - - // Add gap before item (except for first visible item in viewport) - if i > 0 || (i == 0 && firstVisible.pos.start >= viewStart) { - // Only add gap if this isn't the very first item in the viewport - if currentLine > viewStart && currentLine <= viewEnd { - for j := 0; j < l.gap && currentLine <= viewEnd; j++ { - if b.Len() > 0 { - b.WriteByte('\n') - } - currentLine++ - } - } - } - - // Render item or use cache + for _, vis := range visibleItems { + // Get or render the item's view var view string - if cached, ok := l.viewCache.Get(vis.item.ID()); ok && cached != "" { + if cached, ok := l.viewCache.Get(vis.item.ID()); ok { view = cached } else { view = vis.item.View() - // Update cache l.viewCache.Set(vis.item.ID(), view) } - // Handle partial rendering if item extends beyond viewport - lines := strings.Split(view, "\n") - for _, line := range lines { - if currentLine > viewEnd { - break - } - if b.Len() > 0 { - b.WriteByte('\n') + itemLines := strings.Split(view, "\n") + + // Add gap lines before item if needed (except for first item) + if vis.index > 0 && currentLine < vis.pos.start { + gapLines := vis.pos.start - currentLine + for i := 0; i < gapLines; i++ { + lines = append(lines, "") + currentLine++ } - b.WriteString(line) + } + + // Determine which lines of this item to include + startLine := 0 + if vis.pos.start < viewStart { + // Item starts before viewport, skip some lines + startLine = viewStart - vis.pos.start + } + + // Add the item's visible lines + for i := startLine; i < len(itemLines) && currentLine <= viewEnd; i++ { + lines = append(lines, itemLines[i]) currentLine++ } } - // Fill remaining viewport with empty lines if needed - for currentLine <= viewEnd { - if b.Len() > 0 { - b.WriteByte('\n') + // For content that fits entirely in viewport, don't pad with empty lines + // Only pad if we have scrolled or if content is larger than viewport + if l.virtualHeight > l.height || l.offset > 0 { + // Fill remaining viewport with empty lines if needed + for len(lines) < l.height { + lines = append(lines, "") + } + + // Trim to viewport height + if len(lines) > l.height { + lines = lines[:l.height] } - currentLine++ } - return b.String() + return strings.Join(lines, "\n") } @@ -1307,27 +1249,38 @@ func (l *list[T]) PrependItem(item T) tea.Cmd { if l.width > 0 && l.height > 0 { cmds = append(cmds, item.SetSize(l.width, l.height)) } - cmds = append(cmds, l.render()) + + // Recalculate positions after prepending + l.calculateItemPositions() + if l.direction == DirectionForward { if l.offset == 0 { + // If we're at the top, stay at the top + cmds = append(cmds, l.render()) cmd := l.GoToTop() if cmd != nil { cmds = append(cmds, cmd) } } else { - // Get the new item's position to adjust offset - newInx := l.items.Len() - 1 - if newInx < len(l.itemPositions) { - newItem := l.itemPositions[newInx] + // Adjust offset to maintain viewport position + // The prepended item is at index 0 + if len(l.itemPositions) > 0 { + newItem := l.itemPositions[0] newLines := newItem.height if l.items.Len() > 1 { newLines += l.gap } + // Increase offset to keep the same content visible if l.virtualHeight > 0 { - l.offset = min(l.virtualHeight-1, l.offset+newLines) + l.offset = min(l.virtualHeight-l.height, l.offset+newLines) } } + cmds = append(cmds, l.renderWithScrollToSelection(false)) } + } else { + // For backward direction, prepending doesn't affect the offset + // since offset is from the bottom + cmds = append(cmds, l.render()) } return tea.Batch(cmds...) } @@ -1460,22 +1413,13 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd { func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { var cmds []tea.Cmd if inx, ok := l.indexMap.Get(id); ok { - // Store old height if we have it - var oldHeight int + // Store old item position info before update + var oldItemPos itemPosition hasOldItem := false if inx < len(l.itemPositions) { - oldHeight = l.itemPositions[inx].height + oldItemPos = l.itemPositions[inx] hasOldItem = true } - - oldPosition := l.offset - if l.direction == DirectionBackward { - if l.virtualHeight > 0 { - oldPosition = (l.virtualHeight - 1) - l.offset - } else { - oldPosition = 0 - } - } // Update the item l.items.Set(inx, item) @@ -1483,32 +1427,50 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { // Clear cache for this item l.viewCache.Del(id) - cmd := l.render() - - // need to check for nil because of sequence not handling nil - if cmd != nil { - cmds = append(cmds, cmd) - } + // Recalculate positions to get new height + l.calculateItemPositions() - // Adjust offset if needed based on height change + // Adjust offset if item height changed and it's outside the viewport if hasOldItem && inx < len(l.itemPositions) { - newHeight := l.itemPositions[inx].height - diff := newHeight - oldHeight + newItemPos := l.itemPositions[inx] + heightDiff := newItemPos.height - oldItemPos.height - if l.direction == DirectionBackward { - // if we are the last item and there is no offset - // make sure to go to the bottom - if oldPosition < l.itemPositions[inx].end { - if diff != 0 && l.virtualHeight > 0 { - l.offset = util.Clamp(l.offset+diff, 0, l.virtualHeight-1) + if heightDiff != 0 { + // Get current viewport position + viewStart, viewEnd := l.viewPosition() + + if l.direction == DirectionForward { + // Item is above viewport if its end is before viewport start + if oldItemPos.end < viewStart { + // Adjust offset to maintain viewport content + l.offset = max(0, l.offset + heightDiff) + } + } else { + // For backward direction: + // Check if item is outside the current viewport + // Item is completely below viewport if its start is after viewport end + if oldItemPos.start > viewEnd { + // Item below viewport increased height, increase offset to maintain view + l.offset = max(0, l.offset + heightDiff) + } else if oldItemPos.end < viewStart { + // Item is completely above viewport + // No offset adjustment needed for items above in backward direction + // because they don't affect the view from bottom } - } - } else if hasOldItem && l.offset > l.itemPositions[inx].start { - if diff != 0 && l.virtualHeight > 0 { - l.offset = util.Clamp(l.offset+diff, 0, l.virtualHeight-1) } } } + + // Re-render with updated positions and offset + cmd := l.renderWithScrollToSelection(false) + if cmd != nil { + cmds = append(cmds, cmd) + } + + cmds = append(cmds, item.Init()) + if l.width > 0 && l.height > 0 { + cmds = append(cmds, item.SetSize(l.width, l.height)) + } } return tea.Sequence(cmds...) }