mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
28d2de8f39
Moved leaf utility files to subpackages for better organization: - events.go -> core/ (core message types) - clipboard.go -> clipboard/ (clipboard operations) - commands.go -> commands/ (slash commands) - file_processor.go -> fileutil/ (file attachment processing) - preferences.go -> prefs/ (theme/model preferences) - enhanced_styles.go, styles.go, themes.go -> style/ (theming system) Added exports.go to re-export commonly used types for backward compatibility. External importers can still use ui.XXX without changes. All tests pass, basic smoke test successful.
702 lines
18 KiB
Go
702 lines
18 KiB
Go
package ui
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"github.com/mark3labs/kit/internal/ui/clipboard"
|
|
"github.com/mark3labs/kit/internal/ui/style"
|
|
)
|
|
|
|
// highlightStyle is lazily initialized to avoid creating it on every render
|
|
var highlightStyle lipgloss.Style
|
|
|
|
// initHighlightStyle creates the highlight style with proper colors
|
|
func initHighlightStyle() lipgloss.Style {
|
|
if highlightStyle.String() == "" {
|
|
theme := style.GetTheme()
|
|
highlightStyle = lipgloss.NewStyle().
|
|
Background(theme.Secondary).
|
|
Foreground(theme.Background).
|
|
Bold(true)
|
|
}
|
|
return highlightStyle
|
|
}
|
|
|
|
// MessageItem is the interface all scrollback messages must implement.
|
|
// This allows lazy rendering - messages are only rendered when visible.
|
|
type MessageItem interface {
|
|
// Render returns the styled content for this message at the given width.
|
|
// Implementations should cache the result to avoid re-rendering.
|
|
Render(width int) string
|
|
|
|
// Height returns the number of lines this message occupies when rendered.
|
|
Height() int
|
|
|
|
// ID returns a unique identifier for this message (for tracking).
|
|
ID() string
|
|
}
|
|
|
|
// ScrollList manages a viewport over a list of MessageItems.
|
|
// It handles offset-based scrolling and lazy rendering. Only visible
|
|
// items are rendered on each View() call.
|
|
type ScrollList struct {
|
|
items []MessageItem
|
|
offsetIdx int // Index of first visible item
|
|
offsetLine int // Lines to skip from first visible item
|
|
width int
|
|
height int // Viewport height in lines
|
|
autoScroll bool // Whether to auto-scroll to bottom on new content
|
|
itemGap int // Number of blank lines between items (0 = no gap)
|
|
focusedIdx int // Index of focused/selected item (-1 = none)
|
|
selectable bool // Whether items can be selected via mouse/keyboard
|
|
|
|
// Selection tracking for copy+paste (crush-style)
|
|
selection clipboard.CopySelection // Current text selection
|
|
mouseDown bool // Whether mouse button is currently down
|
|
mouseDownX int // X coordinate where mouse was pressed
|
|
mouseDownY int // Y coordinate where mouse was pressed
|
|
mouseDownItem int // Item index where mouse was pressed
|
|
}
|
|
|
|
// NewScrollList creates a new ScrollList with the given dimensions.
|
|
func NewScrollList(width, height int) *ScrollList {
|
|
return &ScrollList{
|
|
items: []MessageItem{},
|
|
offsetIdx: 0,
|
|
offsetLine: 0,
|
|
width: width,
|
|
height: height,
|
|
autoScroll: true, // Start with auto-scroll enabled
|
|
}
|
|
}
|
|
|
|
// SetItems replaces the items in the scroll list. If auto-scroll is enabled,
|
|
// the viewport will scroll to the bottom to show the latest content.
|
|
func (s *ScrollList) SetItems(items []MessageItem) {
|
|
s.items = items
|
|
if s.autoScroll {
|
|
s.GotoBottom()
|
|
}
|
|
}
|
|
|
|
// SetHeight updates the viewport height. Called when the terminal is resized.
|
|
func (s *ScrollList) SetHeight(height int) {
|
|
s.height = height
|
|
s.clampOffset()
|
|
}
|
|
|
|
// SetWidth updates the viewport width. Called when the terminal is resized.
|
|
// This may invalidate cached renders in MessageItems.
|
|
func (s *ScrollList) SetWidth(width int) {
|
|
s.width = width
|
|
s.clampOffset()
|
|
}
|
|
|
|
// SetItemGap sets the number of blank lines between items (0 = no gap).
|
|
func (s *ScrollList) SetItemGap(gap int) {
|
|
s.itemGap = gap
|
|
}
|
|
|
|
// ItemGap returns the current gap between items.
|
|
func (s *ScrollList) ItemGap() int {
|
|
return s.itemGap
|
|
}
|
|
|
|
// SetSelectable enables or disables item selection.
|
|
func (s *ScrollList) SetSelectable(selectable bool) {
|
|
s.selectable = selectable
|
|
}
|
|
|
|
// FocusedIdx returns the currently focused item index (-1 if none).
|
|
func (s *ScrollList) FocusedIdx() int {
|
|
return s.focusedIdx
|
|
}
|
|
|
|
// SetFocused sets the focused item by index.
|
|
func (s *ScrollList) SetFocused(idx int) {
|
|
if idx < -1 {
|
|
s.focusedIdx = -1
|
|
} else if idx >= len(s.items) {
|
|
s.focusedIdx = len(s.items) - 1
|
|
} else {
|
|
s.focusedIdx = idx
|
|
}
|
|
}
|
|
|
|
// SelectItemAtY selects the item at the given Y coordinate (relative to viewport).
|
|
// Returns the selected item index or -1 if no item at that position.
|
|
func (s *ScrollList) SelectItemAtY(y int) int {
|
|
if !s.selectable || len(s.items) == 0 || y < 0 || y >= s.height {
|
|
return -1
|
|
}
|
|
|
|
// Calculate which item is at the given Y position
|
|
currentY := 0
|
|
for idx := s.offsetIdx; idx < len(s.items); idx++ {
|
|
item := s.items[idx]
|
|
itemHeight := item.Height()
|
|
|
|
// Check if y falls within this item
|
|
if y >= currentY && y < currentY+itemHeight {
|
|
s.focusedIdx = idx
|
|
return idx
|
|
}
|
|
|
|
currentY += itemHeight
|
|
|
|
// Add gap after item (except last)
|
|
if s.itemGap > 0 && idx < len(s.items)-1 {
|
|
currentY += s.itemGap
|
|
}
|
|
|
|
// Stop if we've passed the viewport
|
|
if currentY >= s.height {
|
|
break
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
// HandleMouseDown handles mouse button press for selection (crush-style).
|
|
// Returns true if the click was handled.
|
|
func (s *ScrollList) HandleMouseDown(x, y int) bool {
|
|
if !s.selectable || len(s.items) == 0 {
|
|
return false
|
|
}
|
|
|
|
s.mouseDown = true
|
|
s.mouseDownX = x
|
|
s.mouseDownY = y
|
|
|
|
// Find which item and line was clicked
|
|
itemIdx, lineIdx := s.getItemAndLineAtY(y)
|
|
s.mouseDownItem = itemIdx
|
|
|
|
// Start a new selection at click position
|
|
if itemIdx >= 0 {
|
|
s.selection = clipboard.CopySelection{
|
|
StartItemIdx: itemIdx,
|
|
StartLine: lineIdx,
|
|
StartCol: x,
|
|
EndItemIdx: itemIdx,
|
|
EndLine: lineIdx,
|
|
EndCol: x,
|
|
Active: true,
|
|
}
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// HandleMouseDrag handles mouse drag for selection (crush-style).
|
|
// Updates the selection end point. Returns true if selection changed.
|
|
func (s *ScrollList) HandleMouseDrag(x, y int) bool {
|
|
if !s.mouseDown || !s.selectable {
|
|
return false
|
|
}
|
|
|
|
// Find which item and line we're dragging over
|
|
itemIdx, lineIdx := s.getItemAndLineAtY(y)
|
|
if itemIdx < 0 {
|
|
return false
|
|
}
|
|
|
|
// Update selection end point
|
|
s.selection.EndItemIdx = itemIdx
|
|
s.selection.EndLine = lineIdx
|
|
s.selection.EndCol = x
|
|
s.selection.Active = true
|
|
|
|
return true
|
|
}
|
|
|
|
// getItemAndLineAtY converts a Y coordinate to item index and line index within that item.
|
|
// Returns (-1, -1) if Y is outside the viewport or beyond all items.
|
|
func (s *ScrollList) getItemAndLineAtY(y int) (itemIdx, lineIdx int) {
|
|
if y < 0 || y >= s.height || len(s.items) == 0 {
|
|
return -1, -1
|
|
}
|
|
|
|
currentY := 0
|
|
for idx := s.offsetIdx; idx < len(s.items); idx++ {
|
|
item := s.items[idx]
|
|
itemHeight := item.Height()
|
|
|
|
// Check if y falls within this item
|
|
if y >= currentY && y < currentY+itemHeight {
|
|
return idx, y - currentY
|
|
}
|
|
|
|
currentY += itemHeight
|
|
|
|
// Add gap after item (except last)
|
|
if s.itemGap > 0 && idx < len(s.items)-1 {
|
|
currentY += s.itemGap
|
|
}
|
|
|
|
// Stop if we've passed the viewport
|
|
if currentY >= s.height {
|
|
break
|
|
}
|
|
}
|
|
|
|
return -1, -1
|
|
}
|
|
|
|
// HandleMouseUp handles mouse button release (crush-style).
|
|
// Finalizes selection and returns true if there was an active selection.
|
|
func (s *ScrollList) HandleMouseUp(x, y int) bool {
|
|
if !s.mouseDown {
|
|
return false
|
|
}
|
|
|
|
s.mouseDown = false
|
|
|
|
// Check if we have a valid selection
|
|
if s.selection.Active && !s.selection.IsEmpty() {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// GetSelection returns the current text selection.
|
|
func (s *ScrollList) GetSelection() clipboard.CopySelection {
|
|
return s.selection
|
|
}
|
|
|
|
// ClearSelection clears the current text selection.
|
|
func (s *ScrollList) ClearSelection() {
|
|
s.selection = clipboard.CopySelection{}
|
|
s.mouseDown = false
|
|
}
|
|
|
|
// HasSelection returns true if there is an active non-empty selection.
|
|
func (s *ScrollList) HasSelection() bool {
|
|
return s.selection.Active && !s.selection.IsEmpty()
|
|
}
|
|
|
|
// ScrollBy scrolls the viewport by the given number of lines.
|
|
// Positive = scroll down, negative = scroll up.
|
|
func (s *ScrollList) ScrollBy(lines int) {
|
|
if lines > 0 {
|
|
// Scroll down
|
|
for lines > 0 && s.offsetIdx < len(s.items) {
|
|
if s.offsetIdx >= len(s.items) {
|
|
break
|
|
}
|
|
currentItem := s.items[s.offsetIdx]
|
|
itemHeight := currentItem.Height()
|
|
remainingLines := itemHeight - s.offsetLine
|
|
|
|
if lines >= remainingLines {
|
|
// Move to next item
|
|
s.offsetIdx++
|
|
s.offsetLine = 0
|
|
lines -= remainingLines
|
|
// Consume gap lines between items
|
|
if s.itemGap > 0 && s.offsetIdx < len(s.items) {
|
|
if lines >= s.itemGap {
|
|
lines -= s.itemGap
|
|
} else {
|
|
lines = 0
|
|
}
|
|
}
|
|
} else {
|
|
// Stay on current item, skip more lines
|
|
s.offsetLine += lines
|
|
lines = 0
|
|
}
|
|
}
|
|
} else if lines < 0 {
|
|
// Scroll up
|
|
lines = -lines
|
|
for lines > 0 && (s.offsetIdx > 0 || s.offsetLine > 0) {
|
|
if s.offsetLine > 0 {
|
|
// Scroll within current item
|
|
if lines >= s.offsetLine {
|
|
lines -= s.offsetLine
|
|
s.offsetLine = 0
|
|
} else {
|
|
s.offsetLine -= lines
|
|
lines = 0
|
|
}
|
|
} else if s.offsetIdx > 0 {
|
|
// Consume gap lines between items
|
|
if s.itemGap > 0 {
|
|
if lines > s.itemGap {
|
|
lines -= s.itemGap
|
|
} else {
|
|
lines = 0
|
|
continue
|
|
}
|
|
}
|
|
// Move to previous item
|
|
s.offsetIdx--
|
|
if s.offsetIdx < len(s.items) {
|
|
currentItem := s.items[s.offsetIdx]
|
|
itemHeight := currentItem.Height()
|
|
|
|
if lines >= itemHeight {
|
|
lines -= itemHeight
|
|
s.offsetLine = 0
|
|
} else {
|
|
s.offsetLine = itemHeight - lines
|
|
lines = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
s.clampOffset()
|
|
}
|
|
|
|
// GotoBottom scrolls to the end of the list.
|
|
func (s *ScrollList) GotoBottom() {
|
|
if len(s.items) == 0 {
|
|
s.offsetIdx = 0
|
|
s.offsetLine = 0
|
|
return
|
|
}
|
|
|
|
// Calculate total height including gaps
|
|
// Ensure items are rendered before checking height (iteratr pattern)
|
|
totalHeight := 0
|
|
for i, item := range s.items {
|
|
// Render to get actual content (handles non-cached items like reasoning blocks)
|
|
rendered := item.Render(s.width)
|
|
itemHeight := strings.Count(rendered, "\n") + 1
|
|
totalHeight += itemHeight
|
|
// Add gap after each item except the last
|
|
if s.itemGap > 0 && i < len(s.items)-1 {
|
|
totalHeight += s.itemGap
|
|
}
|
|
}
|
|
|
|
// If content fits in viewport, start at top
|
|
if totalHeight <= s.height {
|
|
s.offsetIdx = 0
|
|
s.offsetLine = 0
|
|
return
|
|
}
|
|
|
|
// Otherwise, position viewport at bottom
|
|
remaining := totalHeight - s.height
|
|
for idx := 0; idx < len(s.items); idx++ {
|
|
// Render to get actual content
|
|
rendered := s.items[idx].Render(s.width)
|
|
itemHeight := strings.Count(rendered, "\n") + 1
|
|
if remaining < itemHeight {
|
|
s.offsetIdx = idx
|
|
s.offsetLine = remaining
|
|
return
|
|
}
|
|
remaining -= itemHeight
|
|
// Subtract gap after item (except last)
|
|
if s.itemGap > 0 && idx < len(s.items)-1 {
|
|
remaining -= s.itemGap
|
|
}
|
|
}
|
|
|
|
// Fallback: show last item
|
|
s.offsetIdx = max(0, len(s.items)-1)
|
|
s.offsetLine = 0
|
|
}
|
|
|
|
// GotoTop scrolls to the beginning of the list.
|
|
func (s *ScrollList) GotoTop() {
|
|
s.offsetIdx = 0
|
|
s.offsetLine = 0
|
|
}
|
|
|
|
// AtBottom returns true if the viewport is at the bottom of the list.
|
|
func (s *ScrollList) AtBottom() bool {
|
|
if len(s.items) == 0 {
|
|
return true
|
|
}
|
|
|
|
// Calculate visible height from current position including gaps
|
|
// Calculate height directly from rendered content (handles non-cached items)
|
|
visibleHeight := 0
|
|
for idx := s.offsetIdx; idx < len(s.items); idx++ {
|
|
item := s.items[idx]
|
|
// Render to get actual content
|
|
rendered := item.Render(s.width)
|
|
itemHeight := strings.Count(rendered, "\n") + 1
|
|
|
|
if idx == s.offsetIdx {
|
|
visibleHeight += itemHeight - s.offsetLine
|
|
} else {
|
|
visibleHeight += itemHeight
|
|
}
|
|
|
|
// Add gap after item (except last)
|
|
if s.itemGap > 0 && idx < len(s.items)-1 {
|
|
visibleHeight += s.itemGap
|
|
}
|
|
|
|
if visibleHeight >= s.height {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// AtTop returns true if the viewport is at the top of the list.
|
|
func (s *ScrollList) AtTop() bool {
|
|
return s.offsetIdx == 0 && s.offsetLine == 0
|
|
}
|
|
|
|
// View renders the visible portion of the scrollback.
|
|
// Only items that fit within the viewport height are rendered.
|
|
// ALWAYS returns exactly s.height lines (padded with empty lines if needed)
|
|
// to ensure the input/footer stay fixed at the bottom.
|
|
func (s *ScrollList) View() string {
|
|
if s.height <= 0 {
|
|
return ""
|
|
}
|
|
|
|
var lines []string
|
|
remainingHeight := s.height
|
|
|
|
// Render visible items
|
|
if len(s.items) > 0 {
|
|
for idx := s.offsetIdx; idx < len(s.items) && remainingHeight > 0; idx++ {
|
|
item := s.items[idx]
|
|
content := item.Render(s.width)
|
|
contentLines := strings.Split(content, "\n")
|
|
|
|
startLine := 0
|
|
if idx == s.offsetIdx {
|
|
startLine = s.offsetLine
|
|
}
|
|
|
|
// Check if this item is focused (for visual indicator)
|
|
isFocused := idx == s.focusedIdx
|
|
|
|
for i := startLine; i < len(contentLines) && remainingHeight > 0; i++ {
|
|
line := contentLines[i]
|
|
|
|
// Apply selection highlighting if this line is within selection
|
|
if s.selection.Active && s.isLineInSelection(idx, i) {
|
|
line = s.applyHighlight(line)
|
|
} else if isFocused && s.selectable {
|
|
// Apply subtle focus indicator when item is focused but not in selection
|
|
line = s.applyFocusIndicator(line)
|
|
}
|
|
|
|
lines = append(lines, line)
|
|
remainingHeight--
|
|
}
|
|
|
|
// Add gap lines between items (but not after the last visible item)
|
|
if remainingHeight > 0 && idx < len(s.items)-1 && s.itemGap > 0 {
|
|
for g := 0; g < s.itemGap && remainingHeight > 0; g++ {
|
|
lines = append(lines, "")
|
|
remainingHeight--
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pad with empty lines to ensure exactly s.height lines
|
|
// This keeps the input/footer fixed at the bottom of the screen
|
|
for remainingHeight > 0 {
|
|
lines = append(lines, "")
|
|
remainingHeight--
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// isLineInSelection checks if a specific line within an item is part of the current selection.
|
|
func (s *ScrollList) isLineInSelection(itemIdx, lineIdx int) bool {
|
|
if !s.selection.Active {
|
|
return false
|
|
}
|
|
|
|
// Normalize selection (start <= end)
|
|
startItem := s.selection.StartItemIdx
|
|
startLine := s.selection.StartLine
|
|
endItem := s.selection.EndItemIdx
|
|
endLine := s.selection.EndLine
|
|
|
|
if startItem > endItem || (startItem == endItem && startLine > endLine) {
|
|
startItem, endItem = endItem, startItem
|
|
startLine, endLine = endLine, startLine
|
|
}
|
|
|
|
// Check if item is within selection range
|
|
if itemIdx < startItem || itemIdx > endItem {
|
|
return false
|
|
}
|
|
|
|
// For single item selection
|
|
if startItem == endItem {
|
|
return itemIdx == startItem && lineIdx >= startLine && lineIdx <= endLine
|
|
}
|
|
|
|
// For multi-item selection
|
|
if itemIdx == startItem {
|
|
return lineIdx >= startLine
|
|
}
|
|
if itemIdx == endItem {
|
|
return lineIdx <= endLine
|
|
}
|
|
// Middle items are fully selected
|
|
return itemIdx > startItem && itemIdx < endItem
|
|
}
|
|
|
|
// applyHighlight applies the highlight style to a line.
|
|
// Uses the theme's Highlight color for the background.
|
|
func (s *ScrollList) applyHighlight(line string) string {
|
|
if line == "" {
|
|
return line
|
|
}
|
|
// Apply background/foreground color change for selection
|
|
style := initHighlightStyle()
|
|
return style.Render(line)
|
|
}
|
|
|
|
// applyFocusIndicator applies a subtle visual indicator for focused items.
|
|
func (s *ScrollList) applyFocusIndicator(line string) string {
|
|
if line == "" {
|
|
return line
|
|
}
|
|
// Just return the line as-is - no visual indicator for focus
|
|
// The selection highlighting is enough
|
|
return line
|
|
}
|
|
|
|
// ScrollPercent returns the current scroll position as a percentage (0.0-1.0).
|
|
// 0.0 = at top, 1.0 = at bottom. Useful for scroll indicators.
|
|
func (s *ScrollList) ScrollPercent() float64 {
|
|
if len(s.items) == 0 {
|
|
return 0.0
|
|
}
|
|
|
|
totalHeight := 0
|
|
for _, item := range s.items {
|
|
totalHeight += item.Height()
|
|
}
|
|
|
|
if totalHeight <= s.height {
|
|
return 1.0 // All content fits, consider it "at bottom"
|
|
}
|
|
|
|
// Calculate how many lines are above the viewport
|
|
linesAbove := 0
|
|
for i := 0; i < s.offsetIdx && i < len(s.items); i++ {
|
|
linesAbove += s.items[i].Height()
|
|
}
|
|
linesAbove += s.offsetLine
|
|
|
|
scrollableHeight := totalHeight - s.height
|
|
if scrollableHeight <= 0 {
|
|
return 1.0
|
|
}
|
|
|
|
percent := float64(linesAbove) / float64(scrollableHeight)
|
|
if percent > 1.0 {
|
|
percent = 1.0
|
|
}
|
|
if percent < 0.0 {
|
|
percent = 0.0
|
|
}
|
|
return percent
|
|
}
|
|
|
|
// clampOffset ensures the offset values are within valid bounds after
|
|
// resizing or scrolling operations. Prevents scrolling past the bottom
|
|
// of content (showing empty space when there's content above).
|
|
func (s *ScrollList) clampOffset() {
|
|
if len(s.items) == 0 {
|
|
s.offsetIdx = 0
|
|
s.offsetLine = 0
|
|
return
|
|
}
|
|
|
|
// First, clamp offsetIdx to valid item range
|
|
if s.offsetIdx >= len(s.items) {
|
|
s.offsetIdx = len(s.items) - 1
|
|
}
|
|
if s.offsetIdx < 0 {
|
|
s.offsetIdx = 0
|
|
}
|
|
|
|
// Clamp offsetLine within current item
|
|
if s.offsetIdx < len(s.items) {
|
|
// Calculate height from rendered content (handles non-cached items)
|
|
rendered := s.items[s.offsetIdx].Render(s.width)
|
|
itemHeight := strings.Count(rendered, "\n") + 1
|
|
if s.offsetLine >= itemHeight {
|
|
s.offsetLine = max(0, itemHeight-1)
|
|
}
|
|
}
|
|
if s.offsetLine < 0 {
|
|
s.offsetLine = 0
|
|
}
|
|
|
|
// Prevent scrolling past the bottom (showing empty space at bottom when there's content above)
|
|
// Calculate total content height
|
|
totalHeight := 0
|
|
for i, item := range s.items {
|
|
rendered := item.Render(s.width)
|
|
totalHeight += strings.Count(rendered, "\n") + 1
|
|
if s.itemGap > 0 && i < len(s.items)-1 {
|
|
totalHeight += s.itemGap
|
|
}
|
|
}
|
|
|
|
// If content fits in viewport, force start at top
|
|
if totalHeight <= s.height {
|
|
s.offsetIdx = 0
|
|
s.offsetLine = 0
|
|
return
|
|
}
|
|
|
|
// Calculate how many lines are currently above the viewport
|
|
linesAbove := 0
|
|
for i := 0; i < s.offsetIdx; i++ {
|
|
rendered := s.items[i].Render(s.width)
|
|
linesAbove += strings.Count(rendered, "\n") + 1
|
|
if s.itemGap > 0 && i < len(s.items)-1 {
|
|
linesAbove += s.itemGap
|
|
}
|
|
}
|
|
linesAbove += s.offsetLine
|
|
|
|
// Calculate how many lines are visible from current position to end
|
|
linesFromCurrentToEnd := totalHeight - linesAbove
|
|
|
|
// If there's less content remaining than the viewport height,
|
|
// we've scrolled past the bottom - need to back up
|
|
if linesFromCurrentToEnd < s.height {
|
|
// Position viewport so the last line of content is at the bottom
|
|
targetLine := totalHeight - s.height
|
|
currentLine := 0
|
|
|
|
for idx := 0; idx < len(s.items); idx++ {
|
|
rendered := s.items[idx].Render(s.width)
|
|
itemHeight := strings.Count(rendered, "\n") + 1
|
|
|
|
if currentLine+itemHeight > targetLine {
|
|
// This item contains the target line
|
|
s.offsetIdx = idx
|
|
s.offsetLine = targetLine - currentLine
|
|
return
|
|
}
|
|
|
|
currentLine += itemHeight
|
|
if s.itemGap > 0 && idx < len(s.items)-1 {
|
|
currentLine += s.itemGap
|
|
}
|
|
}
|
|
}
|
|
}
|