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	items := make([]list.Item, len(msgs))
 75	for i, msg := range msgs {
 76		m.idInxMap[msg.ID()] = i
 77		items[i] = msg
 78	}
 79	m.list.SetItems(items...)
 80	m.list.ScrollToBottom()
 81}
 82
 83// AppendMessages appends a new message item to the chat list.
 84func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
 85	items := make([]list.Item, len(msgs))
 86	indexOffset := len(m.idInxMap)
 87	for i, msg := range msgs {
 88		m.idInxMap[msg.ID()] = indexOffset + i
 89		items[i] = msg
 90	}
 91	m.list.AppendItems(items...)
 92}
 93
 94// Animate animates items in the chat list. Only propagates animation messages
 95// to visible items to save CPU. When items are not visible, their animation ID
 96// is tracked so it can be restarted when they become visible again.
 97func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
 98	idx, ok := m.idInxMap[msg.ID]
 99	if !ok {
100		return nil
101	}
102
103	animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
104	if !ok {
105		return nil
106	}
107
108	// Check if item is currently visible.
109	startIdx, endIdx := m.list.VisibleItemIndices()
110	isVisible := idx >= startIdx && idx <= endIdx
111
112	if !isVisible {
113		// Item not visible - pause animation by not propagating.
114		// Track it so we can restart when it becomes visible.
115		m.pausedAnimations[msg.ID] = struct{}{}
116		return nil
117	}
118
119	// Item is visible - remove from paused set and animate.
120	delete(m.pausedAnimations, msg.ID)
121	return animatable.Animate(msg)
122}
123
124// RestartPausedVisibleAnimations restarts animations for items that were paused
125// due to being scrolled out of view but are now visible again.
126func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
127	if len(m.pausedAnimations) == 0 {
128		return nil
129	}
130
131	startIdx, endIdx := m.list.VisibleItemIndices()
132	var cmds []tea.Cmd
133
134	for id := range m.pausedAnimations {
135		idx, ok := m.idInxMap[id]
136		if !ok {
137			// Item no longer exists.
138			delete(m.pausedAnimations, id)
139			continue
140		}
141
142		if idx >= startIdx && idx <= endIdx {
143			// Item is now visible - restart its animation.
144			if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
145				if cmd := animatable.StartAnimation(); cmd != nil {
146					cmds = append(cmds, cmd)
147				}
148			}
149			delete(m.pausedAnimations, id)
150		}
151	}
152
153	if len(cmds) == 0 {
154		return nil
155	}
156	return tea.Batch(cmds...)
157}
158
159// Focus sets the focus state of the chat component.
160func (m *Chat) Focus() {
161	m.list.Focus()
162}
163
164// Blur removes the focus state from the chat component.
165func (m *Chat) Blur() {
166	m.list.Blur()
167}
168
169// ScrollToTop scrolls the chat view to the top and returns a command to restart
170// any paused animations that are now visible.
171func (m *Chat) ScrollToTop() tea.Cmd {
172	m.list.ScrollToTop()
173	return m.RestartPausedVisibleAnimations()
174}
175
176// ScrollToBottom scrolls the chat view to the bottom and returns a command to
177// restart any paused animations that are now visible.
178func (m *Chat) ScrollToBottom() tea.Cmd {
179	m.list.ScrollToBottom()
180	return m.RestartPausedVisibleAnimations()
181}
182
183// ScrollBy scrolls the chat view by the given number of line deltas and returns
184// a command to restart any paused animations that are now visible.
185func (m *Chat) ScrollBy(lines int) tea.Cmd {
186	m.list.ScrollBy(lines)
187	return m.RestartPausedVisibleAnimations()
188}
189
190// ScrollToSelected scrolls the chat view to the selected item and returns a
191// command to restart any paused animations that are now visible.
192func (m *Chat) ScrollToSelected() tea.Cmd {
193	m.list.ScrollToSelected()
194	return m.RestartPausedVisibleAnimations()
195}
196
197// SelectedItemInView returns whether the selected item is currently in view.
198func (m *Chat) SelectedItemInView() bool {
199	return m.list.SelectedItemInView()
200}
201
202// SetSelected sets the selected message index in the chat list.
203func (m *Chat) SetSelected(index int) {
204	m.list.SetSelected(index)
205}
206
207// SelectPrev selects the previous message in the chat list.
208func (m *Chat) SelectPrev() {
209	m.list.SelectPrev()
210}
211
212// SelectNext selects the next message in the chat list.
213func (m *Chat) SelectNext() {
214	m.list.SelectNext()
215}
216
217// SelectFirst selects the first message in the chat list.
218func (m *Chat) SelectFirst() {
219	m.list.SelectFirst()
220}
221
222// SelectLast selects the last message in the chat list.
223func (m *Chat) SelectLast() {
224	m.list.SelectLast()
225}
226
227// SelectFirstInView selects the first message currently in view.
228func (m *Chat) SelectFirstInView() {
229	m.list.SelectFirstInView()
230}
231
232// SelectLastInView selects the last message currently in view.
233func (m *Chat) SelectLastInView() {
234	m.list.SelectLastInView()
235}
236
237// GetMessageItem returns the message item at the given id.
238func (m *Chat) GetMessageItem(id string) chat.MessageItem {
239	idx, ok := m.idInxMap[id]
240	if !ok {
241		return nil
242	}
243	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
244	if !ok {
245		return nil
246	}
247	return item
248}
249
250// HandleMouseDown handles mouse down events for the chat component.
251func (m *Chat) HandleMouseDown(x, y int) bool {
252	if m.list.Len() == 0 {
253		return false
254	}
255
256	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
257	if itemIdx < 0 {
258		return false
259	}
260
261	m.mouseDown = true
262	m.mouseDownItem = itemIdx
263	m.mouseDownX = x
264	m.mouseDownY = itemY
265	m.mouseDragItem = itemIdx
266	m.mouseDragX = x
267	m.mouseDragY = itemY
268
269	// Select the item that was clicked
270	m.list.SetSelected(itemIdx)
271
272	if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
273		return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
274	}
275
276	return true
277}
278
279// HandleMouseUp handles mouse up events for the chat component.
280func (m *Chat) HandleMouseUp(x, y int) bool {
281	if !m.mouseDown {
282		return false
283	}
284
285	// TODO: Handle the behavior when mouse is released after a drag selection
286	// (e.g., copy selected text to clipboard)
287
288	m.mouseDown = false
289	return true
290}
291
292// HandleMouseDrag handles mouse drag events for the chat component.
293func (m *Chat) HandleMouseDrag(x, y int) bool {
294	if !m.mouseDown {
295		return false
296	}
297
298	if m.list.Len() == 0 {
299		return false
300	}
301
302	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
303	if itemIdx < 0 {
304		return false
305	}
306
307	m.mouseDragItem = itemIdx
308	m.mouseDragX = x
309	m.mouseDragY = itemY
310
311	return true
312}
313
314// ClearMouse clears the current mouse interaction state.
315func (m *Chat) ClearMouse() {
316	m.mouseDown = false
317	m.mouseDownItem = -1
318	m.mouseDragItem = -1
319}
320
321// applyHighlightRange applies the current highlight range to the chat items.
322func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
323	if hi, ok := item.(list.Highlightable); ok {
324		// Apply highlight
325		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
326		sLine, sCol, eLine, eCol := -1, -1, -1, -1
327		if idx >= startItemIdx && idx <= endItemIdx {
328			if idx == startItemIdx && idx == endItemIdx {
329				// Single item selection
330				sLine = startLine
331				sCol = startCol
332				eLine = endLine
333				eCol = endCol
334			} else if idx == startItemIdx {
335				// First item - from start position to end of item
336				sLine = startLine
337				sCol = startCol
338				eLine = -1
339				eCol = -1
340			} else if idx == endItemIdx {
341				// Last item - from start of item to end position
342				sLine = 0
343				sCol = 0
344				eLine = endLine
345				eCol = endCol
346			} else {
347				// Middle item - fully highlighted
348				sLine = 0
349				sCol = 0
350				eLine = -1
351				eCol = -1
352			}
353		}
354
355		hi.Highlight(sLine, sCol, eLine, eCol)
356		return hi.(list.Item)
357	}
358
359	return item
360}
361
362// getHighlightRange returns the current highlight range.
363func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
364	if m.mouseDownItem < 0 {
365		return -1, -1, -1, -1, -1, -1
366	}
367
368	downItemIdx := m.mouseDownItem
369	dragItemIdx := m.mouseDragItem
370
371	// Determine selection direction
372	draggingDown := dragItemIdx > downItemIdx ||
373		(dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
374		(dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
375
376	if draggingDown {
377		// Normal forward selection
378		startItemIdx = downItemIdx
379		startLine = m.mouseDownY
380		startCol = m.mouseDownX
381		endItemIdx = dragItemIdx
382		endLine = m.mouseDragY
383		endCol = m.mouseDragX
384	} else {
385		// Backward selection (dragging up)
386		startItemIdx = dragItemIdx
387		startLine = m.mouseDragY
388		startCol = m.mouseDragX
389		endItemIdx = downItemIdx
390		endLine = m.mouseDownY
391		endCol = m.mouseDownX
392	}
393
394	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
395}