refactor(ui): list: remove mouse highlighting state, add render callbacks

Ayman Bagabas created

The caller can now manage mouse state and apply highlighting via
registered render callbacks.

Change summary

internal/ui/list/list.go  | 176 +++++-----------------------------------
internal/ui/model/chat.go | 164 +++++++++++++++++++++++++++++++++++--
2 files changed, 177 insertions(+), 163 deletions(-)

Detailed changes

internal/ui/list/list.go 🔗

@@ -2,8 +2,6 @@ package list
 
 import (
 	"strings"
-
-	"github.com/charmbracelet/x/ansi"
 )
 
 // List represents a list of items that can be lazily rendered. A list is
@@ -23,22 +21,15 @@ type List struct {
 	focused     bool
 	selectedIdx int // The current selected index -1 means no selection
 
-	// 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
-	lastHighlighted map[int]bool // Track which items were highlighted in last update
-
 	// offsetIdx is the index of the first visible item in the viewport.
 	offsetIdx int
 	// offsetLine is the number of lines of the item at offsetIdx that are
 	// scrolled out of view (above the viewport).
 	// It must always be >= 0.
 	offsetLine int
+
+	// renderCallbacks is a list of callbacks to apply when rendering items.
+	renderCallbacks []func(idx, selectedIdx int, item Item) Item
 }
 
 // renderedItem holds the rendered content and height of an item.
@@ -52,17 +43,19 @@ func NewList(items ...Item) *List {
 	l := new(List)
 	l.items = items
 	l.selectedIdx = -1
-	l.mouseDownItem = -1
-	l.mouseDragItem = -1
-	l.lastHighlighted = make(map[int]bool)
 	return l
 }
 
+// RegisterRenderCallback registers a callback to be called when rendering
+// items. This can be used to modify items before they are rendered.
+func (l *List) RegisterRenderCallback(cb func(idx, selectedIdx int, item Item) Item) {
+	l.renderCallbacks = append(l.renderCallbacks, cb)
+}
+
 // SetSize sets the size of the list viewport.
 func (l *List) SetSize(width, height int) {
 	l.width = width
 	l.height = height
-	// l.normalizeOffsets()
 }
 
 // SetGap sets the gap between items.
@@ -92,39 +85,12 @@ func (l *List) getItem(idx int) renderedItem {
 	}
 
 	item := l.items[idx]
