From 295e90e3b4defbf352a8940f2eb054d5351e0ec7 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Tue, 24 Mar 2026 11:54:14 -0400 Subject: [PATCH 1/7] fix: reduce live view scroll sensitivity Replace the old throttle-and-clamp scroll handler with a fixed-rate polling approach that sends at most 1 scroll tick per 100ms interval in each direction. This dramatically reduces scroll speed for both mouse wheel and trackpad input: - Mouse wheel: 1 notch now sends 1 tick (~120px) instead of 7-10 ticks - Trackpad: max 10 ticks/sec instead of 60-100 ticks/sec - Accumulated deltas are tracked with a jitter threshold to filter noise - Interval auto-cleans when scroll input stops Also lower the default scroll sensitivity from 10 to 3. Made-with: Cursor --- .../client/src/components/video.vue | 42 ++++++++++++------- .../client/src/store/settings.ts | 2 +- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index a2a739db..1846dd35 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -703,7 +703,21 @@ }) } - wheelThrottle = false + scrollTimerId: number | null = null + pendingScrollX = 0 + pendingScrollY = 0 + + private sendScrollTick() { + const JITTER_THRESHOLD = 3 + const x = Math.abs(this.pendingScrollX) >= JITTER_THRESHOLD ? Math.sign(this.pendingScrollX) as number : 0 + const y = Math.abs(this.pendingScrollY) >= JITTER_THRESHOLD ? Math.sign(this.pendingScrollY) as number : 0 + this.pendingScrollX = 0 + this.pendingScrollY = 0 + if (x !== 0 || y !== 0) { + this.$client.sendData('wheel', { x, y }) + } + } + onWheel(e: WheelEvent) { if (!this.hosting || this.locked) { return @@ -712,11 +726,6 @@ let x = e.deltaX let y = e.deltaY - // Pixel units unless it's non-zero. - // Note that if deltamode is line or page won't matter since we aren't - // sending the mouse wheel delta to the server anyway. - // The difference between pixel and line can be important however since - // we have a threshold that can be smaller than the line height. if (e.deltaMode !== 0) { x *= WHEEL_LINE_HEIGHT y *= WHEEL_LINE_HEIGHT @@ -727,18 +736,21 @@ y = y * -1 } - x = Math.min(Math.max(x, -this.scroll), this.scroll) - y = Math.min(Math.max(y, -this.scroll), this.scroll) + this.pendingScrollX += x + this.pendingScrollY += y this.sendMousePos(e) - if (!this.wheelThrottle) { - this.wheelThrottle = true - this.$client.sendData('wheel', { x, y }) - - window.setTimeout(() => { - this.wheelThrottle = false - }, 100) + if (this.scrollTimerId === null) { + this.sendScrollTick() + this.scrollTimerId = window.setInterval(() => { + if (this.pendingScrollX === 0 && this.pendingScrollY === 0) { + window.clearInterval(this.scrollTimerId!) + this.scrollTimerId = null + return + } + this.sendScrollTick() + }, 100) as unknown as number } } diff --git a/images/chromium-headful/client/src/store/settings.ts b/images/chromium-headful/client/src/store/settings.ts index 28707345..dd9cdcd9 100644 --- a/images/chromium-headful/client/src/store/settings.ts +++ b/images/chromium-headful/client/src/store/settings.ts @@ -11,7 +11,7 @@ interface KeyboardLayouts { export const state = () => { return { - scroll: get('scroll', 10), + scroll: get('scroll', 3), scroll_invert: get('scroll_invert', true), autoplay: get('autoplay', true), ignore_emotes: get('ignore_emotes', false), From bd277ccf5ef5a0d1fc9d7b1094627649604cec52 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Tue, 24 Mar 2026 13:28:53 -0400 Subject: [PATCH 2/7] fix: wire scroll sensitivity setting to polling interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scroll slider (1–100) now controls the polling interval: - scroll=1 → 200ms interval (5 ticks/sec, slowest) - scroll=10 → 185ms interval (5.4 ticks/sec, default) - scroll=50 → 117ms interval (8.5 ticks/sec) - scroll=100 → 33ms interval (30 ticks/sec, fastest) Restore default scroll to 10 (the original value) since the setting is now meaningful again. Made-with: Cursor --- images/chromium-headful/client/src/components/video.vue | 9 ++++++++- images/chromium-headful/client/src/store/settings.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index 1846dd35..355ed19b 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -707,6 +707,13 @@ pendingScrollX = 0 pendingScrollY = 0 + private get scrollIntervalMs(): number { + const SLOWEST = 200 + const FASTEST = 33 + const t = Math.min(Math.max((this.scroll - 1) / 99, 0), 1) + return Math.round(SLOWEST - t * (SLOWEST - FASTEST)) + } + private sendScrollTick() { const JITTER_THRESHOLD = 3 const x = Math.abs(this.pendingScrollX) >= JITTER_THRESHOLD ? Math.sign(this.pendingScrollX) as number : 0 @@ -750,7 +757,7 @@ return } this.sendScrollTick() - }, 100) as unknown as number + }, this.scrollIntervalMs) as unknown as number } } diff --git a/images/chromium-headful/client/src/store/settings.ts b/images/chromium-headful/client/src/store/settings.ts index dd9cdcd9..28707345 100644 --- a/images/chromium-headful/client/src/store/settings.ts +++ b/images/chromium-headful/client/src/store/settings.ts @@ -11,7 +11,7 @@ interface KeyboardLayouts { export const state = () => { return { - scroll: get('scroll', 3), + scroll: get('scroll', 10), scroll_invert: get('scroll_invert', true), autoplay: get('autoplay', true), ignore_emotes: get('ignore_emotes', false), From e6d0eb6822ec3286ccdaabf1b6375fc76ba4e905 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Tue, 24 Mar 2026 13:42:36 -0400 Subject: [PATCH 3/7] fix: clear scroll interval on component destroy Made-with: Cursor --- images/chromium-headful/client/src/components/video.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index 355ed19b..1e2fea02 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -547,6 +547,10 @@ } beforeDestroy() { + if (this.scrollTimerId !== null) { + window.clearInterval(this.scrollTimerId) + this.scrollTimerId = null + } this.observer.disconnect() this.$accessor.video.setPlayable(false) /* Guacamole Keyboard does not provide destroy functions */ From 2c6ab923cb6e8772df9b11ee7a581d4386a67188 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Tue, 24 Mar 2026 14:42:39 -0400 Subject: [PATCH 4/7] fix: use proportional scaling for scroll ticks instead of direction-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reintroduce magnitude-aware tick calculation so fast scrolling feels responsive. Accumulated pixel deltas are divided by 120 (one Chromium scroll-notch worth of pixels) and clamped by the scroll setting. Slow scroll (1 notch ≈ 100px) still produces 1 tick; fast scroll (500px accumulated) now produces ~4 ticks instead of always 1. Made-with: Cursor --- .../client/src/components/video.vue | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index 1e2fea02..7d8d9762 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -719,11 +719,22 @@ } private sendScrollTick() { + const PIXELS_PER_TICK = 120 const JITTER_THRESHOLD = 3 - const x = Math.abs(this.pendingScrollX) >= JITTER_THRESHOLD ? Math.sign(this.pendingScrollX) as number : 0 - const y = Math.abs(this.pendingScrollY) >= JITTER_THRESHOLD ? Math.sign(this.pendingScrollY) as number : 0 + const rawX = this.pendingScrollX + const rawY = this.pendingScrollY this.pendingScrollX = 0 this.pendingScrollY = 0 + + const scaleAxis = (delta: number): number => { + if (Math.abs(delta) < JITTER_THRESHOLD) return 0 + const ticks = delta / PIXELS_PER_TICK + const clamped = Math.min(Math.max(Math.round(ticks), -this.scroll), this.scroll) + return clamped !== 0 ? clamped : Math.sign(delta) + } + + const x = scaleAxis(rawX) + const y = scaleAxis(rawY) if (x !== 0 || y !== 0) { this.$client.sendData('wheel', { x, y }) } From f4bd9ef94629aebf6b2ee3e8c97cbf36c5ff2571 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Tue, 24 Mar 2026 14:57:30 -0400 Subject: [PATCH 5/7] fix: guard scroll interval callback against hosting/locked state changes Made-with: Cursor --- images/chromium-headful/client/src/components/video.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index 7d8d9762..65bab9be 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -766,7 +766,9 @@ if (this.scrollTimerId === null) { this.sendScrollTick() this.scrollTimerId = window.setInterval(() => { - if (this.pendingScrollX === 0 && this.pendingScrollY === 0) { + if (!this.hosting || this.locked || (this.pendingScrollX === 0 && this.pendingScrollY === 0)) { + this.pendingScrollX = 0 + this.pendingScrollY = 0 window.clearInterval(this.scrollTimerId!) this.scrollTimerId = null return From 08a3238ded69e9ab80f691e7cf785a0158469dd4 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Tue, 24 Mar 2026 18:45:49 -0400 Subject: [PATCH 6/7] fix: simplify scroll fix to minimal divisor approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the interval/accumulation machinery with a one-line fix per Sayan's review: divide raw pixel deltas by 120 (one Chromium scroll notch) before clamping. Keeps the existing throttle, avoids interval lifecycle complexity. Old: 100px delta → clamped to 10 → 10 XButton events → 1200px scroll New: 100px delta → 100/120 = 1 → 1 XButton event → 120px scroll Made-with: Cursor --- .../client/src/components/video.vue | 62 ++++--------------- 1 file changed, 11 insertions(+), 51 deletions(-) diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index 65bab9be..aba2bf63 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -547,10 +547,6 @@ } beforeDestroy() { - if (this.scrollTimerId !== null) { - window.clearInterval(this.scrollTimerId) - this.scrollTimerId = null - } this.observer.disconnect() this.$accessor.video.setPlayable(false) /* Guacamole Keyboard does not provide destroy functions */ @@ -707,39 +703,7 @@ }) } - scrollTimerId: number | null = null - pendingScrollX = 0 - pendingScrollY = 0 - - private get scrollIntervalMs(): number { - const SLOWEST = 200 - const FASTEST = 33 - const t = Math.min(Math.max((this.scroll - 1) / 99, 0), 1) - return Math.round(SLOWEST - t * (SLOWEST - FASTEST)) - } - - private sendScrollTick() { - const PIXELS_PER_TICK = 120 - const JITTER_THRESHOLD = 3 - const rawX = this.pendingScrollX - const rawY = this.pendingScrollY - this.pendingScrollX = 0 - this.pendingScrollY = 0 - - const scaleAxis = (delta: number): number => { - if (Math.abs(delta) < JITTER_THRESHOLD) return 0 - const ticks = delta / PIXELS_PER_TICK - const clamped = Math.min(Math.max(Math.round(ticks), -this.scroll), this.scroll) - return clamped !== 0 ? clamped : Math.sign(delta) - } - - const x = scaleAxis(rawX) - const y = scaleAxis(rawY) - if (x !== 0 || y !== 0) { - this.$client.sendData('wheel', { x, y }) - } - } - + wheelThrottle = false onWheel(e: WheelEvent) { if (!this.hosting || this.locked) { return @@ -758,23 +722,19 @@ y = y * -1 } - this.pendingScrollX += x - this.pendingScrollY += y + const PIXELS_PER_TICK = 120 + x = x === 0 ? 0 : Math.min(Math.max(Math.round(x / PIXELS_PER_TICK) || Math.sign(x), -this.scroll), this.scroll) + y = y === 0 ? 0 : Math.min(Math.max(Math.round(y / PIXELS_PER_TICK) || Math.sign(y), -this.scroll), this.scroll) this.sendMousePos(e) - if (this.scrollTimerId === null) { - this.sendScrollTick() - this.scrollTimerId = window.setInterval(() => { - if (!this.hosting || this.locked || (this.pendingScrollX === 0 && this.pendingScrollY === 0)) { - this.pendingScrollX = 0 - this.pendingScrollY = 0 - window.clearInterval(this.scrollTimerId!) - this.scrollTimerId = null - return - } - this.sendScrollTick() - }, this.scrollIntervalMs) as unknown as number + if (!this.wheelThrottle) { + this.wheelThrottle = true + this.$client.sendData('wheel', { x, y }) + + window.setTimeout(() => { + this.wheelThrottle = false + }, 100) } } From b6eb9dda48fb963b8a816c043d5ebc6f4ef43ebf Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Wed, 25 Mar 2026 09:51:01 -0400 Subject: [PATCH 7/7] chore: restore and expand scroll comment per review feedback Adds back the removed deltaMode/pixel comment and expands it to explain the PIXELS_PER_TICK divisor and scroll clamp. Made-with: Cursor --- images/chromium-headful/client/src/components/video.vue | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index aba2bf63..ee585105 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -712,6 +712,8 @@ let x = e.deltaX let y = e.deltaY + // Normalize to pixel units. deltaMode 1 = lines, 2 = pages; convert + // both to approximate pixel values so the divisor below works uniformly. if (e.deltaMode !== 0) { x *= WHEEL_LINE_HEIGHT y *= WHEEL_LINE_HEIGHT @@ -722,6 +724,12 @@ y = y * -1 } + // The server sends one XTestFakeButtonEvent per unit we pass here, + // and each event scrolls Chromium by ~120 px. Raw pixel deltas from + // trackpads are already in pixels (~120 per notch), so dividing by + // PIXELS_PER_TICK converts them to discrete scroll "ticks". The + // result is clamped to [-scroll, scroll] (the user-facing sensitivity + // setting) so fast swipes don't over-scroll. const PIXELS_PER_TICK = 120 x = x === 0 ? 0 : Math.min(Math.max(Math.round(x / PIXELS_PER_TICK) || Math.sign(x), -this.scroll), this.scroll) y = y === 0 ? 0 : Math.min(Math.max(Math.round(y / PIXELS_PER_TICK) || Math.sign(y), -this.scroll), this.scroll)