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