feat: implement text highlighting in list items (#1536)

Ayman Bagabas created

Change summary

internal/ui/list/item.go      | 278 ++++++++++++++++++++++++++
internal/ui/list/list.go      | 376 ++++++++++++++++++++++++++++++++++++
internal/ui/list/list_test.go |   4 
3 files changed, 639 insertions(+), 19 deletions(-)

Detailed changes

internal/ui/list/item.go 🔗

@@ -1,6 +1,7 @@
 package list
 
 import (
+	"image"
 	"strings"
 
 	"charm.land/lipgloss/v2"
@@ -10,6 +11,50 @@ import (
 	"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 {
@@ -31,6 +76,17 @@ type Focusable interface {
 	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 {
@@ -74,11 +130,148 @@ func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
 	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
 	id      string
 	content string // Raw content string (may contain ANSI styles)
 	wrap    bool   // Whether to wrap text
@@ -88,24 +281,51 @@ type StringItem struct {
 	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(id, content string) *StringItem {
-	return &StringItem{
+	s := &StringItem{
 		id:      id,
 		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(id, content string) *StringItem {
-	return &StringItem{
+	s := &StringItem{
 		id:      id,
 		content: content,
 		wrap:    true,
 		cache:   make(map[int]string),
 	}
+	s.InitHighlight()
+	return s
 }
 
 // WithFocusStyles sets the focus and blur styles for the string item.
@@ -153,6 +373,7 @@ func (s *StringItem) Height(width int) int {
 // 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]
@@ -167,21 +388,41 @@ func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) {
 	}
 
 	// Apply focus/blur styling if configured
-	if style := s.CurrentStyle(); style != nil {
+	style := s.CurrentStyle()
+	if style != nil {
 		content = style.Width(width).Render(content)
 	}
 
-	// Draw the styled string
+	// 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(scr, area)
+	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
 	id          string
 	markdown    string            // Raw markdown content
 	styleConfig *ansi.StyleConfig // Optional style configuration
@@ -204,7 +445,7 @@ func NewMarkdownItem(id, markdown string) *MarkdownItem {
 		maxWidth: DefaultMarkdownMaxWidth,
 		cache:    make(map[int]string),
 	}
-
+	m.InitHighlight()
 	return m
 }
 
@@ -248,16 +489,27 @@ func (m *MarkdownItem) Height(width int) int {
 // 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
-	if style := m.CurrentStyle(); style != nil {
+	style := m.CurrentStyle()
+	if style != nil {
 		rendered = style.Render(rendered)
 	}
 
-	// Draw the rendered markdown
+	// 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(scr, area)
+	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.
@@ -302,6 +554,14 @@ func (m *MarkdownItem) renderMarkdown(width int) string {
 	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("spacer-gap", 1)
 

internal/ui/list/list.go 🔗

@@ -32,6 +32,15 @@ type List struct {
 	// Viewport state
 	offset int // Scroll offset in lines from top
 
+	// Mouse state
+	mouseDown     bool
+	mouseDownItem string // Item ID where mouse was pressed
+	mouseDownX    int    // X position in item content (character offset)
+	mouseDownY    int    // Y position in item (line offset)
+	mouseDragItem string // Current item being dragged over
+	mouseDragX    int    // Current X in item content
+	mouseDragY    int    // Current Y in item
+
 	// Dirty tracking
 	dirty      bool
 	dirtyItems map[string]bool
@@ -362,6 +371,16 @@ func (l *List) SetSize(width, height int) {
 	}
 }
 
+// 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
@@ -569,8 +588,8 @@ func (l *List) Blur() {
 	l.blurSelectedItem()
 }
 
-// IsFocused returns whether the list is focused.
-func (l *List) IsFocused() bool {
+// Focused returns whether the list is focused.
+func (l *List) Focused() bool {
 	return l.focused
 }
 
@@ -612,16 +631,44 @@ func (l *List) SetSelectedIndex(idx int) {
 	l.SetSelected(l.items[idx].ID())
 }
 
-// SelectNext selects the next item in the list (wraps to beginning).
+// SelectFirst selects the first item in the list.
+func (l *List) SelectFirst() {
+	l.SetSelectedIndex(0)
+}
+
+// SelectLast selects the last item in the list.
+func (l *List) SelectLast() {
+	l.SetSelectedIndex(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++ {
-		nextIdx := (startIdx + 1 + i) % len(l.items)
+		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 {
@@ -632,21 +679,38 @@ func (l *List) SelectNext() {
 
 		// Select and scroll to this item
 		l.SetSelected(l.items[nextIdx].ID())
-		l.ScrollToSelected()
 		return
 	}
 }
 
-// SelectPrev selects the previous item in the list (wraps to end).
+// 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++ {
-		prevIdx := (startIdx - 1 - i + len(l.items)) % len(l.items)
+		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 {
@@ -657,7 +721,6 @@ func (l *List) SelectPrev() {
 
 		// Select and scroll to this item
 		l.SetSelected(l.items[prevIdx].ID())
-		l.ScrollToSelected()
 		return
 	}
 }
@@ -754,6 +817,27 @@ func (l *List) TotalHeight() int {
 	return l.totalHeight
 }
 
+// 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
+	item := l.items[l.selectedIdx]
+	pos, ok := l.itemPositions[item.ID()]
+	if !ok {
+		return false
+	}
+
+	// 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() {
 	maxOffset := l.totalHeight - l.height
@@ -793,3 +877,279 @@ func (l *List) blurSelectedItem() {
 		l.dirtyItems[item.ID()] = 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
+	itemID, itemY := l.findItemAtPosition(bufferY)
+	if itemID == "" {
+		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 = itemID
+	l.mouseDownX = x
+	l.mouseDownY = itemY
+	l.mouseDragItem = itemID
+	l.mouseDragX = x
+	l.mouseDragY = itemY
+
+	// Select the clicked item
+	if idx, ok := l.indexMap[itemID]; ok {
+		l.SetSelectedIndex(idx)
+	}
+
+	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
+	itemID, itemY := l.findItemAtPosition(bufferY)
+	if itemID == "" {
+		return false
+	}
+
+	l.mouseDragItem = itemID
+	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 _, item := range l.items {
+		if h, ok := item.(Highlightable); ok {
+			h.SetHighlight(-1, -1, -1, -1)
+			l.dirtyItems[item.ID()] = true
+		}
+	}
+}
+
+// findItemAtPosition finds the item at the given master buffer y coordinate.
+// Returns the item ID and the y offset within that item.
+func (l *List) findItemAtPosition(bufferY int) (itemID string, itemY int) {
+	if bufferY < 0 || bufferY >= l.totalHeight {
+		return "", 0
+	}
+
+	// Linear search through items to find which one contains this y
+	// This could be optimized with binary search if needed
+	for _, item := range l.items {
+		pos, ok := l.itemPositions[item.ID()]
+		if !ok {
+			continue
+		}
+
+		if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height {
+			return item.ID(), bufferY - pos.startLine
+		}
+	}
+
+	return "", 0
+}
+
+// updateHighlight updates the highlight range for highlightable items.
+// Supports highlighting across multiple items and respects drag direction.
+func (l *List) updateHighlight() {
+	if l.mouseDownItem == "" {
+		return
+	}
+
+	// Get start and end item indices
+	downItemIdx := l.indexMap[l.mouseDownItem]
+	dragItemIdx := l.indexMap[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 _, item := range l.items {
+		if h, ok := item.(Highlightable); ok {
+			h.SetHighlight(-1, -1, -1, -1)
+			l.dirtyItems[item.ID()] = 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[l.items[idx].ID()]
+			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[l.items[idx].ID()]
+			item.SetHighlight(0, 0, pos.height-1, 9999)
+		}
+
+		l.dirtyItems[l.items[idx].ID()] = 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 _, item := range l.items {
+		h, ok := item.(Highlightable)
+		if !ok {
+			continue
+		}
+
+		startLine, startCol, endLine, endCol := h.GetHighlight()
+		if startLine < 0 {
+			continue
+		}
+
+		pos, ok := l.itemPositions[item.ID()]
+		if !ok {
+			continue
+		}
+
+		// 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 🔗

@@ -213,7 +213,7 @@ func TestListFocus(t *testing.T) {
 	// Focus the list
 	l.Focus()
 
-	if !l.IsFocused() {
+	if !l.Focused() {
 		t.Error("expected list to be focused")
 	}
 
@@ -236,7 +236,7 @@ func TestListFocus(t *testing.T) {
 
 	// Blur the list
 	l.Blur()
-	if l.IsFocused() {
+	if l.Focused() {
 		t.Error("expected list to be blurred")
 	}
 }