on main help sign in
chore: add mouse support
4451131Kujtim Hoxha
internal/tui/components/chat/chat.go+6
@@ -67,6 +67,7 @@ func New(app *app.App) MessageListCmp {
67 list.WithDirectionBackward(),
67 list.WithDirectionBackward(),
68 list.WithFocus(false),
68 list.WithFocus(false),
69 list.WithKeyMap(defaultListKeyMap),
69 list.WithKeyMap(defaultListKeyMap),
70+ list.WithEnableMouse(),
70 )
71 )
71 return &messageListCmp{
72 return &messageListCmp{
72 app: app,
73 app: app,
@@ -97,6 +98,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
97 case pubsub.Event[message.Message]:
98 case pubsub.Event[message.Message]:
98 cmd := m.handleMessageEvent(msg)
99 cmd := m.handleMessageEvent(msg)
99 return m, cmd
100 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
100 default:
106 default:
101 var cmds []tea.Cmd
107 var cmds []tea.Cmd
102 u, cmd := m.listCmp.Update(msg)
108 u, cmd := m.listCmp.Update(msg)
internal/tui/exp/list/list.go+38−13
@@ -23,7 +23,6 @@ type HasAnim interface {
23 Item
23 Item
24 Spinning() bool
24 Spinning() bool
25}
25}
26−type renderedMsg struct{}
27
26
28type List[T Item] interface {
27type List[T Item] interface {
29 util.Model
28 util.Model
@@ -77,6 +76,7 @@ type confOptions struct {
77 selectedItem string
76 selectedItem string
78 focused bool
77 focused bool
79 resize bool
78 resize bool
79+ enableMouse bool
80}
80}
81
81
82type list[T Item] struct {
82type list[T Item] struct {
@@ -156,6 +156,12 @@ func WithResizeByList() ListOption {
156 }
156 }
157}
157}
158
158
159+func WithEnableMouse() ListOption {
160+ return func(l *confOptions) {
161+ l.enableMouse = true
162+ }
163+}
164+
159func New[T Item](items []T, opts ...ListOption) List[T] {
165func New[T Item](items []T, opts ...ListOption) List[T] {
160 list := &list[T]{
166 list := &list[T]{
161 confOptions: &confOptions{
167 confOptions: &confOptions{
@@ -188,6 +194,11 @@ func (l *list[T]) Init() tea.Cmd {
188// Update implements List.
194// Update implements List.
189func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
195func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
190 switch msg := msg.(type) {
196 switch msg := msg.(type) {
197+ case tea.MouseWheelMsg:
198+ if l.enableMouse {
199+ return l.handleMouseWheel(msg)
200+ }
201+ return l, nil
191 case anim.StepMsg:
202 case anim.StepMsg:
192 var cmds []tea.Cmd
203 var cmds []tea.Cmd
193 for _, item := range l.items.Slice() {
204 for _, item := range l.items.Slice() {
@@ -229,6 +240,17 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
229 return l, nil
240 return l, nil
230}
241}
231
242
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+
232// View implements List.
254// View implements List.
233func (l *list[T]) View() string {
255func (l *list[T]) View() string {
234 if l.height <= 0 || l.width <= 0 {
256 if l.height <= 0 || l.width <= 0 {
@@ -292,9 +314,8 @@ func (l *list[T]) render() tea.Cmd {
292 }
314 }
293 // we are not rendering the first time
315 // we are not rendering the first time
294 if l.rendered != "" {
316 if l.rendered != "" {
295− l.rendered = ""
296 // rerender everything will mostly hit cache
317 // rerender everything will mostly hit cache
297− _ = l.renderIterator(0, false)
318+ l.rendered, _ = l.renderIterator(0, false, "")
298 if l.direction == DirectionBackward {
319 if l.direction == DirectionBackward {
299 l.recalculateItemPositions()
320 l.recalculateItemPositions()
300 }
321 }
@@ -304,14 +325,17 @@ func (l *list[T]) render() tea.Cmd {
304 }
325 }
305 return focusChangeCmd
326 return focusChangeCmd
306 }
327 }
307− finishIndex := l.renderIterator(0, true)
328+ rendered, finishIndex := l.renderIterator(0, true, "")
329+ l.rendered = rendered
330+
308 // recalculate for the initial items
331 // recalculate for the initial items
309 if l.direction == DirectionBackward {
332 if l.direction == DirectionBackward {
310 l.recalculateItemPositions()
333 l.recalculateItemPositions()
311 }
334 }
312 renderCmd := func() tea.Msg {
335 renderCmd := func() tea.Msg {
336+ l.offset = 0
313 // render the rest
337 // render the rest
314− _ = l.renderIterator(finishIndex, false)
338+ l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
315 // needed for backwards
339 // needed for backwards
316 if l.direction == DirectionBackward {
340 if l.direction == DirectionBackward {
317 l.recalculateItemPositions()
341 l.recalculateItemPositions()
@@ -321,7 +345,7 @@ func (l *list[T]) render() tea.Cmd {
321 l.scrollToSelection()
345 l.scrollToSelection()
322 }
346 }
323
347
324− return renderedMsg{}
348+ return nil
325 }
349 }
326 return tea.Batch(focusChangeCmd, renderCmd)
350 return tea.Batch(focusChangeCmd, renderCmd)
327}
351}
@@ -568,13 +592,14 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
568}
592}
569
593
570// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
594// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
571−// returns the last index
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
572−func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
597+func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
573− currentContentHeight := lipgloss.Height(l.rendered) - 1
598+ currentContentHeight := lipgloss.Height(rendered) - 1
574 itemsLen := l.items.Len()
599 itemsLen := l.items.Len()
575 for i := startInx; i < itemsLen; i++ {
600 for i := startInx; i < itemsLen; i++ {
576 if currentContentHeight >= l.height && limitHeight {
601 if currentContentHeight >= l.height && limitHeight {
577− return i
602+ return rendered, i
578 }
603 }
579 // cool way to go through the list in both directions
604 // cool way to go through the list in both directions
580 inx := i
605 inx := i
@@ -602,13 +627,13 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
602 }
627 }
603
628
604 if l.direction == DirectionForward {
629 if l.direction == DirectionForward {
605− l.rendered += rItem.view + strings.Repeat("\n", gap)
630+ rendered += rItem.view + strings.Repeat("\n", gap)
606 } else {
631 } else {
607− l.rendered = rItem.view + strings.Repeat("\n", gap) + l.rendered
632+ rendered = rItem.view + strings.Repeat("\n", gap) + rendered
608 }
633 }
609 currentContentHeight = rItem.end + 1 + l.gap
634 currentContentHeight = rItem.end + 1 + l.gap
610 }
635 }
611− return itemsLen
636+ return rendered, itemsLen
612}
637}
613
638
614func (l *list[T]) renderItem(item Item) renderedItem {
639func (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) {
164 case tea.KeyboardEnhancementsMsg:
164 case tea.KeyboardEnhancementsMsg:
165 p.keyboardEnhancements = msg
165 p.keyboardEnhancements = msg
166 return p, nil
166 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
167 case tea.WindowSizeMsg:
174 case tea.WindowSizeMsg:
168 return p, p.SetSize(msg.Width, msg.Height)
175 return p, p.SetSize(msg.Width, msg.Height)
169 case CancelTimerExpiredMsg:
176 case CancelTimerExpiredMsg:
@@ -906,3 +913,31 @@ func (p *chatPage) Help() help.KeyMap {
906func (p *chatPage) IsChatFocused() bool {
913func (p *chatPage) IsChatFocused() bool {
907 return p.focusedPane == PanelTypeChat
914 return p.focusedPane == PanelTypeChat
908}
915}
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 (
4 "context"
4 "context"
5 "fmt"
5 "fmt"
6 "strings"
6 "strings"
7+ "time"
7
8
8 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/key"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
@@ -33,26 +34,18 @@ import (
33 "github.com/charmbracelet/lipgloss/v2"
34 "github.com/charmbracelet/lipgloss/v2"
34)
35)
35
36
36−// MouseEventFilter filters mouse events based on the current focus state
37+var lastMouseEvent time.Time
37−// This is used with tea.WithFilter to prevent mouse scroll events from
38+
38−// interfering with typing performance in the editor
39func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
39func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
40− // Only filter mouse events
41 switch msg.(type) {
40 switch msg.(type) {
42 case tea.MouseWheelMsg, tea.MouseMotionMsg:
41 case tea.MouseWheelMsg, tea.MouseMotionMsg:
43− // Check if we have an appModel and if editor is focused
42+ now := time.Now()
44− if appModel, ok := m.(*appModel); ok {
43+ // trackpad is sending too many requests
45− if appModel.currentPage == chat.ChatPageID {
44+ if now.Sub(lastMouseEvent) < 5*time.Millisecond {
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
45+ return nil
50− }
51− }
52− }
53 }
46 }
47+ lastMouseEvent = now
54 }
48 }
55− // Allow all other events to pass through
56 return msg
49 return msg
57}
50}
58
51