on main help sign in
feat: implement text highlighting in list items (#1536)
commitf48a3edc33ca62dd9d6ff5f6a226f12f659ce57a authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 2 Dec 2025, 11:33 (1 year ago) parentb71baee
3 files changed+639−19
internal/ui/list/item.go+269−9 internal/ui/list/list.go+368−8 internal/ui/list/list_test.go+2−2
internal/ui/list/item.go+269−9
@@ -1,6 +1,7 @@
11package list
22
33import (
4+ "image"
45 "strings"
56
67 "charm.land/lipgloss/v2"
@@ -10,6 +11,50 @@ import (
1011 "github.com/charmbracelet/ultraviolet/screen"
1112)
1213
14+// toUVStyle converts a lipgloss.Style to a uv.Style, stripping multiline attributes.
15+func toUVStyle(lgStyle lipgloss.Style) uv.Style {
16+ var uvStyle uv.Style
17+
18+ // Colors are already color.Color
19+ uvStyle.Fg = lgStyle.GetForeground()
20+ uvStyle.Bg = lgStyle.GetBackground()
21+
22+ // Build attributes using bitwise OR
23+ var attrs uint8
24+
25+ if lgStyle.GetBold() {
26+ attrs |= uv.AttrBold
27+ }
28+
29+ if lgStyle.GetItalic() {
30+ attrs |= uv.AttrItalic
31+ }
32+
33+ if lgStyle.GetUnderline() {
34+ uvStyle.Underline = uv.UnderlineSingle
35+ }
36+
37+ if lgStyle.GetStrikethrough() {
38+ attrs |= uv.AttrStrikethrough
39+ }
40+
41+ if lgStyle.GetFaint() {
42+ attrs |= uv.AttrFaint
43+ }
44+
45+ if lgStyle.GetBlink() {
46+ attrs |= uv.AttrBlink
47+ }
48+
49+ if lgStyle.GetReverse() {
50+ attrs |= uv.AttrReverse
51+ }
52+
53+ uvStyle.Attrs = attrs
54+
55+ return uvStyle
56+}
57+
1358// Item represents a list item that can draw itself to a UV buffer.
1459// Items implement the uv.Drawable interface.
1560type Item interface {
@@ -31,6 +76,17 @@ type Focusable interface {
3176 IsFocused() bool
3277}
3378
79+// Highlightable is an optional interface for items that support highlighting.
80+// When implemented, items can highlight specific regions (e.g. for search matches).
81+type Highlightable interface {
82+ // SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol).
83+ // Use -1 for all values to clear highlighting.
84+ SetHighlight(startLine, startCol, endLine, endCol int)
85+
86+ // GetHighlight returns the current highlight region.
87+ GetHighlight() (startLine, startCol, endLine, endCol int)
88+}
89+
3490// BaseFocusable provides common focus state and styling for items.
3591// Embed this type to add focus behavior to any item.
3692type BaseFocusable struct {
@@ -74,11 +130,148 @@ func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
74130 b.blurStyle = blurStyle
75131}
76132
133+// BaseHighlightable provides common highlight state for items.
134+// Embed this type to add highlight behavior to any item.
135+type BaseHighlightable struct {
136+ highlightStartLine int
137+ highlightStartCol int
138+ highlightEndLine int
139+ highlightEndCol int
140+ highlightStyle CellStyler
141+}
142+
143+// SetHighlight implements Highlightable interface.
144+func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) {
145+ b.highlightStartLine = startLine
146+ b.highlightStartCol = startCol
147+ b.highlightEndLine = endLine
148+ b.highlightEndCol = endCol
149+}
150+
151+// GetHighlight implements Highlightable interface.
152+func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) {
153+ return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol
154+}
155+
156+// HasHighlight returns true if a highlight region is set.
157+func (b *BaseHighlightable) HasHighlight() bool {
158+ return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 ||
159+ b.highlightEndLine >= 0 || b.highlightEndCol >= 0
160+}
161+
162+// SetHighlightStyle sets the style function used for highlighting.
163+func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) {
164+ b.highlightStyle = style
165+}
166+
167+// GetHighlightStyle returns the current highlight style function.
168+func (b *BaseHighlightable) GetHighlightStyle() CellStyler {
169+ return b.highlightStyle
170+}
171+
172+// InitHighlight initializes the highlight fields with default values.
173+func (b *BaseHighlightable) InitHighlight() {
174+ b.highlightStartLine = -1
175+ b.highlightStartCol = -1
176+ b.highlightEndLine = -1
177+ b.highlightEndCol = -1
178+ b.highlightStyle = LipglossStyleToCellStyler(lipgloss.NewStyle().Reverse(true))
179+}
180+
181+// ApplyHighlight applies highlighting to a screen buffer.
182+// This should be called after drawing content to the buffer.
183+func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
184+ if b.highlightStartLine < 0 {
185+ return
186+ }
187+
188+ var (
189+ topMargin, topBorder, topPadding int
190+ rightMargin, rightBorder, rightPadding int
191+ bottomMargin, bottomBorder, bottomPadding int
192+ leftMargin, leftBorder, leftPadding int
193+ )
194+ if style != nil {
195+ topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
196+ topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
197+ style.GetBorderRightSize(),
198+ style.GetBorderBottomSize(),
199+ style.GetBorderLeftSize()
200+ topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
201+ }
202+
203+ // Calculate content area offsets
204+ contentArea := image.Rectangle{
205+ Min: image.Point{
206+ X: leftMargin + leftBorder + leftPadding,
207+ Y: topMargin + topBorder + topPadding,
208+ },
209+ Max: image.Point{
210+ X: width - (rightMargin + rightBorder + rightPadding),
211+ Y: height - (bottomMargin + bottomBorder + bottomPadding),
212+ },
213+ }
214+
215+ for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
216+ if y >= buf.Height() {
217+ break
218+ }
219+
220+ line := buf.Line(y)
221+
222+ // Determine column range for this line
223+ startCol := 0
224+ if y == b.highlightStartLine {
225+ startCol = min(b.highlightStartCol, len(line))
226+ }
227+
228+ endCol := len(line)
229+ if y == b.highlightEndLine {
230+ endCol = min(b.highlightEndCol, len(line))
231+ }
232+
233+ // Track last non-empty position as we go
234+ lastContentX := -1
235+
236+ // Single pass: check content and track last non-empty position
237+ for x := startCol; x < endCol; x++ {
238+ cell := line.At(x)
239+ if cell == nil {
240+ continue
241+ }
242+
243+ // Update last content position if non-empty
244+ if cell.Content != "" && cell.Content != " " {
245+ lastContentX = x
246+ }
247+ }
248+
249+ // Only apply highlight up to last content position
250+ highlightEnd := endCol
251+ if lastContentX >= 0 {
252+ highlightEnd = lastContentX + 1
253+ } else if lastContentX == -1 {
254+ highlightEnd = startCol // No content on this line
255+ }
256+
257+ // Apply highlight style only to cells with content
258+ for x := startCol; x < highlightEnd; x++ {
259+ if !image.Pt(x, y).In(contentArea) {
260+ continue
261+ }
262+ cell := line.At(x)
263+ cell.Style = b.highlightStyle(cell.Style)
264+ }
265+ }
266+}
267+
77268// StringItem is a simple string-based item with optional text wrapping.
78269// It caches rendered content by width for efficient repeated rendering.
79270// StringItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
271+// StringItem implements Highlightable for text selection/search highlighting.
80272type StringItem struct {
81273 BaseFocusable
274+ BaseHighlightable
82275 id string
83276 content string // Raw content string (may contain ANSI styles)
84277 wrap bool // Whether to wrap text
@@ -88,24 +281,51 @@ type StringItem struct {
88281 cache map[int]string
89282}
90283
284+// CellStyler is a function that applies styles to UV cells.
285+type CellStyler = func(s uv.Style) uv.Style
286+
287+var noColor = lipgloss.NoColor{}
288+
289+// LipglossStyleToCellStyler converts a Lip Gloss style to a CellStyler function.
290+func LipglossStyleToCellStyler(lgStyle lipgloss.Style) CellStyler {
291+ uvStyle := toUVStyle(lgStyle)
292+ return func(s uv.Style) uv.Style {
293+ if uvStyle.Fg != nil && lgStyle.GetForeground() != noColor {
294+ s.Fg = uvStyle.Fg
295+ }
296+ if uvStyle.Bg != nil && lgStyle.GetBackground() != noColor {
297+ s.Bg = uvStyle.Bg
298+ }
299+ s.Attrs |= uvStyle.Attrs
300+ if uvStyle.Underline != 0 {
301+ s.Underline = uvStyle.Underline
302+ }
303+ return s
304+ }
305+}
306+
91307// NewStringItem creates a new string item with the given ID and content.
92308func NewStringItem(id, content string) *StringItem {
93− return &StringItem{
309+ s := &StringItem{
94310 id: id,
95311 content: content,
96312 wrap: false,
97313 cache: make(map[int]string),
98314 }
315+ s.InitHighlight()
316+ return s
99317}
100318
101319// NewWrappingStringItem creates a new string item that wraps text to fit width.
102320func NewWrappingStringItem(id, content string) *StringItem {
103− return &StringItem{
321+ s := &StringItem{
104322 id: id,
105323 content: content,
106324 wrap: true,
107325 cache: make(map[int]string),
108326 }
327+ s.InitHighlight()
328+ return s
109329}
110330
111331// WithFocusStyles sets the focus and blur styles for the string item.
@@ -153,6 +373,7 @@ func (s *StringItem) Height(width int) int {
153373// Draw implements Item and uv.Drawable.
154374func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) {
155375 width := area.Dx()
376+ height := area.Dy()
156377
157378 // Check cache first
158379 content, ok := s.cache[width]
@@ -167,21 +388,41 @@ func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) {
167388 }
168389
169390 // Apply focus/blur styling if configured
170− if style := s.CurrentStyle(); style != nil {
391+ style := s.CurrentStyle()
392+ if style != nil {
171393 content = style.Width(width).Render(content)
172394 }
173395
174− // Draw the styled string
396+ // Create temp buffer to draw content with highlighting
397+ tempBuf := uv.NewScreenBuffer(width, height)
398+
399+ // Draw content to temp buffer first
175400 styled := uv.NewStyledString(content)
176− styled.Draw(scr, area)
401+ styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
402+
403+ // Apply highlighting if active
404+ s.ApplyHighlight(&tempBuf, width, height, style)
405+
406+ // Copy temp buffer to actual screen at the target area
407+ tempBuf.Draw(scr, area)
408+}
409+
410+// SetHighlight implements Highlightable and extends BaseHighlightable.
411+// Clears the cache when highlight changes.
412+func (s *StringItem) SetHighlight(startLine, startCol, endLine, endCol int) {
413+ s.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
414+ // Clear cache when highlight changes
415+ s.cache = make(map[int]string)
177416}
178417
179418// MarkdownItem renders markdown content using Glamour.
180419// It caches all rendered content by width for efficient repeated rendering.
181420// The wrap width is capped at 120 cells by default to ensure readable line lengths.
182421// MarkdownItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
422+// MarkdownItem implements Highlightable for text selection/search highlighting.
183423type MarkdownItem struct {
184424 BaseFocusable
425+ BaseHighlightable
185426 id string
186427 markdown string // Raw markdown content
187428 styleConfig *ansi.StyleConfig // Optional style configuration
@@ -204,7 +445,7 @@ func NewMarkdownItem(id, markdown string) *MarkdownItem {
204445 maxWidth: DefaultMarkdownMaxWidth,
205446 cache: make(map[int]string),
206447 }
207−
448+ m.InitHighlight()
208449 return m
209450}
210451
@@ -248,16 +489,27 @@ func (m *MarkdownItem) Height(width int) int {
248489// Draw implements Item and uv.Drawable.
249490func (m *MarkdownItem) Draw(scr uv.Screen, area uv.Rectangle) {
250491 width := area.Dx()
492+ height := area.Dy()
251493 rendered := m.renderMarkdown(width)
252494
253495 // Apply focus/blur styling if configured
254− if style := m.CurrentStyle(); style != nil {
496+ style := m.CurrentStyle()
497+ if style != nil {
255498 rendered = style.Render(rendered)
256499 }
257500
258− // Draw the rendered markdown
501+ // Create temp buffer to draw content with highlighting
502+ tempBuf := uv.NewScreenBuffer(width, height)
503+
504+ // Draw the rendered markdown to temp buffer
259505 styled := uv.NewStyledString(rendered)
260− styled.Draw(scr, area)
506+ styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
507+
508+ // Apply highlighting if active
509+ m.ApplyHighlight(&tempBuf, width, height, style)
510+
511+ // Copy temp buffer to actual screen at the target area
512+ tempBuf.Draw(scr, area)
261513}
262514
263515// renderMarkdown renders the markdown content at the given width, using cache if available.
@@ -302,6 +554,14 @@ func (m *MarkdownItem) renderMarkdown(width int) string {
302554 return rendered
303555}
304556
557+// SetHighlight implements Highlightable and extends BaseHighlightable.
558+// Clears the cache when highlight changes.
559+func (m *MarkdownItem) SetHighlight(startLine, startCol, endLine, endCol int) {
560+ m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
561+ // Clear cache when highlight changes
562+ m.cache = make(map[int]string)
563+}
564+
305565// Gap is a 1-line spacer item used to add gaps between items.
306566var Gap = NewSpacerItem("spacer-gap", 1)
307567
internal/ui/list/list.go+368−8
@@ -32,6 +32,15 @@ type List struct {
3232 // Viewport state
3333 offset int // Scroll offset in lines from top
3434
35+ // Mouse state
36+ mouseDown bool
37+ mouseDownItem string // Item ID where mouse was pressed
38+ mouseDownX int // X position in item content (character offset)
39+ mouseDownY int // Y position in item (line offset)
40+ mouseDragItem string // Current item being dragged over
41+ mouseDragX int // Current X in item content
42+ mouseDragY int // Current Y in item
43+
3544 // Dirty tracking
3645 dirty bool
3746 dirtyItems map[string]bool
@@ -362,6 +371,16 @@ func (l *List) SetSize(width, height int) {
362371 }
363372}
364373
374+// Height returns the current viewport height.
375+func (l *List) Height() int {
376+ return l.height
377+}
378+
379+// Width returns the current viewport width.
380+func (l *List) Width() int {
381+ return l.width
382+}
383+
365384// GetSize returns the current viewport size.
366385func (l *List) GetSize() (int, int) {
367386 return l.width, l.height
@@ -569,8 +588,8 @@ func (l *List) Blur() {
569588 l.blurSelectedItem()
570589}
571590
572−// IsFocused returns whether the list is focused.
573−func (l *List) IsFocused() bool {
591+// Focused returns whether the list is focused.
592+func (l *List) Focused() bool {
574593 return l.focused
575594}
576595
@@ -612,16 +631,44 @@ func (l *List) SetSelectedIndex(idx int) {
612631 l.SetSelected(l.items[idx].ID())
613632}
614633
615−// SelectNext selects the next item in the list (wraps to beginning).
634+// SelectFirst selects the first item in the list.
635+func (l *List) SelectFirst() {
636+ l.SetSelectedIndex(0)
637+}
638+
639+// SelectLast selects the last item in the list.
640+func (l *List) SelectLast() {
641+ l.SetSelectedIndex(len(l.items) - 1)
642+}
643+
644+// SelectNextWrap selects the next item in the list (wraps to beginning).
645+// When the list is focused, skips non-focusable items.
646+func (l *List) SelectNextWrap() {
647+ l.selectNext(true)
648+}
649+
650+// SelectNext selects the next item in the list (no wrap).
616651// When the list is focused, skips non-focusable items.
617652func (l *List) SelectNext() {
653+ l.selectNext(false)
654+}
655+
656+func (l *List) selectNext(wrap bool) {
618657 if len(l.items) == 0 {
619658 return
620659 }
621660
622661 startIdx := l.selectedIdx
623662 for i := 0; i < len(l.items); i++ {
624− nextIdx := (startIdx + 1 + i) % len(l.items)
663+ var nextIdx int
664+ if wrap {
665+ nextIdx = (startIdx + 1 + i) % len(l.items)
666+ } else {
667+ nextIdx = startIdx + 1 + i
668+ if nextIdx >= len(l.items) {
669+ return
670+ }
671+ }
625672
626673 // If list is focused and item is not focusable, skip it
627674 if l.focused {
@@ -632,21 +679,38 @@ func (l *List) SelectNext() {
632679
633680 // Select and scroll to this item
634681 l.SetSelected(l.items[nextIdx].ID())
635− l.ScrollToSelected()
636682 return
637683 }
638684}
639685
640−// SelectPrev selects the previous item in the list (wraps to end).
686+// SelectPrevWrap selects the previous item in the list (wraps to end).
687+// When the list is focused, skips non-focusable items.
688+func (l *List) SelectPrevWrap() {
689+ l.selectPrev(true)
690+}
691+
692+// SelectPrev selects the previous item in the list (no wrap).
641693// When the list is focused, skips non-focusable items.
642694func (l *List) SelectPrev() {
695+ l.selectPrev(false)
696+}
697+
698+func (l *List) selectPrev(wrap bool) {
643699 if len(l.items) == 0 {
644700 return
645701 }
646702
647703 startIdx := l.selectedIdx
648704 for i := 0; i < len(l.items); i++ {
649− prevIdx := (startIdx - 1 - i + len(l.items)) % len(l.items)
705+ var prevIdx int
706+ if wrap {
707+ prevIdx = (startIdx - 1 - i + len(l.items)) % len(l.items)
708+ } else {
709+ prevIdx = startIdx - 1 - i
710+ if prevIdx < 0 {
711+ return
712+ }
713+ }
650714
651715 // If list is focused and item is not focusable, skip it
652716 if l.focused {
@@ -657,7 +721,6 @@ func (l *List) SelectPrev() {
657721
658722 // Select and scroll to this item
659723 l.SetSelected(l.items[prevIdx].ID())
660− l.ScrollToSelected()
661724 return
662725 }
663726}
@@ -754,6 +817,27 @@ func (l *List) TotalHeight() int {
754817 return l.totalHeight
755818}
756819
820+// SelectedItemInView returns true if the selected item is currently visible in the viewport.
821+func (l *List) SelectedItemInView() bool {
822+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
823+ return false
824+ }
825+
826+ // Get selected item ID and position
827+ item := l.items[l.selectedIdx]
828+ pos, ok := l.itemPositions[item.ID()]
829+ if !ok {
830+ return false
831+ }
832+
833+ // Check if item is within viewport bounds
834+ viewportStart := l.offset
835+ viewportEnd := l.offset + l.height
836+
837+ // Item is visible if any part of it overlaps with the viewport
838+ return pos.startLine < viewportEnd && (pos.startLine+pos.height) > viewportStart
839+}
840+
757841// clampOffset ensures offset is within valid bounds.
758842func (l *List) clampOffset() {
759843 maxOffset := l.totalHeight - l.height
@@ -793,3 +877,279 @@ func (l *List) blurSelectedItem() {
793877 l.dirtyItems[item.ID()] = true
794878 }
795879}
880+
881+// HandleMouseDown handles mouse button press events.
882+// x and y are viewport-relative coordinates (0,0 = top-left of visible area).
883+// Returns true if the event was handled.
884+func (l *List) HandleMouseDown(x, y int) bool {
885+ l.ensureBuilt()
886+
887+ // Convert viewport y to master buffer y
888+ bufferY := y + l.offset
889+
890+ // Find which item was clicked
891+ itemID, itemY := l.findItemAtPosition(bufferY)
892+ if itemID == "" {
893+ return false
894+ }
895+
896+ // Calculate x position within item content
897+ // For now, x is just the viewport x coordinate
898+ // Items can interpret this as character offset in their content
899+
900+ l.mouseDown = true
901+ l.mouseDownItem = itemID
902+ l.mouseDownX = x
903+ l.mouseDownY = itemY
904+ l.mouseDragItem = itemID
905+ l.mouseDragX = x
906+ l.mouseDragY = itemY
907+
908+ // Select the clicked item
909+ if idx, ok := l.indexMap[itemID]; ok {
910+ l.SetSelectedIndex(idx)
911+ }
912+
913+ return true
914+}
915+
916+// HandleMouseDrag handles mouse drag events during selection.
917+// x and y are viewport-relative coordinates.
918+// Returns true if the event was handled.
919+func (l *List) HandleMouseDrag(x, y int) bool {
920+ if !l.mouseDown {
921+ return false
922+ }
923+
924+ l.ensureBuilt()
925+
926+ // Convert viewport y to master buffer y
927+ bufferY := y + l.offset
928+
929+ // Find which item we're dragging over
930+ itemID, itemY := l.findItemAtPosition(bufferY)
931+ if itemID == "" {
932+ return false
933+ }
934+
935+ l.mouseDragItem = itemID
936+ l.mouseDragX = x
937+ l.mouseDragY = itemY
938+
939+ // Update highlight if item supports it
940+ l.updateHighlight()
941+
942+ return true
943+}
944+
945+// HandleMouseUp handles mouse button release events.
946+// Returns true if the event was handled.
947+func (l *List) HandleMouseUp(x, y int) bool {
948+ if !l.mouseDown {
949+ return false
950+ }
951+
952+ l.mouseDown = false
953+
954+ // Final highlight update
955+ l.updateHighlight()
956+
957+ return true
958+}
959+
960+// ClearHighlight clears any active text highlighting.
961+func (l *List) ClearHighlight() {
962+ for _, item := range l.items {
963+ if h, ok := item.(Highlightable); ok {
964+ h.SetHighlight(-1, -1, -1, -1)
965+ l.dirtyItems[item.ID()] = true
966+ }
967+ }
968+}
969+
970+// findItemAtPosition finds the item at the given master buffer y coordinate.
971+// Returns the item ID and the y offset within that item.
972+func (l *List) findItemAtPosition(bufferY int) (itemID string, itemY int) {
973+ if bufferY < 0 || bufferY >= l.totalHeight {
974+ return "", 0
975+ }
976+
977+ // Linear search through items to find which one contains this y
978+ // This could be optimized with binary search if needed
979+ for _, item := range l.items {
980+ pos, ok := l.itemPositions[item.ID()]
981+ if !ok {
982+ continue
983+ }
984+
985+ if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height {
986+ return item.ID(), bufferY - pos.startLine
987+ }
988+ }
989+
990+ return "", 0
991+}
992+
993+// updateHighlight updates the highlight range for highlightable items.
994+// Supports highlighting across multiple items and respects drag direction.
995+func (l *List) updateHighlight() {
996+ if l.mouseDownItem == "" {
997+ return
998+ }
999+
1000+ // Get start and end item indices
1001+ downItemIdx := l.indexMap[l.mouseDownItem]
1002+ dragItemIdx := l.indexMap[l.mouseDragItem]
1003+
1004+ // Determine selection direction
1005+ draggingDown := dragItemIdx > downItemIdx ||
1006+ (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
1007+ (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
1008+
1009+ // Determine actual start and end based on direction
1010+ var startItemIdx, endItemIdx int
1011+ var startLine, startCol, endLine, endCol int
1012+
1013+ if draggingDown {
1014+ // Normal forward selection
1015+ startItemIdx = downItemIdx
1016+ endItemIdx = dragItemIdx
1017+ startLine = l.mouseDownY
1018+ startCol = l.mouseDownX
1019+ endLine = l.mouseDragY
1020+ endCol = l.mouseDragX
1021+ } else {
1022+ // Backward selection (dragging up)
1023+ startItemIdx = dragItemIdx
1024+ endItemIdx = downItemIdx
1025+ startLine = l.mouseDragY
1026+ startCol = l.mouseDragX
1027+ endLine = l.mouseDownY
1028+ endCol = l.mouseDownX
1029+ }
1030+
1031+ // Clear all highlights first
1032+ for _, item := range l.items {
1033+ if h, ok := item.(Highlightable); ok {
1034+ h.SetHighlight(-1, -1, -1, -1)
1035+ l.dirtyItems[item.ID()] = true
1036+ }
1037+ }
1038+
1039+ // Highlight all items in range
1040+ for idx := startItemIdx; idx <= endItemIdx; idx++ {
1041+ item, ok := l.items[idx].(Highlightable)
1042+ if !ok {
1043+ continue
1044+ }
1045+
1046+ if idx == startItemIdx && idx == endItemIdx {
1047+ // Single item selection
1048+ item.SetHighlight(startLine, startCol, endLine, endCol)
1049+ } else if idx == startItemIdx {
1050+ // First item - from start position to end of item
1051+ pos := l.itemPositions[l.items[idx].ID()]
1052+ item.SetHighlight(startLine, startCol, pos.height-1, 9999) // 9999 = end of line
1053+ } else if idx == endItemIdx {
1054+ // Last item - from start of item to end position
1055+ item.SetHighlight(0, 0, endLine, endCol)
1056+ } else {
1057+ // Middle item - fully highlighted
1058+ pos := l.itemPositions[l.items[idx].ID()]
1059+ item.SetHighlight(0, 0, pos.height-1, 9999)
1060+ }
1061+
1062+ l.dirtyItems[l.items[idx].ID()] = true
1063+ }
1064+}
1065+
1066+// GetHighlightedText returns the plain text content of all highlighted regions
1067+// across items, without any styling. Returns empty string if no highlights exist.
1068+func (l *List) GetHighlightedText() string {
1069+ l.ensureBuilt()
1070+
1071+ if l.masterBuffer == nil {
1072+ return ""
1073+ }
1074+
1075+ var result strings.Builder
1076+
1077+ // Iterate through items to find highlighted ones
1078+ for _, item := range l.items {
1079+ h, ok := item.(Highlightable)
1080+ if !ok {
1081+ continue
1082+ }
1083+
1084+ startLine, startCol, endLine, endCol := h.GetHighlight()
1085+ if startLine < 0 {
1086+ continue
1087+ }
1088+
1089+ pos, ok := l.itemPositions[item.ID()]
1090+ if !ok {
1091+ continue
1092+ }
1093+
1094+ // Extract text from highlighted region in master buffer
1095+ for y := startLine; y <= endLine && y < pos.height; y++ {
1096+ bufferY := pos.startLine + y
1097+ if bufferY >= l.masterBuffer.Height() {
1098+ break
1099+ }
1100+
1101+ line := l.masterBuffer.Line(bufferY)
1102+
1103+ // Determine column range for this line
1104+ colStart := 0
1105+ if y == startLine {
1106+ colStart = startCol
1107+ }
1108+
1109+ colEnd := len(line)
1110+ if y == endLine {
1111+ colEnd = min(endCol, len(line))
1112+ }
1113+
1114+ // Track last non-empty position to trim trailing spaces
1115+ lastContentX := -1
1116+ for x := colStart; x < colEnd && x < len(line); x++ {
1117+ cell := line.At(x)
1118+ if cell == nil || cell.IsZero() {
1119+ continue
1120+ }
1121+ if cell.Content != "" && cell.Content != " " {
1122+ lastContentX = x
1123+ }
1124+ }
1125+
1126+ // Extract text from cells using String() method, up to last content
1127+ endX := colEnd
1128+ if lastContentX >= 0 {
1129+ endX = lastContentX + 1
1130+ }
1131+
1132+ for x := colStart; x < endX && x < len(line); x++ {
1133+ cell := line.At(x)
1134+ if cell == nil || cell.IsZero() {
1135+ continue
1136+ }
1137+ result.WriteString(cell.String())
1138+ }
1139+
1140+ // Add newline between lines (but not after the last line)
1141+ if y < endLine && y < pos.height-1 {
1142+ result.WriteRune('\n')
1143+ }
1144+ }
1145+
1146+ // Add newline between items if there are more highlighted items
1147+ if result.Len() > 0 {
1148+ result.WriteRune('\n')
1149+ }
1150+ }
1151+
1152+ // Trim trailing newline if present
1153+ text := result.String()
1154+ return strings.TrimSuffix(text, "\n")
1155+}
internal/ui/list/list_test.go+2−2
@@ -213,7 +213,7 @@ func TestListFocus(t *testing.T) {
213213 // Focus the list
214214 l.Focus()
215215
216− if !l.IsFocused() {
216+ if !l.Focused() {
217217 t.Error("expected list to be focused")
218218 }
219219
@@ -236,7 +236,7 @@ func TestListFocus(t *testing.T) {
236236
237237 // Blur the list
238238 l.Blur()
239− if l.IsFocused() {
239+ if l.Focused() {
240240 t.Error("expected list to be blurred")
241241 }
242242}