on main help sign in
refactor(ui): list: remove mouse highlighting state, add render callbacks

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

commit2dc5d0b02728a411df96c38f41612e01f70eeaf6 authorAyman Bagabas <ayman.bagabas@gmail.com> dateMon 15 Dec 2025, 14:00 (1 year ago) parent5d6dbc8
2 files changed+177−163
internal/ui/list/list.go+23−153 internal/ui/model/chat.go+154−10
internal/ui/list/list.go+23−153
@@ -2,8 +2,6 @@ package list
22
33import (
44 "strings"
5−
6− "github.com/charmbracelet/x/ansi"
75)
86
97// List represents a list of items that can be lazily rendered. A list is
@@ -23,22 +21,15 @@ type List struct {
2321 focused bool
2422 selectedIdx int // The current selected index -1 means no selection
2523
26− // Mouse state
27− mouseDown bool
28− mouseDownItem int // Item index where mouse was pressed
29− mouseDownX int // X position in item content (character offset)
30− mouseDownY int // Y position in item (line offset)
31− mouseDragItem int // Current item index being dragged over
32− mouseDragX int // Current X in item content
33− mouseDragY int // Current Y in item
34− lastHighlighted map[int]bool // Track which items were highlighted in last update
35−
3624 // offsetIdx is the index of the first visible item in the viewport.
3725 offsetIdx int
3826 // offsetLine is the number of lines of the item at offsetIdx that are
3927 // scrolled out of view (above the viewport).
4028 // It must always be >= 0.
4129 offsetLine int
30+
31+ // renderCallbacks is a list of callbacks to apply when rendering items.
32+ renderCallbacks []func(idx, selectedIdx int, item Item) Item
4233}
4334
4435// renderedItem holds the rendered content and height of an item.
@@ -52,17 +43,19 @@ func NewList(items ...Item) *List {
5243 l := new(List)
5344 l.items = items
5445 l.selectedIdx = -1
55− l.mouseDownItem = -1
56− l.mouseDragItem = -1
57− l.lastHighlighted = make(map[int]bool)
5846 return l
5947}
6048
49+// RegisterRenderCallback registers a callback to be called when rendering
50+// items. This can be used to modify items before they are rendered.
51+func (l *List) RegisterRenderCallback(cb func(idx, selectedIdx int, item Item) Item) {
52+ l.renderCallbacks = append(l.renderCallbacks, cb)
53+}
54+
6155// SetSize sets the size of the list viewport.
6256func (l *List) SetSize(width, height int) {
6357 l.width = width
6458 l.height = height
65− // l.normalizeOffsets()
6659}
6760
6861// SetGap sets the gap between items.
@@ -92,39 +85,12 @@ func (l *List) getItem(idx int) renderedItem {
9285 }
9386
9487 item := l.items[idx]
95− if hi, ok := item.(Highlightable); ok {
96− // Apply highlight
97− startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange()
98− sLine, sCol, eLine, eCol := -1, -1, -1, -1
99− if idx >= startItemIdx && idx <= endItemIdx {
100− if idx == startItemIdx && idx == endItemIdx {
101− // Single item selection
102− sLine = startLine
103− sCol = startCol
104− eLine = endLine
105− eCol = endCol
106− } else if idx == startItemIdx {
107− // First item - from start position to end of item
108− sLine = startLine
109− sCol = startCol
110− eLine = -1
111− eCol = -1
112− } else if idx == endItemIdx {
113− // Last item - from start of item to end position
114− sLine = 0
115− sCol = 0
116− eLine = endLine
117− eCol = endCol
118− } else {
119− // Middle item - fully highlighted
120− sLine = 0
121− sCol = 0
122− eLine = -1
123− eCol = -1
88+ if len(l.renderCallbacks) > 0 {
89+ for _, cb := range l.renderCallbacks {
90+ if it := cb(idx, l.selectedIdx, item); it != nil {
91+ item = it
12492 }
12593 }
126−
127− hi.Highlight(sLine, sCol, eLine, eCol)
12894 }
12995
13096 if focusable, isFocusable := item.(Focusable); isFocusable {
@@ -481,80 +447,19 @@ func (l *List) SelectLastInView() {
481447 l.selectedIdx = endIdx
482448}
483449
484−// HandleMouseDown handles mouse down events at the given line in the viewport.
485−// x and y are viewport-relative coordinates (0,0 = top-left of visible area).
486−// Returns true if the event was handled.
487−func (l *List) HandleMouseDown(x, y int) bool {
488− if len(l.items) == 0 {
489− return false
490− }
491−
492− // Find which item was clicked
493− itemIdx, itemY := l.findItemAtY(x, y)
494− if itemIdx < 0 {
495− return false
496− }
497−
498− l.mouseDown = true
499− l.mouseDownItem = itemIdx
500− l.mouseDownX = x
501− l.mouseDownY = itemY
502− l.mouseDragItem = itemIdx
503− l.mouseDragX = x
504− l.mouseDragY = itemY
505−
506− // Select the clicked item
507− l.SetSelected(itemIdx)
508−
509− if clickable, ok := l.items[itemIdx].(MouseClickable); ok {
510− clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
511− }
512−
513− return true
514−}
515−
516−// HandleMouseUp handles mouse up events at the given line in the viewport.
517−// Returns true if the event was handled.
518−func (l *List) HandleMouseUp(x, y int) bool {
519− if !l.mouseDown {
520− return false
521− }
522−
523− l.mouseDown = false
524−
525− return true
526−}
527−
528−// HandleMouseDrag handles mouse drag events at the given line in the viewport.
529−// x and y are viewport-relative coordinates.
530−// Returns true if the event was handled.
531−func (l *List) HandleMouseDrag(x, y int) bool {
532− if !l.mouseDown {
533− return false
534− }
535−
536− if len(l.items) == 0 {
537− return false
538− }
539−
540− // Find which item we're dragging over
541− itemIdx, itemY := l.findItemAtY(x, y)
542− if itemIdx < 0 {
543− return false
450+// ItemAt returns the item at the given index.
451+func (l *List) ItemAt(index int) Item {
452+ if index < 0 || index >= len(l.items) {
453+ return nil
544454 }
545−
546− l.mouseDragItem = itemIdx
547− l.mouseDragX = x
548− l.mouseDragY = itemY
549−
550− return true
455+ return l.items[index]
551456}
552457
553−// ClearHighlight clears any active text highlighting.
554−func (l *List) ClearHighlight() {
555− l.mouseDownItem = -1
556− l.mouseDragItem = -1
557− l.lastHighlighted = make(map[int]bool)
458+// ItemIndexAtPosition returns the item at the given viewport-relative y
459+// coordinate. Returns the item index and the y offset within that item. It
460+// returns -1, -1 if no item is found.
461+func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
462+ return l.findItemAtY(x, y)
558463}
559464
560465// findItemAtY finds the item at the given viewport y coordinate.
@@ -591,41 +496,6 @@ func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
591496 return -1, -1
592497}
593498
594−// getHighlightRange returns the current highlight range.
595−func (l *List) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
596− if l.mouseDownItem < 0 {
597− return -1, -1, -1, -1, -1, -1
598− }
599−
600− downItemIdx := l.mouseDownItem
601− dragItemIdx := l.mouseDragItem
602−
603− // Determine selection direction
604− draggingDown := dragItemIdx > downItemIdx ||
605− (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
606− (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
607−
608− if draggingDown {
609− // Normal forward selection
610− startItemIdx = downItemIdx
611− startLine = l.mouseDownY
612− startCol = l.mouseDownX
613− endItemIdx = dragItemIdx
614− endLine = l.mouseDragY
615− endCol = l.mouseDragX
616− } else {
617− // Backward selection (dragging up)
618− startItemIdx = dragItemIdx
619− startLine = l.mouseDragY
620− startCol = l.mouseDragX
621− endItemIdx = downItemIdx
622− endLine = l.mouseDownY
623− endCol = l.mouseDownX
624− }
625−
626− return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
627−}
628−
629499// countLines counts the number of lines in a string.
630500func countLines(s string) int {
631501 if s == "" {
internal/ui/model/chat.go+154−10
@@ -4,6 +4,7 @@ import (
44 "github.com/charmbracelet/crush/internal/ui/common"
55 "github.com/charmbracelet/crush/internal/ui/list"
66 uv "github.com/charmbracelet/ultraviolet"
7+ "github.com/charmbracelet/x/ansi"
78)
89
910// Chat represents the chat UI model that handles chat interactions and
@@ -11,17 +12,28 @@ import (
1112type Chat struct {
1213 com *common.Common
1314 list *list.List
15+
16+ // Mouse state
17+ mouseDown bool
18+ mouseDownItem int // Item index where mouse was pressed
19+ mouseDownX int // X position in item content (character offset)
20+ mouseDownY int // Y position in item (line offset)
21+ mouseDragItem int // Current item index being dragged over
22+ mouseDragX int // Current X in item content
23+ mouseDragY int // Current Y in item
1424}
1525
1626// NewChat creates a new instance of [Chat] that handles chat interactions and
1727// messages.
1828func NewChat(com *common.Common) *Chat {
29+ c := &Chat{com: com}
1930 l := list.NewList()
2031 l.SetGap(1)
21− return &Chat{
22− com: com,
23− list: l,
24− }
32+ l.RegisterRenderCallback(c.applyHighlightRange)
33+ c.list = l
34+ c.mouseDownItem = -1
35+ c.mouseDragItem = -1
36+ return c
2537}
2638
2739// Height returns the height of the chat view port.
@@ -146,16 +158,148 @@ func (m *Chat) SelectLastInView() {
146158}
147159
148160// HandleMouseDown handles mouse down events for the chat component.
149−func (m *Chat) HandleMouseDown(x, y int) {
150− m.list.HandleMouseDown(x, y)
161+func (m *Chat) HandleMouseDown(x, y int) bool {
162+ if m.list.Len() == 0 {
163+ return false
164+ }
165+
166+ itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
167+ if itemIdx < 0 {
168+ return false
169+ }
170+
171+ m.mouseDown = true
172+ m.mouseDownItem = itemIdx
173+ m.mouseDownX = x
174+ m.mouseDownY = itemY
175+ m.mouseDragItem = itemIdx
176+ m.mouseDragX = x
177+ m.mouseDragY = itemY
178+
179+ // Select the item that was clicked
180+ m.list.SetSelected(itemIdx)
181+
182+ if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
183+ return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
184+ }
185+
186+ return true
151187}
152188
153189// HandleMouseUp handles mouse up events for the chat component.
154−func (m *Chat) HandleMouseUp(x, y int) {
155− m.list.HandleMouseUp(x, y)
190+func (m *Chat) HandleMouseUp(x, y int) bool {
191+ if !m.mouseDown {
192+ return false
193+ }
194+
195+ // TODO: Handle the behavior when mouse is released after a drag selection
196+ // (e.g., copy selected text to clipboard)
197+
198+ m.mouseDown = false
199+ return true
156200}
157201
158202// HandleMouseDrag handles mouse drag events for the chat component.
159−func (m *Chat) HandleMouseDrag(x, y int) {
160− m.list.HandleMouseDrag(x, y)
203+func (m *Chat) HandleMouseDrag(x, y int) bool {
204+ if !m.mouseDown {
205+ return false
206+ }
207+
208+ if m.list.Len() == 0 {
209+ return false
210+ }
211+
212+ itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
213+ if itemIdx < 0 {
214+ return false
215+ }
216+
217+ m.mouseDragItem = itemIdx
218+ m.mouseDragX = x
219+ m.mouseDragY = itemY
220+
221+ return true
222+}
223+
224+// ClearMouse clears the current mouse interaction state.
225+func (m *Chat) ClearMouse() {
226+ m.mouseDown = false
227+ m.mouseDownItem = -1
228+ m.mouseDragItem = -1
229+}
230+
231+// applyHighlightRange applies the current highlight range to the chat items.
232+func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
233+ if hi, ok := item.(list.Highlightable); ok {
234+ // Apply highlight
235+ startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
236+ sLine, sCol, eLine, eCol := -1, -1, -1, -1
237+ if idx >= startItemIdx && idx <= endItemIdx {
238+ if idx == startItemIdx && idx == endItemIdx {
239+ // Single item selection
240+ sLine = startLine
241+ sCol = startCol
242+ eLine = endLine
243+ eCol = endCol
244+ } else if idx == startItemIdx {
245+ // First item - from start position to end of item
246+ sLine = startLine
247+ sCol = startCol
248+ eLine = -1
249+ eCol = -1
250+ } else if idx == endItemIdx {
251+ // Last item - from start of item to end position
252+ sLine = 0
253+ sCol = 0
254+ eLine = endLine
255+ eCol = endCol
256+ } else {
257+ // Middle item - fully highlighted
258+ sLine = 0
259+ sCol = 0
260+ eLine = -1
261+ eCol = -1
262+ }
263+ }
264+
265+ hi.Highlight(sLine, sCol, eLine, eCol)
266+ return hi.(list.Item)
267+ }
268+
269+ return item
270+}
271+
272+// getHighlightRange returns the current highlight range.
273+func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
274+ if m.mouseDownItem < 0 {
275+ return -1, -1, -1, -1, -1, -1
276+ }
277+
278+ downItemIdx := m.mouseDownItem
279+ dragItemIdx := m.mouseDragItem
280+
281+ // Determine selection direction
282+ draggingDown := dragItemIdx > downItemIdx ||
283+ (dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
284+ (dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
285+
286+ if draggingDown {
287+ // Normal forward selection
288+ startItemIdx = downItemIdx
289+ startLine = m.mouseDownY
290+ startCol = m.mouseDownX
291+ endItemIdx = dragItemIdx
292+ endLine = m.mouseDragY
293+ endCol = m.mouseDragX
294+ } else {
295+ // Backward selection (dragging up)
296+ startItemIdx = dragItemIdx
297+ startLine = m.mouseDragY
298+ startCol = m.mouseDragX
299+ endItemIdx = downItemIdx
300+ endLine = m.mouseDownY
301+ endCol = m.mouseDownX
302+ }
303+
304+ return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
161305}