1package model
2
3import (
4 "strings"
5 "time"
6
7 tea "charm.land/bubbletea/v2"
8 "charm.land/lipgloss/v2"
9 "github.com/charmbracelet/crush/internal/ui/anim"
10 "github.com/charmbracelet/crush/internal/ui/chat"
11 "github.com/charmbracelet/crush/internal/ui/common"
12 "github.com/charmbracelet/crush/internal/ui/list"
13 uv "github.com/charmbracelet/ultraviolet"
14 "github.com/charmbracelet/x/ansi"
15 "github.com/clipperhouse/displaywidth"
16 "github.com/clipperhouse/uax29/v2/words"
17)
18
19// Constants for multi-click detection.
20const (
21 doubleClickThreshold = 400 * time.Millisecond // 0.4s is typical double-click threshold
22 clickTolerance = 2 // x,y tolerance for double/tripple click
23)
24
25// DelayedClickMsg is sent after the double-click threshold to trigger a
26// single-click action (like expansion) if no double-click occurred.
27type DelayedClickMsg struct {
28 ClickID int
29 ItemIdx int
30 X, Y int
31}
32
33// Chat represents the chat UI model that handles chat interactions and
34// messages.
35type Chat struct {
36 com *common.Common
37 list *list.List
38 idInxMap map[string]int // Map of message IDs to their indices in the list
39
40 // Animation visibility optimization: track animations paused due to items
41 // being scrolled out of view. When items become visible again, their
42 // animations are restarted.
43 pausedAnimations map[string]struct{}
44
45 // Mouse state
46 mouseDown bool
47 mouseDownItem int // Item index where mouse was pressed
48 mouseDownX int // X position in item content (character offset)
49 mouseDownY int // Y position in item (line offset)
50 mouseDragItem int // Current item index being dragged over
51 mouseDragX int // Current X in item content
52 mouseDragY int // Current Y in item
53
54 // Click tracking for double/triple clicks
55 lastClickTime time.Time
56 lastClickX int
57 lastClickY int
58 clickCount int
59
60 // Pending single click action (delayed to detect double-click)
61 pendingClickID int // Incremented on each click to invalidate old pending clicks
62}
63
64// NewChat creates a new instance of [Chat] that handles chat interactions and
65// messages.
66func NewChat(com *common.Common) *Chat {
67 c := &Chat{
68 com: com,
69 idInxMap: make(map[string]int),
70 pausedAnimations: make(map[string]struct{}),
71 }
72 l := list.NewList()
73 l.SetGap(1)
74 l.RegisterRenderCallback(c.applyHighlightRange)
75 l.RegisterRenderCallback(list.FocusedRenderCallback(l))
76 c.list = l
77 c.mouseDownItem = -1
78 c.mouseDragItem = -1
79 return c
80}
81
82// Height returns the height of the chat view port.
83func (m *Chat) Height() int {
84 return m.list.Height()
85}
86
87// Draw renders the chat UI component to the screen and the given area.
88func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
89 uv.NewStyledString(m.list.Render()).Draw(scr, area)
90}
91
92// SetSize sets the size of the chat view port.
93func (m *Chat) SetSize(width, height int) {
94 m.list.SetSize(width, height)
95 // Anchor to bottom if we were at the bottom.
96 if m.list.AtBottom() {
97 m.list.ScrollToBottom()
98 }
99}
100
101// Len returns the number of items in the chat list.
102func (m *Chat) Len() int {
103 return m.list.Len()
104}
105
106// SetMessages sets the chat messages to the provided list of message items.
107func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
108 m.idInxMap = make(map[string]int)
109 m.pausedAnimations = make(map[string]struct{})
110
111 items := make([]list.Item, len(msgs))
112 for i, msg := range msgs {
113 m.idInxMap[msg.ID()] = i
114 // Register nested tool IDs for tools that contain nested tools.
115 if container, ok := msg.(chat.NestedToolContainer); ok {
116 for _, nested := range container.NestedTools() {
117 m.idInxMap[nested.ID()] = i
118 }
119 }
120 items[i] = msg
121 }
122 m.list.SetItems(items...)
123 m.list.ScrollToBottom()
124}
125
126// AppendMessages appends a new message item to the chat list.
127func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
128 items := make([]list.Item, len(msgs))
129 indexOffset := m.list.Len()
130 for i, msg := range msgs {
131 m.idInxMap[msg.ID()] = indexOffset + i
132 // Register nested tool IDs for tools that contain nested tools.
133 if container, ok := msg.(chat.NestedToolContainer); ok {
134 for _, nested := range container.NestedTools() {
135 m.idInxMap[nested.ID()] = indexOffset + i
136 }
137 }
138 items[i] = msg
139 }
140 m.list.AppendItems(items...)
141}
142
143// UpdateNestedToolIDs updates the ID map for nested tools within a container.
144// Call this after modifying nested tools to ensure animations work correctly.
145func (m *Chat) UpdateNestedToolIDs(containerID string) {
146 idx, ok := m.idInxMap[containerID]
147 if !ok {
148 return
149 }
150
151 item, ok := m.list.ItemAt(idx).(chat.MessageItem)
152 if !ok {
153 return
154 }
155
156 container, ok := item.(chat.NestedToolContainer)
157 if !ok {
158 return
159 }
160
161 // Register all nested tool IDs to point to the container's index.
162 for _, nested := range container.NestedTools() {
163 m.idInxMap[nested.ID()] = idx
164 }
165}
166
167// Animate animates items in the chat list. Only propagates animation messages
168// to visible items to save CPU. When items are not visible, their animation ID
169// is tracked so it can be restarted when they become visible again.
170func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
171 idx, ok := m.idInxMap[msg.ID]
172 if !ok {
173 return nil
174 }
175
176 animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
177 if !ok {
178 return nil
179 }
180
181 // Check if item is currently visible.
182 startIdx, endIdx := m.list.VisibleItemIndices()
183 isVisible := idx >= startIdx && idx <= endIdx
184
185 if !isVisible {
186 // Item not visible - pause animation by not propagating.
187 // Track it so we can restart when it becomes visible.
188 m.pausedAnimations[msg.ID] = struct{}{}
189 return nil
190 }
191
192 // Item is visible - remove from paused set and animate.
193 delete(m.pausedAnimations, msg.ID)
194 return animatable.Animate(msg)
195}
196
197// RestartPausedVisibleAnimations restarts animations for items that were paused
198// due to being scrolled out of view but are now visible again.
199func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
200 if len(m.pausedAnimations) == 0 {
201 return nil
202 }
203
204 startIdx, endIdx := m.list.VisibleItemIndices()
205 var cmds []tea.Cmd
206
207 for id := range m.pausedAnimations {
208 idx, ok := m.idInxMap[id]
209 if !ok {
210 // Item no longer exists.
211 delete(m.pausedAnimations, id)
212 continue
213 }
214
215 if idx >= startIdx && idx <= endIdx {
216 // Item is now visible - restart its animation.
217 if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
218 if cmd := animatable.StartAnimation(); cmd != nil {
219 cmds = append(cmds, cmd)
220 }
221 }
222 delete(m.pausedAnimations, id)
223 }
224 }
225
226 if len(cmds) == 0 {
227 return nil
228 }
229 return tea.Batch(cmds...)
230}
231
232// Focus sets the focus state of the chat component.
233func (m *Chat) Focus() {
234 m.list.Focus()
235}
236
237// Blur removes the focus state from the chat component.
238func (m *Chat) Blur() {
239 m.list.Blur()
240}
241
242// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
243// any paused animations that are now visible.
244func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
245 m.list.ScrollToTop()
246 return m.RestartPausedVisibleAnimations()
247}
248
249// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
250// restart any paused animations that are now visible.
251func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
252 m.list.ScrollToBottom()
253 return m.RestartPausedVisibleAnimations()
254}
255
256// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
257// a command to restart any paused animations that are now visible.
258func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
259 m.list.ScrollBy(lines)
260 return m.RestartPausedVisibleAnimations()
261}
262
263// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
264// command to restart any paused animations that are now visible.
265func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
266 m.list.ScrollToSelected()
267 return m.RestartPausedVisibleAnimations()
268}
269
270// SelectedItemInView returns whether the selected item is currently in view.
271func (m *Chat) SelectedItemInView() bool {
272 return m.list.SelectedItemInView()
273}
274
275func (m *Chat) isSelectable(index int) bool {
276 item := m.list.ItemAt(index)
277 if item == nil {
278 return false
279 }
280 _, ok := item.(list.Focusable)
281 return ok
282}
283
284// SetSelected sets the selected message index in the chat list.
285func (m *Chat) SetSelected(index int) {
286 m.list.SetSelected(index)
287 if index < 0 || index >= m.list.Len() {
288 return
289 }
290 for {
291 if m.isSelectable(m.list.Selected()) {
292 return
293 }
294 if m.list.SelectNext() {
295 continue
296 }
297 // If we're at the end and the last item isn't selectable, walk backwards
298 // to find the nearest selectable item.
299 for {
300 if !m.list.SelectPrev() {
301 return
302 }
303 if m.isSelectable(m.list.Selected()) {
304 return
305 }
306 }
307 }
308}
309
310// SelectPrev selects the previous message in the chat list.
311func (m *Chat) SelectPrev() {
312 for {
313 if !m.list.SelectPrev() {
314 return
315 }
316 if m.isSelectable(m.list.Selected()) {
317 return
318 }
319 }
320}
321
322// SelectNext selects the next message in the chat list.
323func (m *Chat) SelectNext() {
324 for {
325 if !m.list.SelectNext() {
326 return
327 }
328 if m.isSelectable(m.list.Selected()) {
329 return
330 }
331 }
332}
333
334// SelectFirst selects the first message in the chat list.
335func (m *Chat) SelectFirst() {
336 if !m.list.SelectFirst() {
337 return
338 }
339 if m.isSelectable(m.list.Selected()) {
340 return
341 }
342 for {
343 if !m.list.SelectNext() {
344 return
345 }
346 if m.isSelectable(m.list.Selected()) {
347 return
348 }
349 }
350}
351
352// SelectLast selects the last message in the chat list.
353func (m *Chat) SelectLast() {
354 if !m.list.SelectLast() {
355 return
356 }
357 if m.isSelectable(m.list.Selected()) {
358 return
359 }
360 for {
361 if !m.list.SelectPrev() {
362 return
363 }
364 if m.isSelectable(m.list.Selected()) {
365 return
366 }
367 }
368}
369
370// SelectFirstInView selects the first message currently in view.
371func (m *Chat) SelectFirstInView() {
372 startIdx, endIdx := m.list.VisibleItemIndices()
373 for i := startIdx; i <= endIdx; i++ {
374 if m.isSelectable(i) {
375 m.list.SetSelected(i)
376 return
377 }
378 }
379}
380
381// SelectLastInView selects the last message currently in view.
382func (m *Chat) SelectLastInView() {
383 startIdx, endIdx := m.list.VisibleItemIndices()
384 for i := endIdx; i >= startIdx; i-- {
385 if m.isSelectable(i) {
386 m.list.SetSelected(i)
387 return
388 }
389 }
390}
391
392// ClearMessages removes all messages from the chat list.
393func (m *Chat) ClearMessages() {
394 m.idInxMap = make(map[string]int)
395 m.pausedAnimations = make(map[string]struct{})
396 m.list.SetItems()
397 m.ClearMouse()
398}
399
400// RemoveMessage removes a message from the chat list by its ID.
401func (m *Chat) RemoveMessage(id string) {
402 idx, ok := m.idInxMap[id]
403 if !ok {
404 return
405 }
406
407 // Remove from list
408 m.list.RemoveItem(idx)
409
410 // Remove from index map
411 delete(m.idInxMap, id)
412
413 // Rebuild index map for all items after the removed one
414 for i := idx; i < m.list.Len(); i++ {
415 if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
416 m.idInxMap[item.ID()] = i
417 }
418 }
419
420 // Clean up any paused animations for this message
421 delete(m.pausedAnimations, id)
422}
423
424// MessageItem returns the message item with the given ID, or nil if not found.
425func (m *Chat) MessageItem(id string) chat.MessageItem {
426 idx, ok := m.idInxMap[id]
427 if !ok {
428 return nil
429 }
430 item, ok := m.list.ItemAt(idx).(chat.MessageItem)
431 if !ok {
432 return nil
433 }
434 return item
435}
436
437// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
438func (m *Chat) ToggleExpandedSelectedItem() {
439 if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
440 if !expandable.ToggleExpanded() {
441 m.list.ScrollToIndex(m.list.Selected())
442 }
443 if m.list.AtBottom() {
444 m.list.ScrollToBottom()
445 }
446 }
447}
448
449// HandleKeyMsg handles key events for the chat component.
450func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
451 if m.list.Focused() {
452 if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok {
453 return handler.HandleKeyEvent(key)
454 }
455 }
456 return false, nil
457}
458
459// HandleMouseDown handles mouse down events for the chat component.
460// It detects single, double, and triple clicks for text selection.
461// Returns whether the click was handled and an optional command for delayed
462// single-click actions.
463func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) {
464 if m.list.Len() == 0 {
465 return false, nil
466 }
467
468 itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
469 if itemIdx < 0 {
470 return false, nil
471 }
472 if !m.isSelectable(itemIdx) {
473 return false, nil
474 }
475
476 // Increment pending click ID to invalidate any previous pending clicks.
477 m.pendingClickID++
478 clickID := m.pendingClickID
479
480 // Detect multi-click (double/triple)
481 now := time.Now()
482 if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
483 abs(x-m.lastClickX) <= clickTolerance &&
484 abs(y-m.lastClickY) <= clickTolerance {
485 m.clickCount++
486 } else {
487 m.clickCount = 1
488 }
489 m.lastClickTime = now
490 m.lastClickX = x
491 m.lastClickY = y
492
493 // Select the item that was clicked
494 m.list.SetSelected(itemIdx)
495
496 var cmd tea.Cmd
497
498 switch m.clickCount {
499 case 1:
500 // Single click - start selection and schedule delayed click action.
501 m.mouseDown = true
502 m.mouseDownItem = itemIdx
503 m.mouseDownX = x
504 m.mouseDownY = itemY
505 m.mouseDragItem = itemIdx
506 m.mouseDragX = x
507 m.mouseDragY = itemY
508
509 // Schedule delayed click action (e.g., expansion) after a short delay.
510 // If a double-click occurs, the clickID will be invalidated.
511 cmd = tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
512 return DelayedClickMsg{
513 ClickID: clickID,
514 ItemIdx: itemIdx,
515 X: x,
516 Y: itemY,
517 }
518 })
519 case 2:
520 // Double click - select word (no delayed action)
521 m.selectWord(itemIdx, x, itemY)
522 case 3:
523 // Triple click - select line (no delayed action)
524 m.selectLine(itemIdx, itemY)
525 m.clickCount = 0 // Reset after triple click
526 }
527
528 return true, cmd
529}
530
531// HandleDelayedClick handles a delayed single-click action (like expansion).
532// It only executes if the click ID matches (i.e., no double-click occurred)
533// and no text selection was made (drag to select).
534func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
535 // Ignore if this click was superseded by a newer click (double/triple).
536 if msg.ClickID != m.pendingClickID {
537 return false
538 }
539
540 // Don't expand if user dragged to select text.
541 if m.HasHighlight() {
542 return false
543 }
544
545 // Execute the click action (e.g., expansion).
546 selectedItem := m.list.SelectedItem()
547 if clickable, ok := selectedItem.(list.MouseClickable); ok {
548 handled := clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y)
549 // Toggle expansion if applicable.
550 if expandable, ok := selectedItem.(chat.Expandable); ok {
551 if !expandable.ToggleExpanded() {
552 m.list.ScrollToIndex(m.list.Selected())
553 }
554 }
555 if m.list.AtBottom() {
556 m.list.ScrollToBottom()
557 }
558 return handled
559 }
560
561 return false
562}
563
564// HandleMouseUp handles mouse up events for the chat component.
565func (m *Chat) HandleMouseUp(x, y int) bool {
566 if !m.mouseDown {
567 return false
568 }
569
570 m.mouseDown = false
571 return true
572}
573
574// HandleMouseDrag handles mouse drag events for the chat component.
575func (m *Chat) HandleMouseDrag(x, y int) bool {
576 if !m.mouseDown {
577 return false
578 }
579
580 if m.list.Len() == 0 {
581 return false
582 }
583
584 itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
585 if itemIdx < 0 {
586 return false
587 }
588
589 m.mouseDragItem = itemIdx
590 m.mouseDragX = x
591 m.mouseDragY = itemY
592
593 return true
594}
595
596// HasHighlight returns whether there is currently highlighted content.
597func (m *Chat) HasHighlight() bool {
598 startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
599 return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
600}
601
602// HighlightContent returns the currently highlighted content based on the mouse
603// selection. It returns an empty string if no content is highlighted.
604func (m *Chat) HighlightContent() string {
605 startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
606 if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
607 return ""
608 }
609
610 var sb strings.Builder
611 for i := startItemIdx; i <= endItemIdx; i++ {
612 item := m.list.ItemAt(i)
613 if hi, ok := item.(list.Highlightable); ok {
614 startLine, startCol, endLine, endCol := hi.Highlight()
615 listWidth := m.list.Width()
616 var rendered string
617 if rr, ok := item.(list.RawRenderable); ok {
618 rendered = rr.RawRender(listWidth)
619 } else {
620 rendered = item.Render(listWidth)
621 }
622 sb.WriteString(list.HighlightContent(
623 rendered,
624 uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
625 startLine,
626 startCol,
627 endLine,
628 endCol,
629 ))
630 sb.WriteString(strings.Repeat("\n", m.list.Gap()))
631 }
632 }
633
634 return strings.TrimSpace(sb.String())
635}
636
637// ClearMouse clears the current mouse interaction state.
638func (m *Chat) ClearMouse() {
639 m.mouseDown = false
640 m.mouseDownItem = -1
641 m.mouseDragItem = -1
642 m.lastClickTime = time.Time{}
643 m.lastClickX = 0
644 m.lastClickY = 0
645 m.clickCount = 0
646 m.pendingClickID++ // Invalidate any pending delayed click
647}
648
649// applyHighlightRange applies the current highlight range to the chat items.
650func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
651 if hi, ok := item.(list.Highlightable); ok {
652 // Apply highlight
653 startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
654 sLine, sCol, eLine, eCol := -1, -1, -1, -1
655 if idx >= startItemIdx && idx <= endItemIdx {
656 if idx == startItemIdx && idx == endItemIdx {
657 // Single item selection
658 sLine = startLine
659 sCol = startCol
660 eLine = endLine
661 eCol = endCol
662 } else if idx == startItemIdx {
663 // First item - from start position to end of item
664 sLine = startLine
665 sCol = startCol
666 eLine = -1
667 eCol = -1
668 } else if idx == endItemIdx {
669 // Last item - from start of item to end position
670 sLine = 0
671 sCol = 0
672 eLine = endLine
673 eCol = endCol
674 } else {
675 // Middle item - fully highlighted
676 sLine = 0
677 sCol = 0
678 eLine = -1
679 eCol = -1
680 }
681 }
682
683 hi.SetHighlight(sLine, sCol, eLine, eCol)
684 return hi.(list.Item)
685 }
686
687 return item
688}
689
690// getHighlightRange returns the current highlight range.
691func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
692 if m.mouseDownItem < 0 {
693 return -1, -1, -1, -1, -1, -1
694 }
695
696 downItemIdx := m.mouseDownItem
697 dragItemIdx := m.mouseDragItem
698
699 // Determine selection direction
700 draggingDown := dragItemIdx > downItemIdx ||
701 (dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
702 (dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
703
704 if draggingDown {
705 // Normal forward selection
706 startItemIdx = downItemIdx
707 startLine = m.mouseDownY
708 startCol = m.mouseDownX
709 endItemIdx = dragItemIdx
710 endLine = m.mouseDragY
711 endCol = m.mouseDragX
712 } else {
713 // Backward selection (dragging up)
714 startItemIdx = dragItemIdx
715 startLine = m.mouseDragY
716 startCol = m.mouseDragX
717 endItemIdx = downItemIdx
718 endLine = m.mouseDownY
719 endCol = m.mouseDownX
720 }
721
722 return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
723}
724
725// selectWord selects the word at the given position within an item.
726func (m *Chat) selectWord(itemIdx, x, itemY int) {
727 item := m.list.ItemAt(itemIdx)
728 if item == nil {
729 return
730 }
731
732 // Get the rendered content for this item
733 var rendered string
734 if rr, ok := item.(list.RawRenderable); ok {
735 rendered = rr.RawRender(m.list.Width())
736 } else {
737 rendered = item.Render(m.list.Width())
738 }
739
740 lines := strings.Split(rendered, "\n")
741 if itemY < 0 || itemY >= len(lines) {
742 return
743 }
744
745 // Adjust x for the item's left padding (border + padding) to get content column.
746 // The mouse x is in viewport space, but we need content space for boundary detection.
747 offset := chat.MessageLeftPaddingTotal
748 contentX := max(x-offset, 0)
749
750 line := ansi.Strip(lines[itemY])
751 startCol, endCol := findWordBoundaries(line, contentX)
752 if startCol == endCol {
753 // No word found at position, fallback to single click behavior
754 m.mouseDown = true
755 m.mouseDownItem = itemIdx
756 m.mouseDownX = x
757 m.mouseDownY = itemY
758 m.mouseDragItem = itemIdx
759 m.mouseDragX = x
760 m.mouseDragY = itemY
761 return
762 }
763
764 // Set selection to the word boundaries (convert back to viewport space).
765 // Keep mouseDown true so HandleMouseUp triggers the copy.
766 m.mouseDown = true
767 m.mouseDownItem = itemIdx
768 m.mouseDownX = startCol + offset
769 m.mouseDownY = itemY
770 m.mouseDragItem = itemIdx
771 m.mouseDragX = endCol + offset
772 m.mouseDragY = itemY
773}
774
775// selectLine selects the entire line at the given position within an item.
776func (m *Chat) selectLine(itemIdx, itemY int) {
777 item := m.list.ItemAt(itemIdx)
778 if item == nil {
779 return
780 }
781
782 // Get the rendered content for this item
783 var rendered string
784 if rr, ok := item.(list.RawRenderable); ok {
785 rendered = rr.RawRender(m.list.Width())
786 } else {
787 rendered = item.Render(m.list.Width())
788 }
789
790 lines := strings.Split(rendered, "\n")
791 if itemY < 0 || itemY >= len(lines) {
792 return
793 }
794
795 // Get line length (stripped of ANSI codes) and account for padding.
796 // SetHighlight will subtract the offset, so we need to add it here.
797 offset := chat.MessageLeftPaddingTotal
798 lineLen := ansi.StringWidth(lines[itemY])
799
800 // Set selection to the entire line.
801 // Keep mouseDown true so HandleMouseUp triggers the copy.
802 m.mouseDown = true
803 m.mouseDownItem = itemIdx
804 m.mouseDownX = 0
805 m.mouseDownY = itemY
806 m.mouseDragItem = itemIdx
807 m.mouseDragX = lineLen + offset
808 m.mouseDragY = itemY
809}
810
811// findWordBoundaries finds the start and end column of the word at the given column.
812// Returns (startCol, endCol) where endCol is exclusive.
813func findWordBoundaries(line string, col int) (startCol, endCol int) {
814 if line == "" || col < 0 {
815 return 0, 0
816 }
817
818 i := displaywidth.StringGraphemes(line)
819 for i.Next() {
820 }
821
822 // Segment the line into words using UAX#29.
823 lineCol := 0 // tracks the visited column widths
824 lastCol := 0 // tracks the start of the current token
825 iter := words.FromString(line)
826 for iter.Next() {
827 token := iter.Value()
828 tokenWidth := displaywidth.String(token)
829
830 graphemeStart := lineCol
831 graphemeEnd := lineCol + tokenWidth
832 lineCol += tokenWidth
833
834 // If clicked before this token, return the previous token boundaries.
835 if col < graphemeStart {
836 return lastCol, lastCol
837 }
838
839 // Update lastCol to the end of this token for next iteration.
840 lastCol = graphemeEnd
841
842 // If clicked within this token, return its boundaries.
843 if col >= graphemeStart && col < graphemeEnd {
844 // If clicked on whitespace, return empty selection.
845 if strings.TrimSpace(token) == "" {
846 return col, col
847 }
848 return graphemeStart, graphemeEnd
849 }
850 }
851
852 return col, col
853}
854
855// abs returns the absolute value of an integer.
856func abs(x int) int {
857 if x < 0 {
858 return -x
859 }
860 return x
861}