-	if hi, ok := item.(Highlightable); ok {
-		// Apply highlight
-		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange()
-		sLine, sCol, eLine, eCol := -1, -1, -1, -1
-		if idx >= startItemIdx && idx <= endItemIdx {
-			if idx == startItemIdx && idx == endItemIdx {
-				// Single item selection
-				sLine = startLine
-				sCol = startCol
-				eLine = endLine
-				eCol = endCol
-			} else if idx == startItemIdx {
-				// First item - from start position to end of item
-				sLine = startLine
-				sCol = startCol
-				eLine = -1
-				eCol = -1
-			} else if idx == endItemIdx {
-				// Last item - from start of item to end position
-				sLine = 0
-				sCol = 0
-				eLine = endLine
-				eCol = endCol
-			} else {
-				// Middle item - fully highlighted
-				sLine = 0
-				sCol = 0
-				eLine = -1
-				eCol = -1
+	if len(l.renderCallbacks) > 0 {
+		for _, cb := range l.renderCallbacks {
+			if it := cb(idx, l.selectedIdx, item); it != nil {
+				item = it
 			}
 		}
-
-		hi.Highlight(sLine, sCol, eLine, eCol)
 	}
 
 	if focusable, isFocusable := item.(Focusable); isFocusable {
@@ -481,80 +447,19 @@ func (l *List) SelectLastInView() {
 	l.selectedIdx = endIdx
 }
 
-// HandleMouseDown handles mouse down events at the given line in the viewport.
-// 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 {
-	if len(l.items) == 0 {
-		return false
-	}
-
-	// Find which item was clicked
-	itemIdx, itemY := l.findItemAtY(x, y)
-	if itemIdx < 0 {
-		return false
-	}
-
-	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)
-
-	if clickable, ok := l.items[itemIdx].(MouseClickable); ok {
-		clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
-	}
-
-	return true
-}
-
-// HandleMouseUp handles mouse up events at the given line in the viewport.
-// Returns true if the event was handled.
-func (l *List) HandleMouseUp(x, y int) bool {
-	if !l.mouseDown {
-		return false
-	}
-
-	l.mouseDown = false
-
-	return true
-}
-
-// HandleMouseDrag handles mouse drag events at the given line in the viewport.
-// 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
-	}
-
-	if len(l.items) == 0 {
-		return false
-	}
-
-	// Find which item we're dragging over
-	itemIdx, itemY := l.findItemAtY(x, y)
-	if itemIdx < 0 {
-		return false
+// ItemAt returns the item at the given index.
+func (l *List) ItemAt(index int) Item {
+	if index < 0 || index >= len(l.items) {
+		return nil
 	}
-
-	l.mouseDragItem = itemIdx
-	l.mouseDragX = x
-	l.mouseDragY = itemY
-
-	return true
+	return l.items[index]
 }
 
-// ClearHighlight clears any active text highlighting.
-func (l *List) ClearHighlight() {
-	l.mouseDownItem = -1
-	l.mouseDragItem = -1
-	l.lastHighlighted = make(map[int]bool)
+// ItemIndexAtPosition returns the item at the given viewport-relative 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) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
+	return l.findItemAtY(x, y)
 }
 
 // findItemAtY finds the item at the given viewport y coordinate.
@@ -591,41 +496,6 @@ func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
 	return -1, -1
 }
 
-// getHighlightRange returns the current highlight range.
-func (l *List) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
-	if l.mouseDownItem < 0 {
-		return -1, -1, -1, -1, -1, -1
-	}
-
-	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)
-
-	if draggingDown {
-		// Normal forward selection
-		startItemIdx = downItemIdx
-		startLine = l.mouseDownY
-		startCol = l.mouseDownX
-		endItemIdx = dragItemIdx
-		endLine = l.mouseDragY
-		endCol = l.mouseDragX
-	} else {
-		// Backward selection (dragging up)
-		startItemIdx = dragItemIdx
-		startLine = l.mouseDragY
-		startCol = l.mouseDragX
-		endItemIdx = downItemIdx
-		endLine = l.mouseDownY
-		endCol = l.mouseDownX
-	}
-
-	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
-}
-
 // countLines counts the number of lines in a string.
 func countLines(s string) int {
 	if s == "" {

internal/ui/model/chat.go 🔗

@@ -4,6 +4,7 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
 )
 
 // Chat represents the chat UI model that handles chat interactions and
@@ -11,17 +12,28 @@ import (
 type Chat struct {
 	com  *common.Common
 	list *list.List
+
+	// 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
 }
 
 // NewChat creates a new instance of [Chat] that handles chat interactions and
 // messages.
 func NewChat(com *common.Common) *Chat {
+	c := &Chat{com: com}
 	l := list.NewList()
 	l.SetGap(1)
-	return &Chat{
-		com:  com,
-		list: l,
-	}
+	l.RegisterRenderCallback(c.applyHighlightRange)
+	c.list = l
+	c.mouseDownItem = -1
+	c.mouseDragItem = -1
+	return c
 }
 
 // Height returns the height of the chat view port.
@@ -146,16 +158,148 @@ func (m *Chat) SelectLastInView() {
 }
 
 // HandleMouseDown handles mouse down events for the chat component.
-func (m *Chat) HandleMouseDown(x, y int) {
-	m.list.HandleMouseDown(x, y)
+func (m *Chat) HandleMouseDown(x, y int) bool {
+	if m.list.Len() == 0 {
+		return false
+	}
+
+	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
+	if itemIdx < 0 {
+		return false
+	}
+
+	m.mouseDown = true
+	m.mouseDownItem = itemIdx
+	m.mouseDownX = x
+	m.mouseDownY = itemY
+	m.mouseDragItem = itemIdx
+	m.mouseDragX = x
+	m.mouseDragY = itemY
+
+	// Select the item that was clicked
+	m.list.SetSelected(itemIdx)
+
+	if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
+		return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
+	}
+
+	return true
 }
 
 // HandleMouseUp handles mouse up events for the chat component.
-func (m *Chat) HandleMouseUp(x, y int) {
-	m.list.HandleMouseUp(x, y)
+func (m *Chat) HandleMouseUp(x, y int) bool {
+	if !m.mouseDown {
+		return false
+	}
+
+	// TODO: Handle the behavior when mouse is released after a drag selection
+	// (e.g., copy selected text to clipboard)
+
+	m.mouseDown = false
+	return true
 }
 
 // HandleMouseDrag handles mouse drag events for the chat component.
-func (m *Chat) HandleMouseDrag(x, y int) {
-	m.list.HandleMouseDrag(x, y)
+func (m *Chat) HandleMouseDrag(x, y int) bool {
+	if !m.mouseDown {
+		return false
+	}
+
+	if m.list.Len() == 0 {
+		return false
+	}
+
+	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
+	if itemIdx < 0 {
+		return false
+	}
+
+	m.mouseDragItem = itemIdx
+	m.mouseDragX = x
+	m.mouseDragY = itemY
+
+	return true
+}
+
+// ClearMouse clears the current mouse interaction state.
+func (m *Chat) ClearMouse() {
+	m.mouseDown = false
+	m.mouseDownItem = -1
+	m.mouseDragItem = -1
+}
+
+// applyHighlightRange applies the current highlight range to the chat items.
+func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
+	if hi, ok := item.(list.Highlightable); ok {
+		// Apply highlight
+		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
+		sLine, sCol, eLine, eCol := -1, -1, -1, -1
+		if idx >= startItemIdx && idx <= endItemIdx {
+			if idx == startItemIdx && idx == endItemIdx {
+				// Single item selection
+				sLine = startLine
+				sCol = startCol
+				eLine = endLine
+				eCol = endCol
+			} else if idx == startItemIdx {
+				// First item - from start position to end of item
+				sLine = startLine
+				sCol = startCol
+				eLine = -1
+				eCol = -1
+			} else if idx == endItemIdx {
+				// Last item - from start of item to end position
+				sLine = 0
+				sCol = 0
+				eLine = endLine
+				eCol = endCol
+			} else {
+				// Middle item - fully highlighted
+				sLine = 0
+				sCol = 0
+				eLine = -1
+				eCol = -1
+			}
+		}
+
+		hi.Highlight(sLine, sCol, eLine, eCol)
+		return hi.(list.Item)
+	}
+
+	return item
+}
+
+// getHighlightRange returns the current highlight range.
+func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
+	if m.mouseDownItem < 0 {
+		return -1, -1, -1, -1, -1, -1
+	}
+
+	downItemIdx := m.mouseDownItem
+	dragItemIdx := m.mouseDragItem
+
+	// Determine selection direction
+	draggingDown := dragItemIdx > downItemIdx ||
+		(dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
+		(dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
+
+	if draggingDown {
+		// Normal forward selection
+		startItemIdx = downItemIdx
+		startLine = m.mouseDownY
+		startCol = m.mouseDownX
+		endItemIdx = dragItemIdx
+		endLine = m.mouseDragY
+		endCol = m.mouseDragX
+	} else {
+		// Backward selection (dragging up)
+		startItemIdx = dragItemIdx
+		startLine = m.mouseDragY
+		startCol = m.mouseDragX
+		endItemIdx = downItemIdx
+		endLine = m.mouseDownY
+		endCol = m.mouseDownX
+	}
+
+	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
 }