(state: WheelAccelState, dir: 1 | -1, now: number)
| 174 | * step=0 (scrollBy(0) is a no-op, onScroll(false) is idempotent). Exported |
| 175 | * for tests. */ |
| 176 | export function computeWheelStep(state: WheelAccelState, dir: 1 | -1, now: number): number { |
| 177 | if (!state.xtermJs) { |
| 178 | // Device-switch guard ①: idle disengage. Runs BEFORE pendingFlip resolve |
| 179 | // so a pending bounce (28% of last-mouse-events) doesn't bypass it via |
| 180 | // the real-reversal early return. state.time is either the last committed |
| 181 | // event OR the deferred flip — both count as "last activity". |
| 182 | if (state.wheelMode && now - state.time > WHEEL_MODE_IDLE_DISENGAGE_MS) { |
| 183 | state.wheelMode = false; |
| 184 | state.burstCount = 0; |
| 185 | state.mult = state.base; |
| 186 | } |
| 187 | |
| 188 | // Resolve any deferred flip BEFORE touching state.time/dir — we need the |
| 189 | // pre-flip state.dir to distinguish bounce (flip-back) from real reversal |
| 190 | // (flip persisted), and state.time (= bounce timestamp) for the gap check. |
| 191 | if (state.pendingFlip) { |
| 192 | state.pendingFlip = false; |
| 193 | if (dir !== state.dir || now - state.time > WHEEL_BOUNCE_GAP_MAX_MS) { |
| 194 | // Real reversal: new dir persisted, OR flip-back arrived too late. |
| 195 | // Commit. The deferred event's 1 row is lost (acceptable latency). |
| 196 | state.dir = dir; |
| 197 | state.time = now; |
| 198 | state.mult = state.base; |
| 199 | return Math.floor(state.mult); |
| 200 | } |
| 201 | // Bounce confirmed: flipped back to original dir within the window. |
| 202 | // state.dir/mult unchanged from pre-bounce. state.time was advanced to |
| 203 | // the bounce below, so gap here = flip-back interval — reflects the |
| 204 | // user's actual click cadence (bounce IS a physical click, just noisy). |
| 205 | state.wheelMode = true; |
| 206 | } |
| 207 | const gap = now - state.time; |
| 208 | if (dir !== state.dir && state.dir !== 0) { |
| 209 | // Flip. Defer — next event decides bounce vs. real reversal. Advance |
| 210 | // time (but NOT dir/mult): if this turns out to be a bounce, the |
| 211 | // confirm event's gap will be the flip-back interval, which reflects |
| 212 | // the user's actual click rate. The bounce IS a physical wheel click, |
| 213 | // just misread by the encoder — it should count toward cadence. |
| 214 | state.pendingFlip = true; |
| 215 | state.time = now; |
| 216 | return 0; |
| 217 | } |
| 218 | state.dir = dir; |
| 219 | state.time = now; |
| 220 | |
| 221 | // ─── MOUSE (wheel mode, sticky until device-switch signal) ─── |
| 222 | if (state.wheelMode) { |
| 223 | if (gap < WHEEL_BURST_MS) { |
| 224 | // Same-batch burst check (ported from xterm.js): iTerm2 proportional |
| 225 | // reporting sends 2+ SGR events for one detent when macOS gives |
| 226 | // delta>1. Without this, the 2nd event at gap<1ms has m≈1 → STEP*m=15 |
| 227 | // → one gentle click gives 1+15=16 rows. |
| 228 | // |
| 229 | // Device-switch guard ②: trackpad flick produces 100+ events at <5ms |
| 230 | // (measured); mouse produces ≤3. 5+ consecutive → trackpad flick. |
| 231 | if (++state.burstCount >= 5) { |
| 232 | state.wheelMode = false; |
| 233 | state.burstCount = 0; |
no test coverage detected