chat.go

  1package model
  2
  3import (
  4	tea "charm.land/bubbletea/v2"
  5	"github.com/charmbracelet/crush/internal/ui/anim"
  6	"github.com/charmbracelet/crush/internal/ui/chat"
  7	"github.com/charmbracelet/crush/internal/ui/common"
  8	"github.com/charmbracelet/crush/internal/ui/list"
  9	uv "github.com/charmbracelet/ultraviolet"
 10	"github.com/charmbracelet/x/ansi"
 11)
 12
 13// Chat represents the chat UI model that handles chat interactions and
 14// messages.
 15type Chat struct {
 16	com      *common.Common
 17	list     *list.List
 18	idInxMap map[string]int // Map of message IDs to their indices in the list
 19
 20	// Animation visibility optimization: track animations paused due to items
 21	// being scrolled out of view. When items become visible again, their
 22	// animations are restarted.
 23	pausedAnimations map[string]struct{}
 24
 25	// Mouse state
 26	mouseDown     bool
 27	mouseDownItem int // Item index where mouse was pressed
 28	mouseDownX    int // X position in item content (character offset)
 29	mouseDownY    int // Y position in item (line offset)
 30	mouseDragItem int // Current item index being dragged over
 31	mouseDragX    int // Current X in item content
 32	mouseDragY    int // Current Y in item
 33}
 34
 35// NewChat creates a new instance of [Chat] that handles chat interactions and
 36// messages.
 37func NewChat(com *common.Common) *Chat {
 38	c := &Chat{
 39		com:              com,
 40		idInxMap:         make(map[string]int),
 41		pausedAnimations: make(map[string]struct{}),
 42	}
 43	l := list.NewList()
 44	l.SetGap(1)
 45	l.RegisterRenderCallback(c.applyHighlightRange)
 46	c.list = l
 47	c.mouseDownItem = -1
 48	c.mouseDragItem = -1
 49	return c
 50}
 51
 52// Height returns the height of the chat view port.
 53func (m *Chat) Height() int {
 54	return m.list.Height()
 55}
 56
 57// Draw renders the chat UI component to the screen and the given area.
 58func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
 59	uv.NewStyledString(m.list.Render()).Draw(scr, area)
 60}
 61
 62// SetSize sets the size of the chat view port.
 63func (m *Chat) SetSize(width, height int) {
 64	m.list.SetSize(width, height)
 65}
 66
 67// Len returns the number of items in the chat list.
 68func (m *Chat) Len() int {
 69	return m.list.Len()
 70}
 71
 72// SetMessages sets the chat messages to the provided list of message items.
 73func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
 74	m.idInxMap = make(map[string]int)
 75	m.pausedAnimations = make(map[string]struct{})
 76
 77	items := make([]list.Item, len(msgs))
 78	for i, msg := range msgs {
 79		m.idInxMap[msg.ID()] = i
 80		items[i] = msg
 81	}
 82	m.list.SetItems(items...)
 83	m.list.ScrollToBottom()
 84}
 85
 86// AppendMessages appends a new message item to the chat list.
 87func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
 88	items := make([]list.Item, len(msgs))
 89	indexOffset := m.list.Len()
 90	for i, msg := range msgs {
 91		m.idInxMap[msg.ID()] = indexOffset + i
 92		items[i] = msg
 93	}
 94	m.list.AppendItems(items...)
 95}
 96
 97// Animate animates items in the chat list. Only propagates animation messages
 98// to visible items to save CPU. When items are not visible, their animation ID
 99// is tracked so it can be restarted when they become visible again.
