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