diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index f691f211246ad13a5b9500fd6424169b93be02da..d78198e55f68ad39bdbd49d8222405ed0a97fd42 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -17,6 +17,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" + uv "github.com/charmbracelet/ultraviolet" ) type SendMsg struct { @@ -56,6 +57,8 @@ type messageListCmp struct { lastUserMessageTime int64 defaultListKeyMap list.KeyMap + + selStart, selEnd uv.Position } // New creates a new message list component with custom keybindings @@ -85,6 +88,7 @@ func (m *messageListCmp) Init() tea.Cmd { // Update handles incoming messages and updates the component state. func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd switch msg := msg.(type) { case pubsub.Event[permission.PermissionNotification]: return m, m.handlePermissionRequest(msg.Payload) @@ -102,29 +106,78 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := m.handleMessageEvent(msg) return m, cmd + case tea.MouseClickMsg: + if msg.Button == tea.MouseLeft { + m.listCmp.StartSelection(msg.X, msg.Y) + } + + case tea.MouseMotionMsg: + if msg.Button == tea.MouseLeft { + m.listCmp.EndSelection(msg.X, msg.Y) + if msg.Y <= 1 { + // Scroll up while dragging the mouse + cmds = append(cmds, m.listCmp.MoveUp(1)) + } else if msg.Y >= m.height-1 { + // Scroll down while dragging the mouse + cmds = append(cmds, m.listCmp.MoveDown(1)) + } + } + + case tea.MouseReleaseMsg: + if msg.Button == tea.MouseLeft { + m.listCmp.EndSelection(msg.X, msg.Y) + } + case tea.MouseWheelMsg: u, cmd := m.listCmp.Update(msg) m.listCmp = u.(list.List[list.Item]) return m, cmd - default: - var cmds []tea.Cmd - u, cmd := m.listCmp.Update(msg) - m.listCmp = u.(list.List[list.Item]) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) } + + u, cmd := m.listCmp.Update(msg) + m.listCmp = u.(list.List[list.Item]) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) } +var zeroPos = uv.Position{} + // View renders the message list or an initial screen if empty. func (m *messageListCmp) View() string { t := styles.CurrentTheme() - return t.S().Base. + view := t.S().Base. Padding(1, 1, 0, 1). Width(m.width). Height(m.height). Render( m.listCmp.View(), ) + + area := uv.Rect(0, 0, m.width, m.height) + scr := uv.NewScreenBuffer(area.Dx(), area.Dy()) + uv.NewStyledString(view).Draw(scr, area) + if m.selStart != zeroPos && m.selEnd != zeroPos { + selArea := uv.Rectangle{ + Min: m.selStart, + Max: m.selEnd, + } + if m.selStart.X > m.selEnd.X || (m.selStart.X == m.selEnd.X && m.selStart.Y > m.selEnd.Y) { + selArea.Min, selArea.Max = selArea.Max, selArea.Min + } + for y := 0; y < area.Dy(); y++ { + for x := 0; x < area.Dx(); x++ { + cell := scr.CellAt(x, y) + if cell != nil && uv.Pos(x, y).In(selArea) { + cell = cell.Clone() + cell.Style = cell.Style.Reverse(true) + scr.SetCell(x, y, cell) + } + } + } + } + + return scr.Render() } func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd { diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 44a849fcf6027813feb49be5a68c401f4253eeb6..a46e3aa3dc870589f4a175990d49eb188ea9bbb8 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" ) type Item interface { @@ -45,6 +46,8 @@ type List[T Item] interface { DeleteItem(string) tea.Cmd PrependItem(T) tea.Cmd AppendItem(T) tea.Cmd + StartSelection(col, line int) + EndSelection(col, line int) } type direction int @@ -93,6 +96,11 @@ type list[T Item] struct { rendered string movingByItem bool + + selectionStartCol int + selectionStartLine int + selectionEndCol int + selectionEndLine int } type ListOption func(*confOptions) @@ -187,6 +195,20 @@ func New[T Item](items []T, opts ...ListOption) List[T] { return list } +// StartSelection implements List. +func (l *list[T]) StartSelection(col, line int) { + l.selectionStartCol = col + l.selectionStartLine = line + l.selectionEndCol = col + l.selectionEndLine = line +} + +// EndSelection implements List. +func (l *list[T]) EndSelection(col, line int) { + l.selectionEndCol = col + l.selectionEndLine = line +} + // Init implements List. func (l *list[T]) Init() tea.Cmd { return l.render() @@ -280,10 +302,39 @@ func (l *list[T]) View() string { if l.resize { return strings.Join(lines, "\n") } - return t.S().Base. + + view = t.S().Base. Height(l.height). Width(l.width). Render(strings.Join(lines, "\n")) + + area := uv.Rect(0, 0, l.width, l.height) + scr := uv.NewScreenBuffer(area.Dx(), area.Dy()) + uv.NewStyledString(view).Draw(scr, area) + selArea := uv.Rectangle{ + Min: uv.Pos(l.selectionStartCol, l.selectionStartLine), + Max: uv.Pos(l.selectionEndCol, l.selectionEndLine), + } + selArea = selArea.Canon() + for y := 0; y < scr.Height(); y++ { + for x := 0; x < scr.Width(); x++ { + cell := scr.CellAt(x, y) + firstLine := y == selArea.Min.Y && x >= l.selectionStartCol && x < scr.Width() + lastLine := y == selArea.Max.Y-1 && x >= 0 && x < l.selectionEndCol + middleLine := y > selArea.Min.Y && y < selArea.Max.Y-1 && x >= 0 && x < scr.Width() + if cell != nil && + (y >= selArea.Min.Y && y < selArea.Max.Y) && + (firstLine || // First line until the end + lastLine || // Last line until the end + middleLine) { // Middle lines + cell = cell.Clone() + cell.Style = cell.Style.Reverse(true) + scr.SetCell(x, y, cell) + } + } + } + + return scr.Render() } func (l *list[T]) viewPosition() (int, int) { diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 9696e316e6f546a1e4ffaab02941951093a87dfb..4a89d45fe1e3fc928d98e543e5c6d04623da7aa8 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -164,7 +164,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyboardEnhancementsMsg: p.keyboardEnhancements = msg return p, nil - case tea.MouseWheelMsg: + case tea.MouseMsg: if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) { u, cmd := p.chat.Update(msg) p.chat = u.(chat.MessageListCmp)