on main help sign in
feat(scrolling): move to delta coalescing filter (#3135)
ac4bd9cKieran Klukas
internal/cmd/root.go+2−1
@@ -127,12 +127,13 @@ crush --continue
127 com := common.DefaultCommon(ws)
127 com := common.DefaultCommon(ws)
128 model := ui.New(com, sessionID, continueLast)
128 model := ui.New(com, sessionID, continueLast)
129
129
130+ inputFilter := ui.NewFilter()
130 var env uv.Environ = os.Environ()
131 var env uv.Environ = os.Environ()
131 program := tea.NewProgram(
132 program := tea.NewProgram(
132 model,
133 model,
133 tea.WithEnvironment(env),
134 tea.WithEnvironment(env),
134 tea.WithContext(cmd.Context()),
135 tea.WithContext(cmd.Context()),
135− tea.WithFilter(ui.MouseEventFilter),
136+ tea.WithFilter(inputFilter.Filter),
136 )
137 )
137 go ws.Subscribe(program)
138 go ws.Subscribe(program)
138
139
internal/ui/common/interface.go+9
@@ -9,3 +9,12 @@ type Model[T any] interface {
9 Update(msg tea.Msg) (T, tea.Cmd)
9 Update(msg tea.Msg) (T, tea.Cmd)
10 View() string
10 View() string
11}
11}
12+
13+// CoalescedWheelMsg is emitted by the input filter after collapsing many
14+// terminal wheel samples into one message. DeltaX and DeltaY carry the summed
15+// scroll magnitude so handlers can scroll proportionally.
16+type CoalescedWheelMsg struct {
17+ Mouse tea.Mouse
18+ DeltaX float64
19+ DeltaY float64
20+}
internal/ui/dialog/arguments.go+3−3
@@ -230,11 +230,11 @@ func (a *Arguments) HandleMsg(msg tea.Msg) Action {
230 a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
230 a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
231 return ActionCmd{Cmd: cmd}
231 return ActionCmd{Cmd: cmd}
232 }
232 }
233− case tea.MouseWheelMsg:
233+ case common.CoalescedWheelMsg:
234− a.viewport, _ = a.viewport.Update(msg)
234+ a.viewport, _ = a.viewport.Update(tea.MouseWheelMsg(msg.Mouse))
235 // If focused field scrolled out of view, focus the visible field
235 // If focused field scrolled out of view, focus the visible field
236 if !a.isFieldVisible(a.focused) {
236 if !a.isFieldVisible(a.focused) {
237− a.focusInput(a.findVisibleFieldByOffset(msg.Button == tea.MouseWheelDown))
237+ a.focusInput(a.findVisibleFieldByOffset(msg.DeltaY > 0))
238 }
238 }
239 case tea.PasteMsg:
239 case tea.PasteMsg:
240 var cmd tea.Cmd
240 var cmd tea.Cmd
internal/ui/dialog/permissions.go+6−7
@@ -278,18 +278,17 @@ func (p *Permissions) HandleMsg(msg tea.Msg) Action {
278 p.viewport, _ = p.viewport.Update(msg)
278 p.viewport, _ = p.viewport.Update(msg)
279 }
279 }
280 }
280 }
281− case tea.MouseWheelMsg:
281+ case common.CoalescedWheelMsg:
282 if p.hasDiffView() {
282 if p.hasDiffView() {
283− switch msg.Button {
283+ if msg.DeltaX < 0 {
284− case tea.MouseWheelLeft:
285 p.scrollLeft()
284 p.scrollLeft()
286− case tea.MouseWheelRight:
285+ } else if msg.DeltaX > 0 {
287 p.scrollRight()
286 p.scrollRight()
288− default:
287+ } else {
289− p.viewport, _ = p.viewport.Update(msg)
288+ p.viewport, _ = p.viewport.Update(tea.MouseWheelMsg(msg.Mouse))
290 }
289 }
291 } else {
290 } else {
292− p.viewport, _ = p.viewport.Update(msg)
291+ p.viewport, _ = p.viewport.Update(tea.MouseWheelMsg(msg.Mouse))
293 }
292 }
294 default:
293 default:
295 // Pass unhandled keys to viewport for non-diff content scrolling.
294 // Pass unhandled keys to viewport for non-diff content scrolling.
internal/ui/model/filter.go+84−8
@@ -4,19 +4,95 @@ import (
4 "time"
4 "time"
5
5
6 tea "charm.land/bubbletea/v2"
6 tea "charm.land/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/ui/common"
7)
8)
8
9
9−var lastMouseEvent time.Time
10+// inputFilterInterval targets roughly one filtered sample per 60 Hz frame.
11+// This keeps continuous mouse input responsive while bounding how many
12+// terminal escape samples can queue ahead of later key presses.
13+const inputFilterInterval = 16 * time.Millisecond
10
14
15+// Filter coalesces high-frequency terminal mouse input before it reaches the
16+// Bubble Tea update loop. It is safe for use with tea.WithFilter.
17+type Filter struct {
18+ now func() time.Time
19+
20+ lastMotion time.Time
21+ lastWheel time.Time
22+ wheelDX float64
23+ wheelDY float64
24+}
25+
26+// NewFilter returns a Bubble Tea input filter that coalesces high-frequency
27+// terminal mouse input before it reaches the update queue.
28+func NewFilter() *Filter {
29+ return &Filter{now: time.Now}
30+}
31+
32+// Filter is passed to tea.WithFilter and runs before Bubble Tea dispatches a
33+// message to the model. Returning nil drops noisy samples at the input
34+// boundary, which is early enough to keep later key presses from waiting
35+// behind a mouse flood in the update queue.
11−func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
36+func (f *Filter) Filter(_ tea.Model, msg tea.Msg) tea.Msg {
12− switch msg.(type) {
37+ switch typed := msg.(type) {
13− case tea.MouseWheelMsg, tea.MouseMotionMsg:
38+ case tea.MouseWheelMsg:
14− now := time.Now()
39+ mouse := typed.Mouse()
15− // trackpad is sending too many requests
40+ dx, dy := wheelDeltas(mouse)
16− if now.Sub(lastMouseEvent) < 15*time.Millisecond {
41+ f.wheelDX += dx
42+ f.wheelDY += dy
43+ if !f.allow(&f.lastWheel) {
44+ return nil
45+ }
46+ aggregated := common.CoalescedWheelMsg{
47+ Mouse: mouse,
48+ DeltaX: f.wheelDX,
49+ DeltaY: f.wheelDY,
50+ }
51+ f.wheelDX = 0
52+ f.wheelDY = 0
53+ return aggregated
54+
55+ case tea.MouseMotionMsg:
56+ if !f.allow(&f.lastMotion) {
17 return nil
57 return nil
18 }
58 }
19− lastMouseEvent = now
59+ return msg
20 }
60 }
61+
21 return msg
62 return msg
22}
63}
64+
65+// allow reports whether enough time has elapsed for the next sample in one
66+// coalescing stream. Each stream owns a separate last timestamp so wheel and
67+// motion events can be throttled independently.
68+func (f *Filter) allow(last *time.Time) bool {
69+ now := f.now
70+ if now == nil {
71+ now = time.Now
72+ }
73+ at := now()
74+ if !last.IsZero() && at.Sub(*last) < inputFilterInterval {
75+ return false
76+ }
77+ *last = at
78+ return true
79+}
80+
81+// wheelDeltas converts Bubble Tea's wheel button direction into signed scroll
82+// deltas. Bubble Tea represents wheel movement as button presses, so we
83+// synthesize a unit delta for each sample and let Filter aggregate the units
84+// when events are coalesced.
85+func wheelDeltas(mouse tea.Mouse) (float64, float64) {
86+ switch mouse.Button {
87+ case tea.MouseWheelUp:
88+ return 0, -1
89+ case tea.MouseWheelDown:
90+ return 0, 1
91+ case tea.MouseWheelLeft:
92+ return -1, 0
93+ case tea.MouseWheelRight:
94+ return 1, 0
95+ default:
96+ return 0, 0
97+ }
98+}
internal/ui/model/filter_test.gonew file+137
@@ -0,0 +1,137 @@
1+package model
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ tea "charm.land/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/ui/common"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func newTestFilter(t *testing.T) (*Filter, *time.Time) {
13+ t.Helper()
14+ now := time.Now()
15+ f := &Filter{now: func() time.Time { return now }}
16+ return f, &now
17+}
18+
19+func wheelUp() tea.MouseWheelMsg {
20+ return tea.MouseWheelMsg(tea.Mouse{Button: tea.MouseWheelUp})
21+}
22+
23+func wheelDown() tea.MouseWheelMsg {
24+ return tea.MouseWheelMsg(tea.Mouse{Button: tea.MouseWheelDown})
25+}
26+
27+func TestFilter_SingleEventPassthrough(t *testing.T) {
28+ t.Parallel()
29+ f, _ := newTestFilter(t)
30+
31+ result := f.Filter(nil, wheelDown())
32+ msg, ok := result.(common.CoalescedWheelMsg)
33+ require.True(t, ok, "expected CoalescedWheelMsg")
34+ require.Equal(t, float64(0), msg.DeltaX)
35+ require.Equal(t, float64(1), msg.DeltaY)
36+}
37+
38+func TestFilter_CoalescesSameDirection(t *testing.T) {
39+ t.Parallel()
40+ f, now := newTestFilter(t)
41+
42+ // First event passes through.
43+ result := f.Filter(nil, wheelDown())
44+ require.IsType(t, common.CoalescedWheelMsg{}, result)
45+
46+ // Second event within window gets dropped.
47+ *now = now.Add(5 * time.Millisecond)
48+ result = f.Filter(nil, wheelDown())
49+ require.Nil(t, result)
50+
51+ // Third event within window gets dropped.
52+ *now = now.Add(5 * time.Millisecond)
53+ result = f.Filter(nil, wheelDown())
54+ require.Nil(t, result)
55+
56+ // After window expires, accumulated delta is emitted.
57+ *now = now.Add(20 * time.Millisecond)
58+ result = f.Filter(nil, wheelDown())
59+ msg, ok := result.(common.CoalescedWheelMsg)
60+ require.True(t, ok, "expected CoalescedWheelMsg after window")
61+ require.Equal(t, float64(3), msg.DeltaY, "should accumulate 3 down events")
62+}
63+
64+func TestFilter_DirectionChangeResetsAccumulator(t *testing.T) {
65+ t.Parallel()
66+ f, now := newTestFilter(t)
67+
68+ // Scroll down (passes through immediately).
69+ f.Filter(nil, wheelDown())
70+
71+ // Immediately scroll up (within window). Accumulates but suppressed.
72+ *now = now.Add(5 * time.Millisecond)
73+ result := f.Filter(nil, wheelUp())
74+ require.Nil(t, result, "within time window, should be suppressed")
75+
76+ // After window, the up delta includes the suppressed event plus this one.
77+ *now = now.Add(20 * time.Millisecond)
78+ result = f.Filter(nil, wheelUp())
79+ msg, ok := result.(common.CoalescedWheelMsg)
80+ require.True(t, ok)
81+ require.Equal(t, float64(-2), msg.DeltaY, "should have 2 accumulated up events")
82+}
83+
84+func TestFilter_WindowExpiryEmitsAccumulated(t *testing.T) {
85+ t.Parallel()
86+ f, now := newTestFilter(t)
87+
88+ // First event passes through immediately.
89+ result := f.Filter(nil, wheelUp())
90+ require.IsType(t, common.CoalescedWheelMsg{}, result)
91+
92+ // Two more events within window get accumulated.
93+ *now = now.Add(5 * time.Millisecond)
94+ f.Filter(nil, wheelUp())
95+ *now = now.Add(5 * time.Millisecond)
96+ f.Filter(nil, wheelUp())
97+
98+ // Wait for window to expire.
99+ *now = now.Add(20 * time.Millisecond)
100+
101+ // Next event triggers emission of accumulated delta (2 suppressed + 1 new).
102+ result = f.Filter(nil, wheelUp())
103+ msg, ok := result.(common.CoalescedWheelMsg)
104+ require.True(t, ok)
105+ require.Equal(t, float64(-3), msg.DeltaY, "should include accumulated events plus triggering event")
106+}
107+
108+func TestFilter_MotionThrottledIndependently(t *testing.T) {
109+ t.Parallel()
110+ f, now := newTestFilter(t)
111+
112+ motion := tea.MouseMotionMsg(tea.Mouse{X: 1, Y: 1})
113+
114+ // First motion passes.
115+ result := f.Filter(nil, motion)
116+ require.NotNil(t, result)
117+
118+ // Motion within window is dropped.
119+ *now = now.Add(5 * time.Millisecond)
120+ result = f.Filter(nil, motion)
121+ require.Nil(t, result)
122+
123+ // Wheel event is not affected by motion throttle.
124+ *now = now.Add(5 * time.Millisecond)
125+ result = f.Filter(nil, wheelDown())
126+ require.NotNil(t, result, "wheel should not be throttled by motion")
127+}
128+
129+func TestFilter_NonMouseMessagesPassThrough(t *testing.T) {
130+ t.Parallel()
131+ f, _ := newTestFilter(t)
132+
133+ type customMsg struct{}
134+ msg := customMsg{}
135+ result := f.Filter(nil, msg)
136+ require.Equal(t, msg, result, "non-mouse messages should pass through unchanged")
137+}
internal/ui/model/ui.go+19−27
@@ -62,10 +62,6 @@ import (
62 xstrings "github.com/charmbracelet/x/exp/strings"
62 xstrings "github.com/charmbracelet/x/exp/strings"
63)
63)
64
64
65−// MouseScrollThreshold defines how many lines to scroll the chat when a mouse
66−// wheel event occurs.
67−const MouseScrollThreshold = 5
68−
69// Compact mode breakpoints.
65// Compact mode breakpoints.
70const (
66const (
71 compactModeWidthBreakpoint = 120
67 compactModeWidthBreakpoint = 120
@@ -855,41 +851,37 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
855 }))
851 }))
856 }
852 }
857 }
853 }
858− case tea.MouseWheelMsg:
854+ case common.CoalescedWheelMsg:
859 // Pass mouse events to dialogs first if any are open.
855 // Pass mouse events to dialogs first if any are open.
860 if m.dialog.HasDialogs() {
856 if m.dialog.HasDialogs() {
861 m.dialog.Update(msg)
857 m.dialog.Update(msg)
862 return m, tea.Batch(cmds...)
858 return m, tea.Batch(cmds...)
863 }
859 }
864
860
865− // Otherwise handle mouse wheel for chat.
861+ // Otherwise handle mouse wheel for chat. Use the coalesced delta
862+ // directly as the line count. Terminals like Ghostty send DeltaY=3
863+ // per physical wheel tick (matching their native scrollback), while
864+ // others send DeltaY=1.
866 switch m.state {
865 switch m.state {
867 case uiChat:
866 case uiChat:
868− switch msg.Button {
867+ lines := int(msg.DeltaY)
869− case tea.MouseWheelUp:
868+ if lines == 0 {
869+ break
870+ }
870− if cmd := m.chat.ScrollByAndAnimate(-MouseScrollThreshold); cmd != nil {
871+ if cmd := m.chat.ScrollByAndAnimate(lines); cmd != nil {
871− cmds = append(cmds, cmd)
872+ cmds = append(cmds, cmd)
872− }
873+ }
873− if !m.chat.SelectedItemInView() {
874+ if !m.chat.SelectedItemInView() {
875+ if lines < 0 {
874 m.chat.SelectPrev()
876 m.chat.SelectPrev()
875− if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
877+ } else if m.chat.AtBottom() {
876− cmds = append(cmds, cmd)
878+ m.chat.SelectLast()
877− }
879+ } else {
880+ m.chat.SelectNext()
878 }
881 }
879− case tea.MouseWheelDown:
882+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
880− if cmd := m.chat.ScrollByAndAnimate(MouseScrollThreshold); cmd != nil {
881 cmds = append(cmds, cmd)
883 cmds = append(cmds, cmd)
882 }
884 }
883− if !m.chat.SelectedItemInView() {
884− if m.chat.AtBottom() {
885− m.chat.SelectLast()
886− } else {
887− m.chat.SelectNext()
888− }
889− if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
890− cmds = append(cmds, cmd)
891− }
892− }
893 }
885 }
894 }
886 }
895 case anim.StepMsg:
887 case anim.StepMsg: