on main help sign in
wip: implement selection
commitebd68ac17c5fba96baff33d6260e6036f21de286 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 5 Aug 2025, 18:21 (1 year ago) parent976d49c
5 files changed+396−46
internal/tui/components/chat/chat.go+101−6 internal/tui/exp/list/list.go+267−6 internal/tui/page/chat/chat.go+9−17 internal/tui/styles/icons.go+18−16 internal/tui/tui.go+1−1
internal/tui/components/chat/chat.go+101−6
@@ -2,8 +2,10 @@ package chat
22
33import (
44 "context"
5+ "fmt"
56 "time"
67
8+ "github.com/atotto/clipboard"
79 "github.com/charmbracelet/bubbles/v2/key"
810 tea "github.com/charmbracelet/bubbletea/v2"
911 "github.com/charmbracelet/crush/internal/app"
@@ -42,6 +44,8 @@ type MessageListCmp interface {
4244
4345 SetSession(session.Session) tea.Cmd
4446 GoToBottom() tea.Cmd
47+ GetSelectedText() string
48+ CopySelectedText() tea.Cmd
4549}
4650
4751// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -56,6 +60,12 @@ type messageListCmp struct {
5660
5761 lastUserMessageTime int64
5862 defaultListKeyMap list.KeyMap
63+
64+ // Click tracking for double/triple click detection
65+ lastClickTime time.Time
66+ lastClickX int
67+ lastClickY int
68+ clickCount int
5969}
6070
6171// New creates a new message list component with custom keybindings
@@ -87,23 +97,42 @@ func (m *messageListCmp) Init() tea.Cmd {
8797func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8898 switch msg := msg.(type) {
8999 case tea.MouseClickMsg:
100+ x := msg.X - 1 // Adjust for padding
101+ y := msg.Y - 1 // Adjust for padding
102+ if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
103+ return m, nil // Ignore clicks outside the component
104+ }
90105 if msg.Button == tea.MouseLeft {
91− m.listCmp.StartSelection(msg.X, msg.Y-1)
106+ return m, m.handleMouseClick(x, y)
92107 }
93108 return m, nil
94109 case tea.MouseMotionMsg:
95− if msg.Button == tea.MouseLeft {
96− m.listCmp.EndSelection(msg.X, msg.Y-1)
97− if msg.Y <= 1 {
110+ x := msg.X - 1 // Adjust for padding
111+ y := msg.Y - 1 // Adjust for padding
112+ if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
113+ if y < 0 {
98114 return m, m.listCmp.MoveUp(1)
99− } else if msg.Y >= m.height-1 {
115+ }
116+ if y >= m.height-1 {
100117 return m, m.listCmp.MoveDown(1)
101118 }
119+ return m, nil // Ignore clicks outside the component
120+ }
121+ if msg.Button == tea.MouseLeft {
122+ m.listCmp.EndSelection(x, y)
102123 }
103124 return m, nil
104125 case tea.MouseReleaseMsg:
126+ x := msg.X - 1 // Adjust for padding
127+ y := msg.Y - 1 // Adjust for padding
105128 if msg.Button == tea.MouseLeft {
106− m.listCmp.EndSelection(msg.X, msg.Y-1)
129+ if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
130+ m.listCmp.SelectionStop()
131+ return m, m.CopySelectedText()
132+ }
133+ m.listCmp.EndSelection(x, y)
134+ m.listCmp.SelectionStop()
135+ return m, m.CopySelectedText()
107136 }
108137 return m, nil
109138 case pubsub.Event[permission.PermissionNotification]:
@@ -586,3 +615,69 @@ func (m *messageListCmp) Bindings() []key.Binding {
586615func (m *messageListCmp) GoToBottom() tea.Cmd {
587616 return m.listCmp.GoToBottom()
588617}
618+
619+// handleMouseClick handles mouse click events and detects double/triple clicks.
620+func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
621+ const (
622+ doubleClickThreshold = 500 * time.Millisecond
623+ clickTolerance = 2 // pixels
624+ )
625+
626+ now := time.Now()
627+
628+ // Check if this is a potential multi-click
629+ if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
630+ abs(x-m.lastClickX) <= clickTolerance &&
631+ abs(y-m.lastClickY) <= clickTolerance {
632+ m.clickCount++
633+ } else {
634+ m.clickCount = 1
635+ }
636+
637+ m.lastClickTime = now
638+ m.lastClickX = x
639+ m.lastClickY = y
640+
641+ switch m.clickCount {
642+ case 1:
643+ // Single click - start selection
644+ m.listCmp.StartSelection(x, y)
645+ case 2:
646+ // Double click - select word
647+ m.listCmp.SelectWord(x, y)
648+ case 3:
649+ // Triple click - select paragraph
650+ m.listCmp.SelectParagraph(x, y)
651+ m.clickCount = 0 // Reset after triple click
652+ }
653+
654+ return nil
655+}
656+
657+// GetSelectedText returns the currently selected text from the list component.
658+func (m *messageListCmp) GetSelectedText() string {
659+ return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
660+}
661+
662+// CopySelectedText copies the currently selected text to the clipboard.
663+func (m *messageListCmp) CopySelectedText() tea.Cmd {
664+ return nil
665+ selectedText := m.GetSelectedText()
666+ if selectedText == "" {
667+ return util.ReportInfo("No text selected")
668+ }
669+
670+ err := clipboard.WriteAll(selectedText)
671+ if err != nil {
672+ return util.ReportError(fmt.Errorf("failed to copy selected text to clipboard: %w", err))
673+ }
674+ return util.ReportInfo("Selected text copied to clipboard")
675+}
676+
677+// abs returns the absolute value of an integer.
678+func abs(x int) int {
679+ if x < 0 {
680+ return -x
681+ }
682+ return x
683+}
internal/tui/exp/list/list.go+267−6
@@ -1,7 +1,6 @@
11package list
22
33import (
4− "log/slog"
54 "slices"
65 "strings"
76 "sync"
@@ -15,6 +14,8 @@ import (
1514 "github.com/charmbracelet/crush/internal/tui/util"
1615 "github.com/charmbracelet/lipgloss/v2"
1716 uv "github.com/charmbracelet/ultraviolet"
17+ "github.com/charmbracelet/x/ansi"
18+ "github.com/rivo/uniseg"
1819)
1920
2021type Item interface {
@@ -50,6 +51,11 @@ type List[T Item] interface {
5051 AppendItem(T) tea.Cmd
5152 StartSelection(col, line int)
5253 EndSelection(col, line int)
54+ SelectionStop()
55+ SelectionClear()
56+ SelectWord(col, line int)
57+ SelectParagraph(col, line int)
58+ GetSelectedText(paddingLeft int) string
5359}
5460
5561type direction int
@@ -103,6 +109,8 @@ type list[T Item] struct {
103109 selectionStartLine int
104110 selectionEndCol int
105111 selectionEndLine int
112+
113+ selectionActive bool
106114}
107115
108116type ListOption func(*confOptions)
@@ -298,7 +306,8 @@ func (l *list[T]) View() string {
298306 Height(l.height).
299307 Width(l.width).
300308 Render(strings.Join(lines, "\n"))
301− if l.selectionStartCol < 0 {
309+
310+ if !l.hasSelection() {
302311 return view
303312 }
304313 area := uv.Rect(0, 0, l.width, l.height)
@@ -311,8 +320,8 @@ func (l *list[T]) View() string {
311320 }
312321 selArea = selArea.Canon()
313322
314− specialChars := make(map[string]bool, len(styles.AllIcons))
315− for _, icon := range styles.AllIcons {
323+ specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
324+ for _, icon := range styles.SelectionIgnoreIcons {
316325 specialChars[icon] = true
317326 }
318327
@@ -951,21 +960,67 @@ func (l *list[T]) decrementOffset(n int) {
951960
952961// MoveDown implements List.
953962func (l *list[T]) MoveDown(n int) tea.Cmd {
963+ oldOffset := l.offset
954964 if l.direction == DirectionForward {
955965 l.incrementOffset(n)
956966 } else {
957967 l.decrementOffset(n)
958968 }
969+
970+ if oldOffset == l.offset {
971+ // no change in offset, so no need to change selection
972+ return nil
973+ }
974+ // if we are not actively selecting move the whole selection down
975+ if l.hasSelection() && !l.selectionActive {
976+ if l.selectionStartLine < l.selectionEndLine {
977+ l.selectionStartLine -= n
978+ l.selectionEndLine -= n
979+ } else {
980+ l.selectionStartLine -= n
981+ l.selectionEndLine -= n
982+ }
983+ }
984+ if l.selectionActive {
985+ if l.selectionStartLine < l.selectionEndLine {
986+ l.selectionStartLine -= n
987+ } else {
988+ l.selectionEndLine -= n
989+ }
990+ }
959991 return l.changeSelectionWhenScrolling()
960992}
961993
962994// MoveUp implements List.
963995func (l *list[T]) MoveUp(n int) tea.Cmd {
996+ oldOffset := l.offset
964997 if l.direction == DirectionForward {
965998 l.decrementOffset(n)
966999 } else {
9671000 l.incrementOffset(n)
9681001 }
1002+
1003+ if oldOffset == l.offset {
1004+ // no change in offset, so no need to change selection
1005+ return nil
1006+ }
1007+
1008+ if l.hasSelection() && !l.selectionActive {
1009+ if l.selectionStartLine > l.selectionEndLine {
1010+ l.selectionStartLine += n
1011+ l.selectionEndLine += n
1012+ } else {
1013+ l.selectionStartLine += n
1014+ l.selectionEndLine += n
1015+ }
1016+ }
1017+ if l.selectionActive {
1018+ if l.selectionStartLine > l.selectionEndLine {
1019+ l.selectionStartLine += n
1020+ } else {
1021+ l.selectionEndLine += n
1022+ }
1023+ }
9691024 return l.changeSelectionWhenScrolling()
9701025}
9711026
@@ -1164,18 +1219,224 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
11641219 return tea.Sequence(cmds...)
11651220}
11661221
1222+func (l *list[T]) hasSelection() bool {
1223+ return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1224+}
1225+
11671226// StartSelection implements List.
11681227func (l *list[T]) StartSelection(col, line int) {
11691228 l.selectionStartCol = col
11701229 l.selectionStartLine = line
11711230 l.selectionEndCol = col
11721231 l.selectionEndLine = line
1173− slog.Info("Position", "col", col, "line", line)
1232+ l.selectionActive = true
11741233}
11751234
11761235// EndSelection implements List.
11771236func (l *list[T]) EndSelection(col, line int) {
1237+ if !l.selectionActive {
1238+ return
1239+ }
11781240 l.selectionEndCol = col
11791241 l.selectionEndLine = line
1180− slog.Info("Position", "col", col, "line", line)
1242+}
1243+
1244+func (l *list[T]) SelectionStop() {
1245+ l.selectionActive = false
1246+}
1247+
1248+func (l *list[T]) SelectionClear() {
1249+ l.selectionStartCol = -1
1250+ l.selectionStartLine = -1
1251+ l.selectionEndCol = -1
1252+ l.selectionEndLine = -1
1253+ l.selectionActive = false
1254+}
1255+
1256+func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1257+ lines := strings.Split(l.rendered, "\n")
1258+ for i, l := range lines {
1259+ lines[i] = ansi.Strip(l)
1260+ }
1261+
1262+ if l.direction == DirectionBackward {
1263+ line = ((len(lines) - 1) - l.height) + line + 1
1264+ }
1265+
1266+ if l.offset > 0 {
1267+ if l.direction == DirectionBackward {
1268+ line -= l.offset
1269+ } else {
1270+ line += l.offset
1271+ }
1272+ }
1273+
1274+ currentLine := lines[line]
1275+ gr := uniseg.NewGraphemes(currentLine)
1276+ startCol = -1
1277+ upTo := col
1278+ for gr.Next() {
1279+ if gr.IsWordBoundary() && upTo > 0 {
1280+ startCol = col - upTo + 1
1281+ } else if gr.IsWordBoundary() && upTo < 0 {
1282+ endCol = col - upTo + 1
1283+ break
1284+ }
1285+ if upTo == 0 && gr.Str() == " " {
1286+ return 0, 0
1287+ }
1288+ upTo -= 1
1289+ }
1290+ if startCol == -1 {
1291+ return 0, 0
1292+ }
1293+ return
1294+}
1295+
1296+func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1297+ lines := strings.Split(l.rendered, "\n")
1298+ for i, l := range lines {
1299+ lines[i] = ansi.Strip(l)
1300+ for _, icon := range styles.SelectionIgnoreIcons {
1301+ lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1302+ }
1303+ }
1304+ if l.direction == DirectionBackward {
1305+ line = (len(lines) - 1) - l.height + line + 1
1306+ }
1307+
1308+ if strings.TrimSpace(lines[line]) == "" {
1309+ return 0, 0, false
1310+ }
1311+
1312+ if l.offset > 0 {
1313+ if l.direction == DirectionBackward {
1314+ line -= l.offset
1315+ } else {
1316+ line += l.offset
1317+ }
1318+ }
1319+
1320+ // Ensure line is within bounds
1321+ if line < 0 || line >= len(lines) {
1322+ return 0, 0, false
1323+ }
1324+
1325+ // Find start of paragraph (search backwards for empty line or start of text)
1326+ startLine = line
1327+ for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1328+ startLine--
1329+ }
1330+
1331+ // Find end of paragraph (search forwards for empty line or end of text)
1332+ endLine = line
1333+ for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1334+ endLine++
1335+ }
1336+
1337+ // revert the line numbers if we are in backward direction
1338+ if l.direction == DirectionBackward {
1339+ startLine = startLine - (len(lines) - 1) + l.height - 1
1340+ endLine = endLine - (len(lines) - 1) + l.height - 1
1341+ }
1342+ if l.offset > 0 {
1343+ if l.direction == DirectionBackward {
1344+ startLine += l.offset
1345+ endLine += l.offset
1346+ } else {
1347+ startLine -= l.offset
1348+ endLine -= l.offset
1349+ }
1350+ }
1351+ return startLine, endLine, true
1352+}
1353+
1354+// SelectWord selects the word at the given position.
1355+func (l *list[T]) SelectWord(col, line int) {
1356+ startCol, endCol := l.findWordBoundaries(col, line)
1357+ l.selectionStartCol = startCol
1358+ l.selectionStartLine = line
1359+ l.selectionEndCol = endCol
1360+ l.selectionEndLine = line
1361+ l.selectionActive = false // Not actively selecting, just selected
1362+}
1363+
1364+// SelectParagraph selects the paragraph at the given position.
1365+func (l *list[T]) SelectParagraph(col, line int) {
1366+ startLine, endLine, found := l.findParagraphBoundaries(line)
1367+ if !found {
1368+ return
1369+ }
1370+ l.selectionStartCol = 0
1371+ l.selectionStartLine = startLine
1372+ l.selectionEndCol = l.width - 1
1373+ l.selectionEndLine = endLine
1374+ l.selectionActive = false // Not actively selecting, just selected
1375+}
1376+
1377+// GetSelectedText returns the currently selected text.
1378+func (l *list[T]) GetSelectedText(paddingLeft int) string {
1379+ return ""
1380+ // if !l.hasSelection() {
1381+ // return ""
1382+ // }
1383+ //
1384+ // startLine := l.selectionStartLine
1385+ // endLine := l.selectionEndLine
1386+ // startCol := l.selectionStartCol
1387+ // endCol := l.selectionEndCol
1388+ //
1389+ // if l.direction == DirectionBackward {
1390+ // startLine = (lipgloss.Height(l.rendered) - 1) - startLine
1391+ // endLine = (lipgloss.Height(l.rendered) - 1) - endLine
1392+ // }
1393+ //
1394+ // if l.offset > 0 {
1395+ // if l.direction == DirectionBackward {
1396+ // startLine += l.offset
1397+ // endLine += l.offset
1398+ // } else {
1399+ // startLine -= l.offset
1400+ // endLine -= l.offset
1401+ // }
1402+ // }
1403+ //
1404+ // lines := strings.Split(l.rendered, "\n")
1405+ //
1406+ // if startLine < 0 || endLine < 0 || startLine >= len(lines) || endLine >= len(lines) {
1407+ // return ""
1408+ // }
1409+ //
1410+ // var result strings.Builder
1411+ // for i := range lines {
1412+ // lines[i] = ansi.Strip(lines[i])
1413+ // for _, icon := range styles.SelectionIgnoreIcons {
1414+ // lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1415+ // }
1416+ //
1417+ // if i == startLine {
1418+ // if startCol < 0 || startCol >= len(lines[i]) {
1419+ // startCol = 0
1420+ // }
1421+ // if startCol < paddingLeft {
1422+ // startCol = paddingLeft
1423+ // }
1424+ // if i != endLine {
1425+ // endCol = len(lines[i])
1426+ // }
1427+ // result.WriteString(strings.TrimRightFunc(lines[i][startCol:endCol], unicode.IsSpace))
1428+ // } else if i > startLine && i < endLine {
1429+ // result.WriteString(strings.TrimRightFunc(lines[i][paddingLeft:], unicode.IsSpace))
1430+ // } else if i == endLine {
1431+ // if endCol < 0 || endCol >= len(lines[i]) {
1432+ // endCol = len(lines[i])
1433+ // }
1434+ // if endCol < paddingLeft {
1435+ // endCol = paddingLeft
1436+ // }
1437+ // result.WriteString(strings.TrimRightFunc(lines[i][paddingLeft:endCol], unicode.IsSpace))
1438+ // }
1439+ // }
1440+ //
1441+ // return result.String()
11811442}
internal/tui/page/chat/chat.go+9−17
@@ -172,30 +172,22 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172172 }
173173 return p, nil
174174 case tea.MouseClickMsg:
175− if msg.Button == tea.MouseLeft {
176− if p.isMouseOverChat(msg.X, msg.Y) {
177− u, cmd := p.chat.Update(msg)
178− p.chat = u.(chat.MessageListCmp)
179− return p, cmd
180− }
181− }
175+ u, cmd := p.chat.Update(msg)
176+ p.chat = u.(chat.MessageListCmp)
177+ return p, cmd
182178 return p, nil
183179 case tea.MouseMotionMsg:
184180 if msg.Button == tea.MouseLeft {
185− if p.isMouseOverChat(msg.X, msg.Y) {
186− u, cmd := p.chat.Update(msg)
187− p.chat = u.(chat.MessageListCmp)
188− return p, cmd
189− }
181+ u, cmd := p.chat.Update(msg)
182+ p.chat = u.(chat.MessageListCmp)
183+ return p, cmd
190184 }
191185 return p, nil
192186 case tea.MouseReleaseMsg:
193187 if msg.Button == tea.MouseLeft {
194− if p.isMouseOverChat(msg.X, msg.Y) {
195− u, cmd := p.chat.Update(msg)
196− p.chat = u.(chat.MessageListCmp)
197− return p, cmd
198− }
188+ u, cmd := p.chat.Update(msg)
189+ p.chat = u.(chat.MessageListCmp)
190+ return p, cmd
199191 }
200192 return p, nil
201193 case tea.WindowSizeMsg:
internal/tui/styles/icons.go+18−16
@@ -16,24 +16,26 @@ const (
1616 ToolSuccess string = "✓"
1717 ToolError string = "×"
1818
19− BorderThin string = "│"
19+ BorderThin string = "│"
20+ BorderThick string = "▌"
2021)
2122
22−var AllIcons = []string{
23− CheckIcon,
24− ErrorIcon,
25− WarningIcon,
26− InfoIcon,
27− HintIcon,
28− SpinnerIcon,
29− LoadingIcon,
30− DocumentIcon,
31− ModelIcon,
32−
33− // Tool call icons
34− ToolPending,
35− ToolSuccess,
36− ToolError,
23+var SelectionIgnoreIcons = []string{
24+ // CheckIcon,
25+ // ErrorIcon,
26+ // WarningIcon,
27+ // InfoIcon,
28+ // HintIcon,
29+ // SpinnerIcon,
30+ // LoadingIcon,
31+ // DocumentIcon,
32+ // ModelIcon,
33+ //
34+ // // Tool call icons
35+ // ToolPending,
36+ // ToolSuccess,
37+ // ToolError,
3738
3839 BorderThin,
40+ BorderThick,
3941}
internal/tui/tui.go+1−1
@@ -41,7 +41,7 @@ func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
4141 case tea.MouseWheelMsg, tea.MouseMotionMsg:
4242 now := time.Now()
4343 // trackpad is sending too many requests
44− if now.Sub(lastMouseEvent) < 20*time.Millisecond {
44+ if now.Sub(lastMouseEvent) < 15*time.Millisecond {
4545 return nil
4646 }
4747 lastMouseEvent = now