fix(mio): interpolate the desk between samples, so a dragged window pushes smoothly - #481
Merged
Merged
Conversation
…ushes smoothly Mio measures the desk on a 50 ms throttle — a dozen `getBoundingClientRect()` reads is not a per-frame cost — but it was handing those samples straight to a per-frame solver. A window drag writes the window's `left` / `top` on every `pointermove`, so by the time the next sample landed the rect had travelled a whole interval, and the contact pass, which is a positional push, applied that entire delta in one step. Mio resting against a window edge hopped, held for three frames, and hopped again as the window was dragged past it. The window hooks were never the bottleneck: `WINDOW_BOUNDS_CHANGED` is rAF-coalesced and Mio does not listen to it anyway. Nor is the answer a faster sample. It is to stop treating a sample as the truth right now and start treating it as a keyframe: hold the previous sample beside the current one and hand the solver a rect lerped between the two, so a 20 Hz measurement drives a 60 Hz push. Sizes interpolate too, which makes a resizing window sweep against Mio instead of stepping. The lerp spans the throttle rather than the measured gap between the last two samples, and that distinction is what makes the hand-off seamless. The gap is irregular by construction — the ticker decides which frame `readSurfaces()` runs on, so a 50 ms throttle fires at 50 ms and at 66.7 ms in whatever order the frames fall. Spread over the previous gap, the interpolation is left unfinished whenever a short gap follows a long one, and the next keyframe pair starts from the position the last one was still travelling toward: a jump, the smaller cousin of the one being removed. Spread over the throttle, it has always arrived before the next sample can land, because the throttle is a floor on the gap. The worst case degrades to a hold of a frame or two. Interpolation rather than extrapolation costs one interval of latency — Mio is pushed by where the window was 50 ms ago, invisible on a decorative blob — and buys never overshooting and snapping back when the drag stops. Three cases are deliberately not interpolated, because none of them are motion: an obstacle with no previous keyframe (a window that just opened is solid where it is, not slid in from nowhere), a gap longer than 250 ms (the tab was in the background), and anything following a layer resize, where a new origin re-bases every obstacle coordinate at once. A still desk — the overwhelmingly common case — short-circuits and hands back the measured array itself. Contact still treats obstacles as static: a moving window displaces Mio without imparting momentum, exactly as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Mio jumped rather than slid when a window was dragged into it: resting against a window edge, it hopped, held for three frames, and hopped again as the window swept past.
What was actually wrong
Not the window hooks.
src/window/pointer.tswrites the window'sleft/topon everypointermove, andWINDOW_BOUNDS_CHANGEDis rAF-coalesced — both are ~60 Hz. Mio doesn't listen to that hook at all; it pollswp.os.getWallpaperSurfaces()behindSURFACE_REFRESH_MS = 50. So its obstacle set was up to three frames stale, then advanced by a whole interval of pointer travel at once — and contact resolution is a positional push, so that entire delta landed in one step.The fix
Not a faster sample. Stop treating a sample as the truth right now and start treating it as a keyframe: hold the previous sample beside the current one and hand the solver a rect lerped between the two, so a 20 Hz measurement drives a 60 Hz push. Sizes interpolate too, which makes a resizing window sweep against Mio instead of stepping.
New module
src/mio/obstacle-track.ts;readSurfaces()insrc/mio/mio.tsnow samples on the throttle and presents every frame.The lerp spans the throttle, not the measured gap
This is the part that took a second pass. The gap between samples is irregular by construction — the ticker decides which frame
readSurfaces()runs on, so a 50 ms throttle fires at 50 ms and at 66.7 ms in whatever order the frames fall. Spread the lerp over the previous gap and it is left unfinished whenever a short gap follows a long one, so the next keyframe pair starts from the position the last one was still travelling toward — a jump, the smaller cousin of the one being removed.Spread over the throttle, it has always arrived before the next sample can land, because the throttle is a floor on the gap. The worst case degrades to a hold of a frame or two at the end of an interval.
mio-obstacle-track.test.tspins that case specifically.Interpolation, not extrapolation
Costs one interval of latency — Mio is pushed by where the window was 50 ms ago, invisible on a decorative blob — and buys never overshooting and snapping back when the drag stops.
Three cases deliberately not interpolated
None of them are motion:
A still desk, the overwhelmingly common case, short-circuits and hands back the measured array itself — no allocation, byte-identical behaviour to before.
Scope
Contact still treats obstacles as static: a moving window displaces Mio without imparting momentum, exactly as today. The same keyframe pair would give obstacle velocity for free, but making contact reflect about relative velocity changes how contact feels everywhere, not just during drags — deliberately left out.
Internal only: no hook, no
wp.ossurface, no payload change.Verification
npm run typecheck,npm run lint,npm run build— cleannpm run test:js— 299 files, 3646 tests green (12 new)🤖 Generated with Claude Code