chore: add mouse support

Kujtim Hoxha created

Change summary

internal/tui/components/chat/chat.go |  6 +++
internal/tui/exp/list/list.go        | 51 ++++++++++++++++++++++-------
internal/tui/page/chat/chat.go       | 35 ++++++++++++++++++++
internal/tui/tui.go                  | 23 ++++--------
4 files changed, 87 insertions(+), 28 deletions(-)

Detailed changes

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)

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 {

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
+}

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
 }