chore: add mouse support

This commit is contained in:
Kujtim Hoxha
2025-07-24 23:26:22 +02:00
parent 5ba54bc7ee
commit 4451131bf1
4 changed files with 87 additions and 28 deletions

View File

@@ -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)

View File

@@ -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 {

View File

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

View File

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