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