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}