From ebd68ac17c5fba96baff33d6260e6036f21de286 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 5 Aug 2025 18:21:02 +0200 Subject: [PATCH] wip: implement selection --- internal/tui/components/chat/chat.go | 107 ++++++++++- internal/tui/exp/list/list.go | 273 ++++++++++++++++++++++++++- internal/tui/page/chat/chat.go | 26 +-- internal/tui/styles/icons.go | 34 ++-- internal/tui/tui.go | 2 +- 5 files changed, 396 insertions(+), 46 deletions(-) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 1762e836554d607e9e3a8eec03c60b5d25419f41..b1af201f33be840fa626244a42aa2d88fa18649c 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -2,8 +2,10 @@ package chat import ( "context" + "fmt" "time" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" @@ -42,6 +44,8 @@ type MessageListCmp interface { SetSession(session.Session) tea.Cmd GoToBottom() tea.Cmd + GetSelectedText() string + CopySelectedText() tea.Cmd } // messageListCmp implements MessageListCmp, providing a virtualized list @@ -56,6 +60,12 @@ type messageListCmp struct { lastUserMessageTime int64 defaultListKeyMap list.KeyMap + + // Click tracking for double/triple click detection + lastClickTime time.Time + lastClickX int + lastClickY int + clickCount int } // New creates a new message list component with custom keybindings @@ -87,23 +97,42 @@ func (m *messageListCmp) Init() tea.Cmd { func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.MouseClickMsg: + x := msg.X - 1 // Adjust for padding + y := msg.Y - 1 // Adjust for padding + if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { + return m, nil // Ignore clicks outside the component + } if msg.Button == tea.MouseLeft { - m.listCmp.StartSelection(msg.X, msg.Y-1) + return m, m.handleMouseClick(x, y) } return m, nil case tea.MouseMotionMsg: - if msg.Button == tea.MouseLeft { - m.listCmp.EndSelection(msg.X, msg.Y-1) - if msg.Y <= 1 { + x := msg.X - 1 // Adjust for padding + y := msg.Y - 1 // Adjust for padding + if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { + if y < 0 { return m, m.listCmp.MoveUp(1) - } else if msg.Y >= m.height-1 { + } + if y >= m.height-1 { return m, m.listCmp.MoveDown(1) } + return m, nil // Ignore clicks outside the component + } + if msg.Button == tea.MouseLeft { + m.listCmp.EndSelection(x, y) } return m, nil case tea.MouseReleaseMsg: + x := msg.X - 1 // Adjust for padding + y := msg.Y - 1 // Adjust for padding if msg.Button == tea.MouseLeft { - m.listCmp.EndSelection(msg.X, msg.Y-1) + if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { + m.listCmp.SelectionStop() + return m, m.CopySelectedText() + } + m.listCmp.EndSelection(x, y) + m.listCmp.SelectionStop() + return m, m.CopySelectedText() } return m, nil case pubsub.Event[permission.PermissionNotification]: @@ -586,3 +615,69 @@ func (m *messageListCmp) Bindings() []key.Binding { func (m *messageListCmp) GoToBottom() tea.Cmd { return m.listCmp.GoToBottom() } + +// handleMouseClick handles mouse click events and detects double/triple clicks. +func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd { + const ( + doubleClickThreshold = 500 * time.Millisecond + clickTolerance = 2 // pixels + ) + + now := time.Now() + + // Check if this is a potential multi-click + if now.Sub(m.lastClickTime) <= doubleClickThreshold && + abs(x-m.lastClickX) <= clickTolerance && + abs(y-m.lastClickY) <= clickTolerance { + m.clickCount++ + } else { + m.clickCount = 1 + } + + m.lastClickTime = now + m.lastClickX = x + m.lastClickY = y + + switch m.clickCount { + case 1: + // Single click - start selection + m.listCmp.StartSelection(x, y) + case 2: + // Double click - select word + m.listCmp.SelectWord(x, y) + case 3: + // Triple click - select paragraph + m.listCmp.SelectParagraph(x, y) + m.clickCount = 0 // Reset after triple click + } + + return nil +} + +// GetSelectedText returns the currently selected text from the list component. +func (m *messageListCmp) GetSelectedText() string { + return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding +} + +// CopySelectedText copies the currently selected text to the clipboard. +func (m *messageListCmp) CopySelectedText() tea.Cmd { + return nil + selectedText := m.GetSelectedText() + if selectedText == "" { + return util.ReportInfo("No text selected") + } + + err := clipboard.WriteAll(selectedText) + if err != nil { + return util.ReportError(fmt.Errorf("failed to copy selected text to clipboard: %w", err)) + } + return util.ReportInfo("Selected text copied to clipboard") +} + +// abs returns the absolute value of an integer. +func abs(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index ca1fa7ee7e73af0499f99a037d9729fcb5ed345d..f1a798a7dd23a534157ba0a143936d157b0a4dbe 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -1,7 +1,6 @@ package list import ( - "log/slog" "slices" "strings" "sync" @@ -15,6 +14,8 @@ import ( "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" ) type Item interface { @@ -50,6 +51,11 @@ type List[T Item] interface { AppendItem(T) tea.Cmd StartSelection(col, line int) EndSelection(col, line int) + SelectionStop() + SelectionClear() + SelectWord(col, line int) + SelectParagraph(col, line int) + GetSelectedText(paddingLeft int) string } type direction int @@ -103,6 +109,8 @@ type list[T Item] struct { selectionStartLine int selectionEndCol int selectionEndLine int + + selectionActive bool } type ListOption func(*confOptions) @@ -298,7 +306,8 @@ func (l *list[T]) View() string { Height(l.height). Width(l.width). Render(strings.Join(lines, "\n")) - if l.selectionStartCol < 0 { + + if !l.hasSelection() { return view } area := uv.Rect(0, 0, l.width, l.height) @@ -311,8 +320,8 @@ func (l *list[T]) View() string { } selArea = selArea.Canon() - specialChars := make(map[string]bool, len(styles.AllIcons)) - for _, icon := range styles.AllIcons { + specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons)) + for _, icon := range styles.SelectionIgnoreIcons { specialChars[icon] = true } @@ -951,21 +960,67 @@ func (l *list[T]) decrementOffset(n int) { // MoveDown implements List. func (l *list[T]) MoveDown(n int) tea.Cmd { + oldOffset := l.offset if l.direction == DirectionForward { l.incrementOffset(n) } else { l.decrementOffset(n) } + + if oldOffset == l.offset { + // no change in offset, so no need to change selection + return nil + } + // if we are not actively selecting move the whole selection down + if l.hasSelection() && !l.selectionActive { + if l.selectionStartLine < l.selectionEndLine { + l.selectionStartLine -= n + l.selectionEndLine -= n + } else { + l.selectionStartLine -= n + l.selectionEndLine -= n + } + } + if l.selectionActive { + if l.selectionStartLine < l.selectionEndLine { + l.selectionStartLine -= n + } else { + l.selectionEndLine -= n + } + } return l.changeSelectionWhenScrolling() } // MoveUp implements List. func (l *list[T]) MoveUp(n int) tea.Cmd { + oldOffset := l.offset if l.direction == DirectionForward { l.decrementOffset(n) } else { l.incrementOffset(n) } + + if oldOffset == l.offset { + // no change in offset, so no need to change selection + return nil + } + + if l.hasSelection() && !l.selectionActive { + if l.selectionStartLine > l.selectionEndLine { + l.selectionStartLine += n + l.selectionEndLine += n + } else { + l.selectionStartLine += n + l.selectionEndLine += n + } + } + if l.selectionActive { + if l.selectionStartLine > l.selectionEndLine { + l.selectionStartLine += n + } else { + l.selectionEndLine += n + } + } return l.changeSelectionWhenScrolling() } @@ -1164,18 +1219,224 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { return tea.Sequence(cmds...) } +func (l *list[T]) hasSelection() bool { + return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine +} + // StartSelection implements List. func (l *list[T]) StartSelection(col, line int) { l.selectionStartCol = col l.selectionStartLine = line l.selectionEndCol = col l.selectionEndLine = line - slog.Info("Position", "col", col, "line", line) + l.selectionActive = true } // EndSelection implements List. func (l *list[T]) EndSelection(col, line int) { + if !l.selectionActive { + return + } l.selectionEndCol = col l.selectionEndLine = line - slog.Info("Position", "col", col, "line", line) +} + +func (l *list[T]) SelectionStop() { + l.selectionActive = false +} + +func (l *list[T]) SelectionClear() { + l.selectionStartCol = -1 + l.selectionStartLine = -1 + l.selectionEndCol = -1 + l.selectionEndLine = -1 + l.selectionActive = false +} + +func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) { + lines := strings.Split(l.rendered, "\n") + for i, l := range lines { + lines[i] = ansi.Strip(l) + } + + if l.direction == DirectionBackward { + line = ((len(lines) - 1) - l.height) + line + 1 + } + + if l.offset > 0 { + if l.direction == DirectionBackward { + line -= l.offset + } else { + line += l.offset + } + } + + currentLine := lines[line] + gr := uniseg.NewGraphemes(currentLine) + startCol = -1 + upTo := col + for gr.Next() { + if gr.IsWordBoundary() && upTo > 0 { + startCol = col - upTo + 1 + } else if gr.IsWordBoundary() && upTo < 0 { + endCol = col - upTo + 1 + break + } + if upTo == 0 && gr.Str() == " " { + return 0, 0 + } + upTo -= 1 + } + if startCol == -1 { + return 0, 0 + } + return +} + +func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) { + lines := strings.Split(l.rendered, "\n") + for i, l := range lines { + lines[i] = ansi.Strip(l) + for _, icon := range styles.SelectionIgnoreIcons { + lines[i] = strings.ReplaceAll(lines[i], icon, " ") + } + } + if l.direction == DirectionBackward { + line = (len(lines) - 1) - l.height + line + 1 + } + + if strings.TrimSpace(lines[line]) == "" { + return 0, 0, false + } + + if l.offset > 0 { + if l.direction == DirectionBackward { + line -= l.offset + } else { + line += l.offset + } + } + + // Ensure line is within bounds + if line < 0 || line >= len(lines) { + return 0, 0, false + } + + // Find start of paragraph (search backwards for empty line or start of text) + startLine = line + for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" { + startLine-- + } + + // Find end of paragraph (search forwards for empty line or end of text) + endLine = line + for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" { + endLine++ + } + + // revert the line numbers if we are in backward direction + if l.direction == DirectionBackward { + startLine = startLine - (len(lines) - 1) + l.height - 1 + endLine = endLine - (len(lines) - 1) + l.height - 1 + } + if l.offset > 0 { + if l.direction == DirectionBackward { + startLine += l.offset + endLine += l.offset + } else { + startLine -= l.offset + endLine -= l.offset + } + } + return startLine, endLine, true +} + +// SelectWord selects the word at the given position. +func (l *list[T]) SelectWord(col, line int) { + startCol, endCol := l.findWordBoundaries(col, line) + l.selectionStartCol = startCol + l.selectionStartLine = line + l.selectionEndCol = endCol + l.selectionEndLine = line + l.selectionActive = false // Not actively selecting, just selected +} + +// SelectParagraph selects the paragraph at the given position. +func (l *list[T]) SelectParagraph(col, line int) { + startLine, endLine, found := l.findParagraphBoundaries(line) + if !found { + return + } + l.selectionStartCol = 0 + l.selectionStartLine = startLine + l.selectionEndCol = l.width - 1 + l.selectionEndLine = endLine + l.selectionActive = false // Not actively selecting, just selected +} + +// GetSelectedText returns the currently selected text. +func (l *list[T]) GetSelectedText(paddingLeft int) string { + return "" + // if !l.hasSelection() { + // return "" + // } + // + // startLine := l.selectionStartLine + // endLine := l.selectionEndLine + // startCol := l.selectionStartCol + // endCol := l.selectionEndCol + // + // if l.direction == DirectionBackward { + // startLine = (lipgloss.Height(l.rendered) - 1) - startLine + // endLine = (lipgloss.Height(l.rendered) - 1) - endLine + // } + // + // if l.offset > 0 { + // if l.direction == DirectionBackward { + // startLine += l.offset + // endLine += l.offset + // } else { + // startLine -= l.offset + // endLine -= l.offset + // } + // } + // + // lines := strings.Split(l.rendered, "\n") + // + // if startLine < 0 || endLine < 0 || startLine >= len(lines) || endLine >= len(lines) { + // return "" + // } + // + // var result strings.Builder + // for i := range lines { + // lines[i] = ansi.Strip(lines[i]) + // for _, icon := range styles.SelectionIgnoreIcons { + // lines[i] = strings.ReplaceAll(lines[i], icon, " ") + // } + // + // if i == startLine { + // if startCol < 0 || startCol >= len(lines[i]) { + // startCol = 0 + // } + // if startCol < paddingLeft { + // startCol = paddingLeft + // } + // if i != endLine { + // endCol = len(lines[i]) + // } + // result.WriteString(strings.TrimRightFunc(lines[i][startCol:endCol], unicode.IsSpace)) + // } else if i > startLine && i < endLine { + // result.WriteString(strings.TrimRightFunc(lines[i][paddingLeft:], unicode.IsSpace)) + // } else if i == endLine { + // if endCol < 0 || endCol >= len(lines[i]) { + // endCol = len(lines[i]) + // } + // if endCol < paddingLeft { + // endCol = paddingLeft + // } + // result.WriteString(strings.TrimRightFunc(lines[i][paddingLeft:endCol], unicode.IsSpace)) + // } + // } + // + // return result.String() } diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 955d6c710b53220a1e986e0c4e50ea49220b1485..6fd3849fbf37d01489f624924c77c299a06d2087 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -172,30 +172,22 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return p, nil case tea.MouseClickMsg: - if msg.Button == tea.MouseLeft { - if p.isMouseOverChat(msg.X, msg.Y) { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - } - } + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + return p, cmd return p, nil case tea.MouseMotionMsg: if msg.Button == tea.MouseLeft { - if p.isMouseOverChat(msg.X, msg.Y) { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - } + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + return p, cmd } return p, nil case tea.MouseReleaseMsg: if msg.Button == tea.MouseLeft { - if p.isMouseOverChat(msg.X, msg.Y) { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - } + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + return p, cmd } return p, nil case tea.WindowSizeMsg: diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go index 8aede9493b0201a972c1db116538525e55128c10..d9d1ab06f96ff64f8772e1b0f4b099a0ebed2b0a 100644 --- a/internal/tui/styles/icons.go +++ b/internal/tui/styles/icons.go @@ -16,24 +16,26 @@ const ( ToolSuccess string = "✓" ToolError string = "×" - BorderThin string = "│" + BorderThin string = "│" + BorderThick string = "▌" ) -var AllIcons = []string{ - CheckIcon, - ErrorIcon, - WarningIcon, - InfoIcon, - HintIcon, - SpinnerIcon, - LoadingIcon, - DocumentIcon, - ModelIcon, - - // Tool call icons - ToolPending, - ToolSuccess, - ToolError, +var SelectionIgnoreIcons = []string{ + // CheckIcon, + // ErrorIcon, + // WarningIcon, + // InfoIcon, + // HintIcon, + // SpinnerIcon, + // LoadingIcon, + // DocumentIcon, + // ModelIcon, + // + // // Tool call icons + // ToolPending, + // ToolSuccess, + // ToolError, BorderThin, + BorderThick, } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 18b26b017e40564a45e850206882ccca8ced8251..60f10de20fc231999557d2aba8d7d8b35fd70659 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -41,7 +41,7 @@ func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg { case tea.MouseWheelMsg, tea.MouseMotionMsg: now := time.Now() // trackpad is sending too many requests - if now.Sub(lastMouseEvent) < 20*time.Millisecond { + if now.Sub(lastMouseEvent) < 15*time.Millisecond { return nil } lastMouseEvent = now