@@ -2,6 +2,7 @@ package model
22
33import (
44 "strings"
5+ "time"
56
67 tea "charm.land/bubbletea/v2"
78 "charm.land/lipgloss/v2"
@@ -11,8 +12,24 @@ import (
1112 "github.com/charmbracelet/crush/internal/ui/list"
1213 uv "github.com/charmbracelet/ultraviolet"
1314 "github.com/charmbracelet/x/ansi"
15+ "github.com/clipperhouse/displaywidth"
16+ "github.com/clipperhouse/uax29/v2/words"
1417)
1518
19+// Constants for multi-click detection.
20+const (
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.
27+type DelayedClickMsg struct {
28+ ClickID int
29+ ItemIdx int
30+ X, Y int
31+}
32+
1633// Chat represents the chat UI model that handles chat interactions and
1734// messages.
1835type Chat struct {
@@ -33,6 +50,15 @@ type Chat struct {
3350 mouseDragItem int // Current item index being dragged over
3451 mouseDragX int // Current X in item content
3552 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
3662}
3763
3864// NewChat creates a new instance of [Chat] that handles chat interactions and
@@ -426,35 +452,97 @@ func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
426452}
427453
428454// HandleMouseDown handles mouse down events for the chat component.
429−func (m *Chat) HandleMouseDown(x, y int) bool {
455+// It detects single, double, and triple clicks for text selection.
456+// Returns whether the click was handled and an optional command for delayed
457+// single-click actions.
458+func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) {
430459 if m.list.Len() == 0 {
431− return false
460+ return false, nil
432461 }
433462
434463 itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
435464 if itemIdx < 0 {
436− return false
465+ return false, nil
437466 }
438467 if !m.isSelectable(itemIdx) {
439− return false
468+ return false, nil
440469 }
441470
442− m.mouseDown = true
443− m.mouseDownItem = itemIdx
444− m.mouseDownX = x
445− m.mouseDownY = itemY
446− m.mouseDragItem = itemIdx
447− m.mouseDragX = x
448− m.mouseDragY = itemY
471+ // Increment pending click ID to invalidate any previous pending clicks.
472+ m.pendingClickID++
473+ clickID := m.pendingClickID
474+
475+ // Detect multi-click (double/triple)
476+ now := time.Now()
477+ if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
478+ abs(x-m.lastClickX) <= clickTolerance &&
479+ abs(y-m.lastClickY) <= clickTolerance {
480+ m.clickCount++
481+ } else {
482+ m.clickCount = 1
483+ }
484+ m.lastClickTime = now
485+ m.lastClickX = x
486+ m.lastClickY = y
449487
450488 // Select the item that was clicked
451489 m.list.SetSelected(itemIdx)
452490
491+ var cmd tea.Cmd
492+
493+ switch m.clickCount {
494+ case 1:
495+ // Single click - start selection and schedule delayed click action.
496+ m.mouseDown = true
497+ m.mouseDownItem = itemIdx
498+ m.mouseDownX = x
499+ m.mouseDownY = itemY
500+ m.mouseDragItem = itemIdx
501+ m.mouseDragX = x
502+ m.mouseDragY = itemY
503+
504+ // Schedule delayed click action (e.g., expansion) after a short delay.
505+ // If a double-click occurs, the clickID will be invalidated.
506+ cmd = tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
507+ return DelayedClickMsg{
508+ ClickID: clickID,
509+ ItemIdx: itemIdx,
510+ X: x,
511+ Y: itemY,
512+ }
513+ })
514+ case 2:
515+ // Double click - select word (no delayed action)
516+ m.selectWord(itemIdx, x, itemY)
517+ case 3:
518+ // Triple click - select line (no delayed action)
519+ m.selectLine(itemIdx, itemY)
520+ m.clickCount = 0 // Reset after triple click
521+ }
522+
523+ return true, cmd
524+}
525+
526+// HandleDelayedClick handles a delayed single-click action (like expansion).
527+// It only executes if the click ID matches (i.e., no double-click occurred)
528+// and no text selection was made (drag to select).
529+func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
530+ // Ignore if this click was superseded by a newer click (double/triple).
531+ if msg.ClickID != m.pendingClickID {
532+ return false
533+ }
534+
535+ // Don't expand if user dragged to select text.
536+ if m.HasHighlight() {
537+ return false
538+ }
539+
540+ // Execute the click action (e.g., expansion).
453541 if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
454− return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
542+ return clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y)
455543 }
456544
457− return true
545+ return false
458546}
459547
460548// HandleMouseUp handles mouse up events for the chat component.
@@ -535,6 +623,11 @@ func (m *Chat) ClearMouse() {
535623 m.mouseDown = false
536624 m.mouseDownItem = -1
537625 m.mouseDragItem = -1
626+ m.lastClickTime = time.Time{}
627+ m.lastClickX = 0
628+ m.lastClickY = 0
629+ m.clickCount = 0
630+ m.pendingClickID++ // Invalidate any pending delayed click
538631}
539632
540633// applyHighlightRange applies the current highlight range to the chat items.
@@ -612,3 +705,144 @@ func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemId
612705
613706 return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
614707}
708+
709+// selectWord selects the word at the given position within an item.
710+func (m *Chat) selectWord(itemIdx, x, itemY int) {
711+ item := m.list.ItemAt(itemIdx)
712+ if item == nil {
713+ return
714+ }
715+
716+ // Get the rendered content for this item
717+ var rendered string
718+ if rr, ok := item.(list.RawRenderable); ok {
719+ rendered = rr.RawRender(m.list.Width())
720+ } else {
721+ rendered = item.Render(m.list.Width())
722+ }
723+
724+ lines := strings.Split(rendered, "\n")
725+ if itemY < 0 || itemY >= len(lines) {
726+ return
727+ }
728+
729+ // Adjust x for the item's left padding (border + padding) to get content column.
730+ // The mouse x is in viewport space, but we need content space for boundary detection.
731+ offset := chat.MessageLeftPaddingTotal
732+ contentX := x - offset
733+ if contentX < 0 {
734+ contentX = 0
735+ }
736+
737+ line := ansi.Strip(lines[itemY])
738+ startCol, endCol := findWordBoundaries(line, contentX)
739+ if startCol == endCol {
740+ // No word found at position, fallback to single click behavior
741+ m.mouseDown = true
742+ m.mouseDownItem = itemIdx
743+ m.mouseDownX = x
744+ m.mouseDownY = itemY
745+ m.mouseDragItem = itemIdx
746+ m.mouseDragX = x
747+ m.mouseDragY = itemY
748+ return
749+ }
750+
751+ // Set selection to the word boundaries (convert back to viewport space).
752+ // Keep mouseDown true so HandleMouseUp triggers the copy.
753+ m.mouseDown = true
754+ m.mouseDownItem = itemIdx
755+ m.mouseDownX = startCol + offset
756+ m.mouseDownY = itemY
757+ m.mouseDragItem = itemIdx
758+ m.mouseDragX = endCol + offset
759+ m.mouseDragY = itemY
760+}
761+
762+// selectLine selects the entire line at the given position within an item.
763+func (m *Chat) selectLine(itemIdx, itemY int) {
764+ item := m.list.ItemAt(itemIdx)
765+ if item == nil {
766+ return
767+ }
768+
769+ // Get the rendered content for this item
770+ var rendered string
771+ if rr, ok := item.(list.RawRenderable); ok {
772+ rendered = rr.RawRender(m.list.Width())
773+ } else {
774+ rendered = item.Render(m.list.Width())
775+ }
776+
777+ lines := strings.Split(rendered, "\n")
778+ if itemY < 0 || itemY >= len(lines) {
779+ return
780+ }
781+
782+ // Get line length (stripped of ANSI codes) and account for padding.
783+ // SetHighlight will subtract the offset, so we need to add it here.
784+ offset := chat.MessageLeftPaddingTotal
785+ lineLen := ansi.StringWidth(lines[itemY])
786+
787+ // Set selection to the entire line.
788+ // Keep mouseDown true so HandleMouseUp triggers the copy.
789+ m.mouseDown = true
790+ m.mouseDownItem = itemIdx
791+ m.mouseDownX = 0
792+ m.mouseDownY = itemY
793+ m.mouseDragItem = itemIdx
794+ m.mouseDragX = lineLen + offset
795+ m.mouseDragY = itemY
796+}
797+
798+// findWordBoundaries finds the start and end column of the word at the given column.
799+// Returns (startCol, endCol) where endCol is exclusive.
800+func findWordBoundaries(line string, col int) (startCol, endCol int) {
801+ if line == "" || col < 0 {
802+ return 0, 0
803+ }
804+
805+ i := displaywidth.StringGraphemes(line)
806+ for i.Next() {
807+ }
808+
809+ // Segment the line into words using UAX#29.
810+ lineCol := 0 // tracks the visited column widths
811+ lastCol := 0 // tracks the start of the current token
812+ iter := words.FromString(line)
813+ for iter.Next() {
814+ token := iter.Value()
815+ tokenWidth := displaywidth.String(token)
816+
817+ graphemeStart := lineCol
818+ graphemeEnd := lineCol + tokenWidth
819+ lineCol += tokenWidth
820+
821+ // If clicked before this token, return the previous token boundaries.
822+ if col < graphemeStart {
823+ return lastCol, lastCol
824+ }
825+
826+ // Update lastCol to the end of this token for next iteration.
827+ lastCol = graphemeEnd
828+
829+ // If clicked within this token, return its boundaries.
830+ if col >= graphemeStart && col < graphemeEnd {
831+ // If clicked on whitespace, return empty selection.
832+ if strings.TrimSpace(token) == "" {
833+ return col, col
834+ }
835+ return graphemeStart, graphemeEnd
836+ }
837+ }
838+
839+ return col, col
840+}
841+
842+// abs returns the absolute value of an integer.
843+func abs(x int) int {
844+ if x < 0 {
845+ return -x
846+ }
847+ return x
848+}