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
+7
View File
@@ -29,6 +29,13 @@ each lands in is decided when it starts.
row would be 4.4 mm tall. Do not "fix" this by changing the device density — row would be 4.4 mm tall. Do not "fix" this by changing the device density —
trav likes it where it is. trav likes it where it is.
- Android 13 / API 33, `gsi_r1-userdebug`, arm64-v8a, 65 GB free. - Android 13 / API 33, `gsi_r1-userdebug`, arm64-v8a, 65 GB free.
- **The wheel can only ever give us detents.** `getevent -pl` (2026-09-14):
`och1970_holl_key` advertises `KEY (0001): KEY_UP KEY_DOWN` and *nothing
else* — no `EV_REL`/`REL_WHEEL`, no `EV_ABS`, no resolution. The `och1970`
is a Hall latch, so the driver quantises the magnet into clicks and hands
userspace one key press each. There is no sub-detent position to read
without a kernel driver change, so every bit of nuance has to be derived
from the *timing* between detents (see `WheelScroll`).
- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN**, - Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN**,
mapped by the stock `Generic.kl` (checked 2026-09-11 via `dumpsys input`) mapped by the stock `Generic.kl` (checked 2026-09-11 via `dumpsys input`)
to **`KEYCODE_DPAD_UP` / `DPAD_DOWN`** — not volume. Side/PTT button sits to **`KEYCODE_DPAD_UP` / `DPAD_DOWN`** — not volume. Side/PTT button sits
@@ -108,7 +108,7 @@ public class ListActivity extends BaseActivity {
/** Wheel up moves the list down, the way pushing the page up does. */ /** Wheel up moves the list down, the way pushing the page up does. */
@Override protected void onWheel(int dir) { @Override protected void onWheel(int dir) {
wheel.push(dir * Ui.dp(this, WheelScroll.STEP_DP)); wheel.push(dir);
} }
private String defaultTitle() { private String defaultTitle() {
@@ -1,67 +1,112 @@
package me.teafry.andtunes; package me.teafry.andtunes;
import android.os.SystemClock;
import android.view.Choreographer; import android.view.Choreographer;
import android.widget.ListView; 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 * <p>The wheel is a Hall latch (<code>och1970_holl_key</code>) and the driver
* reads as a teleport — the list jumps a track at a time instead of gliding * exposes nothing but <code>KEY_UP</code> / <code>KEY_DOWN</code> — one key
* the way a finger drag does. So a detent doesn't scroll: it adds to a pixel * press per detent, no <code>REL_WHEEL</code>, no resolution. So there is no
* <em>debt</em>, and a Choreographer callback pays a fraction of that debt off * finer signal to read: all the nuance has to come from <em>when</em> the
* every frame. One detent eases to a stop; a fast spin piles debt up faster * detents arrive, not how far the wheel turned.
* than it drains and becomes one continuous movement.
* *
* <p>Frame-driven rather than animated per detent, because a new detent has to * <p>Which is what this does. A detent is an impulse into a velocity, not a
* blend into the motion already underway — restarting an animation each time * jump: the list then moves at that velocity and coasts to a stop, so one
* is what makes wheel scrolling stutter. * 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 { final class WheelScroll implements Choreographer.FrameCallback {
/** Fraction of the outstanding debt paid per frame: the easing curve. */ /** How far one unhurried detent travels, all told, in dp. */
private static final float FRICTION = 0.28f; private static final float STEP_DP = 44f;
/** Below this the remainder is paid in one go rather than halved forever. */ /** What the fastest detent is worth, as a multiple of {@link #STEP_DP}. */
private static final float SETTLE_PX = 1.5f; private static final float MAX_GAIN = 8f;
/** How far one detent travels. Deliberately not a row height. */ /** Detents this close together (ms) earn the whole gain. */
static final int STEP_DP = 52; 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 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; private boolean running;
WheelScroll(ListView list) { WheelScroll(ListView list) {
this.list = list; this.list = list;
this.density = list.getResources().getDisplayMetrics().density;
} }
/** One detent, {@code px} signed pixels in the direction to travel. */ /** One detent. {@code dir} is +1 or -1 in the direction to travel. */
void push(float px) { void push(int dir) {
debt += px; 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) { if (!running) {
running = true; running = true;
lastFrameNs = 0;
Choreographer.getInstance().postFrameCallback(this); 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) { @Override public void doFrame(long frameTimeNanos) {
float step = Math.abs(debt) <= SETTLE_PX ? debt : debt * FRICTION; float dt = lastFrameNs == 0 ? 1f / 60f
int px = Math.round(step); : (frameTimeNanos - lastFrameNs) / 1_000_000_000f;
if (px != 0) { lastFrameNs = frameTimeNanos;
list.scrollListBy(px); dt = Math.min(dt, 0.05f); // a dropped frame shouldn't lurch
debt -= px; // what actually moved, so hitting either end ends it
} else { float move = velocity * dt + residue;
debt = 0; int px = (int) move;
} residue = move - px;
if (Math.abs(debt) >= 0.5f) { 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); Choreographer.getInstance().postFrameCallback(this);
} else { } else {
debt = 0; stop();
running = false;
} }
} }
/** Drop any motion still owed (the screen is going away). */ /** Drop any motion still owed (the screen is going away). */
void stop() { void stop() {
debt = 0; velocity = 0;
residue = 0;
lastFrameNs = 0;
running = false; running = false;
Choreographer.getInstance().removeFrameCallback(this); Choreographer.getInstance().removeFrameCallback(this);
} }
+2 -2
View File
@@ -26,8 +26,8 @@ import sys
import zipfile import zipfile
from pathlib import Path from pathlib import Path
VERSION_NAME = "0.2.2" VERSION_NAME = "0.2.3"
VERSION_CODE = 4 VERSION_CODE = 5
PACKAGE = "me.teafry.andtunes" PACKAGE = "me.teafry.andtunes"
MIN_SDK = 26 MIN_SDK = 26
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.20.4" __version__ = "0.20.5"
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
{"version_name": "0.2.2", "version_code": 4} {"version_name": "0.2.3", "version_code": 5}
+11 -4
View File
@@ -31,10 +31,17 @@ that *is* there plays exactly that song, shuffle or not.
stats over MTP, ~840 KB of writes and a full tree walk with the progress stats over MTP, ~840 KB of writes and a full tree walk with the progress
line frozen. They report now, and `_write_index` reads `Art/` in one line frozen. They report now, and `_write_index` reads `Art/` in one
listing instead of a stat per album. listing instead of a stat per album.
- [x] **The wheel scrolls smoothly, and the other way** (`WheelScroll`). A - [x] **The wheel scrolls kinetically, and the other way** (`WheelScroll`;
detent adds to a pixel debt that a Choreographer callback pays off a v0.20.5 / andTunes 0.2.3 refined the first pass). `getevent -pl` settled
fraction per frame, so one detent eases to a stop and a fast spin blends what's possible: `och1970_holl_key` advertises `KEY_UP KEY_DOWN` and
into one movement instead of teleporting a row at a time. Direction 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, so every bit of nuance comes from the *timing*. A
detent is an impulse into a velocity rather than a jump: the list coasts
and eases out, and an impulse is worth up to 8× more when detents arrive
30 ms apart instead of 220 ms. 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. Direction
flipped in lists only; volume on the other screens is unchanged. flipped in lists only; volume on the other screens is unchanged.
### Round 54 (2026-09-14) — one artist spelled two ways (v0.20.3, andTunes 0.2.1) ### Round 54 (2026-09-14) — one artist spelled two ways (v0.20.3, andTunes 0.2.1)