fix(tui): add modifier-held precision wheel scrolling

Route Option/Alt or Ctrl wheel input through a gated precision path that scrolls at most one row per short interval, while preserving the existing accelerated behavior for plain wheel input. Keep precision active briefly after modifier release so queued wheel events from the same gesture do not jump into acceleration mid-stream.
This commit is contained in:
Brooklyn Nicholson 2026-04-29 20:39:39 -05:00
parent b978fd8b26
commit fc0f358f37

View file

@ -21,6 +21,8 @@ import { patchTurnState } from './turnStore.js'
import { getUiState } from './uiStore.js' import { getUiState } from './uiStore.js'
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
const PRECISION_WHEEL_MIN_GAP_MS = 80
const PRECISION_WHEEL_STICKY_MS = 80
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
@ -36,6 +38,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
// rows = wheelStep × accelMult. State mutates in place across renders. // rows = wheelStep × accelMult. State mutates in place across renders.
const wheelAccelRef = useRef(initWheelAccelForHost()) const wheelAccelRef = useRef(initWheelAccelForHost())
const precisionWheelRef = useRef<{ active: boolean; dir: 0 | -1 | 1; lastEventAtMs: number; lastScrollAtMs: number }>(
{ active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 }
)
useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), []) useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), [])
const scrollTranscript = (delta: number) => { const scrollTranscript = (delta: number) => {
@ -284,8 +290,43 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (key.wheelUp || key.wheelDown) { if (key.wheelUp || key.wheelDown) {
const dir: -1 | 1 = key.wheelUp ? -1 : 1 const dir: -1 | 1 = key.wheelUp ? -1 : 1
const now = Date.now()
// Modifier-held wheel = precision mode: at most one wheelStep per short
// interval. Smooth mice / trackpads emit many raw wheel events for one
// intended line step, so raw 1:1 still moves too far.
// SGR/X10 mouse encoding only carries shift/meta/ctrl bits; Cmd on
// macOS is intercepted by the terminal, so we honor Option (meta) on
// Mac / Alt (meta) on Win+Linux / Ctrl as a portable fallback. Shift
// is reserved for selection extension.
const hasModifier = key.meta || key.ctrl
const precision = precisionWheelRef.current
// Keep precision active through the current wheel burst after the
// modifier is released. Otherwise a stream of queued/momentum wheel
// events can hand off mid-burst into the accelerated path and jump.
const precisionSticky = now - precision.lastEventAtMs < PRECISION_WHEEL_STICKY_MS
if (hasModifier || precisionSticky) {
if (!precision.active) {
precision.active = true
wheelAccelRef.current = initWheelAccelForHost()
}
precision.lastEventAtMs = now
if (dir === precision.dir && now - precision.lastScrollAtMs < PRECISION_WHEEL_MIN_GAP_MS) {
return
}
precision.lastScrollAtMs = now
precision.dir = dir
return scrollTranscript(dir * wheelStep)
}
precision.active = false
// 0 = direction-flip bounce deferred; skip the no-op scroll. // 0 = direction-flip bounce deferred; skip the no-op scroll.
const rows = computeWheelStep(wheelAccelRef.current, dir, Date.now()) const rows = computeWheelStep(wheelAccelRef.current, dir, now)
return rows ? scrollTranscript(dir * rows * wheelStep) : undefined return rows ? scrollTranscript(dir * rows * wheelStep) : undefined
} }