diff --git a/internal/ui/lazylist/highlight.go b/internal/ui/lazylist/highlight.go index c5b885238771d57f28e4177684c7e24e1336a5b8..e53d10353dd286fcfe2642db102736c27df34adc 100644 --- a/internal/ui/lazylist/highlight.go +++ b/internal/ui/lazylist/highlight.go @@ -86,135 +86,6 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin return buf.Render() } -// RenderWithHighlight renders content with optional focus styling and highlighting. -// This is a helper that combines common rendering logic for all items. -// The content parameter should be the raw rendered content before focus styling. -// The style parameter should come from CurrentStyle() and may be nil. -// func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string { -// // Apply focus/blur styling if configured -// rendered := content -// if style != nil { -// rendered = style.Render(rendered) -// } -// -// if !b.HasHighlight() { -// return rendered -// } -// -// height := lipgloss.Height(rendered) -// -// // Create temp buffer to draw content with highlighting -// tempBuf := uv.NewScreenBuffer(width, height) -// -// // Draw the rendered content to temp buffer -// styled := uv.NewStyledString(rendered) -// styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) -// -// // Apply highlighting if active -// b.ApplyHighlight(&tempBuf, width, height, style) -// -// return tempBuf.Render() -// } - -// ApplyHighlight applies highlighting to a screen buffer. -// This should be called after drawing content to the buffer. -// func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) { -// if b.highlightStartLine < 0 { -// return -// } -// -// var ( -// topMargin, topBorder, topPadding int -// rightMargin, rightBorder, rightPadding int -// bottomMargin, bottomBorder, bottomPadding int -// leftMargin, leftBorder, leftPadding int -// ) -// if style != nil { -// topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin() -// topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(), -// style.GetBorderRightSize(), -// style.GetBorderBottomSize(), -// style.GetBorderLeftSize() -// topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding() -// } -// -// slog.Info("Applying highlight", -// "highlightStartLine", b.highlightStartLine, -// "highlightStartCol", b.highlightStartCol, -// "highlightEndLine", b.highlightEndLine, -// "highlightEndCol", b.highlightEndCol, -// "width", width, -// "height", height, -// "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin), -// "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder), -// "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding), -// ) -// -// // Calculate content area offsets -// contentArea := image.Rectangle{ -// Min: image.Point{ -// X: leftMargin + leftBorder + leftPadding, -// Y: topMargin + topBorder + topPadding, -// }, -// Max: image.Point{ -// X: width - (rightMargin + rightBorder + rightPadding), -// Y: height - (bottomMargin + bottomBorder + bottomPadding), -// }, -// } -// -// for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ { -// if y >= buf.Height() { -// break -// } -// -// line := buf.Line(y) -// -// // Determine column range for this line -// startCol := 0 -// if y == b.highlightStartLine { -// startCol = min(b.highlightStartCol, len(line)) -// } -// -// endCol := len(line) -// if y == b.highlightEndLine { -// endCol = min(b.highlightEndCol, len(line)) -// } -// -// // Track last non-empty position as we go -// lastContentX := -1 -// -// // Single pass: check content and track last non-empty position -// for x := startCol; x < endCol; x++ { -// cell := line.At(x) -// if cell == nil { -// continue -// } -// -// // Update last content position if non-empty -// if cell.Content != "" && cell.Content != " " { -// lastContentX = x -// } -// } -// -// // Only apply highlight up to last content position -// highlightEnd := endCol -// if lastContentX >= 0 { -// highlightEnd = lastContentX + 1 -// } else if lastContentX == -1 { -// highlightEnd = startCol // No content on this line -// } -// -// // Apply highlight style only to cells with content -// for x := startCol; x < highlightEnd; x++ { -// if !image.Pt(x, y).In(contentArea) { -// continue -// } -// cell := line.At(x) -// cell.Style = b.highlightStyle(cell.Style) -// } -// } -// } - // ToHighlighter converts a [lipgloss.Style] to a [Highlighter]. func ToHighlighter(lgStyle lipgloss.Style) Highlighter { return func(uv.Style) uv.Style { diff --git a/internal/ui/list/example_test.go b/internal/ui/list/example_test.go deleted file mode 100644 index e656fe059f16d98db84cccb8af328ffdc92864c1..0000000000000000000000000000000000000000 --- a/internal/ui/list/example_test.go +++ /dev/null @@ -1,275 +0,0 @@ -package list_test - -import ( - "fmt" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/ui/list" - uv "github.com/charmbracelet/ultraviolet" -) - -// Example demonstrates basic list usage with string items. -func Example_basic() { - // Create some items - items := []list.Item{ - list.NewStringItem("First item"), - list.NewStringItem("Second item"), - list.NewStringItem("Third item"), - } - - // Create a list with options - l := list.New(items...) - l.SetSize(80, 10) - l.SetSelected(0) - if true { - l.Focus() - } - - // Draw to a screen buffer - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - l.Draw(&screen, area) - - // Render to string - output := screen.Render() - fmt.Println(output) -} - -// BorderedItem demonstrates a focusable item with borders. -type BorderedItem struct { - id string - content string - focused bool - width int -} - -func NewBorderedItem(id, content string) *BorderedItem { - return &BorderedItem{ - id: id, - content: content, - width: 80, - } -} - -func (b *BorderedItem) ID() string { - return b.id -} - -func (b *BorderedItem) Height(width int) int { - // Account for border (2 lines for top and bottom) - b.width = width // Update width for rendering - return lipgloss.Height(b.render()) -} - -func (b *BorderedItem) Draw(scr uv.Screen, area uv.Rectangle) { - rendered := b.render() - styled := uv.NewStyledString(rendered) - styled.Draw(scr, area) -} - -func (b *BorderedItem) render() string { - style := lipgloss.NewStyle(). - Width(b.width-4). - Padding(0, 1) - - if b.focused { - style = style. - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("205")) - } else { - style = style. - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")) - } - - return style.Render(b.content) -} - -func (b *BorderedItem) Focus() { - b.focused = true -} - -func (b *BorderedItem) Blur() { - b.focused = false -} - -func (b *BorderedItem) IsFocused() bool { - return b.focused -} - -// Example demonstrates focusable items with borders. -func Example_focusable() { - // Create focusable items - items := []list.Item{ - NewBorderedItem("1", "Focusable Item 1"), - NewBorderedItem("2", "Focusable Item 2"), - NewBorderedItem("3", "Focusable Item 3"), - } - - // Create list with first item selected and focused - l := list.New(items...) - l.SetSize(80, 20) - l.SetSelected(0) - if true { - l.Focus() - } - - // Draw to screen - screen := uv.NewScreenBuffer(80, 20) - area := uv.Rect(0, 0, 80, 20) - l.Draw(&screen, area) - - // The first item will have a colored border since it's focused - output := screen.Render() - fmt.Println(output) -} - -// Example demonstrates dynamic item updates. -func Example_dynamicUpdates() { - items := []list.Item{ - list.NewStringItem("Item 1"), - list.NewStringItem("Item 2"), - } - - l := list.New(items...) - l.SetSize(80, 10) - - // Draw initial state - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - l.Draw(&screen, area) - - // Update an item - l.UpdateItem(2, list.NewStringItem("Updated Item 2")) - - // Draw again - only changed item is re-rendered - l.Draw(&screen, area) - - // Append a new item - l.AppendItem(list.NewStringItem("New Item 3")) - - // Draw again - master buffer grows efficiently - l.Draw(&screen, area) - - output := screen.Render() - fmt.Println(output) -} - -// Example demonstrates scrolling with a large list. -func Example_scrolling() { - // Create many items - items := make([]list.Item, 100) - for i := range items { - items[i] = list.NewStringItem( - fmt.Sprintf("Item %d", i), - ) - } - - // Create list with small viewport - l := list.New(items...) - l.SetSize(80, 10) - l.SetSelected(0) - - // Draw initial view (shows items 0-9) - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - l.Draw(&screen, area) - - // Scroll down - l.ScrollBy(5) - l.Draw(&screen, area) // Now shows items 5-14 - - // Jump to specific item - l.ScrollToItem(50) - l.Draw(&screen, area) // Now shows item 50 and neighbors - - // Scroll to bottom - l.ScrollToBottom() - l.Draw(&screen, area) // Now shows last 10 items - - output := screen.Render() - fmt.Println(output) -} - -// VariableHeightItem demonstrates items with different heights. -type VariableHeightItem struct { - id string - lines []string - width int -} - -func NewVariableHeightItem(id string, lines []string) *VariableHeightItem { - return &VariableHeightItem{ - id: id, - lines: lines, - width: 80, - } -} - -func (v *VariableHeightItem) ID() string { - return v.id -} - -func (v *VariableHeightItem) Height(width int) int { - return len(v.lines) -} - -func (v *VariableHeightItem) Draw(scr uv.Screen, area uv.Rectangle) { - content := "" - for i, line := range v.lines { - if i > 0 { - content += "\n" - } - content += line - } - styled := uv.NewStyledString(content) - styled.Draw(scr, area) -} - -// Example demonstrates variable height items. -func Example_variableHeights() { - items := []list.Item{ - NewVariableHeightItem("1", []string{"Short item"}), - NewVariableHeightItem("2", []string{ - "This is a taller item", - "that spans multiple lines", - "to demonstrate variable heights", - }), - NewVariableHeightItem("3", []string{"Another short item"}), - NewVariableHeightItem("4", []string{ - "A medium height item", - "with two lines", - }), - } - - l := list.New(items...) - l.SetSize(80, 15) - - screen := uv.NewScreenBuffer(80, 15) - area := uv.Rect(0, 0, 80, 15) - l.Draw(&screen, area) - - output := screen.Render() - fmt.Println(output) -} - -// Example demonstrates markdown items in a list. -func Example_markdown() { - // Create markdown items - items := []list.Item{ - list.NewMarkdownItem("# Welcome\n\nThis is a **markdown** item."), - list.NewMarkdownItem("## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"), - list.NewMarkdownItem("### Code Block\n\n```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```"), - } - - // Create list - l := list.New(items...) - l.SetSize(80, 20) - - screen := uv.NewScreenBuffer(80, 20) - area := uv.Rect(0, 0, 80, 20) - l.Draw(&screen, area) - - output := screen.Render() - fmt.Println(output) -} diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go deleted file mode 100644 index 51f930d033d61d7f89634222f0f05b3e0041ac17..0000000000000000000000000000000000000000 --- a/internal/ui/list/item.go +++ /dev/null @@ -1,578 +0,0 @@ -package list - -import ( - "image" - "strings" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/glamour/v2" - "github.com/charmbracelet/glamour/v2/ansi" - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/ultraviolet/screen" -) - -// toUVStyle converts a lipgloss.Style to a uv.Style, stripping multiline attributes. -func toUVStyle(lgStyle lipgloss.Style) uv.Style { - var uvStyle uv.Style - - // Colors are already color.Color - uvStyle.Fg = lgStyle.GetForeground() - uvStyle.Bg = lgStyle.GetBackground() - - // Build attributes using bitwise OR - var attrs uint8 - - if lgStyle.GetBold() { - attrs |= uv.AttrBold - } - - if lgStyle.GetItalic() { - attrs |= uv.AttrItalic - } - - if lgStyle.GetUnderline() { - uvStyle.Underline = uv.UnderlineSingle - } - - if lgStyle.GetStrikethrough() { - attrs |= uv.AttrStrikethrough - } - - if lgStyle.GetFaint() { - attrs |= uv.AttrFaint - } - - if lgStyle.GetBlink() { - attrs |= uv.AttrBlink - } - - if lgStyle.GetReverse() { - attrs |= uv.AttrReverse - } - - uvStyle.Attrs = attrs - - return uvStyle -} - -// Item represents a list item that can draw itself to a UV buffer. -// Items implement the uv.Drawable interface. -type Item interface { - uv.Drawable - - // Height returns the item's height in lines for the given width. - // This allows items to calculate height based on text wrapping and available space. - Height(width int) int -} - -// Focusable is an optional interface for items that support focus. -// When implemented, items can change appearance when focused (borders, colors, etc). -type Focusable interface { - Focus() - Blur() - IsFocused() bool -} - -// Highlightable is an optional interface for items that support highlighting. -// When implemented, items can highlight specific regions (e.g. for search matches). -type Highlightable interface { - // SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol). - // Use -1 for all values to clear highlighting. - SetHighlight(startLine, startCol, endLine, endCol int) - - // GetHighlight returns the current highlight region. - GetHighlight() (startLine, startCol, endLine, endCol int) -} - -// BaseFocusable provides common focus state and styling for items. -// Embed this type to add focus behavior to any item. -type BaseFocusable struct { - focused bool - focusStyle *lipgloss.Style - blurStyle *lipgloss.Style -} - -// Focus implements Focusable interface. -func (b *BaseFocusable) Focus() { - b.focused = true -} - -// Blur implements Focusable interface. -func (b *BaseFocusable) Blur() { - b.focused = false -} - -// IsFocused implements Focusable interface. -func (b *BaseFocusable) IsFocused() bool { - return b.focused -} - -// HasFocusStyles returns true if both focus and blur styles are configured. -func (b *BaseFocusable) HasFocusStyles() bool { - return b.focusStyle != nil && b.blurStyle != nil -} - -// CurrentStyle returns the current style based on focus state. -// Returns nil if no styles are configured, or if the current state's style is nil. -func (b *BaseFocusable) CurrentStyle() *lipgloss.Style { - if b.focused { - return b.focusStyle - } - return b.blurStyle -} - -// SetFocusStyles sets the focus and blur styles. -func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) { - b.focusStyle = focusStyle - b.blurStyle = blurStyle -} - -// BaseHighlightable provides common highlight state for items. -// Embed this type to add highlight behavior to any item. -type BaseHighlightable struct { - highlightStartLine int - highlightStartCol int - highlightEndLine int - highlightEndCol int - highlightStyle CellStyler -} - -// SetHighlight implements Highlightable interface. -func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) { - b.highlightStartLine = startLine - b.highlightStartCol = startCol - b.highlightEndLine = endLine - b.highlightEndCol = endCol -} - -// GetHighlight implements Highlightable interface. -func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) { - return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol -} - -// HasHighlight returns true if a highlight region is set. -func (b *BaseHighlightable) HasHighlight() bool { - return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 || - b.highlightEndLine >= 0 || b.highlightEndCol >= 0 -} - -// SetHighlightStyle sets the style function used for highlighting. -func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) { - b.highlightStyle = style -} - -// GetHighlightStyle returns the current highlight style function. -func (b *BaseHighlightable) GetHighlightStyle() CellStyler { - return b.highlightStyle -} - -// InitHighlight initializes the highlight fields with default values. -func (b *BaseHighlightable) InitHighlight() { - b.highlightStartLine = -1 - b.highlightStartCol = -1 - b.highlightEndLine = -1 - b.highlightEndCol = -1 - b.highlightStyle = LipglossStyleToCellStyler(lipgloss.NewStyle().Reverse(true)) -} - -// ApplyHighlight applies highlighting to a screen buffer. -// This should be called after drawing content to the buffer. -func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) { - if b.highlightStartLine < 0 { - return - } - - var ( - topMargin, topBorder, topPadding int - rightMargin, rightBorder, rightPadding int - bottomMargin, bottomBorder, bottomPadding int - leftMargin, leftBorder, leftPadding int - ) - if style != nil { - topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin() - topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(), - style.GetBorderRightSize(), - style.GetBorderBottomSize(), - style.GetBorderLeftSize() - topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding() - } - - // Calculate content area offsets - contentArea := image.Rectangle{ - Min: image.Point{ - X: leftMargin + leftBorder + leftPadding, - Y: topMargin + topBorder + topPadding, - }, - Max: image.Point{ - X: width - (rightMargin + rightBorder + rightPadding), - Y: height - (bottomMargin + bottomBorder + bottomPadding), - }, - } - - for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ { - if y >= buf.Height() { - break - } - - line := buf.Line(y) - - // Determine column range for this line - startCol := 0 - if y == b.highlightStartLine { - startCol = min(b.highlightStartCol, len(line)) - } - - endCol := len(line) - if y == b.highlightEndLine { - endCol = min(b.highlightEndCol, len(line)) - } - - // Track last non-empty position as we go - lastContentX := -1 - - // Single pass: check content and track last non-empty position - for x := startCol; x < endCol; x++ { - cell := line.At(x) - if cell == nil { - continue - } - - // Update last content position if non-empty - if cell.Content != "" && cell.Content != " " { - lastContentX = x - } - } - - // Only apply highlight up to last content position - highlightEnd := endCol - if lastContentX >= 0 { - highlightEnd = lastContentX + 1 - } else if lastContentX == -1 { - highlightEnd = startCol // No content on this line - } - - // Apply highlight style only to cells with content - for x := startCol; x < highlightEnd; x++ { - if !image.Pt(x, y).In(contentArea) { - continue - } - cell := line.At(x) - cell.Style = b.highlightStyle(cell.Style) - } - } -} - -// StringItem is a simple string-based item with optional text wrapping. -// It caches rendered content by width for efficient repeated rendering. -// StringItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles. -// StringItem implements Highlightable for text selection/search highlighting. -type StringItem struct { - BaseFocusable - BaseHighlightable - content string // Raw content string (may contain ANSI styles) - wrap bool // Whether to wrap text - - // Cache for rendered content at specific widths - // Key: width, Value: string - cache map[int]string -} - -// CellStyler is a function that applies styles to UV cells. -type CellStyler = func(s uv.Style) uv.Style - -var noColor = lipgloss.NoColor{} - -// LipglossStyleToCellStyler converts a Lip Gloss style to a CellStyler function. -func LipglossStyleToCellStyler(lgStyle lipgloss.Style) CellStyler { - uvStyle := toUVStyle(lgStyle) - return func(s uv.Style) uv.Style { - if uvStyle.Fg != nil && lgStyle.GetForeground() != noColor { - s.Fg = uvStyle.Fg - } - if uvStyle.Bg != nil && lgStyle.GetBackground() != noColor { - s.Bg = uvStyle.Bg - } - s.Attrs |= uvStyle.Attrs - if uvStyle.Underline != 0 { - s.Underline = uvStyle.Underline - } - return s - } -} - -// NewStringItem creates a new string item with the given ID and content. -func NewStringItem(content string) *StringItem { - s := &StringItem{ - content: content, - wrap: false, - cache: make(map[int]string), - } - s.InitHighlight() - return s -} - -// NewWrappingStringItem creates a new string item that wraps text to fit width. -func NewWrappingStringItem(content string) *StringItem { - s := &StringItem{ - content: content, - wrap: true, - cache: make(map[int]string), - } - s.InitHighlight() - return s -} - -// WithFocusStyles sets the focus and blur styles for the string item. -// If both styles are non-nil, the item will implement Focusable. -func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *StringItem { - s.SetFocusStyles(focusStyle, blurStyle) - return s -} - -// Height implements Item. -func (s *StringItem) Height(width int) int { - // Calculate content width if we have styles - contentWidth := width - if style := s.CurrentStyle(); style != nil { - hFrameSize := style.GetHorizontalFrameSize() - if hFrameSize > 0 { - contentWidth -= hFrameSize - } - } - - var lines int - if !s.wrap { - // No wrapping - height is just the number of newlines + 1 - lines = strings.Count(s.content, "\n") + 1 - } else { - // Use lipgloss.Wrap to wrap the content and count lines - // This preserves ANSI styles and is much faster than rendering to a buffer - wrapped := lipgloss.Wrap(s.content, contentWidth, "") - lines = strings.Count(wrapped, "\n") + 1 - } - - // Add vertical frame size if we have styles - if style := s.CurrentStyle(); style != nil { - lines += style.GetVerticalFrameSize() - } - - return lines -} - -// Draw implements Item and uv.Drawable. -func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - - // Check cache first - content, ok := s.cache[width] - if !ok { - // Not cached - create and cache - content = s.content - if s.wrap { - // Wrap content using lipgloss - content = lipgloss.Wrap(s.content, width, "") - } - s.cache[width] = content - } - - // Apply focus/blur styling if configured - style := s.CurrentStyle() - if style != nil { - content = style.Width(width).Render(content) - } - - // Create temp buffer to draw content with highlighting - tempBuf := uv.NewScreenBuffer(width, height) - - // Draw content to temp buffer first - styled := uv.NewStyledString(content) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - // Apply highlighting if active - s.ApplyHighlight(&tempBuf, width, height, style) - - // Copy temp buffer to actual screen at the target area - tempBuf.Draw(scr, area) -} - -// SetHighlight implements Highlightable and extends BaseHighlightable. -// Clears the cache when highlight changes. -func (s *StringItem) SetHighlight(startLine, startCol, endLine, endCol int) { - s.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) - // Clear cache when highlight changes - s.cache = make(map[int]string) -} - -// MarkdownItem renders markdown content using Glamour. -// It caches all rendered content by width for efficient repeated rendering. -// The wrap width is capped at 120 cells by default to ensure readable line lengths. -// MarkdownItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles. -// MarkdownItem implements Highlightable for text selection/search highlighting. -type MarkdownItem struct { - BaseFocusable - BaseHighlightable - markdown string // Raw markdown content - styleConfig *ansi.StyleConfig // Optional style configuration - maxWidth int // Maximum wrap width (default 120) - - // Cache for rendered content at specific widths - // Key: width (capped to maxWidth), Value: rendered markdown string - cache map[int]string -} - -// DefaultMarkdownMaxWidth is the default maximum width for markdown rendering. -const DefaultMarkdownMaxWidth = 120 - -// NewMarkdownItem creates a new markdown item with the given ID and markdown content. -// If focusStyle and blurStyle are both non-nil, the item will implement Focusable. -func NewMarkdownItem(markdown string) *MarkdownItem { - m := &MarkdownItem{ - markdown: markdown, - maxWidth: DefaultMarkdownMaxWidth, - cache: make(map[int]string), - } - m.InitHighlight() - return m -} - -// WithStyleConfig sets a custom Glamour style configuration for the markdown item. -func (m *MarkdownItem) WithStyleConfig(styleConfig ansi.StyleConfig) *MarkdownItem { - m.styleConfig = &styleConfig - return m -} - -// WithMaxWidth sets the maximum wrap width for markdown rendering. -func (m *MarkdownItem) WithMaxWidth(maxWidth int) *MarkdownItem { - m.maxWidth = maxWidth - return m -} - -// WithFocusStyles sets the focus and blur styles for the markdown item. -// If both styles are non-nil, the item will implement Focusable. -func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *MarkdownItem { - m.SetFocusStyles(focusStyle, blurStyle) - return m -} - -// Height implements Item. -func (m *MarkdownItem) Height(width int) int { - // Render the markdown to get its height - rendered := m.renderMarkdown(width) - - // Apply focus/blur styling if configured to get accurate height - if style := m.CurrentStyle(); style != nil { - rendered = style.Render(rendered) - } - - return strings.Count(rendered, "\n") + 1 -} - -// Draw implements Item and uv.Drawable. -func (m *MarkdownItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - rendered := m.renderMarkdown(width) - - // Apply focus/blur styling if configured - style := m.CurrentStyle() - if style != nil { - rendered = style.Render(rendered) - } - - // Create temp buffer to draw content with highlighting - tempBuf := uv.NewScreenBuffer(width, height) - - // Draw the rendered markdown to temp buffer - styled := uv.NewStyledString(rendered) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - // Apply highlighting if active - m.ApplyHighlight(&tempBuf, width, height, style) - - // Copy temp buffer to actual screen at the target area - tempBuf.Draw(scr, area) -} - -// renderMarkdown renders the markdown content at the given width, using cache if available. -// Width is always capped to maxWidth to ensure readable line lengths. -func (m *MarkdownItem) renderMarkdown(width int) string { - // Cap width to maxWidth - cappedWidth := min(width, m.maxWidth) - - // Check cache first (always cache all rendered markdown) - if cached, ok := m.cache[cappedWidth]; ok { - return cached - } - - // Not cached - render now - opts := []glamour.TermRendererOption{ - glamour.WithWordWrap(cappedWidth), - } - - // Add style config if provided - if m.styleConfig != nil { - opts = append(opts, glamour.WithStyles(*m.styleConfig)) - } - - renderer, err := glamour.NewTermRenderer(opts...) - if err != nil { - // Fallback to plain text on error - return m.markdown - } - - rendered, err := renderer.Render(m.markdown) - if err != nil { - // Fallback to plain text on error - return m.markdown - } - - // Trim trailing whitespace - rendered = strings.TrimRight(rendered, "\n\r ") - - // Always cache - m.cache[cappedWidth] = rendered - - return rendered -} - -// SetHighlight implements Highlightable and extends BaseHighlightable. -// Clears the cache when highlight changes. -func (m *MarkdownItem) SetHighlight(startLine, startCol, endLine, endCol int) { - m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) - // Clear cache when highlight changes - m.cache = make(map[int]string) -} - -// Gap is a 1-line spacer item used to add gaps between items. -var Gap = NewSpacerItem(1) - -// SpacerItem is an empty item that takes up vertical space. -// Useful for adding gaps between items in a list. -type SpacerItem struct { - height int -} - -var _ Item = (*SpacerItem)(nil) - -// NewSpacerItem creates a new spacer item with the given ID and height in lines. -func NewSpacerItem(height int) *SpacerItem { - return &SpacerItem{ - height: height, - } -} - -// Height implements Item. -func (s *SpacerItem) Height(width int) int { - return s.height -} - -// Draw implements Item. -// Spacer items don't draw anything, they just take up space. -func (s *SpacerItem) Draw(scr uv.Screen, area uv.Rectangle) { - // Ensure the area is filled with spaces to clear any existing content - spacerArea := uv.Rect(area.Min.X, area.Min.Y, area.Dx(), area.Min.Y+min(1, s.height)) - if spacerArea.Overlaps(area) { - screen.ClearArea(scr, spacerArea) - } -} diff --git a/internal/ui/list/item_test.go b/internal/ui/list/item_test.go deleted file mode 100644 index 550a7b3a3cbe1bda035e91fca3efd01df87395db..0000000000000000000000000000000000000000 --- a/internal/ui/list/item_test.go +++ /dev/null @@ -1,593 +0,0 @@ -package list - -import ( - "strings" - "testing" - - "github.com/charmbracelet/glamour/v2/ansi" - uv "github.com/charmbracelet/ultraviolet" -) - -func TestRenderHelper(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - } - - l := New(items...) - l.SetSize(80, 10) - - // Render to string - output := l.Render() - - if len(output) == 0 { - t.Error("expected non-empty output from Render()") - } - - // Check that output contains the items - if !strings.Contains(output, "Item 1") { - t.Error("expected output to contain 'Item 1'") - } - if !strings.Contains(output, "Item 2") { - t.Error("expected output to contain 'Item 2'") - } - if !strings.Contains(output, "Item 3") { - t.Error("expected output to contain 'Item 3'") - } -} - -func TestRenderWithScrolling(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - NewStringItem("Item 4"), - NewStringItem("Item 5"), - } - - l := New(items...) - l.SetSize(80, 2) // Small viewport - - // Initial render should show first 2 items - output := l.Render() - if !strings.Contains(output, "Item 1") { - t.Error("expected output to contain 'Item 1'") - } - if !strings.Contains(output, "Item 2") { - t.Error("expected output to contain 'Item 2'") - } - if strings.Contains(output, "Item 3") { - t.Error("expected output to NOT contain 'Item 3' in initial view") - } - - // Scroll down and render - l.ScrollBy(2) - output = l.Render() - - // Now should show items 3 and 4 - if strings.Contains(output, "Item 1") { - t.Error("expected output to NOT contain 'Item 1' after scrolling") - } - if !strings.Contains(output, "Item 3") { - t.Error("expected output to contain 'Item 3' after scrolling") - } - if !strings.Contains(output, "Item 4") { - t.Error("expected output to contain 'Item 4' after scrolling") - } -} - -func TestRenderEmptyList(t *testing.T) { - l := New() - l.SetSize(80, 10) - - output := l.Render() - if output != "" { - t.Errorf("expected empty output for empty list, got: %q", output) - } -} - -func TestRenderVsDrawConsistency(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - } - - l := New(items...) - l.SetSize(80, 10) - - // Render using Render() method - renderOutput := l.Render() - - // Render using Draw() method - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - l.Draw(&screen, area) - drawOutput := screen.Render() - - // Trim any trailing whitespace for comparison - renderOutput = strings.TrimRight(renderOutput, "\n") - drawOutput = strings.TrimRight(drawOutput, "\n") - - // Both methods should produce the same output - if renderOutput != drawOutput { - t.Errorf("Render() and Draw() produced different outputs:\nRender():\n%q\n\nDraw():\n%q", - renderOutput, drawOutput) - } -} - -func BenchmarkRender(b *testing.B) { - items := make([]Item, 100) - for i := range items { - items[i] = NewStringItem("Item content here") - } - - l := New(items...) - l.SetSize(80, 24) - l.Render() // Prime the buffer - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = l.Render() - } -} - -func BenchmarkRenderWithScrolling(b *testing.B) { - items := make([]Item, 1000) - for i := range items { - items[i] = NewStringItem("Item content here") - } - - l := New(items...) - l.SetSize(80, 24) - l.Render() // Prime the buffer - - b.ResetTimer() - for i := 0; i < b.N; i++ { - l.ScrollBy(1) - _ = l.Render() - } -} - -func TestStringItemCache(t *testing.T) { - item := NewStringItem("Test content") - - // First draw at width 80 should populate cache - screen1 := uv.NewScreenBuffer(80, 5) - area1 := uv.Rect(0, 0, 80, 5) - item.Draw(&screen1, area1) - - if len(item.cache) != 1 { - t.Errorf("expected cache to have 1 entry after first draw, got %d", len(item.cache)) - } - if _, ok := item.cache[80]; !ok { - t.Error("expected cache to have entry for width 80") - } - - // Second draw at same width should reuse cache - screen2 := uv.NewScreenBuffer(80, 5) - area2 := uv.Rect(0, 0, 80, 5) - item.Draw(&screen2, area2) - - if len(item.cache) != 1 { - t.Errorf("expected cache to still have 1 entry after second draw, got %d", len(item.cache)) - } - - // Draw at different width should add to cache - screen3 := uv.NewScreenBuffer(40, 5) - area3 := uv.Rect(0, 0, 40, 5) - item.Draw(&screen3, area3) - - if len(item.cache) != 2 { - t.Errorf("expected cache to have 2 entries after draw at different width, got %d", len(item.cache)) - } - if _, ok := item.cache[40]; !ok { - t.Error("expected cache to have entry for width 40") - } -} - -func TestWrappingItemHeight(t *testing.T) { - // Short text that fits in one line - item1 := NewWrappingStringItem("Short") - if h := item1.Height(80); h != 1 { - t.Errorf("expected height 1 for short text, got %d", h) - } - - // Long text that wraps - longText := "This is a very long line that will definitely wrap when constrained to a narrow width" - item2 := NewWrappingStringItem(longText) - - // At width 80, should be fewer lines than width 20 - height80 := item2.Height(80) - height20 := item2.Height(20) - - if height20 <= height80 { - t.Errorf("expected more lines at narrow width (20: %d lines) than wide width (80: %d lines)", - height20, height80) - } - - // Non-wrapping version should always be 1 line - item3 := NewStringItem(longText) - if h := item3.Height(20); h != 1 { - t.Errorf("expected height 1 for non-wrapping item, got %d", h) - } -} - -func TestMarkdownItemBasic(t *testing.T) { - markdown := "# Hello\n\nThis is a **test**." - item := NewMarkdownItem(markdown) - - // Test that height is calculated - height := item.Height(80) - if height < 1 { - t.Errorf("expected height >= 1, got %d", height) - } - - // Test drawing - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - item.Draw(&screen, area) - - // Should not panic and should render something - rendered := screen.Render() - if len(rendered) == 0 { - t.Error("expected non-empty rendered output") - } -} - -func TestMarkdownItemCache(t *testing.T) { - markdown := "# Test\n\nSome content." - item := NewMarkdownItem(markdown) - - // First render at width 80 should populate cache - height1 := item.Height(80) - if len(item.cache) != 1 { - t.Errorf("expected cache to have 1 entry after first render, got %d", len(item.cache)) - } - - // Second render at same width should reuse cache - height2 := item.Height(80) - if height1 != height2 { - t.Errorf("expected consistent height, got %d then %d", height1, height2) - } - if len(item.cache) != 1 { - t.Errorf("expected cache to still have 1 entry, got %d", len(item.cache)) - } - - // Render at different width should add to cache - _ = item.Height(40) - if len(item.cache) != 2 { - t.Errorf("expected cache to have 2 entries after different width, got %d", len(item.cache)) - } -} - -func TestMarkdownItemMaxCacheWidth(t *testing.T) { - markdown := "# Test\n\nSome content." - item := NewMarkdownItem(markdown).WithMaxWidth(50) - - // Render at width 40 (below limit) - should cache at width 40 - _ = item.Height(40) - if len(item.cache) != 1 { - t.Errorf("expected cache to have 1 entry for width 40, got %d", len(item.cache)) - } - - // Render at width 80 (above limit) - should cap to 50 and cache - _ = item.Height(80) - // Cache should have width 50 entry (capped from 80) - if len(item.cache) != 2 { - t.Errorf("expected cache to have 2 entries (40 and 50), got %d", len(item.cache)) - } - if _, ok := item.cache[50]; !ok { - t.Error("expected cache to have entry for width 50 (capped from 80)") - } - - // Render at width 100 (also above limit) - should reuse cached width 50 - _ = item.Height(100) - if len(item.cache) != 2 { - t.Errorf("expected cache to still have 2 entries (reusing 50), got %d", len(item.cache)) - } -} - -func TestMarkdownItemWithStyleConfig(t *testing.T) { - markdown := "# Styled\n\nContent with **bold** text." - - // Create a custom style config - styleConfig := ansi.StyleConfig{ - Document: ansi.StyleBlock{ - Margin: uintPtr(0), - }, - } - - item := NewMarkdownItem(markdown).WithStyleConfig(styleConfig) - - // Render should use the custom style - height := item.Height(80) - if height < 1 { - t.Errorf("expected height >= 1, got %d", height) - } - - // Draw should work without panic - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - item.Draw(&screen, area) - - rendered := screen.Render() - if len(rendered) == 0 { - t.Error("expected non-empty rendered output with custom style") - } -} - -func TestMarkdownItemInList(t *testing.T) { - items := []Item{ - NewMarkdownItem("# First\n\nMarkdown item."), - NewMarkdownItem("# Second\n\nAnother item."), - NewStringItem("Regular string item"), - } - - l := New(items...) - l.SetSize(80, 20) - - // Should render without error - output := l.Render() - if len(output) == 0 { - t.Error("expected non-empty output from list with markdown items") - } - - // Should contain content from markdown items - if !strings.Contains(output, "First") { - t.Error("expected output to contain 'First'") - } - if !strings.Contains(output, "Second") { - t.Error("expected output to contain 'Second'") - } - if !strings.Contains(output, "Regular string item") { - t.Error("expected output to contain 'Regular string item'") - } -} - -func TestMarkdownItemHeightWithWidth(t *testing.T) { - // Test that widths are capped to maxWidth - markdown := "This is a paragraph with some text." - - item := NewMarkdownItem(markdown).WithMaxWidth(50) - - // At width 30 (below limit), should cache and render at width 30 - height30 := item.Height(30) - if height30 < 1 { - t.Errorf("expected height >= 1, got %d", height30) - } - - // At width 100 (above maxWidth), should cap to 50 and cache - height100 := item.Height(100) - if height100 < 1 { - t.Errorf("expected height >= 1, got %d", height100) - } - - // Both should be cached (width 30 and capped width 50) - if len(item.cache) != 2 { - t.Errorf("expected cache to have 2 entries (30 and 50), got %d", len(item.cache)) - } - if _, ok := item.cache[30]; !ok { - t.Error("expected cache to have entry for width 30") - } - if _, ok := item.cache[50]; !ok { - t.Error("expected cache to have entry for width 50 (capped from 100)") - } -} - -func BenchmarkMarkdownItemRender(b *testing.B) { - markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3" - item := NewMarkdownItem(markdown) - - // Prime the cache - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - item.Draw(&screen, area) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - item.Draw(&screen, area) - } -} - -func BenchmarkMarkdownItemUncached(b *testing.B) { - markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - item := NewMarkdownItem(markdown) - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - item.Draw(&screen, area) - } -} - -func TestSpacerItem(t *testing.T) { - spacer := NewSpacerItem(3) - - // Check height - if h := spacer.Height(80); h != 3 { - t.Errorf("expected height 3, got %d", h) - } - - // Height should be constant regardless of width - if h := spacer.Height(20); h != 3 { - t.Errorf("expected height 3 for width 20, got %d", h) - } - - // Draw should not produce any visible content - screen := uv.NewScreenBuffer(20, 3) - area := uv.Rect(0, 0, 20, 3) - spacer.Draw(&screen, area) - - output := screen.Render() - // Should be empty (just spaces) - for _, line := range strings.Split(output, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - t.Errorf("expected empty spacer output, got: %q", line) - } - } -} - -func TestSpacerItemInList(t *testing.T) { - // Create a list with items separated by spacers - items := []Item{ - NewStringItem("Item 1"), - NewSpacerItem(1), - NewStringItem("Item 2"), - NewSpacerItem(2), - NewStringItem("Item 3"), - } - - l := New(items...) - l.SetSize(20, 10) - - output := l.Render() - - // Should contain all three items - if !strings.Contains(output, "Item 1") { - t.Error("expected output to contain 'Item 1'") - } - if !strings.Contains(output, "Item 2") { - t.Error("expected output to contain 'Item 2'") - } - if !strings.Contains(output, "Item 3") { - t.Error("expected output to contain 'Item 3'") - } - - // Total height should be: 1 (item1) + 1 (spacer1) + 1 (item2) + 2 (spacer2) + 1 (item3) = 6 - expectedHeight := 6 - if l.TotalHeight() != expectedHeight { - t.Errorf("expected total height %d, got %d", expectedHeight, l.TotalHeight()) - } -} - -func TestSpacerItemNavigation(t *testing.T) { - // Spacers should not be selectable (they're not focusable) - items := []Item{ - NewStringItem("Item 1"), - NewSpacerItem(1), - NewStringItem("Item 2"), - } - - l := New(items...) - l.SetSize(20, 10) - - // Select first item - l.SetSelected(0) - if l.SelectedIndex() != 0 { - t.Errorf("expected selected index 0, got %d", l.SelectedIndex()) - } - - // Can select the spacer (it's a valid item, just not focusable) - l.SetSelected(1) - if l.SelectedIndex() != 1 { - t.Errorf("expected selected index 1, got %d", l.SelectedIndex()) - } - - // Can select item after spacer - l.SetSelected(2) - if l.SelectedIndex() != 2 { - t.Errorf("expected selected index 2, got %d", l.SelectedIndex()) - } -} - -// Helper function to create a pointer to uint -func uintPtr(v uint) *uint { - return &v -} - -func TestListDoesNotEatLastLine(t *testing.T) { - // Create items that exactly fill the viewport - items := []Item{ - NewStringItem("Line 1"), - NewStringItem("Line 2"), - NewStringItem("Line 3"), - NewStringItem("Line 4"), - NewStringItem("Line 5"), - } - - // Create list with height exactly matching content (5 lines, no gaps) - l := New(items...) - l.SetSize(20, 5) - - // Render the list - output := l.Render() - - // Count actual lines in output - lines := strings.Split(strings.TrimRight(output, "\n"), "\n") - actualLineCount := 0 - for _, line := range lines { - if strings.TrimSpace(line) != "" { - actualLineCount++ - } - } - - // All 5 items should be visible - if !strings.Contains(output, "Line 1") { - t.Error("expected output to contain 'Line 1'") - } - if !strings.Contains(output, "Line 2") { - t.Error("expected output to contain 'Line 2'") - } - if !strings.Contains(output, "Line 3") { - t.Error("expected output to contain 'Line 3'") - } - if !strings.Contains(output, "Line 4") { - t.Error("expected output to contain 'Line 4'") - } - if !strings.Contains(output, "Line 5") { - t.Error("expected output to contain 'Line 5'") - } - - if actualLineCount != 5 { - t.Errorf("expected 5 lines with content, got %d", actualLineCount) - } -} - -func TestListWithScrollDoesNotEatLastLine(t *testing.T) { - // Create more items than viewport height - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - NewStringItem("Item 4"), - NewStringItem("Item 5"), - NewStringItem("Item 6"), - NewStringItem("Item 7"), - } - - // Viewport shows 3 items at a time - l := New(items...) - l.SetSize(20, 3) - - // Need to render first to build the buffer and calculate total height - _ = l.Render() - - // Now scroll to bottom - l.ScrollToBottom() - - output := l.Render() - - t.Logf("Output:\n%s", output) - t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight()) - - // Should show last 3 items: 5, 6, 7 - if !strings.Contains(output, "Item 5") { - t.Error("expected output to contain 'Item 5'") - } - if !strings.Contains(output, "Item 6") { - t.Error("expected output to contain 'Item 6'") - } - if !strings.Contains(output, "Item 7") { - t.Error("expected output to contain 'Item 7'") - } - - // Should not show earlier items - if strings.Contains(output, "Item 1") { - t.Error("expected output to NOT contain 'Item 1' when scrolled to bottom") - } -} diff --git a/internal/ui/list/lazylist.go b/internal/ui/list/lazylist.go deleted file mode 100644 index 58ce3bf3eff9869f220af3574fc920865090e172..0000000000000000000000000000000000000000 --- a/internal/ui/list/lazylist.go +++ /dev/null @@ -1,1007 +0,0 @@ -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/list.go b/internal/ui/list/list.go deleted file mode 100644 index 7cde0f879ae3705421d5a6a27fd04c87a59ac0bc..0000000000000000000000000000000000000000 --- a/internal/ui/list/list.go +++ /dev/null @@ -1,1126 +0,0 @@ -package list - -import ( - "strings" - - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/ultraviolet/screen" - "github.com/charmbracelet/x/exp/ordered" -) - -// List is a scrollable list component that implements uv.Drawable. -// It efficiently manages a large number of items by caching rendered content -// in a master buffer and extracting only the visible viewport when drawn. -type List struct { - // Configuration - width, height int - - // Data - items []Item - - // Focus & Selection - focused bool - selectedIdx int // Currently selected item index (-1 if none) - - // Master buffer containing ALL rendered items - masterBuffer *uv.ScreenBuffer - totalHeight int - - // Item positioning in master buffer - itemPositions []itemPosition - - // Viewport state - offset int // Scroll offset in lines from top - - // Mouse state - mouseDown bool - mouseDownItem int // Item index where mouse was pressed - mouseDownX int // X position in item content (character offset) - mouseDownY int // Y position in item (line offset) - mouseDragItem int // Current item index being dragged over - mouseDragX int // Current X in item content - mouseDragY int // Current Y in item - - // Dirty tracking - dirty bool - dirtyItems map[int]bool -} - -type itemPosition struct { - startLine int - height int -} - -// New creates a new list with the given items. -func New(items ...Item) *List { - l := &List{ - items: items, - itemPositions: make([]itemPosition, len(items)), - dirtyItems: make(map[int]bool), - selectedIdx: -1, - mouseDownItem: -1, - mouseDragItem: -1, - } - - l.dirty = true - return l -} - -// ensureBuilt ensures the master buffer is built. -// This is called by methods that need itemPositions or totalHeight. -func (l *List) ensureBuilt() { - if l.width <= 0 || l.height <= 0 { - return - } - - if l.dirty { - l.rebuildMasterBuffer() - } else if len(l.dirtyItems) > 0 { - l.updateDirtyItems() - } -} - -// Draw implements uv.Drawable. -// Draws the visible viewport of the list to the given screen buffer. -func (l *List) Draw(scr uv.Screen, area uv.Rectangle) { - if area.Dx() <= 0 || area.Dy() <= 0 { - return - } - - // Update internal dimensions if area size changed - widthChanged := l.width != area.Dx() - heightChanged := l.height != area.Dy() - - l.width = area.Dx() - l.height = area.Dy() - - // Only width changes require rebuilding master buffer - // Height changes only affect viewport clipping, not item rendering - if widthChanged { - l.dirty = true - } - - // Height changes require clamping offset to new bounds - if heightChanged { - l.clampOffset() - } - - if len(l.items) == 0 { - screen.ClearArea(scr, area) - return - } - - // Ensure buffer is built - l.ensureBuilt() - - // Draw visible portion to the target screen - l.drawViewport(scr, area) -} - -// Render renders the visible viewport to a string. -// This is a convenience method that creates a temporary screen buffer, -// draws to it, and returns the rendered string. -func (l *List) Render() string { - if l.width <= 0 || l.height <= 0 { - return "" - } - - if len(l.items) == 0 { - return "" - } - - // Ensure buffer is built - l.ensureBuilt() - - // Extract visible lines directly from master buffer - return l.renderViewport() -} - -// renderViewport renders the visible portion of the master buffer to a string. -func (l *List) renderViewport() string { - if l.masterBuffer == nil { - return "" - } - - buf := l.masterBuffer.Buffer - - // Calculate visible region in master buffer - srcStartY := l.offset - srcEndY := l.offset + l.height - - // Clamp to actual buffer bounds - if srcStartY >= len(buf.Lines) { - // Beyond end of content, return empty lines - emptyLine := strings.Repeat(" ", l.width) - lines := make([]string, l.height) - for i := range lines { - lines[i] = emptyLine - } - return strings.Join(lines, "\n") - } - if srcEndY > len(buf.Lines) { - srcEndY = len(buf.Lines) - } - - // Build result with proper line handling - lines := make([]string, l.height) - lineIdx := 0 - - // Render visible lines from buffer - for y := srcStartY; y < srcEndY && lineIdx < l.height; y++ { - lines[lineIdx] = buf.Lines[y].Render() - lineIdx++ - } - - // Pad remaining lines with spaces to maintain viewport height - emptyLine := strings.Repeat(" ", l.width) - for ; lineIdx < l.height; lineIdx++ { - lines[lineIdx] = emptyLine - } - - return strings.Join(lines, "\n") -} - -// drawViewport draws the visible portion from master buffer to target screen. -func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) { - if l.masterBuffer == nil { - screen.ClearArea(scr, area) - return - } - - buf := l.masterBuffer.Buffer - - // Calculate visible region in master buffer - srcStartY := l.offset - srcEndY := l.offset + area.Dy() - - // Clamp to actual buffer bounds - if srcStartY >= buf.Height() { - screen.ClearArea(scr, area) - return - } - if srcEndY > buf.Height() { - srcEndY = buf.Height() - } - - // Copy visible lines to target screen - destY := area.Min.Y - for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ { - 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++ - } - - // Clear any remaining area if content is shorter than viewport - if destY < area.Max.Y { - clearArea := uv.Rect(area.Min.X, destY, area.Dx(), area.Max.Y-destY) - screen.ClearArea(scr, clearArea) - } -} - -// rebuildMasterBuffer composes all items into the master buffer. -func (l *List) rebuildMasterBuffer() { - if len(l.items) == 0 { - l.totalHeight = 0 - l.dirty = false - return - } - - // Calculate total height - l.totalHeight = l.calculateTotalHeight() - - // Create or resize master buffer - if l.masterBuffer == nil || l.masterBuffer.Width() != l.width || l.masterBuffer.Height() != l.totalHeight { - buf := uv.NewScreenBuffer(l.width, l.totalHeight) - l.masterBuffer = &buf - } - - // Clear buffer - screen.Clear(l.masterBuffer) - - // Draw each item - currentY := 0 - for i, item := range l.items { - itemHeight := item.Height(l.width) - - // Draw item to master buffer - area := uv.Rect(0, currentY, l.width, itemHeight) - item.Draw(l.masterBuffer, area) - - // Store position - l.itemPositions[i] = itemPosition{ - startLine: currentY, - height: itemHeight, - } - - // Advance position - currentY += itemHeight - } - - l.dirty = false - l.dirtyItems = make(map[int]bool) -} - -// updateDirtyItems efficiently updates only changed items using slice operations. -func (l *List) updateDirtyItems() { - if len(l.dirtyItems) == 0 { - return - } - - // Check if all dirty items have unchanged heights - allSameHeight := true - for idx := range l.dirtyItems { - item := l.items[idx] - pos := l.itemPositions[idx] - newHeight := item.Height(l.width) - if newHeight != pos.height { - allSameHeight = false - break - } - } - - // Optimization: If all dirty items have unchanged heights, re-render in place - if allSameHeight { - buf := l.masterBuffer.Buffer - for idx := range l.dirtyItems { - item := l.items[idx] - pos := l.itemPositions[idx] - - // Clear the item's area - for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ { - buf.Lines[y] = uv.NewLine(l.width) - } - - // Re-render item - area := uv.Rect(0, pos.startLine, l.width, pos.height) - item.Draw(l.masterBuffer, area) - } - - l.dirtyItems = make(map[int]bool) - return - } - - // Height changed - full rebuild - l.dirty = true - l.dirtyItems = make(map[int]bool) - l.rebuildMasterBuffer() -} - -// updatePositionsBelow updates the startLine for all items below the given index. -func (l *List) updatePositionsBelow(fromIdx int, delta int) { - for i := fromIdx + 1; i < len(l.items); i++ { - pos := l.itemPositions[i] - pos.startLine += delta - l.itemPositions[i] = pos - } -} - -// calculateTotalHeight calculates the total height of all items plus gaps. -func (l *List) calculateTotalHeight() int { - if len(l.items) == 0 { - return 0 - } - - total := 0 - for _, item := range l.items { - total += item.Height(l.width) - } - return total -} - -// SetSize updates the viewport size. -func (l *List) SetSize(width, height int) { - widthChanged := l.width != width - heightChanged := l.height != height - - l.width = width - l.height = height - - // Width changes require full rebuild (items may reflow) - if widthChanged { - l.dirty = true - } - - // Height changes require clamping offset to new bounds - if heightChanged { - l.clampOffset() - } -} - -// Height returns the current viewport height. -func (l *List) Height() int { - return l.height -} - -// Width returns the current viewport width. -func (l *List) Width() int { - return l.width -} - -// GetSize returns the current viewport size. -func (l *List) GetSize() (int, int) { - return l.width, l.height -} - -// Len returns the number of items in the list. -func (l *List) Len() int { - return len(l.items) -} - -// SetItems replaces all items in the list. -func (l *List) SetItems(items []Item) { - l.items = items - l.itemPositions = make([]itemPosition, len(items)) - l.dirty = true -} - -// Items returns all items in the list. -func (l *List) Items() []Item { - return l.items -} - -// AppendItem adds an item to the end of the list. Returns true if successful. -func (l *List) AppendItem(item Item) bool { - l.items = append(l.items, item) - l.itemPositions = append(l.itemPositions, itemPosition{}) - - // If buffer not built yet, mark dirty for full rebuild - if l.masterBuffer == nil || l.width <= 0 { - l.dirty = true - return true - } - - // Process any pending dirty items before modifying buffer structure - if len(l.dirtyItems) > 0 { - l.updateDirtyItems() - } - - // Efficient append: insert lines at end of buffer - itemHeight := item.Height(l.width) - startLine := l.totalHeight - - // Expand buffer - newLines := make([]uv.Line, itemHeight) - for i := range newLines { - newLines[i] = uv.NewLine(l.width) - } - l.masterBuffer.Buffer.Lines = append(l.masterBuffer.Buffer.Lines, newLines...) - - // Draw new item - area := uv.Rect(0, startLine, l.width, itemHeight) - item.Draw(l.masterBuffer, area) - - // Update tracking - l.itemPositions[len(l.items)-1] = itemPosition{ - startLine: startLine, - height: itemHeight, - } - l.totalHeight += itemHeight - - return true -} - -// PrependItem adds an item to the beginning of the list. Returns true if -// successful. -func (l *List) PrependItem(item Item) bool { - l.items = append([]Item{item}, l.items...) - l.itemPositions = append([]itemPosition{{}}, l.itemPositions...) - if l.selectedIdx >= 0 { - l.selectedIdx++ - } - - // If buffer not built yet, mark dirty for full rebuild - if l.masterBuffer == nil || l.width <= 0 { - l.dirty = true - return true - } - - // Process any pending dirty items before modifying buffer structure - if len(l.dirtyItems) > 0 { - l.updateDirtyItems() - } - - // Efficient prepend: insert lines at start of buffer - itemHeight := item.Height(l.width) - - // Create new lines - newLines := make([]uv.Line, itemHeight) - for i := range newLines { - newLines[i] = uv.NewLine(l.width) - } - - // Insert at beginning - buf := l.masterBuffer.Buffer - buf.Lines = append(newLines, buf.Lines...) - - // Draw new item - area := uv.Rect(0, 0, l.width, itemHeight) - item.Draw(l.masterBuffer, area) - - // Update all positions (shift everything down) - for i := range l.itemPositions { - pos := l.itemPositions[i] - pos.startLine += itemHeight - l.itemPositions[i] = pos - } - - // Add position for new item at start - l.itemPositions[0] = itemPosition{ - startLine: 0, - height: itemHeight, - } - - l.totalHeight += itemHeight - - return true -} - -// UpdateItem replaces an item with the same index. Returns true if successful. -func (l *List) UpdateItem(idx int, item Item) bool { - if idx < 0 || idx >= len(l.items) { - return false - } - l.items[idx] = item - l.dirtyItems[idx] = true - return true -} - -// DeleteItem removes an item by index. Returns true if successful. -func (l *List) DeleteItem(idx int) bool { - if idx < 0 || idx >= len(l.items) { - return false - } - - // Get position before deleting - pos := l.itemPositions[idx] - - // Process any pending dirty items before modifying buffer structure - if len(l.dirtyItems) > 0 { - l.updateDirtyItems() - } - - l.items = append(l.items[:idx], l.items[idx+1:]...) - l.itemPositions = append(l.itemPositions[:idx], l.itemPositions[idx+1:]...) - - // 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 buffer not built yet, mark dirty for full rebuild - if l.masterBuffer == nil { - l.dirty = true - return true - } - - // Efficient delete: remove lines from buffer - deleteStart := pos.startLine - deleteEnd := pos.startLine + pos.height - buf := l.masterBuffer.Buffer - - if deleteEnd <= len(buf.Lines) { - buf.Lines = append(buf.Lines[:deleteStart], buf.Lines[deleteEnd:]...) - l.totalHeight -= pos.height - l.updatePositionsBelow(idx-1, -pos.height) - } else { - // Position data corrupt, rebuild - l.dirty = true - } - - return true -} - -// Focus focuses the list and the selected item (if focusable). -func (l *List) Focus() { - l.focused = true - l.focusSelectedItem() -} - -// Blur blurs the list and the selected item (if focusable). -func (l *List) Blur() { - l.focused = false - l.blurSelectedItem() -} - -// Focused returns whether the list is focused. -func (l *List) Focused() bool { - return l.focused -} - -// SetSelected sets the selected item by ID. -func (l *List) SetSelected(idx int) { - if idx < 0 || idx >= len(l.items) { - return - } - if l.selectedIdx == idx { - return - } - - prevIdx := l.selectedIdx - l.selectedIdx = idx - - // Update focus states if list is focused - if l.focused { - if prevIdx >= 0 && prevIdx < len(l.items) { - if f, ok := l.items[prevIdx].(Focusable); ok { - f.Blur() - l.dirtyItems[prevIdx] = true - } - } - - if f, ok := l.items[idx].(Focusable); ok { - f.Focus() - l.dirtyItems[idx] = true - } - } -} - -// SelectFirst selects the first item in the list. -func (l *List) SelectFirst() { - l.SetSelected(0) -} - -// SelectLast selects the last item in the list. -func (l *List) SelectLast() { - l.SetSelected(len(l.items) - 1) -} - -// SelectNextWrap selects the next item in the list (wraps to beginning). -// When the list is focused, skips non-focusable items. -func (l *List) SelectNextWrap() { - l.selectNext(true) -} - -// SelectNext selects the next item in the list (no wrap). -// When the list is focused, skips non-focusable items. -func (l *List) SelectNext() { - l.selectNext(false) -} - -func (l *List) selectNext(wrap bool) { - if len(l.items) == 0 { - return - } - - startIdx := l.selectedIdx - for i := 0; i < len(l.items); i++ { - var nextIdx int - if wrap { - nextIdx = (startIdx + 1 + i) % len(l.items) - } else { - nextIdx = startIdx + 1 + i - if nextIdx >= len(l.items) { - return - } - } - - // If list is focused and item is not focusable, skip it - if l.focused { - if _, ok := l.items[nextIdx].(Focusable); !ok { - continue - } - } - - // Select and scroll to this item - l.SetSelected(nextIdx) - return - } -} - -// SelectPrevWrap selects the previous item in the list (wraps to end). -// When the list is focused, skips non-focusable items. -func (l *List) SelectPrevWrap() { - l.selectPrev(true) -} - -// SelectPrev selects the previous item in the list (no wrap). -// When the list is focused, skips non-focusable items. -func (l *List) SelectPrev() { - l.selectPrev(false) -} - -func (l *List) selectPrev(wrap bool) { - if len(l.items) == 0 { - return - } - - startIdx := l.selectedIdx - for i := 0; i < len(l.items); i++ { - var prevIdx int - if wrap { - prevIdx = (startIdx - 1 - i + len(l.items)) % len(l.items) - } else { - prevIdx = startIdx - 1 - i - if prevIdx < 0 { - return - } - } - - // If list is focused and item is not focusable, skip it - if l.focused { - if _, ok := l.items[prevIdx].(Focusable); !ok { - continue - } - } - - // Select and scroll to this item - l.SetSelected(prevIdx) - return - } -} - -// SelectedItem returns the currently selected item, or nil if none. -func (l *List) SelectedItem() Item { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return nil - } - return l.items[l.selectedIdx] -} - -// SelectedIndex returns the index of the currently selected item, or -1 if none. -func (l *List) SelectedIndex() int { - return l.selectedIdx -} - -// AtBottom returns whether the viewport is scrolled to the bottom. -func (l *List) AtBottom() bool { - l.ensureBuilt() - return l.offset >= l.totalHeight-l.height -} - -// AtTop returns whether the viewport is scrolled to the top. -func (l *List) AtTop() bool { - return l.offset <= 0 -} - -// ScrollBy scrolls the viewport by the given number of lines. -// Positive values scroll down, negative scroll up. -func (l *List) ScrollBy(deltaLines int) { - l.offset += deltaLines - l.clampOffset() -} - -// ScrollToTop scrolls to the top of the list. -func (l *List) ScrollToTop() { - l.offset = 0 -} - -// ScrollToBottom scrolls to the bottom of the list. -func (l *List) ScrollToBottom() { - l.ensureBuilt() - if l.totalHeight > l.height { - l.offset = l.totalHeight - l.height - } else { - l.offset = 0 - } -} - -// ScrollToItem scrolls to make the item with the given ID visible. -func (l *List) ScrollToItem(idx int) { - l.ensureBuilt() - pos := l.itemPositions[idx] - itemStart := pos.startLine - itemEnd := pos.startLine + pos.height - viewStart := l.offset - viewEnd := l.offset + l.height - - // Check if item is already fully visible - if itemStart >= viewStart && itemEnd <= viewEnd { - return - } - - // Scroll to show item - if itemStart < viewStart { - l.offset = itemStart - } else if itemEnd > viewEnd { - l.offset = itemEnd - l.height - } - - l.clampOffset() -} - -// ScrollToSelected scrolls to make the selected item visible. -func (l *List) ScrollToSelected() { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return - } - l.ScrollToItem(l.selectedIdx) -} - -// Offset returns the current scroll offset. -func (l *List) Offset() int { - return l.offset -} - -// TotalHeight returns the total height of all items including gaps. -func (l *List) TotalHeight() int { - return l.totalHeight -} - -// SelectFirstInView selects the first item that is fully visible in the viewport. -func (l *List) SelectFirstInView() { - l.ensureBuilt() - - viewportStart := l.offset - viewportEnd := l.offset + l.height - - for i := range l.items { - pos := l.itemPositions[i] - - // Check if item is fully within viewport bounds - if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd { - l.SetSelected(i) - return - } - } -} - -// SelectLastInView selects the last item that is fully visible in the viewport. -func (l *List) SelectLastInView() { - l.ensureBuilt() - - viewportStart := l.offset - viewportEnd := l.offset + l.height - - for i := len(l.items) - 1; i >= 0; i-- { - pos := l.itemPositions[i] - - // Check if item is fully within viewport bounds - if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd { - l.SetSelected(i) - return - } - } -} - -// SelectedItemInView returns true if the selected item is currently visible in the viewport. -func (l *List) SelectedItemInView() bool { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return false - } - - // Get selected item ID and position - pos := l.itemPositions[l.selectedIdx] - - // Check if item is within viewport bounds - viewportStart := l.offset - viewportEnd := l.offset + l.height - - // Item is visible if any part of it overlaps with the viewport - return pos.startLine < viewportEnd && (pos.startLine+pos.height) > viewportStart -} - -// clampOffset ensures offset is within valid bounds. -func (l *List) clampOffset() { - l.offset = ordered.Clamp(l.offset, 0, l.totalHeight-l.height) -} - -// focusSelectedItem focuses the currently selected item if it's focusable. -func (l *List) focusSelectedItem() { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return - } - - item := l.items[l.selectedIdx] - if f, ok := item.(Focusable); ok { - f.Focus() - l.dirtyItems[l.selectedIdx] = true - } -} - -// blurSelectedItem blurs the currently selected item if it's focusable. -func (l *List) blurSelectedItem() { - if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { - return - } - - item := l.items[l.selectedIdx] - if f, ok := item.(Focusable); ok { - f.Blur() - l.dirtyItems[l.selectedIdx] = true - } -} - -// HandleMouseDown handles mouse button press events. -// x and y are viewport-relative coordinates (0,0 = top-left of visible area). -// Returns true if the event was handled. -func (l *List) HandleMouseDown(x, y int) bool { - l.ensureBuilt() - - // Convert viewport y to master buffer y - bufferY := y + l.offset - - // Find which item was clicked - itemIdx, itemY := l.findItemAtPosition(bufferY) - if itemIdx < 0 { - return false - } - - // Calculate x position within item content - // For now, x is just the viewport x coordinate - // Items can interpret this as character offset in their content - - 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 during selection. -// x and y are viewport-relative coordinates. -// Returns true if the event was handled. -func (l *List) HandleMouseDrag(x, y int) bool { - if !l.mouseDown { - return false - } - - l.ensureBuilt() - - // Convert viewport y to master buffer y - bufferY := y + l.offset - - // Find which item we're dragging over - itemIdx, itemY := l.findItemAtPosition(bufferY) - if itemIdx < 0 { - return false - } - - l.mouseDragItem = itemIdx - l.mouseDragX = x - l.mouseDragY = itemY - - // Update highlight if item supports it - l.updateHighlight() - - return true -} - -// HandleMouseUp handles mouse button release events. -// Returns true if the event was handled. -func (l *List) HandleMouseUp(x, y int) bool { - if !l.mouseDown { - return false - } - - l.mouseDown = false - - // Final highlight update - l.updateHighlight() - - return true -} - -// ClearHighlight clears any active text highlighting. -func (l *List) ClearHighlight() { - for i, item := range l.items { - if h, ok := item.(Highlightable); ok { - h.SetHighlight(-1, -1, -1, -1) - l.dirtyItems[i] = true - } - } - l.mouseDownItem = -1 - l.mouseDragItem = -1 -} - -// findItemAtPosition finds the item at the given master buffer y coordinate. -// Returns the item index and the y offset within that item. It returns -1, -1 -// if no item is found. -func (l *List) findItemAtPosition(bufferY int) (itemIdx int, itemY int) { - if bufferY < 0 || bufferY >= l.totalHeight { - return -1, -1 - } - - // Linear search through items to find which one contains this y - // This could be optimized with binary search if needed - for i := range l.items { - pos := l.itemPositions[i] - if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height { - return i, bufferY - pos.startLine - } - } - - return -1, -1 -} - -// updateHighlight updates the highlight range for highlightable items. -// Supports highlighting across multiple items and respects drag direction. -func (l *List) 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) - 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 - pos := l.itemPositions[idx] - item.SetHighlight(startLine, startCol, pos.height-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 - pos := l.itemPositions[idx] - item.SetHighlight(0, 0, pos.height-1, 9999) - } - - l.dirtyItems[idx] = true - } -} - -// GetHighlightedText returns the plain text content of all highlighted regions -// across items, without any styling. Returns empty string if no highlights exist. -func (l *List) GetHighlightedText() string { - l.ensureBuilt() - - if l.masterBuffer == nil { - return "" - } - - 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 - } - - pos := l.itemPositions[i] - - // Extract text from highlighted region in master buffer - for y := startLine; y <= endLine && y < pos.height; y++ { - bufferY := pos.startLine + y - if bufferY >= l.masterBuffer.Height() { - break - } - - line := l.masterBuffer.Line(bufferY) - - // 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 using String() method, 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() { - continue - } - result.WriteString(cell.String()) - } - - // Add newline between lines (but not after the last line) - if y < endLine && y < pos.height-1 { - result.WriteRune('\n') - } - } - - // Add newline between items if there are more highlighted items - if result.Len() > 0 { - result.WriteRune('\n') - } - } - - // Trim trailing newline if present - text := result.String() - return strings.TrimSuffix(text, "\n") -} diff --git a/internal/ui/list/list_test.go b/internal/ui/list/list_test.go deleted file mode 100644 index ac2a64e5e06879340ec8da93d273dfd53882e60e..0000000000000000000000000000000000000000 --- a/internal/ui/list/list_test.go +++ /dev/null @@ -1,578 +0,0 @@ -package list - -import ( - "strings" - "testing" - - "charm.land/lipgloss/v2" - uv "github.com/charmbracelet/ultraviolet" - "github.com/stretchr/testify/require" -) - -func TestNewList(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - } - - l := New(items...) - l.SetSize(80, 24) - - if len(l.items) != 3 { - t.Errorf("expected 3 items, got %d", len(l.items)) - } - - if l.width != 80 || l.height != 24 { - t.Errorf("expected size 80x24, got %dx%d", l.width, l.height) - } -} - -func TestListDraw(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - } - - l := New(items...) - l.SetSize(80, 10) - - // Create a screen buffer to draw into - screen := uv.NewScreenBuffer(80, 10) - area := uv.Rect(0, 0, 80, 10) - - // Draw the list - l.Draw(&screen, area) - - // Verify the buffer has content - output := screen.Render() - if len(output) == 0 { - t.Error("expected non-empty output") - } -} - -func TestListAppendItem(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - } - - l := New(items...) - l.AppendItem(NewStringItem("Item 2")) - - if len(l.items) != 2 { - t.Errorf("expected 2 items after append, got %d", len(l.items)) - } -} - -func TestListDeleteItem(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - } - - l := New(items...) - l.DeleteItem(2) - - if len(l.items) != 2 { - t.Errorf("expected 2 items after delete, got %d", len(l.items)) - } -} - -func TestListUpdateItem(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - } - - l := New(items...) - l.SetSize(80, 10) - - // Update item - newItem := NewStringItem("Updated Item 2") - l.UpdateItem(1, newItem) - - if l.items[1].(*StringItem).content != "Updated Item 2" { - t.Errorf("expected updated content, got '%s'", l.items[1].(*StringItem).content) - } -} - -func TestListSelection(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - } - - l := New(items...) - l.SetSelected(0) - - if l.SelectedIndex() != 0 { - t.Errorf("expected selected index 0, got %d", l.SelectedIndex()) - } - - l.SelectNext() - if l.SelectedIndex() != 1 { - t.Errorf("expected selected index 1 after SelectNext, got %d", l.SelectedIndex()) - } - - l.SelectPrev() - if l.SelectedIndex() != 0 { - t.Errorf("expected selected index 0 after SelectPrev, got %d", l.SelectedIndex()) - } -} - -func TestListScrolling(t *testing.T) { - items := []Item{ - NewStringItem("Item 1"), - NewStringItem("Item 2"), - NewStringItem("Item 3"), - NewStringItem("Item 4"), - NewStringItem("Item 5"), - } - - l := New(items...) - l.SetSize(80, 2) // Small viewport - - // Draw to initialize the master buffer - screen := uv.NewScreenBuffer(80, 2) - area := uv.Rect(0, 0, 80, 2) - l.Draw(&screen, area) - - if l.Offset() != 0 { - t.Errorf("expected initial offset 0, got %d", l.Offset()) - } - - l.ScrollBy(2) - if l.Offset() != 2 { - t.Errorf("expected offset 2 after ScrollBy(2), got %d", l.Offset()) - } - - l.ScrollToTop() - if l.Offset() != 0 { - t.Errorf("expected offset 0 after ScrollToTop, got %d", l.Offset()) - } -} - -// FocusableTestItem is a test item that implements Focusable. -type FocusableTestItem struct { - id string - content string - focused bool -} - -func (f *FocusableTestItem) ID() string { - return f.id -} - -func (f *FocusableTestItem) Height(width int) int { - return 1 -} - -func (f *FocusableTestItem) Draw(scr uv.Screen, area uv.Rectangle) { - prefix := "[ ]" - if f.focused { - prefix = "[X]" - } - content := prefix + " " + f.content - styled := uv.NewStyledString(content) - styled.Draw(scr, area) -} - -func (f *FocusableTestItem) Focus() { - f.focused = true -} - -func (f *FocusableTestItem) Blur() { - f.focused = false -} - -func (f *FocusableTestItem) IsFocused() bool { - return f.focused -} - -func TestListFocus(t *testing.T) { - items := []Item{ - &FocusableTestItem{id: "1", content: "Item 1"}, - &FocusableTestItem{id: "2", content: "Item 2"}, - } - - l := New(items...) - l.SetSize(80, 10) - l.SetSelected(0) - - // Focus the list - l.Focus() - - if !l.Focused() { - t.Error("expected list to be focused") - } - - // Check if selected item is focused - selectedItem := l.SelectedItem().(*FocusableTestItem) - if !selectedItem.IsFocused() { - t.Error("expected selected item to be focused") - } - - // Select next and check focus changes - l.SelectNext() - if selectedItem.IsFocused() { - t.Error("expected previous item to be blurred") - } - - newSelectedItem := l.SelectedItem().(*FocusableTestItem) - if !newSelectedItem.IsFocused() { - t.Error("expected new selected item to be focused") - } - - // Blur the list - l.Blur() - if l.Focused() { - t.Error("expected list to be blurred") - } -} - -// TestFocusNavigationAfterAppendingToViewportHeight reproduces the bug: -// Append items until viewport is full, select last, then navigate backwards. -func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) { - t.Parallel() - - focusStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("86")) - - blurStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")) - - // Start with one item - items := []Item{ - NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle), - } - - l := New(items...) - l.SetSize(20, 15) // 15 lines viewport height - l.SetSelected(0) - l.Focus() - - // Initial draw to build buffer - screen := uv.NewScreenBuffer(20, 15) - l.Draw(&screen, uv.Rect(0, 0, 20, 15)) - - // Append items until we exceed viewport height - // Each focusable item with border is 5 lines tall - for i := 2; i <= 4; i++ { - item := NewStringItem("Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle) - l.AppendItem(item) - } - - // Select the last item - l.SetSelected(3) - - // Draw - screen = uv.NewScreenBuffer(20, 15) - l.Draw(&screen, uv.Rect(0, 0, 20, 15)) - output := screen.Render() - - t.Logf("After selecting last item:\n%s", output) - require.Contains(t, output, "38;5;86", "expected focus color on last item") - - // Now navigate backwards - l.SelectPrev() - - screen = uv.NewScreenBuffer(20, 15) - l.Draw(&screen, uv.Rect(0, 0, 20, 15)) - output = screen.Render() - - t.Logf("After SelectPrev:\n%s", output) - require.Contains(t, output, "38;5;86", "expected focus color after SelectPrev") - - // Navigate backwards again - l.SelectPrev() - - screen = uv.NewScreenBuffer(20, 15) - l.Draw(&screen, uv.Rect(0, 0, 20, 15)) - output = screen.Render() - - t.Logf("After second SelectPrev:\n%s", output) - require.Contains(t, output, "38;5;86", "expected focus color after second SelectPrev") -} - -func TestFocusableItemUpdate(t *testing.T) { - // Create styles with borders - focusStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("86")) - - blurStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")) - - // Create a focusable item - item := NewStringItem("Test Item").WithFocusStyles(&focusStyle, &blurStyle) - - // Initially not focused - render with blur style - screen1 := uv.NewScreenBuffer(20, 5) - area := uv.Rect(0, 0, 20, 5) - item.Draw(&screen1, area) - output1 := screen1.Render() - - // Focus the item - item.Focus() - - // Render again - should show focus style - screen2 := uv.NewScreenBuffer(20, 5) - item.Draw(&screen2, area) - output2 := screen2.Render() - - // Outputs should be different (different border colors) - if output1 == output2 { - t.Error("expected different output after focusing, but got same output") - } - - // Verify focus state - if !item.IsFocused() { - t.Error("expected item to be focused") - } - - // Blur the item - item.Blur() - - // Render again - should show blur style again - screen3 := uv.NewScreenBuffer(20, 5) - item.Draw(&screen3, area) - output3 := screen3.Render() - - // Output should match original blur output - if output1 != output3 { - t.Error("expected same output after blurring as initial state") - } - - // Verify blur state - if item.IsFocused() { - t.Error("expected item to be blurred") - } -} - -func TestFocusableItemHeightWithBorder(t *testing.T) { - // Create a style with a border (adds 2 to vertical height) - borderStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()) - - // Item without styles has height 1 - plainItem := NewStringItem("Test") - plainHeight := plainItem.Height(20) - if plainHeight != 1 { - t.Errorf("expected plain height 1, got %d", plainHeight) - } - - // Item with border should add border height (2 lines) - item := NewStringItem("Test").WithFocusStyles(&borderStyle, &borderStyle) - itemHeight := item.Height(20) - expectedHeight := 1 + 2 // content + border - if itemHeight != expectedHeight { - t.Errorf("expected height %d (content 1 + border 2), got %d", - expectedHeight, itemHeight) - } -} - -func TestFocusableItemInList(t *testing.T) { - focusStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("86")) - - blurStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")) - - // Create list with focusable items - items := []Item{ - NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle), - NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle), - NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle), - } - - l := New(items...) - l.SetSize(80, 20) - l.SetSelected(0) - - // Focus the list - l.Focus() - - // First item should be focused - firstItem := items[0].(*StringItem) - if !firstItem.IsFocused() { - t.Error("expected first item to be focused after focusing list") - } - - // Render to ensure changes are visible - output1 := l.Render() - if !strings.Contains(output1, "Item 1") { - t.Error("expected output to contain first item") - } - - // Select second item - l.SetSelected(1) - - // First item should be blurred, second focused - if firstItem.IsFocused() { - t.Error("expected first item to be blurred after changing selection") - } - - secondItem := items[1].(*StringItem) - if !secondItem.IsFocused() { - t.Error("expected second item to be focused after selection") - } - - // Render again - should show updated focus - output2 := l.Render() - if !strings.Contains(output2, "Item 2") { - t.Error("expected output to contain second item") - } - - // Outputs should be different - if output1 == output2 { - t.Error("expected different output after selection change") - } -} - -func TestFocusableItemWithNilStyles(t *testing.T) { - // Test with nil styles - should render inner item directly - item := NewStringItem("Plain Item").WithFocusStyles(nil, nil) - - // Height should be based on content (no border since styles are nil) - itemHeight := item.Height(20) - if itemHeight != 1 { - t.Errorf("expected height 1 (no border), got %d", itemHeight) - } - - // Draw should work without styles - screen := uv.NewScreenBuffer(20, 5) - area := uv.Rect(0, 0, 20, 5) - item.Draw(&screen, area) - output := screen.Render() - - // Should contain the inner content - if !strings.Contains(output, "Plain Item") { - t.Error("expected output to contain inner item content") - } - - // Focus/blur should still work but not change appearance - item.Focus() - screen2 := uv.NewScreenBuffer(20, 5) - item.Draw(&screen2, area) - output2 := screen2.Render() - - // Output should be identical since no styles - if output != output2 { - t.Error("expected same output with nil styles whether focused or not") - } - - if !item.IsFocused() { - t.Error("expected item to be focused") - } -} - -func TestFocusableItemWithOnlyFocusStyle(t *testing.T) { - // Test with only focus style (blur is nil) - focusStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("86")) - - item := NewStringItem("Test").WithFocusStyles(&focusStyle, nil) - - // When not focused, should use nil blur style (no border) - screen1 := uv.NewScreenBuffer(20, 5) - area := uv.Rect(0, 0, 20, 5) - item.Draw(&screen1, area) - output1 := screen1.Render() - - // Focus the item - item.Focus() - screen2 := uv.NewScreenBuffer(20, 5) - item.Draw(&screen2, area) - output2 := screen2.Render() - - // Outputs should be different (focused has border, blurred doesn't) - if output1 == output2 { - t.Error("expected different output when only focus style is set") - } -} - -func TestFocusableItemLastLineNotEaten(t *testing.T) { - // Create focusable items with borders - focusStyle := lipgloss.NewStyle(). - Padding(1). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("86")) - - blurStyle := lipgloss.NewStyle(). - BorderForeground(lipgloss.Color("240")) - - items := []Item{ - NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle), - Gap, - NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle), - Gap, - NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle), - Gap, - NewStringItem("Item 4").WithFocusStyles(&focusStyle, &blurStyle), - Gap, - NewStringItem("Item 5").WithFocusStyles(&focusStyle, &blurStyle), - } - - // Items with padding(1) and border are 5 lines each - // Viewport of 10 lines fits exactly 2 items - l := New() - l.SetSize(20, 10) - - for _, item := range items { - l.AppendItem(item) - } - - // Focus the list - l.Focus() - - // Select last item - l.SetSelected(len(items) - 1) - - // Scroll to bottom - l.ScrollToBottom() - - output := l.Render() - - t.Logf("Output:\n%s", output) - t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight()) - - // Select previous - will skip gaps and go to Item 4 - l.SelectPrev() - - output = l.Render() - - t.Logf("Output:\n%s", output) - t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight()) - - // Should show items 3 (unfocused), 4 (focused), and part of 5 (unfocused) - if !strings.Contains(output, "Item 3") { - t.Error("expected output to contain 'Item 3'") - } - if !strings.Contains(output, "Item 4") { - t.Error("expected output to contain 'Item 4'") - } - if !strings.Contains(output, "Item 5") { - t.Error("expected output to contain 'Item 5'") - } - - // Count bottom borders - should have 1 (focused item 4) - bottomBorderCount := 0 - for _, line := range strings.Split(output, "\r\n") { - if strings.Contains(line, "╰") || strings.Contains(line, "└") { - bottomBorderCount++ - } - } - - if bottomBorderCount != 1 { - t.Errorf("expected 1 bottom border (focused item 4), got %d", bottomBorderCount) - } -} diff --git a/internal/ui/list/simplelist.go b/internal/ui/list/simplelist.go deleted file mode 100644 index 10cb0912d42a8d25f2ea635ff69ea41653bbdbce..0000000000000000000000000000000000000000 --- a/internal/ui/list/simplelist.go +++ /dev/null @@ -1,972 +0,0 @@ -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/items.go b/internal/ui/model/items.go index 09fa1e74a60d2f94eaaf07859edd7383a6eef787..92e7de403eef9ba4dffd7df85872d3c30bddeb4f 100644 --- a/internal/ui/model/items.go +++ b/internal/ui/model/items.go @@ -2,14 +2,11 @@ package model import ( "fmt" - "image" - "log/slog" "path/filepath" "strings" "time" "charm.land/lipgloss/v2" - uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/crush/internal/config" @@ -111,8 +108,6 @@ func (m *MessageContentItem) Render(width int) string { // ToolCallItem represents a rendered tool call with its header and content. type ToolCallItem struct { - BaseFocusable - BaseHighlightable id string toolCall message.ToolCall toolResult message.ToolResult @@ -139,7 +134,6 @@ func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.To maxWidth: 120, sty: sty, } - t.InitHighlight() return t } @@ -156,18 +150,12 @@ func (t *ToolCallItem) ID() string { // FocusStyle returns the focus style. func (t *ToolCallItem) FocusStyle() lipgloss.Style { - if t.focusStyle != nil { - return *t.focusStyle - } - return lipgloss.Style{} + return t.sty.Chat.Message.ToolCallFocused } // BlurStyle returns the blur style. func (t *ToolCallItem) BlurStyle() lipgloss.Style { - if t.blurStyle != nil { - return *t.blurStyle - } - return lipgloss.Style{} + return t.sty.Chat.Message.ToolCallBlurred } // HighlightStyle returns the highlight style. @@ -189,24 +177,10 @@ func (t *ToolCallItem) Render(width int) string { rendered := toolrender.Render(ctx) return rendered - - // return t.RenderWithHighlight(rendered, width, style) -} - -// SetHighlight implements list.Highlightable. -func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) { - t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) -} - -// UpdateResult updates the tool result and invalidates the cache if needed. -func (t *ToolCallItem) UpdateResult(result message.ToolResult) { - t.toolResult = result } // AttachmentItem represents a file attachment in a user message. type AttachmentItem struct { - BaseFocusable - BaseHighlightable id string filename string path string @@ -221,7 +195,6 @@ func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *Attachmen path: path, sty: sty, } - a.InitHighlight() return a } @@ -232,18 +205,12 @@ func (a *AttachmentItem) ID() string { // FocusStyle returns the focus style. func (a *AttachmentItem) FocusStyle() lipgloss.Style { - if a.focusStyle != nil { - return *a.focusStyle - } - return lipgloss.Style{} + return a.sty.Chat.Message.AssistantFocused } // BlurStyle returns the blur style. func (a *AttachmentItem) BlurStyle() lipgloss.Style { - if a.blurStyle != nil { - return *a.blurStyle - } - return lipgloss.Style{} + return a.sty.Chat.Message.AssistantBlurred } // HighlightStyle returns the highlight style. @@ -410,16 +377,6 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s return items } - // Create base styles for the message - var focusStyle, blurStyle lipgloss.Style - if msg.Role == message.User { - focusStyle = sty.Chat.Message.UserFocused - blurStyle = sty.Chat.Message.UserBlurred - } else { - focusStyle = sty.Chat.Message.AssistantFocused - blurStyle = sty.Chat.Message.AssistantBlurred - } - // Process user messages if msg.Role == message.User { // Add main text content @@ -444,8 +401,6 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s attachment.Path, sty, ) - item.SetHighlightStyle(ToStyler(sty.TextSelection)) - item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) } @@ -566,11 +521,6 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s sty, ) - item.SetHighlightStyle(ToStyler(sty.TextSelection)) - - // Tool calls use muted style with optional focus border - item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred) - items = append(items, item) } @@ -596,299 +546,3 @@ func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResu } return resultMap } - -// BaseFocusable provides common focus state and styling for items. -// Embed this type to add focus behavior to any item. -type BaseFocusable struct { - focused bool - focusStyle *lipgloss.Style - blurStyle *lipgloss.Style -} - -// Focus implements Focusable interface. -func (b *BaseFocusable) Focus(width int, content string) string { - if b.focusStyle != nil { - return b.focusStyle.Render(content) - } - return content -} - -// Blur implements Focusable interface. -func (b *BaseFocusable) Blur(width int, content string) string { - if b.blurStyle != nil { - return b.blurStyle.Render(content) - } - return content -} - -// Focus implements Focusable interface. -// func (b *BaseFocusable) Focus() { -// b.focused = true -// } - -// Blur implements Focusable interface. -// func (b *BaseFocusable) Blur() { -// b.focused = false -// } - -// Focused implements Focusable interface. -func (b *BaseFocusable) Focused() bool { - return b.focused -} - -// HasFocusStyles returns true if both focus and blur styles are configured. -func (b *BaseFocusable) HasFocusStyles() bool { - return b.focusStyle != nil && b.blurStyle != nil -} - -// CurrentStyle returns the current style based on focus state. -// Returns nil if no styles are configured, or if the current state's style is nil. -func (b *BaseFocusable) CurrentStyle() *lipgloss.Style { - if b.focused { - return b.focusStyle - } - return b.blurStyle -} - -// SetFocusStyles sets the focus and blur styles. -func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) { - b.focusStyle = focusStyle - b.blurStyle = blurStyle -} - -// CellStyler defines a function that styles a [uv.Style]. -type CellStyler func(uv.Style) uv.Style - -// BaseHighlightable provides common highlight state for items. -// Embed this type to add highlight behavior to any item. -type BaseHighlightable struct { - highlightStartLine int - highlightStartCol int - highlightEndLine int - highlightEndCol int - highlightStyle CellStyler -} - -// SetHighlight implements Highlightable interface. -func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) { - b.highlightStartLine = startLine - b.highlightStartCol = startCol - b.highlightEndLine = endLine - b.highlightEndCol = endCol -} - -// GetHighlight implements Highlightable interface. -func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) { - return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol -} - -// HasHighlight returns true if a highlight region is set. -func (b *BaseHighlightable) HasHighlight() bool { - return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 || - b.highlightEndLine >= 0 || b.highlightEndCol >= 0 -} - -// SetHighlightStyle sets the style function used for highlighting. -func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) { - b.highlightStyle = style -} - -// GetHighlightStyle returns the current highlight style function. -func (b *BaseHighlightable) GetHighlightStyle() CellStyler { - return b.highlightStyle -} - -// InitHighlight initializes the highlight fields with default values. -func (b *BaseHighlightable) InitHighlight() { - b.highlightStartLine = -1 - b.highlightStartCol = -1 - b.highlightEndLine = -1 - b.highlightEndCol = -1 - b.highlightStyle = ToStyler(lipgloss.NewStyle().Reverse(true)) -} - -// Highlight implements Highlightable interface. -func (b *BaseHighlightable) Highlight(width int, content string, startLine, startCol, endLine, endCol int) string { - b.SetHighlight(startLine, startCol, endLine, endCol) - return b.RenderWithHighlight(content, width, nil) -} - -// RenderWithHighlight renders content with optional focus styling and highlighting. -// This is a helper that combines common rendering logic for all items. -// The content parameter should be the raw rendered content before focus styling. -// The style parameter should come from CurrentStyle() and may be nil. -func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string { - // Apply focus/blur styling if configured - rendered := content - if style != nil { - rendered = style.Render(rendered) - } - - if !b.HasHighlight() { - return rendered - } - - height := lipgloss.Height(rendered) - - // Create temp buffer to draw content with highlighting - tempBuf := uv.NewScreenBuffer(width, height) - - // Draw the rendered content to temp buffer - styled := uv.NewStyledString(rendered) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - // Apply highlighting if active - b.ApplyHighlight(&tempBuf, width, height, style) - - return tempBuf.Render() -} - -// ApplyHighlight applies highlighting to a screen buffer. -// This should be called after drawing content to the buffer. -func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) { - if b.highlightStartLine < 0 { - return - } - - var ( - topMargin, topBorder, topPadding int - rightMargin, rightBorder, rightPadding int - bottomMargin, bottomBorder, bottomPadding int - leftMargin, leftBorder, leftPadding int - ) - if style != nil { - topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin() - topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(), - style.GetBorderRightSize(), - style.GetBorderBottomSize(), - style.GetBorderLeftSize() - topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding() - } - - slog.Info("Applying highlight", - "highlightStartLine", b.highlightStartLine, - "highlightStartCol", b.highlightStartCol, - "highlightEndLine", b.highlightEndLine, - "highlightEndCol", b.highlightEndCol, - "width", width, - "height", height, - "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin), - "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder), - "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding), - ) - - // Calculate content area offsets - contentArea := image.Rectangle{ - Min: image.Point{ - X: leftMargin + leftBorder + leftPadding, - Y: topMargin + topBorder + topPadding, - }, - Max: image.Point{ - X: width - (rightMargin + rightBorder + rightPadding), - Y: height - (bottomMargin + bottomBorder + bottomPadding), - }, - } - - for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ { - if y >= buf.Height() { - break - } - - line := buf.Line(y) - - // Determine column range for this line - startCol := 0 - if y == b.highlightStartLine { - startCol = min(b.highlightStartCol, len(line)) - } - - endCol := len(line) - if y == b.highlightEndLine { - endCol = min(b.highlightEndCol, len(line)) - } - - // Track last non-empty position as we go - lastContentX := -1 - - // Single pass: check content and track last non-empty position - for x := startCol; x < endCol; x++ { - cell := line.At(x) - if cell == nil { - continue - } - - // Update last content position if non-empty - if cell.Content != "" && cell.Content != " " { - lastContentX = x - } - } - - // Only apply highlight up to last content position - highlightEnd := endCol - if lastContentX >= 0 { - highlightEnd = lastContentX + 1 - } else if lastContentX == -1 { - highlightEnd = startCol // No content on this line - } - - // Apply highlight style only to cells with content - for x := startCol; x < highlightEnd; x++ { - if !image.Pt(x, y).In(contentArea) { - continue - } - cell := line.At(x) - cell.Style = b.highlightStyle(cell.Style) - } - } -} - -// ToStyler converts a [lipgloss.Style] to a [CellStyler]. -func ToStyler(lgStyle lipgloss.Style) CellStyler { - return func(uv.Style) uv.Style { - return ToStyle(lgStyle) - } -} - -// ToStyle converts an inline [lipgloss.Style] to a [uv.Style]. -func ToStyle(lgStyle lipgloss.Style) uv.Style { - var uvStyle uv.Style - - // Colors are already color.Color - uvStyle.Fg = lgStyle.GetForeground() - uvStyle.Bg = lgStyle.GetBackground() - - // Build attributes using bitwise OR - var attrs uint8 - - if lgStyle.GetBold() { - attrs |= uv.AttrBold - } - - if lgStyle.GetItalic() { - attrs |= uv.AttrItalic - } - - if lgStyle.GetUnderline() { - uvStyle.Underline = uv.UnderlineSingle - } - - if lgStyle.GetStrikethrough() { - attrs |= uv.AttrStrikethrough - } - - if lgStyle.GetFaint() { - attrs |= uv.AttrFaint - } - - if lgStyle.GetBlink() { - attrs |= uv.AttrBlink - } - - if lgStyle.GetReverse() { - attrs |= uv.AttrReverse - } - - uvStyle.Attrs = attrs - - return uvStyle -}