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	// Mouse state
 21	mouseDown     bool
 22	mouseDownItem int // Item index where mouse was pressed
 23	mouseDownX    int // X position in item content (character offset)
 24	mouseDownY    int // Y position in item (line offset)
 25	mouseDragItem int // Current item index being dragged over
 26	mouseDragX    int // Current X in item content
 27	mouseDragY    int // Current Y in item
 28}
 29
 30// NewChat creates a new instance of [Chat] that handles chat interactions and
 31// messages.
 32func NewChat(com *common.Common) *Chat {
 33	c := &Chat{com: com, idInxMap: make(map[string]int)}
 34	l := list.NewList()
 35	l.SetGap(1)
 36	l.RegisterRenderCallback(c.applyHighlightRange)
 37	c.list = l
 38	c.mouseDownItem = -1
 39	c.mouseDragItem = -1
 40	return c
 41}
 42
 43// Height returns the height of the chat view port.
 44func (m *Chat) Height() int {
 45	return m.list.Height()
 46}
 47
 48// Draw renders the chat UI component to the screen and the given area.
 49func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
 50	uv.NewStyledString(m.list.Render()).Draw(scr, area)
 51}
 52
 53// SetSize sets the size of the chat view port.
 54func (m *Chat) SetSize(width, height int) {
 55	m.list.SetSize(width, height)
 56}
 57
 58// Len returns the number of items in the chat list.
 59func (m *Chat) Len() int {
 60	return m.list.Len()
 61}
 62
 63// SetMessages sets the chat messages to the provided list of message items.
 64func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
 65	items := make([]list.Item, len(msgs))
 66	for i, msg := range msgs {
 67		m.idInxMap[msg.ID()] = i
 68		items[i] = msg
 69	}
 70	m.list.SetItems(items...)
 71	m.list.ScrollToBottom()
 72}
 73
 74// AppendMessages appends a new message item to the chat list.
 75func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
 76	items := make([]list.Item, len(msgs))
 77	indexOffset := len(m.idInxMap)
 78	for i, msg := range msgs {
 79		m.idInxMap[msg.ID()] = indexOffset + i
 80		items[i] = msg
 81	}
 82	m.list.AppendItems(items...)
 83}
 84
 85// Animate animated items in the chat list.
 86func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
 87	item, ok := m.idInxMap[msg.ID]
 88	// Item with the given ID exists
 89	if !ok {
 90		return nil
 91	}
 92	if animatable, ok := m.list.ItemAt(item).(chat.Animatable); ok {
 93		return animatable.Animate(msg)
 94	}
 95	return nil
 96}
 97
 98// Focus sets the focus state of the chat component.
 99func (m *Chat) Focus() {
100	m.list.Focus()
101}
102
103// Blur removes the focus state from the chat component.
104func (m *Chat) Blur() {
105	m.list.Blur()
106}
107
108// ScrollToTop scrolls the chat view to the top.
109func (m *Chat) ScrollToTop() {
110	m.list.ScrollToTop()
111}
112
113// ScrollToBottom scrolls the chat view to the bottom.
114func (m *Chat) ScrollToBottom() {
115	m.list.ScrollToBottom()
116}
117
118// ScrollBy scrolls the chat view by the given number of line deltas.
119func (m *Chat) ScrollBy(lines int) {
120	m.list.ScrollBy(lines)
121}
122
123// ScrollToSelected scrolls the chat view to the selected item.
124func (m *Chat) ScrollToSelected() {
125	m.list.ScrollToSelected()
126}
127
128// SelectedItemInView returns whether the selected item is currently in view.
129func (m *Chat) SelectedItemInView() bool {
130	return m.list.SelectedItemInView()
131}
132
133// SetSelected sets the selected message index in the chat list.
134func (m *Chat) SetSelected(index int) {
135	m.list.SetSelected(index)
136}
137
138// SelectPrev selects the previous message in the chat list.
139func (m *Chat) SelectPrev() {
140	m.list.SelectPrev()
141}
142
143// SelectNext selects the next message in the chat list.
144func (m *Chat) SelectNext() {
145	m.list.SelectNext()
146}
147
148// SelectFirst selects the first message in the chat list.
149func (m *Chat) SelectFirst() {
150	m.list.SelectFirst()
151}
152
153// SelectLast selects the last message in the chat list.
154func (m *Chat) SelectLast() {
155	m.list.SelectLast()
156}
157
158// SelectFirstInView selects the first message currently in view.
159func (m *Chat) SelectFirstInView() {
160	m.list.SelectFirstInView()
161}
162
163// SelectLastInView selects the last message currently in view.
164func (m *Chat) SelectLastInView() {
165	m.list.SelectLastInView()
166}
167
168// GetMessageItem returns the message item at the given id.
169func (m *Chat) GetMessageItem(id string) chat.MessageItem {
170	idx, ok := m.idInxMap[id]
171	if !ok {
172		return nil
173	}
174	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
175	if !ok {
176		return nil
177	}
178	return item
179}
180
181// HandleMouseDown handles mouse down events for the chat component.
182func (m *Chat) HandleMouseDown(x, y int) bool {
183	if m.list.Len() == 0 {
184		return false
185	}
186
187	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
188	if itemIdx < 0 {
189		return false
190	}
191
192	m.mouseDown = true
193	m.mouseDownItem = itemIdx
194	m.mouseDownX = x
195	m.mouseDownY = itemY
196	m.mouseDragItem = itemIdx
197	m.mouseDragX = x
198	m.mouseDragY = itemY
199
200	// Select the item that was clicked
201	m.list.SetSelected(itemIdx)
202
203	if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
204		return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
205	}
206
207	return true
208}
209
210// HandleMouseUp handles mouse up events for the chat component.
211func (m *Chat) HandleMouseUp(x, y int) bool {
212	if !m.mouseDown {
213		return false
214	}
215
216	// TODO: Handle the behavior when mouse is released after a drag selection
217	// (e.g., copy selected text to clipboard)
218
219	m.mouseDown = false
220	return true
221}
222
223// HandleMouseDrag handles mouse drag events for the chat component.
224func (m *Chat) HandleMouseDrag(x, y int) bool {
225	if !m.mouseDown {
226		return false
227	}
228
229	if m.list.Len() == 0 {
230		return false
231	}
232
233	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
234	if itemIdx < 0 {
235		return false
236	}
237
238	m.mouseDragItem = itemIdx
239	m.mouseDragX = x
240	m.mouseDragY = itemY
241
242	return true
243}
244
245// ClearMouse clears the current mouse interaction state.
246func (m *Chat) ClearMouse() {
247	m.mouseDown = false
248	m.mouseDownItem = -1
249	m.mouseDragItem = -1
250}
251
252// applyHighlightRange applies the current highlight range to the chat items.
253func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
254	if hi, ok := item.(list.Highlightable); ok {
255		// Apply highlight
256		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
257		sLine, sCol, eLine, eCol := -1, -1, -1, -1
258		if idx >= startItemIdx && idx <= endItemIdx {
259			if idx == startItemIdx && idx == endItemIdx {
260				// Single item selection
261				sLine = startLine
262				sCol = startCol
263				eLine = endLine
264				eCol = endCol
265			} else if idx == startItemIdx {
266				// First item - from start position to end of item
267				sLine = startLine
268				sCol = startCol
269				eLine = -1
270				eCol = -1
271			} else if idx == endItemIdx {
272				// Last item - from start of item to end position
273				sLine = 0
274				sCol = 0
275				eLine = endLine
276				eCol = endCol
277			} else {
278				// Middle item - fully highlighted
279				sLine = 0
280				sCol = 0
281				eLine = -1
282				eCol = -1
283			}
284		}
285
286		hi.Highlight(sLine, sCol, eLine, eCol)
287		return hi.(list.Item)
288	}
289
290	return item
291}
292
293// getHighlightRange returns the current highlight range.
294func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
295	if m.mouseDownItem < 0 {
296		return -1, -1, -1, -1, -1, -1
297	}
298
299	downItemIdx := m.mouseDownItem
300	dragItemIdx := m.mouseDragItem
301
302	// Determine selection direction
303	draggingDown := dragItemIdx > downItemIdx ||
304		(dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
305		(dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
306
307	if draggingDown {
308		// Normal forward selection
309		startItemIdx = downItemIdx
310		startLine = m.mouseDownY
311		startCol = m.mouseDownX
312		endItemIdx = dragItemIdx
313		endLine = m.mouseDragY
314		endCol = m.mouseDragX
315	} else {
316		// Backward selection (dragging up)
317		startItemIdx = dragItemIdx
318		startLine = m.mouseDragY
319		startCol = m.mouseDragX
320		endItemIdx = downItemIdx
321		endLine = m.mouseDownY
322		endCol = m.mouseDownX
323	}
324
325	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
326}