refactor(ui): cleanup and remove unused list code

Ayman Bagabas created

Change summary

internal/ui/lazylist/highlight.go |  129 ---
internal/ui/list/example_test.go  |  275 --------
internal/ui/list/item.go          |  578 ----------------
internal/ui/list/item_test.go     |  593 -----------------
internal/ui/list/lazylist.go      | 1007 -----------------------------
internal/ui/list/list.go          | 1126 ---------------------------------
internal/ui/list/list_test.go     |  578 ----------------
internal/ui/list/simplelist.go    |  972 ----------------------------
internal/ui/model/items.go        |  354 ----------
9 files changed, 4 insertions(+), 5,608 deletions(-)

Detailed changes

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 {

internal/ui/list/example_test.go 🔗

@@ -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)
-}

internal/ui/list/item.go 🔗

@@ -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)
-	}
-}

internal/ui/list/item_test.go 🔗

@@ -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")
-	}
-}

internal/ui/list/lazylist.go 🔗

@@ -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
-}

internal/ui/list/list.go 🔗

@@ -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")
-}

internal/ui/list/list_test.go 🔗

@@ -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)
-	}
-}

internal/ui/list/simplelist.go 🔗

@@ -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()
-}

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
-}