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}