diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index fe1df1fb4fc9014407095db465e79409e693462d..90d117e64dec449d09f8ef301de661a1feefd22c 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -67,6 +67,7 @@ func New(app *app.App) MessageListCmp { list.WithDirectionBackward(), list.WithFocus(false), list.WithKeyMap(defaultListKeyMap), + list.WithEnableMouse(), ) return &messageListCmp{ app: app, @@ -97,6 +98,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case pubsub.Event[message.Message]: cmd := m.handleMessageEvent(msg) return m, cmd + + 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) diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 0d9c06cd920fb6c6671a0cba89acbee3912051ca..68ed2de3a6a14b9d68c7f8d45644f27df86ecdec 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -23,7 +23,6 @@ type HasAnim interface { Item Spinning() bool } -type renderedMsg struct{} type List[T Item] interface { util.Model @@ -77,6 +76,7 @@ type confOptions struct { selectedItem string focused bool resize bool + enableMouse bool } type list[T Item] struct { @@ -156,6 +156,12 @@ func WithResizeByList() ListOption { } } +func WithEnableMouse() ListOption { + return func(l *confOptions) { + l.enableMouse = true + } +} + func New[T Item](items []T, opts ...ListOption) List[T] { list := &list[T]{ confOptions: &confOptions{ @@ -188,6 +194,11 @@ func (l *list[T]) Init() tea.Cmd { // Update implements List. func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.MouseWheelMsg: + if l.enableMouse { + return l.handleMouseWheel(msg) + } + return l, nil case anim.StepMsg: var cmds []tea.Cmd for _, item := range l.items.Slice() { @@ -229,6 +240,17 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return l, nil } +func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg.Button { + case tea.MouseWheelDown: + cmd = l.MoveDown(ViewportDefaultScrollSize) + case tea.MouseWheelUp: + cmd = l.MoveUp(ViewportDefaultScrollSize) + } + return l, cmd +} + // View implements List. func (l *list[T]) View() string { if l.height <= 0 || l.width <= 0 { @@ -292,9 +314,8 @@ func (l *list[T]) render() tea.Cmd { } // we are not rendering the first time if l.rendered != "" { - l.rendered = "" // rerender everything will mostly hit cache - _ = l.renderIterator(0, false) + l.rendered, _ = l.renderIterator(0, false, "") if l.direction == DirectionBackward { l.recalculateItemPositions() } @@ -304,14 +325,17 @@ func (l *list[T]) render() tea.Cmd { } return focusChangeCmd } - finishIndex := l.renderIterator(0, true) + rendered, finishIndex := l.renderIterator(0, true, "") + l.rendered = rendered + // recalculate for the initial items if l.direction == DirectionBackward { l.recalculateItemPositions() } renderCmd := func() tea.Msg { + l.offset = 0 // render the rest - _ = l.renderIterator(finishIndex, false) + l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered) // needed for backwards if l.direction == DirectionBackward { l.recalculateItemPositions() @@ -321,7 +345,7 @@ func (l *list[T]) render() tea.Cmd { l.scrollToSelection() } - return renderedMsg{} + return nil } return tea.Batch(focusChangeCmd, renderCmd) } @@ -568,13 +592,14 @@ func (l *list[T]) blurSelectedItem() tea.Cmd { } // render iterator renders items starting from the specific index and limits hight if limitHeight != -1 -// returns the last index -func (l *list[T]) renderIterator(startInx int, limitHeight bool) int { - currentContentHeight := lipgloss.Height(l.rendered) - 1 +// returns the last index and the rendered content so far +// we pass the rendered content around and don't use l.rendered to prevent jumping of the content +func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) { + currentContentHeight := lipgloss.Height(rendered) - 1 itemsLen := l.items.Len() for i := startInx; i < itemsLen; i++ { if currentContentHeight >= l.height && limitHeight { - return i + return rendered, i } // cool way to go through the list in both directions inx := i @@ -602,13 +627,13 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int { } if l.direction == DirectionForward { - l.rendered += rItem.view + strings.Repeat("\n", gap) + rendered += rItem.view + strings.Repeat("\n", gap) } else { - l.rendered = rItem.view + strings.Repeat("\n", gap) + l.rendered + rendered = rItem.view + strings.Repeat("\n", gap) + rendered } currentContentHeight = rItem.end + 1 + l.gap } - return itemsLen + return rendered, itemsLen } func (l *list[T]) renderItem(item Item) renderedItem { diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 0ec245be671e78803bb86cfbf28d2d0eb342bd67..073ac869bb5f3916e5eccbb37da135c0b012f251 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -164,6 +164,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyboardEnhancementsMsg: p.keyboardEnhancements = msg return p, nil + case tea.MouseWheelMsg: + if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) { + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + return p, cmd + } + return p, nil case tea.WindowSizeMsg: return p, p.SetSize(msg.Width, msg.Height) case CancelTimerExpiredMsg: @@ -906,3 +913,31 @@ func (p *chatPage) Help() help.KeyMap { func (p *chatPage) IsChatFocused() bool { return p.focusedPane == PanelTypeChat } + +// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds. +// Returns true if the mouse is over the chat area, false otherwise. +func (p *chatPage) isMouseOverChat(x, y int) bool { + // No session means no chat area + if p.session.ID == "" { + return false + } + + var chatX, chatY, chatWidth, chatHeight int + + if p.compact { + // In compact mode: chat area starts after header and spans full width + chatX = 0 + chatY = HeaderHeight + chatWidth = p.width + chatHeight = p.height - EditorHeight - HeaderHeight + } else { + // In non-compact mode: chat area spans from left edge to sidebar + chatX = 0 + chatY = 0 + chatWidth = p.width - SideBarWidth + chatHeight = p.height - EditorHeight + } + + // Check if mouse coordinates are within chat bounds + return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0e2587666f5a8c58be1466149a6b6f7a9dfb2a59..c4c88199de49fd9145dcf21fc78d452b8de14e9a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" @@ -33,26 +34,18 @@ import ( "github.com/charmbracelet/lipgloss/v2" ) -// MouseEventFilter filters mouse events based on the current focus state -// This is used with tea.WithFilter to prevent mouse scroll events from -// interfering with typing performance in the editor +var lastMouseEvent time.Time + func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg { - // Only filter mouse events switch msg.(type) { case tea.MouseWheelMsg, tea.MouseMotionMsg: - // Check if we have an appModel and if editor is focused - if appModel, ok := m.(*appModel); ok { - if appModel.currentPage == chat.ChatPageID { - if chatPage, ok := appModel.pages[appModel.currentPage].(chat.ChatPage); ok { - // If editor is focused (not chatFocused), filter out mouse wheel/motion events - if !chatPage.IsChatFocused() { - return nil // Filter out the event - } - } - } + now := time.Now() + // trackpad is sending too many requests + if now.Sub(lastMouseEvent) < 5*time.Millisecond { + return nil } + lastMouseEvent = now } - // Allow all other events to pass through return msg }