v0.20.5: the wheel learns to coast

getevent settled what the wheel can and can't tell us: och1970_holl_key
advertises KEY_UP and KEY_DOWN and nothing else -- no REL_WHEEL, no
resolution, because the och1970 is a Hall latch the driver quantises into
clicks. There is no sub-detent position to read without a kernel driver
change, so all the nuance has to come from *when* the detents arrive.

So a detent is now an impulse into a velocity rather than a jump. The list
coasts and eases out, and an impulse is worth up to 8x more when detents come
30 ms apart than when they come 220 ms apart -- squared, so the gain stays out
of the way while you're hunting for one row. Measured on the device: one
unhurried detent moves about half a row, twelve fast ones move forty-odd.
Turning back the other way kills the coast first, so a correction bites
instead of fighting the glide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
This commit is contained in:
2026-09-14 13:51:42 -07:00
co-authored by Claude Opus 5
parent efa07b05c6
commit f091a545f5
8 changed files with 100 additions and 41 deletions
@@ -108,7 +108,7 @@ public class ListActivity extends BaseActivity {
/** Wheel up moves the list down, the way pushing the page up does. */
@Override protected void onWheel(int dir) {
wheel.push(dir * Ui.dp(this, WheelScroll.STEP_DP));
wheel.push(dir);
}
private String defaultTitle() {
@@ -1,67 +1,112 @@
package me.teafry.andtunes;
import android.os.SystemClock;
import android.view.Choreographer;
import android.widget.ListView;
/**
* Smooth scrolling for the R1's wheel.
* Kinetic scrolling for the R1's wheel.
*
* <p>The wheel arrives as discrete detents, and moving a whole row per detent
* reads as a teleport — the list jumps a track at a time instead of gliding
* the way a finger drag does. So a detent doesn't scroll: it adds to a pixel
* <em>debt</em>, and a Choreographer callback pays a fraction of that debt off
* every frame. One detent eases to a stop; a fast spin piles debt up faster
* than it drains and becomes one continuous movement.
* <p>The wheel is a Hall latch (<code>och1970_holl_key</code>) and the driver
* exposes nothing but <code>KEY_UP</code> / <code>KEY_DOWN</code> — one key
* press per detent, no <code>REL_WHEEL</code>, no resolution. So there is no
* finer signal to read: all the nuance has to come from <em>when</em> the
* detents arrive, not how far the wheel turned.
*
* <p>Frame-driven rather than animated per detent, because a new detent has to
* blend into the motion already underway — restarting an animation each time
* is what makes wheel scrolling stutter.
* <p>Which is what this does. A detent is an impulse into a velocity, not a
* jump: the list then moves at that velocity and coasts to a stop, so one
* click nudges and a spin glides. And an impulse is worth more the faster the
* detents come — {@link #MAX_GAIN}× at a hard spin — so a long list crosses in
* a flick while a single click still lands a row or so. Turning back the other
* way kills the glide first, so a correction bites immediately instead of
* fighting the coast.
*/
final class WheelScroll implements Choreographer.FrameCallback {
/** Fraction of the outstanding debt paid per frame: the easing curve. */
private static final float FRICTION = 0.28f;
/** Below this the remainder is paid in one go rather than halved forever. */
private static final float SETTLE_PX = 1.5f;
/** How far one detent travels. Deliberately not a row height. */
static final int STEP_DP = 52;
/** How far one unhurried detent travels, all told, in dp. */
private static final float STEP_DP = 44f;
/** What the fastest detent is worth, as a multiple of {@link #STEP_DP}. */
private static final float MAX_GAIN = 8f;
/** Detents this close together (ms) earn the whole gain. */
private static final float FAST_MS = 30f;
/** Detents this far apart (ms) earn none of it. */
private static final float SLOW_MS = 220f;
/** Fraction of the speed still there one second into a coast. */
private static final float KEEP_PER_S = 0.05f;
/** Below this (dp/s) the glide has finished. */
private static final float STOP_DP_S = 18f;
/** Ceiling, so a frantic spin stays readable rather than teleporting. */
private static final float MAX_DP_S = 9000f;
private final ListView list;
private float debt;
private final float density;
private float velocity; // px/s, positive = toward the end of the list
private float residue; // sub-pixel remainder, so slow moves aren't lost
private long lastDetentMs;
private long lastFrameNs;
private boolean running;
WheelScroll(ListView list) {
this.list = list;
this.density = list.getResources().getDisplayMetrics().density;
}
/** One detent, {@code px} signed pixels in the direction to travel. */
void push(float px) {
debt += px;
/** One detent. {@code dir} is +1 or -1 in the direction to travel. */
void push(int dir) {
long now = SystemClock.uptimeMillis();
float gap = lastDetentMs == 0 ? SLOW_MS : now - lastDetentMs;
lastDetentMs = now;
float impulse = dir * STEP_DP * gainFor(gap) * density * -(float) Math.log(KEEP_PER_S);
if (velocity != 0 && Math.signum(impulse) != Math.signum(velocity)) {
velocity = 0; // a change of mind stops the coast, then steers
residue = 0;
}
float cap = MAX_DP_S * density;
velocity = Math.max(-cap, Math.min(cap, velocity + impulse));
if (!running) {
running = true;
lastFrameNs = 0;
Choreographer.getInstance().postFrameCallback(this);
}
}
/**
* 1 at {@link #SLOW_MS} apart, {@link #MAX_GAIN} at {@link #FAST_MS}.
*
* <p>Squared, so the gain stays out of the way until the wheel is really
* moving — a slow hunt for one row shouldn't feel twitchy.
*/
private static float gainFor(float gapMs) {
float t = (SLOW_MS - Math.min(Math.max(gapMs, FAST_MS), SLOW_MS))
/ (SLOW_MS - FAST_MS);
return 1f + (MAX_GAIN - 1f) * t * t;
}
@Override public void doFrame(long frameTimeNanos) {
float step = Math.abs(debt) <= SETTLE_PX ? debt : debt * FRICTION;
int px = Math.round(step);
if (px != 0) {
list.scrollListBy(px);
debt -= px; // what actually moved, so hitting either end ends it
} else {
debt = 0;
}
if (Math.abs(debt) >= 0.5f) {
float dt = lastFrameNs == 0 ? 1f / 60f
: (frameTimeNanos - lastFrameNs) / 1_000_000_000f;
lastFrameNs = frameTimeNanos;
dt = Math.min(dt, 0.05f); // a dropped frame shouldn't lurch
float move = velocity * dt + residue;
int px = (int) move;
residue = move - px;
if (px != 0) list.scrollListBy(px);
velocity *= (float) Math.pow(KEEP_PER_S, dt);
if (Math.abs(velocity) >= STOP_DP_S * density) {
Choreographer.getInstance().postFrameCallback(this);
} else {
debt = 0;
running = false;
stop();
}
}
/** Drop any motion still owed (the screen is going away). */
void stop() {
debt = 0;
velocity = 0;
residue = 0;
lastFrameNs = 0;
running = false;
Choreographer.getInstance().removeFrameCallback(this);
}