on main help sign in
chore: add mouse support
commit4451131bf1d8e11d4176dcce118d9eb0efd207c4 authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 24 Jul 2025, 23:26 (1 year ago) parent5ba54bc
4 files changed+87−28
internal/tui/components/chat/chat.go+6 internal/tui/exp/list/list.go+38−13 internal/tui/page/chat/chat.go+35 internal/tui/tui.go+8−15
internal/tui/components/chat/chat.go+6
@@ -67,6 +67,7 @@ func New(app *app.App) MessageListCmp {
6767 list.WithDirectionBackward(),
6868 list.WithFocus(false),
6969 list.WithKeyMap(defaultListKeyMap),
70+ list.WithEnableMouse(),
7071 )
7172 return &messageListCmp{
7273 app: app,
@@ -97,6 +98,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9798 case pubsub.Event[message.Message]:
9899 cmd := m.handleMessageEvent(msg)
99100 return m, cmd
101+
102+ case tea.MouseWheelMsg:
103+ u, cmd := m.listCmp.Update(msg)
104+ m.listCmp = u.(list.List[list.Item])
105+ return m, cmd
100106 default:
101107 var cmds []tea.Cmd
102108 u, cmd := m.listCmp.Update(msg)
internal/tui/exp/list/list.go+38−13
@@ -23,7 +23,6 @@ type HasAnim interface {
2323 Item
2424 Spinning() bool
2525}
26−type renderedMsg struct{}
2726
2827type List[T Item] interface {
2928 util.Model
@@ -77,6 +76,7 @@ type confOptions struct {
7776 selectedItem string
7877 focused bool
7978 resize bool
79+ enableMouse bool
8080}
8181
8282type list[T Item] struct {
@@ -156,6 +156,12 @@ func WithResizeByList() ListOption {
156156 }
157157}
158158
159+func WithEnableMouse() ListOption {
160+ return func(l *confOptions) {
161+ l.enableMouse = true
162+ }
163+}
164+
159165func New[T Item](items []T, opts ...ListOption) List[T] {
160166 list := &list[T]{
161167 confOptions: &confOptions{
@@ -188,6 +194,11 @@ func (l *list[T]) Init() tea.Cmd {
188194// Update implements List.
189195func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
190196 switch msg := msg.(type) {
197+ case tea.MouseWheelMsg:
198+ if l.enableMouse {
199+ return l.handleMouseWheel(msg)
200+ }
201+ return l, nil
191202 case anim.StepMsg:
192203 var cmds []tea.Cmd
193204 for _, item := range l.items.Slice() {
@@ -229,6 +240,17 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
229240 return l, nil
230241}
231242
243+func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
244+ var cmd tea.Cmd
245+ switch msg.Button {
246+ case tea.MouseWheelDown:
247+ cmd = l.MoveDown(ViewportDefaultScrollSize)
248+ case tea.MouseWheelUp:
249+ cmd = l.MoveUp(ViewportDefaultScrollSize)
250+ }
251+ return l, cmd
252+}
253+
232254// View implements List.
233255func (l *list[T]) View() string {
234256 if l.height <= 0 || l.width <= 0 {
@@ -292,9 +314,8 @@ func (l *list[T]) render() tea.Cmd {
292314 }
293315 // we are not rendering the first time
294316 if l.rendered != "" {
295− l.rendered = ""
296317 // rerender everything will mostly hit cache
297− _ = l.renderIterator(0, false)
318+ l.rendered, _ = l.renderIterator(0, false, "")
298319 if l.direction == DirectionBackward {
299320 l.recalculateItemPositions()
300321 }
@@ -304,14 +325,17 @@ func (l *list[T]) render() tea.Cmd {
304325 }
305326 return focusChangeCmd
306327 }
307− finishIndex := l.renderIterator(0, true)
328+ rendered, finishIndex := l.renderIterator(0, true, "")
329+ l.rendered = rendered
330+
308331 // recalculate for the initial items
309332 if l.direction == DirectionBackward {
310333 l.recalculateItemPositions()
311334 }
312335 renderCmd := func() tea.Msg {
336+ l.offset = 0
313337 // render the rest
314− _ = l.renderIterator(finishIndex, false)
338+ l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
315339 // needed for backwards
316340 if l.direction == DirectionBackward {
317341 l.recalculateItemPositions()
@@ -321,7 +345,7 @@ func (l *list[T]) render() tea.Cmd {
321345 l.scrollToSelection()
322346 }
323347
324− return renderedMsg{}
348+ return nil
325349 }
326350 return tea.Batch(focusChangeCmd, renderCmd)
327351}
@@ -568,13 +592,14 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
568592}
569593
570594// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
571−// returns the last index
572−func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
573− currentContentHeight := lipgloss.Height(l.rendered) - 1
595+// returns the last index and the rendered content so far
596+// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
597+func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
598+ currentContentHeight := lipgloss.Height(rendered) - 1
574599 itemsLen := l.items.Len()
575600 for i := startInx; i < itemsLen; i++ {
576601 if currentContentHeight >= l.height && limitHeight {
577− return i
602+ return rendered, i
578603 }
579604 // cool way to go through the list in both directions
580605 inx := i
@@ -602,13 +627,13 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
602627 }
603628
604629 if l.direction == DirectionForward {
605− l.rendered += rItem.view + strings.Repeat("\n", gap)
630+ rendered += rItem.view + strings.Repeat("\n", gap)
606631 } else {
607− l.rendered = rItem.view + strings.Repeat("\n", gap) + l.rendered
632+ rendered = rItem.view + strings.Repeat("\n", gap) + rendered
608633 }
609634 currentContentHeight = rItem.end + 1 + l.gap
610635 }
611− return itemsLen
636+ return rendered, itemsLen
612637}
613638
614639func (l *list[T]) renderItem(item Item) renderedItem {
internal/tui/page/chat/chat.go+35
@@ -164,6 +164,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
164164 case tea.KeyboardEnhancementsMsg:
165165 p.keyboardEnhancements = msg
166166 return p, nil
167+ case tea.MouseWheelMsg:
168+ if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) {
169+ u, cmd := p.chat.Update(msg)
170+ p.chat = u.(chat.MessageListCmp)
171+ return p, cmd
172+ }
173+ return p, nil
167174 case tea.WindowSizeMsg:
168175 return p, p.SetSize(msg.Width, msg.Height)
169176 case CancelTimerExpiredMsg:
@@ -906,3 +913,31 @@ func (p *chatPage) Help() help.KeyMap {
906913func (p *chatPage) IsChatFocused() bool {
907914 return p.focusedPane == PanelTypeChat
908915}
916+
917+// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
918+// Returns true if the mouse is over the chat area, false otherwise.
919+func (p *chatPage) isMouseOverChat(x, y int) bool {
920+ // No session means no chat area
921+ if p.session.ID == "" {
922+ return false
923+ }
924+
925+ var chatX, chatY, chatWidth, chatHeight int
926+
927+ if p.compact {
928+ // In compact mode: chat area starts after header and spans full width
929+ chatX = 0
930+ chatY = HeaderHeight
931+ chatWidth = p.width
932+ chatHeight = p.height - EditorHeight - HeaderHeight
933+ } else {
934+ // In non-compact mode: chat area spans from left edge to sidebar
935+ chatX = 0
936+ chatY = 0
937+ chatWidth = p.width - SideBarWidth
938+ chatHeight = p.height - EditorHeight
939+ }
940+
941+ // Check if mouse coordinates are within chat bounds
942+ return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
943+}
internal/tui/tui.go+8−15
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "strings"
7+ "time"
78
89 "github.com/charmbracelet/bubbles/v2/key"
910 tea "github.com/charmbracelet/bubbletea/v2"
@@ -33,26 +34,18 @@ import (
3334 "github.com/charmbracelet/lipgloss/v2"
3435)
3536
36−// MouseEventFilter filters mouse events based on the current focus state
37−// This is used with tea.WithFilter to prevent mouse scroll events from
38−// interfering with typing performance in the editor
37+var lastMouseEvent time.Time
38+
3939func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
40− // Only filter mouse events
4140 switch msg.(type) {
4241 case tea.MouseWheelMsg, tea.MouseMotionMsg:
43− // Check if we have an appModel and if editor is focused
44− if appModel, ok := m.(*appModel); ok {
45− if appModel.currentPage == chat.ChatPageID {
46− if chatPage, ok := appModel.pages[appModel.currentPage].(chat.ChatPage); ok {
47− // If editor is focused (not chatFocused), filter out mouse wheel/motion events
48− if !chatPage.IsChatFocused() {
49− return nil // Filter out the event
50− }
51− }
52− }
42+ now := time.Now()
43+ // trackpad is sending too many requests
44+ if now.Sub(lastMouseEvent) < 5*time.Millisecond {
45+ return nil
5346 }
47+ lastMouseEvent = now
5448 }
55− // Allow all other events to pass through
5649 return msg
5750}
5851