Skip to content

Commit e1e64eb

Browse files
committed
scroll acceleration ghostty fix
1 parent 283f60d commit e1e64eb

File tree

3 files changed

+54
-19
lines changed

3 files changed

+54
-19
lines changed

packages/core/src/lib/scroll-acceleration.ts

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export class MacOSScrollAccel implements ScrollAcceleration {
3636
private velocityHistory: number[] = []
3737
private readonly historySize = 3
3838
private readonly streakTimeout = 150
39+
// some terminals send 2 or more ticks for each mouse wheel ticks, for example Ghostty, with a small delay between each, 3ms on averate.
40+
// We ignore these ticks otherwise they would cause faster acceleration to kick in
41+
// https://github.com/ghostty-org/ghostty/discussions/7577
42+
private readonly minTickInterval = 6
3943

4044
constructor(
4145
private opts: {
@@ -51,32 +55,30 @@ export class MacOSScrollAccel implements ScrollAcceleration {
5155
const maxMultiplier = this.opts.maxMultiplier ?? 6
5256

5357
const dt = this.lastTickTime ? now - this.lastTickTime : Infinity
54-
this.lastTickTime = now
5558

56-
// Reset streak if too much time has passed
57-
if (dt > this.streakTimeout) {
59+
// Reset streak if too much time has passed or first tick
60+
if (dt === Infinity || dt > this.streakTimeout) {
61+
this.lastTickTime = now
5862
this.velocityHistory = []
5963
return 1
6064
}
6165

62-
// Track recent intervals
63-
if (dt !== Infinity) {
64-
this.velocityHistory.push(dt)
65-
if (this.velocityHistory.length > this.historySize) {
66-
this.velocityHistory.shift()
67-
}
66+
// Ignore ticks closer than minTickInterval (they're part of the same logical tick)
67+
if (dt < this.minTickInterval) {
68+
return 1
6869
}
6970

70-
// Calculate average interval (lower = faster scrolling)
71-
const avgInterval =
72-
this.velocityHistory.length > 0
73-
? this.velocityHistory.reduce((a, b) => a + b, 0) / this.velocityHistory.length
74-
: Infinity
71+
this.lastTickTime = now
7572

76-
if (avgInterval === Infinity) {
77-
return 1
73+
74+
this.velocityHistory.push(dt)
75+
if (this.velocityHistory.length > this.historySize) {
76+
this.velocityHistory.shift()
7877
}
7978

79+
// Calculate average interval (lower = faster scrolling)
80+
const avgInterval = this.velocityHistory.reduce((a, b) => a + b, 0) / this.velocityHistory.length
81+
8082
// Convert interval to velocity: faster ticks = higher velocity
8183
// Normalize to a reference interval (e.g., 100ms = velocity of 1)
8284
const referenceInterval = 100

packages/core/src/renderables/ScrollBox.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,8 @@ export class ScrollBoxRenderable extends BoxRenderable {
204204
this.scrollAccel = scrollAcceleration
205205
} else if (process.platform === "darwin") {
206206
this.scrollAccel = new MacOSScrollAccel()
207-
} else {
208-
this.scrollAccel = new LinearScrollAccel()
209207
}
208+
this.scrollAccel ??= new LinearScrollAccel()
210209

211210
this.wrapper = new BoxRenderable(ctx, {
212211
flexDirection: "column",

packages/core/src/tests/scrollbox.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createTestRenderer, type TestRenderer, type MockMouse } from "../testin
33
import { ScrollBoxRenderable } from "../renderables/ScrollBox"
44
import { BoxRenderable } from "../renderables/Box"
55
import { TextRenderable } from "../renderables/Text"
6-
import { MacOSScrollAccel } from "../lib/scroll-acceleration"
6+
import { LinearScrollAccel, MacOSScrollAccel } from "../lib/scroll-acceleration"
77

88
let testRenderer: TestRenderer
99
let mockMouse: MockMouse
@@ -108,6 +108,40 @@ describe("ScrollBoxRenderable - Mouse interaction", () => {
108108
expect(scrollBox.scrollTop).toBeGreaterThan(0)
109109
})
110110

111+
test("single isolated scroll has same distance as linear", async () => {
112+
const linearBox = new ScrollBoxRenderable(testRenderer, {
113+
width: 50,
114+
height: 20,
115+
scrollAcceleration: new LinearScrollAccel(),
116+
})
117+
118+
for (let i = 0; i < 100; i++) linearBox.add(new TextRenderable(testRenderer, { text: `Line ${i}` }))
119+
testRenderer.root.add(linearBox)
120+
await renderOnce()
121+
122+
await mockMouse.scroll(25, 10, "down")
123+
await renderOnce()
124+
const linearDistance = linearBox.scrollTop
125+
126+
testRenderer.destroy()
127+
;({ renderer: testRenderer, mockMouse, renderOnce } = await createTestRenderer({ width: 80, height: 24 }))
128+
129+
const accelBox = new ScrollBoxRenderable(testRenderer, {
130+
width: 50,
131+
height: 20,
132+
scrollAcceleration: new MacOSScrollAccel(),
133+
})
134+
135+
for (let i = 0; i < 100; i++) accelBox.add(new TextRenderable(testRenderer, { text: `Line ${i}` }))
136+
testRenderer.root.add(accelBox)
137+
await renderOnce()
138+
139+
await mockMouse.scroll(25, 10, "down")
140+
await renderOnce()
141+
142+
expect(accelBox.scrollTop).toBe(linearDistance)
143+
})
144+
111145
test("acceleration makes rapid scrolls cover more distance", async () => {
112146
const scrollBox = new ScrollBoxRenderable(testRenderer, {
113147
width: 50,

0 commit comments

Comments
 (0)