100func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
101	idx, ok := m.idInxMap[msg.ID]
102	if !ok {
103		return nil
104	}
105
106	animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
107	if !ok {
108		return nil
109	}
110
111	// Check if item is currently visible.
112	startIdx, endIdx := m.list.VisibleItemIndices()
113	isVisible := idx >= startIdx && idx <= endIdx
114
115	if !isVisible {
116		// Item not visible - pause animation by not propagating.
117		// Track it so we can restart when it becomes visible.
118		m.pausedAnimations[msg.ID] = struct{}{}
119		return nil
120	}
121
122	// Item is visible - remove from paused set and animate.
123	delete(m.pausedAnimations, msg.ID)
124	return animatable.Animate(msg)
125}
126
127// RestartPausedVisibleAnimations restarts animations for items that were paused
128// due to being scrolled out of view but are now visible again.
129func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
130	if len(m.pausedAnimations) == 0 {
131		return nil
132	}
133
134	startIdx, endIdx := m.list.VisibleItemIndices()
135	var cmds []tea.Cmd
136
137	for id := range m.pausedAnimations {
138		idx, ok := m.idInxMap[id]
139		if !ok {
140			// Item no longer exists.
141			delete(m.pausedAnimations, id)
142			continue
143		}
144
145		if idx >= startIdx && idx <= endIdx {
146			// Item is now visible - restart its animation.
147			if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
148				if cmd := animatable.StartAnimation(); cmd != nil {
149					cmds = append(cmds, cmd)
150				}
151			}
152			delete(m.pausedAnimations, id)
153		}
154	}
155
156	if len(cmds) == 0 {
157		return nil
158	}
159	return tea.Batch(cmds...)
160}
161
162// Focus sets the focus state of the chat component.
163func (m *Chat) Focus() {
164	m.list.Focus()
165}
166
167// Blur removes the focus state from the chat component.
168func (m *Chat) Blur() {
169	m.list.Blur()
170}
171
172// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
173// any paused animations that are now visible.
174func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
175	m.list.ScrollToTop()
176	return m.RestartPausedVisibleAnimations()
177}
178
179// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
180// restart any paused animations that are now visible.
181func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
182	m.list.ScrollToBottom()
183	return m.RestartPausedVisibleAnimations()
184}
185
186// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
187// a command to restart any paused animations that are now visible.
188func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
189	m.list.ScrollBy(lines)
190	return m.RestartPausedVisibleAnimations()
191}
192
193// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
194// command to restart any paused animations that are now visible.
195func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
196	m.list.ScrollToSelected()
197	return m.RestartPausedVisibleAnimations()
198}
199
200// SelectedItemInView returns whether the selected item is currently in view.
201func (m *Chat) SelectedItemInView() bool {
202	return m.list.SelectedItemInView()
203}
204
205// SetSelected sets the selected message index in the chat list.
206func (m *Chat) SetSelected(index int) {
207	m.list.SetSelected(index)
208}
209
210// SelectPrev selects the previous message in the chat list.
211func (m *Chat) SelectPrev() {
212	m.list.SelectPrev()
213}
214
215// SelectNext selects the next message in the chat list.
216func (m *Chat) SelectNext() {
217	m.list.SelectNext()
218}
219
220// SelectFirst selects the first message in the chat list.
221func (m *Chat) SelectFirst() {
222	m.list.SelectFirst()
223}
224
225// SelectLast selects the last message in the chat list.
226func (m *Chat) SelectLast() {
227	m.list.SelectLast()
228}
229
230// SelectFirstInView selects the first message currently in view.
231func (m *Chat) SelectFirstInView() {
232	m.list.SelectFirstInView()
233}
234
235// SelectLastInView selects the last message currently in view.
236func (m *Chat) SelectLastInView() {
237	m.list.SelectLastInView()
238}
239
240// ClearMessages removes all messages from the chat list.
241func (m *Chat) ClearMessages() {
242	m.idInxMap = make(map[string]int)
243	m.pausedAnimations = make(map[string]struct{})
244	m.list.SetItems()
245	m.ClearMouse()
246}
247
248// MessageItem returns the message item with the given ID, or nil if not found.
249func (m *Chat) MessageItem(id string) chat.MessageItem {
250	idx, ok := m.idInxMap[id]
251	if !ok {
252		return nil
253	}
254	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
255	if !ok {
256		return nil
257	}
258	return item
259}
260
261// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
262func (m *Chat) ToggleExpandedSelectedItem() {
263	if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
264		expandable.ToggleExpanded()
265	}
266}
267
268// HandleMouseDown handles mouse down events for the chat component.
269func (m *Chat) HandleMouseDown(x, y int) bool {
270	if m.list.Len() == 0 {
271		return false
272	}
273
274	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
275	if itemIdx < 0 {
276		return false
277	}
278
279	m.mouseDown = true
280	m.mouseDownItem = itemIdx
281	m.mouseDownX = x
282	m.mouseDownY = itemY
283	m.mouseDragItem = itemIdx
284	m.mouseDragX = x
285	m.mouseDragY = itemY
286
287	// Select the item that was clicked
288	m.list.SetSelected(itemIdx)
289
290	if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
291		return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
292	}
293
294	return true
295}
296
297// HandleMouseUp handles mouse up events for the chat component.
298func (m *Chat) HandleMouseUp(x, y int) bool {
299	if !m.mouseDown {
300		return false
301	}
302
303	// TODO: Handle the behavior when mouse is released after a drag selection
304	// (e.g., copy selected text to clipboard)
305
306	m.mouseDown = false
307	return true
308}
309
310// HandleMouseDrag handles mouse drag events for the chat component.
311func (m *Chat) HandleMouseDrag(x, y int) bool {
312	if !m.mouseDown {
313		return false
314	}
315
316	if m.list.Len() == 0 {
317		return false
318	}
319
320	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
321	if itemIdx < 0 {
322		return false
323	}
324
325	m.mouseDragItem = itemIdx
326	m.mouseDragX = x
327	m.mouseDragY = itemY
328
329	return true
330}
331
332// ClearMouse clears the current mouse interaction state.
333func (m *Chat) ClearMouse() {
334	m.mouseDown = false
335	m.mouseDownItem = -1
336	m.mouseDragItem = -1
337}
338
339// applyHighlightRange applies the current highlight range to the chat items.
340func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
341	if hi, ok := item.(list.Highlightable); ok {
342		// Apply highlight
343		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
344		sLine, sCol, eLine, eCol := -1, -1, -1, -1
345		if idx >= startItemIdx && idx <= endItemIdx {
346			if idx == startItemIdx && idx == endItemIdx {
347				// Single item selection
348				sLine = startLine
349				sCol = startCol
350				eLine = endLine
351				eCol = endCol
352			} else if idx == startItemIdx {
353				// First item - from start position to end of item
354				sLine = startLine
355				sCol = startCol
356				eLine = -1
357				eCol = -1
358			} else if idx == endItemIdx {
359				// Last item - from start of item to end position
360				sLine = 0
361				sCol = 0
362				eLine = endLine
363				eCol = endCol
364			} else {
365				// Middle item - fully highlighted
366				sLine = 0
367				sCol = 0
368				eLine = -1
369				eCol = -1
370			}
371		}
372
373		hi.Highlight(sLine, sCol, eLine, eCol)
374		return hi.(list.Item)
375	}
376
377	return item
378}
379
380// getHighlightRange returns the current highlight range.
381func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
382	if m.mouseDownItem < 0 {
383		return -1, -1, -1, -1, -1, -1
384	}
385
386	downItemIdx := m.mouseDownItem
387	dragItemIdx := m.mouseDragItem
388
389	// Determine selection direction
390	draggingDown := dragItemIdx > downItemIdx ||
391		(dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
392		(dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
393
394	if draggingDown {
395		// Normal forward selection
396		startItemIdx = downItemIdx
397		startLine = m.mouseDownY
398		startCol = m.mouseDownX
399		endItemIdx = dragItemIdx
400		endLine = m.mouseDragY
401		endCol = m.mouseDragX
402	} else {
403		// Backward selection (dragging up)
404		startItemIdx = dragItemIdx
405		startLine = m.mouseDragY
406		startCol = m.mouseDragX
407		endItemIdx = downItemIdx
408		endLine = m.mouseDownY
409		endCol = m.mouseDownX
410	}
411
412	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
413}