Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4274b8b8d0 | ||
|
|
73f956f8e0 |
No files matched your search
@@ -45,6 +45,42 @@ window_size()` now answers physical pixels. On a 1.0 display nothing
|
||||
changes. The override is what lets `run-headless.sh --phone` open a window
|
||||
at your phone's own 1080x2424 and 2.55.
|
||||
|
||||
## 2026-09-07: the fling curve was the identity function
|
||||
|
||||
You said the fling "seems to just be linear velocity with an abrupt stop."
|
||||
It was, exactly: `android_fling_spline`'s lookup returned `t` for every
|
||||
`t`. Two halves of AOSP's spline build loop had been transposed, which made
|
||||
its two tables identical, and the lookup interpolated one against the
|
||||
other -- which reduces algebraically to `t`. So a fling coasted at its
|
||||
release speed for the whole (correctly computed) duration and stopped dead
|
||||
at the end of it.
|
||||
|
||||
Ported exactly now from `OverScroller.java` and Compose's
|
||||
`SplineBasedDecay.kt`, which agree line for line. One public addition:
|
||||
|
||||
**`FlingCalculator::velocity_at(velocity, elapsed) -> f32`**, beside the
|
||||
existing `position_at` -- AOSP's `mCurrVelocity` and Compose's
|
||||
`FlingInfo.velocity`. It is what makes "is this decelerating" answerable
|
||||
rather than inferred, and it is what `List::tick_fling`'s new
|
||||
`iris fling tick:` debug line reports each frame.
|
||||
|
||||
The lesson worth keeping, since it cost two builds on your phone: every
|
||||
test the calculator had compared it with itself -- monotonic, correctly
|
||||
signed, integrates to the closed form, per-tick deltas non-increasing --
|
||||
and **all of them pass on a straight line**. The numbers now come from
|
||||
`iris/benches/fling_spline_reference.py`, a separate hand transcription of
|
||||
the two sources, checked in beside the tests.
|
||||
|
||||
## 2026-09-07: the Android insets bridge counts its own dispatches
|
||||
|
||||
`AndroidUiState::insets_report() -> String` is new, and the bench app's
|
||||
Diagnostics pane shows it. It carries the last insets plus **how many times
|
||||
the platform has delivered any**, because "the keyboard did not push
|
||||
anything up" has two causes that look identical on screen -- the listener
|
||||
never fired, or it fired with a zero height -- and you have no logcat on
|
||||
the phone. `dispatches=0` prints a sentence saying so rather than the
|
||||
numbers, which would be defaults rather than measurements.
|
||||
|
||||
## 2026-09-07: widgets can animate, and a fling finally moves
|
||||
|
||||
Iris's phone said "fling still doesn't work" twice. The velocity was only
|
||||
|
||||
+50
-17
@@ -351,13 +351,27 @@ agent ticks it here with the evidence.
|
||||
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
||||
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
|
||||
|
||||
- [ ] **"Fling still doesn't work."** *(Three defects fixed 2026-09-07;
|
||||
open until the phone says so. **The line to look for:**
|
||||
`adb logcat | grep "iris drag release"` -- `samples=1` or `span=0.0ms`
|
||||
means the batched samples are not reaching the tracker there, while a
|
||||
sensible span with `v=` in the thousands and
|
||||
`outcome=Released(Some(…))` means the gesture was measured right and
|
||||
anything still wrong is downstream.)* Second report; the emulator's
|
||||
- [ ] **"Fling still doesn't work."** -> on `ed04d4c`, 2026-09-07:
|
||||
*"flinging now does technically do something, but it seems to just be
|
||||
linear velocity with an abrupt stop."* **It was exactly that, and the
|
||||
arithmetic said so.** `distance_fraction(t)` returned `t` for every `t`
|
||||
-- a constant-speed slide for the whole duration, then a stop at full
|
||||
distance -- because two halves of AOSP's spline build loop were
|
||||
transposed, which made `SPLINE_POSITION` and `SPLINE_TIME` identical, and
|
||||
the lookup bracketed `t` between `SPLINE_TIME` entries rather than
|
||||
between even time steps. The two cancelled to the identity. Ported
|
||||
exactly now from `OverScroller.java` and
|
||||
`androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt`
|
||||
(they agree line for line), with `iris/benches/fling_spline_reference.py`
|
||||
as an independent transcription supplying the numbers the tests assert
|
||||
on. Emulator, 2026-09-07: a released `v=3750` decelerates
|
||||
`3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s` across 32
|
||||
frames; a flick into the end of the list stops there in one tick with no
|
||||
overshoot; a tap 200ms into a fling ends it at 11 ticks instead of 32.
|
||||
**Open until the phone says so** -- a flick should now visibly slow
|
||||
before it stops. Its earlier three defects (the velocity, the missing
|
||||
animation registration, the 56x coefficient) are all still fixed and were
|
||||
never the linear part.* Second report; the emulator's
|
||||
`ui-trace` swipe flings (verified 2026-09-06 with `render()` counts),
|
||||
a finger on the phone does not. What differs: a real flick at 120Hz is
|
||||
batched by Android into few `MotionEvent`s with *historical* samples
|
||||
@@ -383,11 +397,29 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
||||
`EditText` shows the IME on every tap of a focused field; do the same
|
||||
(`FocusHost`: a tap on a focused field requests the IME, idempotent
|
||||
when it is already shown).
|
||||
- [x] **"Message box does not push up the scroll area."** *(Fixed
|
||||
2026-09-07: height and visibility are two JNI values now. Emulator:
|
||||
`iris insets: … bottom=883 ime_bottom=883 ime_visible=true`, composer
|
||||
box `31,2277..1048,2329` -> `31,1457..1048,1509`, and the list follows
|
||||
because it is `rest(1)` in the same `Span`.)* Since
|
||||
- [ ] **"Message box does not push up the scroll area."**
|
||||
**Reopened by the phone on 2026-09-07** -- *"similarly, the keyboard
|
||||
raising up does not push things upwards"* -- after being ticked on
|
||||
emulator evidence the day before (`ime_bottom=883`, composer box
|
||||
`31,2277..1048,2329` -> `31,1457..1048,1509`). The JNI half was right;
|
||||
what was wrong is one line of `iris/android-app/app/build.gradle`:
|
||||
**`targetSdk = 34`** against `compileSdk = 37`, while the Compose app in
|
||||
`app/` targets 37 and *does* push up on her phone. Below target 35 the
|
||||
window keeps the legacy behaviour, where `adjustResize` shrinks it for
|
||||
the IME and `getInsets(ime()).bottom` therefore measures zero;
|
||||
`setDecorFitsSystemWindows(false)` opts out of that and still takes on
|
||||
the API 36 emulator here, which is why every test run passed. Now
|
||||
`targetSdk = 37`, plus a `WindowInsetsAnimation.Callback` for the devices
|
||||
where only the animation path carries the height -- which also makes the
|
||||
push-up animate (`ime_bottom=509, 663, 833, 881, 883` instead of one
|
||||
jump). **This is a reading, not a measurement**: no Android 17 device is
|
||||
reachable from here. So the Diagnostics pane now prints
|
||||
`insets: dispatches=N left=… ime_bottom=… ime_visible=…` --
|
||||
**screenshot that line with the keyboard open.** `ime_bottom` in the
|
||||
hundreds and the composer risen means fixed; `dispatches` climbing with
|
||||
`ime_bottom=0` means the reading was wrong and the window is still being
|
||||
resized; `dispatches=0` means the listener is not firing at all, which is
|
||||
a third thing again.* Since
|
||||
`MainActivity` went edge-to-edge (`e12c708`), `adjustResize` no
|
||||
longer resizes the window, so the app owns the IME inset -- but
|
||||
`ime_bottom` is passed through JNI as the boolean `1`/`0` (the
|
||||
@@ -418,11 +450,12 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
||||
screenshotted the emulator's GLES path, where a resume may not
|
||||
destroy the surface at all.
|
||||
|
||||
**Fixed in `ba2afba`, with `clearing_the_atlas_re_renders_cached_text_
|
||||
instead_of_reusing_it` run and passing (2026-09-07). Ticked on the
|
||||
code; still wants phone-side confirmation** -- no emulator here has a
|
||||
Vulkan adapter, and the GLES path may not destroy the surface at all,
|
||||
so the emulator cannot reproduce the state Iris photographed.
|
||||
**Fixed in `ba2afba` and confirmed on the phone (Iris, 2026-09-07:
|
||||
"the resume glyph corruption is fixed").** Closed. The emulator could
|
||||
never have settled it -- no Vulkan adapter here, and the GLES path may
|
||||
not destroy the surface at all -- so the phone was the only place this
|
||||
could be answered, and it has been. `clearing_the_atlas_re_renders_
|
||||
cached_text_instead_of_reusing_it` is what keeps it.
|
||||
|
||||
The reading above is right and the mechanism is one step narrower than
|
||||
"cached text primitives". `IrisViewPeer::surface_changed`
|
||||
|
||||
+123
@@ -186,6 +186,129 @@ runs inside `cargo test`.
|
||||
verification run before a build goes to the phone. Not for iterating
|
||||
on layout.
|
||||
|
||||
### The 2026-09-07 phone report on `ed04d4c`: the fling was linear, and the keyboard is a targetSdk
|
||||
|
||||
Iris's three lines on the `ed04d4c` build (Pixel 9 Pro XL, GrapheneOS
|
||||
Android 17, Mali-G715, `content_scale` 2.55, 120Hz): item 4 (the resume
|
||||
glyph corruption) **is fixed**, confirmed on the phone; "flinging now does
|
||||
technically do something, but it seems to just be linear velocity with an
|
||||
abrupt stop"; and "similarly, the keyboard raising up does not push things
|
||||
upwards."
|
||||
|
||||
**The fling was exactly, arithmetically linear.** Not approximately.
|
||||
`android_fling_spline::distance_fraction(t)` returned `t` for every `t`,
|
||||
which is a constant-speed slide for the full `duration()` and then a stop
|
||||
at full distance -- Iris's sentence, read straight off the code. Two
|
||||
transposed halves of one AOSP loop did it, and they compounded:
|
||||
|
||||
1. AOSP's `SplineOverScroller` static initialiser **solves** the bisection
|
||||
on the `P1`/`P2` curve and **samples** `SPLINE_POSITION[i]` from the
|
||||
tension curve (`coef * ((1-x) * START_TENSION + x) + x³`); the second
|
||||
half of the loop does the reverse to build `SPLINE_TIME`. iris had both
|
||||
halves solving on the tension curve and sampling `P1`/`P2` -- so its two
|
||||
loops were the *same computation*, and `SPLINE_POSITION == SPLINE_TIME`
|
||||
element for element.
|
||||
2. The lookup then bracketed `t` between **`SPLINE_TIME` entries** and
|
||||
interpolated `SPLINE_POSITION`. AOSP brackets between the even time
|
||||
steps `index / N` and `(index + 1) / N` (`SPLINE_TIME` is used only by
|
||||
`adjustDuration`, which iris has no analogue of). With the two arrays
|
||||
identical, `d_inf + (d_sup - d_inf)(t - t_inf)/(t_sup - t_inf)` reduces
|
||||
to `t_inf + (t - t_inf)` = `t`.
|
||||
|
||||
Every test the calculator had compared it with itself -- monotonic, signed,
|
||||
integrates to the closed form, per-tick deltas non-increasing -- and all of
|
||||
them pass on a linear curve. That is the shape to distrust: `<=` is not
|
||||
deceleration.
|
||||
|
||||
**Sources, read rather than remembered.** `frameworks/base`
|
||||
`core/java/android/widget/OverScroller.java` from
|
||||
`android.googlesource.com` (`?format=TEXT`, base64), and
|
||||
`androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt` and
|
||||
`FlingCalculator.kt` out of the `-sources.jar` on
|
||||
`dl.google.com/dl/android/maven2` (there is no androidx checkout here and
|
||||
`cs.android.com` is JS-only; `androidx.tech` is now a parked domain serving
|
||||
an unrelated site). The two agree line for line, which is why iris ports
|
||||
one curve rather than two. The formulas, for the record:
|
||||
|
||||
P1 = START_TENSION * INFLEXION = 0.5 * 0.35
|
||||
P2 = 1 - END_TENSION * (1 - INFLEXION) = 1 - 1 * 0.65
|
||||
SPLINE_POSITION[i]: solve coef*((1-x)P1 + xP2) + x³ = i/100 for x,
|
||||
then take coef*((1-x)ST + x) + x³
|
||||
physical_coeff = 9.80665 * 39.37 * density * 160 * 0.84
|
||||
l = ln(0.35 * |v| / (0.015 * physical_coeff))
|
||||
distance = 0.015 * physical_coeff * exp(rate/(rate-1) * l)
|
||||
duration = exp(l / (rate - 1)), rate = ln(0.78)/ln(0.9)
|
||||
at time t: index = floor(100 * t/duration)
|
||||
vcoef = (POS[index+1] - POS[index]) * 100
|
||||
position = distance * (POS[index] + (t/duration - index/100) * vcoef)
|
||||
speed = vcoef * distance / duration
|
||||
|
||||
**What changed.** `iris/src/sense.rs`'s `android_fling_spline` builds one
|
||||
table, indexed by even time steps, and `sample(t)` answers AOSP's
|
||||
`distanceCoef`/`velocityCoef` pair; `FlingCalculator` gained `velocity_at`
|
||||
beside `position_at`. `iris/benches/fling_spline_reference.py` is an
|
||||
independent hand transcription of both sources and prints the numbers the
|
||||
tests assert on -- checked in because "numbers computed by the code under
|
||||
test" is exactly how the last three tests passed through this defect.
|
||||
Tests: `the_spline_matches_aosps_own_table` (the curve is not the
|
||||
identity: 27.4% of the distance at a tenth of the time, 85.8% at half),
|
||||
`a_flick_decelerates_the_way_aosp_says_it_does` (11064px/s at density
|
||||
2.55: 6334px over 1.636s, speed 9202 -> 4733 -> 2650 -> 951px/s), and
|
||||
`tick_fling_applies_shrinking_incremental_deltas` strengthened from
|
||||
"non-increasing" to "the last delta is under 80% of the first". **Negative
|
||||
control run**: with `sample` forced back to returning `t`, exactly those
|
||||
three fail and the other eleven pass.
|
||||
|
||||
**Emulator evidence (API 36 AVD, debug, `force-gles`, 2026-09-07).** A
|
||||
`ui-trace` swipe of 900px in 120ms releases at `v=3750` and the new
|
||||
`iris fling tick:` debug line reports, frame by frame,
|
||||
`speed=-3746 -> -2624 -> -1834 -> -1144 -> -752 -> -449 -> -243 -> -83px/s`
|
||||
over 32 frames ending at `t=0.664s`, with the per-frame `dy` falling
|
||||
`94 -> 34 -> 20 -> 13 -> 8 -> 4.4px`. **The end**: the same flick in the
|
||||
other direction, from a list already at its newest end, produces exactly
|
||||
one tick and stops -- no overshoot. **A finger during a fling**: swipe,
|
||||
then a tap 200ms later, ends the fling at `t=0.248s` and 11 ticks instead
|
||||
of running its full 0.55s.
|
||||
|
||||
**The keyboard: the bench app targeted SDK 34.** `app/build.gradle` said
|
||||
`targetSdk = 34` while `compileSdk` was 37 and the Compose app in `app/`
|
||||
targets 37 -- and that Compose app's keyboard *does* push its transcript up
|
||||
on Iris's phone. Below target 35 a window keeps the legacy behaviour, where
|
||||
`adjustResize` shrinks the window for the IME so `getInsets(ime()).bottom`
|
||||
measures the overlap with an already-shrunk window and is zero;
|
||||
`MainActivity`'s `setDecorFitsSystemWindows(false)` opts out of that and
|
||||
still takes on the API 36 emulator here, which is why every test run showed
|
||||
the push-up working. It is deprecated as of API 35 and Android 17 is where
|
||||
it appears no longer to. Fixed by `targetSdk = 37`, where edge-to-edge is
|
||||
not opt-in.
|
||||
|
||||
That is a *reading*, not a measurement -- no Android 17 device is reachable
|
||||
from here -- so the second half of the change is making the phone able to
|
||||
answer it. `MainActivity` now also registers a
|
||||
`WindowInsetsAnimation.Callback` (`DISPATCH_MODE_CONTINUE_ON_SUBTREE`,
|
||||
`onProgress` forwarding, `onEnd` re-reading `getRootWindowInsets` so an
|
||||
interrupted animation cannot leave a frozen value), which delivers the IME
|
||||
height on devices where only the animation path carries it and, on every
|
||||
device, makes the push-up *animate* with the keyboard: the emulator log now
|
||||
shows `ime_bottom=509, 663, 833, 881, 883` instead of one jump to 883. And
|
||||
`insets::Shared::updates` counts every dispatch, which
|
||||
`AndroidUiState::insets_report()` puts in the **Diagnostics pane**:
|
||||
|
||||
insets: dispatches=27 left=0 top=142 right=0 bottom=63 ime_bottom=0 ime_visible=false
|
||||
|
||||
Screenshot-verified on the emulator. Iris has no logcat, and "the listener
|
||||
never fired" and "it fired with a zero height" look identical on screen;
|
||||
`dispatches=0` prints its own sentence instead of the numbers, since those
|
||||
would be defaults rather than measurements.
|
||||
|
||||
**What to look at on the next build.** Open the keyboard, press
|
||||
`Diagnostics`, screenshot the `insets:` line. `ime_bottom` in the hundreds
|
||||
with the composer risen: fixed. `dispatches` climbing but `ime_bottom=0`:
|
||||
the targetSdk reading was wrong and the window is still being resized.
|
||||
`dispatches=0`: the listener is not being called at all, which is a
|
||||
different fault from either. For the fling, a flick should now visibly
|
||||
slow before it stops rather than running out at speed.
|
||||
|
||||
### The 22:16 phone report, worked 2026-09-06/07
|
||||
|
||||
Iris's four items are listed in docs/IRIS_TODO.md's "From the phone,
|
||||
|
||||
@@ -22,7 +22,28 @@ android {
|
||||
// untested fallback path is its own defect. `build-apk.sh`'s
|
||||
// `cargo ndk -P` is kept at the same number.
|
||||
minSdk = 29
|
||||
targetSdk = 34
|
||||
// 37, matching `compileSdk` and the Compose app in `app/` -- which
|
||||
// is the one part of this that is measured rather than reasoned:
|
||||
// that app targets 37 and its keyboard does push the transcript up
|
||||
// on Iris's phone, and this one targeted 34 and does not
|
||||
// (2026-09-07). The emulator here is API 36 and the push-up works
|
||||
// there at either target, so the target is the only difference the
|
||||
// two devices do not share.
|
||||
//
|
||||
// The mechanism, stated as the reading it is: below targetSdk 35
|
||||
// a window keeps the legacy behaviour, where `adjustResize` shrinks
|
||||
// the window for the IME and `getInsets(ime()).bottom` therefore
|
||||
// measures the overlap with an already-shrunk window -- zero, with
|
||||
// nothing left to push up. `MainActivity`'s
|
||||
// `setDecorFitsSystemWindows(false)` opts out of that, and on API
|
||||
// 36 it still takes; Android 16 deprecated it and Android 17 is
|
||||
// where it appears not to. At 35+ edge-to-edge is not opt-in, so
|
||||
// the app is handed the real overlap without relying on a
|
||||
// deprecated call. If the phone still reports `ime_bottom=0` with
|
||||
// a nonzero `dispatches` in the Diagnostics pane, this reading was
|
||||
// wrong and the `WindowInsetsAnimation.Callback` in
|
||||
// `MainActivity` is the other half to look at.
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import android.app.Activity;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.WindowInsetsAnimation;
|
||||
import android.widget.FrameLayout;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
||||
@@ -50,39 +52,88 @@ public final class MainActivity extends Activity {
|
||||
getWindow().setDecorFitsSystemWindows(false);
|
||||
}
|
||||
|
||||
// **The keyboard's height arrives twice, over two different
|
||||
// paths, and the phone needs the second one** (Iris, 2026-09-07:
|
||||
// the emulator pushed the composer up and her Pixel did not).
|
||||
// `setOnApplyWindowInsetsListener` is the platform's *settled*
|
||||
// answer; `WindowInsetsAnimation.Callback` is the running one, and
|
||||
// an IME that animates in delivers every intermediate height
|
||||
// through the callback with the static dispatch arriving only at
|
||||
// the ends -- on some devices only at `onEnd`. Registering both
|
||||
// means neither device depends on the other's timing, and it is
|
||||
// also what makes the push-up *animate* with the keyboard rather
|
||||
// than jump when it lands.
|
||||
//
|
||||
// The two do not disagree, because they are the same call with the
|
||||
// same numbers read out of whichever `WindowInsets` is current.
|
||||
// `DISPATCH_MODE_CONTINUE_ON_SUBTREE` so this view consuming
|
||||
// nothing keeps the ordinary dispatch running underneath.
|
||||
// `onEnd` re-reads the root's insets rather than trusting the last
|
||||
// `onProgress`: an animation interrupted mid-flight never delivers
|
||||
// its final frame, which is exactly the fault the Compose app hit
|
||||
// (AGENTS.md, "the composer can get stuck floating above the
|
||||
// bottom of the screen").
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
|
||||
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
||||
@Override
|
||||
public WindowInsets onProgress(
|
||||
WindowInsets insets, List<WindowInsetsAnimation> running) {
|
||||
sendInsets(view, insets);
|
||||
return insets;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnd(WindowInsetsAnimation animation) {
|
||||
WindowInsets settled = view.getRootWindowInsets();
|
||||
if (settled != null) {
|
||||
sendInsets(view, settled);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||
int left = insets.getSystemWindowInsetLeft();
|
||||
int top = insets.getSystemWindowInsetTop();
|
||||
int right = insets.getSystemWindowInsetRight();
|
||||
int bottom = insets.getSystemWindowInsetBottom();
|
||||
// **Two separate answers, because they are separate questions**
|
||||
// (Iris's phone, 2026-09-06: "message box does not push up the
|
||||
// scroll area"). `isVisible(ime())` says whether the keyboard is
|
||||
// up; `getInsets(ime()).bottom` says how tall it is. An earlier
|
||||
// pass sent the boolean *as* the height (0 or 1) because under
|
||||
// plain `adjustResize` the window shrinks to make room and the
|
||||
// ime inset therefore measures a zero overlap by construction --
|
||||
// true then, and no longer true now: `setDecorFitsSystemWindows
|
||||
// (false)` above makes this an edge-to-edge window, which is
|
||||
// exactly the case where the system stops resizing and hands the
|
||||
// app the real overlap instead. Sending 1 for it left the Rust
|
||||
// side padding the composer by one physical pixel, so the
|
||||
// keyboard covered the bar and the transcript alike.
|
||||
//
|
||||
// The visibility is still sent in its own right rather than
|
||||
// inferred from `height > 0`: the two disagree during the
|
||||
// keyboard's slide-in and -out (visible, height still climbing),
|
||||
// and "is the IME up" drives the bench's own state machine
|
||||
// (`bench_client.rs`'s `ime_state`) where a half-open frame
|
||||
// reading as "closed" is a miscount.
|
||||
int imeBottom = 0;
|
||||
int imeVisible = 0;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
|
||||
}
|
||||
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
|
||||
sendInsets((IrisView) v, insets);
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
/** Read one `WindowInsets` and hand it to the Rust side. The only
|
||||
* place that reads these fields, so the static dispatch and the
|
||||
* animation callback above cannot come to report different things. */
|
||||
private static void sendInsets(IrisView view, WindowInsets insets) {
|
||||
int left = insets.getSystemWindowInsetLeft();
|
||||
int top = insets.getSystemWindowInsetTop();
|
||||
int right = insets.getSystemWindowInsetRight();
|
||||
int bottom = insets.getSystemWindowInsetBottom();
|
||||
// **Two separate answers, because they are separate questions**
|
||||
// (Iris's phone, 2026-09-06: "message box does not push up the
|
||||
// scroll area"). `isVisible(ime())` says whether the keyboard is
|
||||
// up; `getInsets(ime()).bottom` says how tall it is. An earlier
|
||||
// pass sent the boolean *as* the height (0 or 1) because under
|
||||
// plain `adjustResize` the window shrinks to make room and the ime
|
||||
// inset therefore measures a zero overlap by construction -- true
|
||||
// then, and no longer true now that this is an edge-to-edge window
|
||||
// (`targetSdk` 35+, plus the `setDecorFitsSystemWindows` call
|
||||
// above for the devices below that), which is exactly the case
|
||||
// where the system stops resizing and hands the app the real
|
||||
// overlap instead. Sending 1 for it left the Rust side padding the
|
||||
// composer by one physical pixel, so the keyboard covered the bar
|
||||
// and the transcript alike.
|
||||
//
|
||||
// The visibility is still sent in its own right rather than
|
||||
// inferred from `height > 0`: the two disagree during the
|
||||
// keyboard's slide-in and -out (visible, height still climbing),
|
||||
// and "is the IME up" drives the bench's own state machine
|
||||
// (`bench_client.rs`'s `ime_state`) where a half-open frame
|
||||
// reading as "closed" is a miscount.
|
||||
int imeBottom = 0;
|
||||
int imeVisible = 0;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
|
||||
}
|
||||
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
|
||||
}
|
||||
}
|
||||
@@ -536,10 +536,15 @@ impl BenchClient {
|
||||
Some(stats) => format!("{stats}"),
|
||||
None => "no frames recorded yet".to_string(),
|
||||
};
|
||||
match &self.android_state().renderer {
|
||||
let renderer = match &self.android_state().renderer {
|
||||
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
||||
}
|
||||
};
|
||||
// The insets line goes in the pane, not just the log: Iris has no
|
||||
// logcat on her phone, and "the keyboard does not push the
|
||||
// composer up" cannot be told from "the listener never fired"
|
||||
// without it (`AndroidUiState::insets_report`).
|
||||
format!("{renderer}\n{}", self.android_state().insets_report())
|
||||
}
|
||||
|
||||
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AOSP's fling spline, transcribed independently of the Rust port.
|
||||
|
||||
This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table`
|
||||
and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code
|
||||
grading its own homework. Every test iris's fling had before 2026-09-07
|
||||
compared the curve with itself -- monotonic, signed, integrates to the closed
|
||||
form -- and all of them passed while `distance_fraction(t)` was returning
|
||||
exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a
|
||||
test have to come from somewhere else, and this is the somewhere else.
|
||||
|
||||
Transcribed by hand from, and only from:
|
||||
|
||||
* frameworks/base `core/java/android/widget/OverScroller.java`,
|
||||
`SplineOverScroller`'s static initialiser, `getSplineDeceleration`,
|
||||
`getSplineFlingDistance`, `getSplineFlingDuration` and `update`.
|
||||
* androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt`
|
||||
(`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and
|
||||
`FlingCalculator.kt` (`computeDeceleration`, `flingDistance`,
|
||||
`flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for
|
||||
line, which is why iris ports one curve rather than two.
|
||||
|
||||
Run it with no arguments; it prints the table entries and the (velocity,
|
||||
density, t) points the Rust tests assert on.
|
||||
"""
|
||||
|
||||
NB_SAMPLES = 100
|
||||
INFLEXION = 0.35
|
||||
START_TENSION = 0.5
|
||||
END_TENSION = 1.0
|
||||
P1 = START_TENSION * INFLEXION
|
||||
P2 = 1.0 - END_TENSION * (1.0 - INFLEXION)
|
||||
|
||||
# ViewConfiguration.getScrollFriction(), and SplineOverScroller's own
|
||||
# "look and feel tuning" constant -- a different number in a different place
|
||||
# of the same formula, which is the pair iris got the wrong way round once.
|
||||
SCROLL_FRICTION = 0.015
|
||||
TUNING = 0.84
|
||||
GRAVITY_EARTH = 9.80665
|
||||
INCHES_PER_METER = 39.37
|
||||
|
||||
import math
|
||||
|
||||
DECELERATION_RATE = math.log(0.78) / math.log(0.9)
|
||||
|
||||
|
||||
def spline_positions():
|
||||
"""SPLINE_POSITION: distance fraction at each of 101 even time steps."""
|
||||
position = [0.0] * (NB_SAMPLES + 1)
|
||||
x_min = 0.0
|
||||
for i in range(NB_SAMPLES):
|
||||
alpha = i / NB_SAMPLES
|
||||
x_max = 1.0
|
||||
while True:
|
||||
x = x_min + (x_max - x_min) / 2.0
|
||||
coef = 3.0 * x * (1.0 - x)
|
||||
# Solved on the P1/P2 curve...
|
||||
tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x
|
||||
if abs(tx - alpha) < 1e-5:
|
||||
break
|
||||
if tx > alpha:
|
||||
x_max = x
|
||||
else:
|
||||
x_min = x
|
||||
# ...and sampled on the tension curve.
|
||||
position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x
|
||||
position[NB_SAMPLES] = 1.0
|
||||
return position
|
||||
|
||||
|
||||
POSITION = spline_positions()
|
||||
|
||||
|
||||
def fling_sample(t):
|
||||
"""(distance fraction, velocity fraction) at time fraction `t`."""
|
||||
t = min(max(t, 0.0), 1.0)
|
||||
index = int(t * NB_SAMPLES)
|
||||
if index >= NB_SAMPLES:
|
||||
return 1.0, 0.0
|
||||
t_inf = index / NB_SAMPLES
|
||||
t_sup = (index + 1) / NB_SAMPLES
|
||||
velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf)
|
||||
return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef
|
||||
|
||||
|
||||
def physical_coefficient(density):
|
||||
return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING
|
||||
|
||||
|
||||
def deceleration(velocity, density):
|
||||
return math.log(
|
||||
INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density))
|
||||
)
|
||||
|
||||
|
||||
def fling_distance(velocity, density):
|
||||
l = deceleration(velocity, density)
|
||||
return (
|
||||
SCROLL_FRICTION
|
||||
* physical_coefficient(density)
|
||||
* math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l)
|
||||
)
|
||||
|
||||
|
||||
def fling_duration_s(velocity, density):
|
||||
l = deceleration(velocity, density)
|
||||
return math.exp(l / (DECELERATION_RATE - 1.0))
|
||||
|
||||
|
||||
def position_at(velocity, density, t_seconds):
|
||||
d = fling_duration_s(velocity, density)
|
||||
return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0]
|
||||
|
||||
|
||||
def velocity_at(velocity, density, t_seconds):
|
||||
d = fling_duration_s(velocity, density)
|
||||
return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("SPLINE_POSITION at a few indices (index: value)")
|
||||
for i in (0, 1, 10, 25, 50, 75, 99, 100):
|
||||
print(f" {i:3}: {POSITION[i]:.6f}")
|
||||
print()
|
||||
print("distance/velocity fraction at time fractions")
|
||||
for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
|
||||
d, v = fling_sample(t)
|
||||
print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}")
|
||||
print()
|
||||
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
|
||||
# 2.75 is this checkout's emulator.
|
||||
for density in (2.55, 2.75):
|
||||
for velocity in (5000.0, 11064.0):
|
||||
dur = fling_duration_s(velocity, density)
|
||||
print(
|
||||
f"density={density} v={velocity}: "
|
||||
f"distance={fling_distance(velocity, density):.3f}px "
|
||||
f"duration={dur:.4f}s"
|
||||
)
|
||||
# Deliberately not round fractions. The velocity coefficient is
|
||||
# piecewise *constant* across each of the 100 samples, so it
|
||||
# steps at t = k/100 and a test asserting on 0.75 is asserting
|
||||
# on which side of a discontinuity the last float landed --
|
||||
# which is genuinely different between Python and Rust and says
|
||||
# nothing about the curve.
|
||||
for frac in (0.125, 0.335, 0.505, 0.755):
|
||||
t = frac * dur
|
||||
print(
|
||||
f" t={frac:>4} of duration ({t:.4f}s): "
|
||||
f"pos={position_at(velocity, density, t):.3f}px "
|
||||
f"vel={velocity_at(velocity, density, t):.3f}px/s"
|
||||
)
|
||||
@@ -65,6 +65,18 @@ pub struct Insets {
|
||||
#[derive(Default)]
|
||||
pub struct Shared {
|
||||
pub insets: Insets,
|
||||
/// How many times Java has called `applyWindowInsetsNative` for this
|
||||
/// peer, whether or not the numbers changed. Deliberately **not** a
|
||||
/// field of `Insets`, which is compared for equality each frame to
|
||||
/// decide whether to re-run `on_insets_changed`; a counter in there
|
||||
/// would make every dispatch look like a change.
|
||||
///
|
||||
/// It exists because "the keyboard does not push anything up" has two
|
||||
/// completely different causes that look identical on screen -- the
|
||||
/// listener never fired, or it fired with a zero `ime_bottom` -- and
|
||||
/// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is
|
||||
/// in the `Diagnostics` overlay, so one screenshot separates them.
|
||||
pub updates: u64,
|
||||
}
|
||||
|
||||
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
|
||||
@@ -102,7 +114,8 @@ extern "system" fn apply_window_insets<'local>(
|
||||
ime_visible: jint,
|
||||
) {
|
||||
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
||||
shared.borrow_mut().insets = Insets {
|
||||
let mut shared = shared.borrow_mut();
|
||||
shared.insets = Insets {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
@@ -110,6 +123,7 @@ extern "system" fn apply_window_insets<'local>(
|
||||
ime_bottom,
|
||||
ime_visible: ime_visible != 0,
|
||||
};
|
||||
shared.updates += 1;
|
||||
}
|
||||
// Insets can change (the keyboard opening) with no resize and no
|
||||
// touch, so nothing else here would otherwise ask for a frame.
|
||||
|
||||
@@ -130,6 +130,28 @@ impl AndroidUiState {
|
||||
pub fn insets(&self) -> Insets {
|
||||
self.shared.borrow().insets
|
||||
}
|
||||
|
||||
/// The insets state as one line for a diagnostics pane, including how
|
||||
/// many times the platform has delivered any -- see
|
||||
/// `insets::Shared::updates` for why the count is the load-bearing
|
||||
/// part. `dispatches=0` says the listener has never run and the
|
||||
/// numbers beside it are defaults rather than measurements, which is
|
||||
/// the distinction a screenshot otherwise cannot make (UI_RULES.md,
|
||||
/// "design the unknown state first").
|
||||
pub fn insets_report(&self) -> String {
|
||||
let shared = self.shared.borrow();
|
||||
let i = shared.insets;
|
||||
if shared.updates == 0 {
|
||||
return "insets: dispatches=0 -- the platform has never called \
|
||||
onApplyWindowInsets, so nothing below was measured"
|
||||
.to_string();
|
||||
}
|
||||
format!(
|
||||
"insets: dispatches={} left={} top={} right={} bottom={} ime_bottom={} \
|
||||
ime_visible={}",
|
||||
shared.updates, i.left, i.top, i.right, i.bottom, i.ime_bottom, i.ime_visible,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl HasRoot for AndroidUiState {
|
||||
|
||||
+178
-63
@@ -921,21 +921,38 @@ impl VelocityTracker {
|
||||
|
||||
/// Android's fling deceleration curve, ported from AOSP's
|
||||
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
||||
/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and
|
||||
/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a
|
||||
/// fling here travels the same distance a Compose `LazyColumn`'s own
|
||||
/// Compose's `androidx.compose.animation.AndroidFlingSpline` and
|
||||
/// `androidx.compose.animation.FlingCalculator` reuse) so a fling here
|
||||
/// travels the same distance a Compose `LazyColumn`'s own
|
||||
/// `ScrollableDefaults.flingBehavior()` would for the same initial
|
||||
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
|
||||
/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum"
|
||||
/// asked for the same physics a reader's muscle memory already expects
|
||||
/// from every other Android scroll view.
|
||||
/// from every other Android scroll view. Both sources were read at
|
||||
/// `frameworks/base`'s `core/java/android/widget/OverScroller.java` and
|
||||
/// `androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt`
|
||||
/// (2026-09-07); they agree line for line.
|
||||
///
|
||||
/// The curve is a cubic-Bezier-derived spline sampled into two lookup
|
||||
/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]):
|
||||
/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total
|
||||
/// distance/time elapsed at the `i`th of 100 even steps along the curve's
|
||||
/// own parameter. A lookup at an arbitrary time fraction interpolates
|
||||
/// between the two bracketing samples.
|
||||
/// **One table, indexed by even steps of *time*.** `SPLINE_POSITION[i]`
|
||||
/// is the fraction of the total distance covered at time fraction
|
||||
/// `i / NB_SAMPLES`, so a lookup brackets `t` between `index / N` and
|
||||
/// `(index + 1) / N` -- never between table entries. AOSP builds a second
|
||||
/// table, `SPLINE_TIME`, purely for `adjustDuration` (re-timing a fling
|
||||
/// whose target moved), which nothing here has; it is deliberately not
|
||||
/// built, so there is one array and one indexing rule rather than two of
|
||||
/// each to pick the wrong one from.
|
||||
///
|
||||
/// The wrong one was picked, and this is what it cost. Until 2026-09-07
|
||||
/// the two halves of AOSP's build loop were transposed -- the bisection
|
||||
/// solved the tension curve and the sample evaluated the `P1`/`P2` one,
|
||||
/// where AOSP does the opposite -- which made this table and `SPLINE_TIME`
|
||||
/// *identical*, and the old lookup, which bracketed `t` between
|
||||
/// `SPLINE_TIME` entries, then returned exactly `t` for every `t`. A fling
|
||||
/// coasted at constant speed for its whole duration and stopped dead:
|
||||
/// Iris's phone report of 2026-09-07, "just linear velocity with an abrupt
|
||||
/// stop", verbatim out of the arithmetic. Every test it had compared the
|
||||
/// curve with itself, so none of them could see it;
|
||||
/// `the_spline_matches_aosps_own_table` pins the absolute numbers now.
|
||||
mod android_fling_spline {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -948,24 +965,30 @@ mod android_fling_spline {
|
||||
const P1: f32 = START_TENSION * INFLEXION;
|
||||
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
|
||||
|
||||
pub(super) struct Spline {
|
||||
position: [f32; NB_SAMPLES + 1],
|
||||
time: [f32; NB_SAMPLES + 1],
|
||||
/// What a lookup answers: how far along the fling is, and how fast it
|
||||
/// is going there -- AOSP's `distanceCoef`/`velocityCoef` and Compose's
|
||||
/// `AndroidFlingSpline.FlingResult`. Both are fractions of the fling's
|
||||
/// *total* distance, the second per unit of its *total* duration, so a
|
||||
/// caller scales them by `distance` and `distance / duration`.
|
||||
pub(super) struct SplineSample {
|
||||
pub(super) distance_fraction: f32,
|
||||
pub(super) velocity_fraction: f32,
|
||||
}
|
||||
|
||||
fn build() -> Spline {
|
||||
fn build() -> [f32; NB_SAMPLES + 1] {
|
||||
let mut position = [0.0f32; NB_SAMPLES + 1];
|
||||
let mut time = [0.0f32; NB_SAMPLES + 1];
|
||||
let (mut x_min, mut y_min) = (0.0f32, 0.0f32);
|
||||
for i in 0..NB_SAMPLES {
|
||||
let mut x_min = 0.0f32;
|
||||
for (i, slot) in position.iter_mut().enumerate().take(NB_SAMPLES) {
|
||||
let alpha = i as f32 / NB_SAMPLES as f32;
|
||||
|
||||
let mut x_max = 1.0f32;
|
||||
let (mut x, mut coef);
|
||||
loop {
|
||||
x = x_min + (x_max - x_min) / 2.0;
|
||||
coef = 3.0 * x * (1.0 - x);
|
||||
let tx = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
|
||||
// Solved on the `P1`/`P2` curve and sampled on the tension
|
||||
// one. Transposing these two is the defect this module's
|
||||
// doc comment describes; they are not interchangeable.
|
||||
let tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
|
||||
if (tx - alpha).abs() < 1e-5 {
|
||||
break;
|
||||
}
|
||||
@@ -975,50 +998,37 @@ mod android_fling_spline {
|
||||
x_min = x;
|
||||
}
|
||||
}
|
||||
position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
|
||||
|
||||
let mut y_max = 1.0f32;
|
||||
let (mut y, mut coef_y);
|
||||
loop {
|
||||
y = y_min + (y_max - y_min) / 2.0;
|
||||
coef_y = 3.0 * y * (1.0 - y);
|
||||
let dy = coef_y * ((1.0 - y) * START_TENSION + y * END_TENSION) + y * y * y;
|
||||
if (dy - alpha).abs() < 1e-5 {
|
||||
break;
|
||||
}
|
||||
if dy > alpha {
|
||||
y_max = y;
|
||||
} else {
|
||||
y_min = y;
|
||||
}
|
||||
}
|
||||
time[i] = coef_y * ((1.0 - y) * P1 + y * P2) + y * y * y;
|
||||
*slot = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
|
||||
}
|
||||
position[NB_SAMPLES] = 1.0;
|
||||
time[NB_SAMPLES] = 1.0;
|
||||
Spline { position, time }
|
||||
position
|
||||
}
|
||||
|
||||
static SPLINE: OnceLock<Spline> = OnceLock::new();
|
||||
static SPLINE_POSITION: OnceLock<[f32; NB_SAMPLES + 1]> = OnceLock::new();
|
||||
|
||||
/// The fraction of total distance covered at `time_fraction` (0..=1
|
||||
/// of the fling's total duration). Finds the bracketing samples in
|
||||
/// `SPLINE_TIME` and interpolates linearly between their matching
|
||||
/// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller
|
||||
/// .flingPosition` does.
|
||||
pub(super) fn distance_fraction(time_fraction: f32) -> f32 {
|
||||
let spline = SPLINE.get_or_init(build);
|
||||
/// Sample the curve at `time_fraction` (0..=1 of the fling's total
|
||||
/// duration), exactly as AOSP's `SplineOverScroller.update` and
|
||||
/// Compose's `AndroidFlingSpline.flingPosition` do.
|
||||
pub(super) fn sample(time_fraction: f32) -> SplineSample {
|
||||
let position = SPLINE_POSITION.get_or_init(build);
|
||||
let t = time_fraction.clamp(0.0, 1.0);
|
||||
let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1);
|
||||
let t_inf = spline.time[index];
|
||||
let t_sup = spline.time[index + 1];
|
||||
let d_inf = spline.position[index];
|
||||
let d_sup = spline.position[index + 1];
|
||||
let span = t_sup - t_inf;
|
||||
if span <= 0.0 {
|
||||
d_inf
|
||||
} else {
|
||||
d_inf + (d_sup - d_inf) * (t - t_inf) / span
|
||||
let index = (t * NB_SAMPLES as f32) as usize;
|
||||
if index >= NB_SAMPLES {
|
||||
// The end of the fling: all of the distance covered and
|
||||
// nothing left moving. AOSP's `distanceCoef = 1f` /
|
||||
// `velocityCoef = 0f` defaults, which its
|
||||
// `if (index < NB_SAMPLES)` leaves in place.
|
||||
return SplineSample {
|
||||
distance_fraction: 1.0,
|
||||
velocity_fraction: 0.0,
|
||||
};
|
||||
}
|
||||
let t_inf = index as f32 / NB_SAMPLES as f32;
|
||||
let t_sup = (index + 1) as f32 / NB_SAMPLES as f32;
|
||||
let velocity_fraction = (position[index + 1] - position[index]) / (t_sup - t_inf);
|
||||
SplineSample {
|
||||
distance_fraction: position[index] + (t - t_inf) * velocity_fraction,
|
||||
velocity_fraction,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1106,17 +1116,35 @@ impl FlingCalculator {
|
||||
}
|
||||
|
||||
/// The signed distance covered by `elapsed` into a fling of this
|
||||
/// `velocity` that started at `t0` -- what a per-frame ticker
|
||||
/// (`List::tick_fling`) calls to find how far to have scrolled by now.
|
||||
/// Clamped to the full `distance()` once `elapsed` reaches
|
||||
/// `duration()`, so a caller need not special-case "past the end."
|
||||
/// `velocity` -- what a per-frame ticker (`List::tick_fling`) calls to
|
||||
/// find how far to have scrolled by now. Clamped to the full
|
||||
/// `distance()` once `elapsed` reaches `duration()`, so a caller need
|
||||
/// not special-case "past the end."
|
||||
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
||||
let duration = self.duration(velocity);
|
||||
if duration.is_zero() {
|
||||
return 0.0;
|
||||
}
|
||||
let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0);
|
||||
self.distance(velocity) * android_fling_spline::distance_fraction(fraction)
|
||||
let fraction = elapsed.as_secs_f32() / duration.as_secs_f32();
|
||||
self.distance(velocity) * android_fling_spline::sample(fraction).distance_fraction
|
||||
}
|
||||
|
||||
/// The signed *speed* at `elapsed` into the same fling, in the units
|
||||
/// `velocity` was given in -- AOSP's `mCurrVelocity` and Compose's
|
||||
/// `FlingInfo.velocity`. It falls from roughly `velocity` at the start
|
||||
/// to zero at `duration()`, which is the whole difference between a
|
||||
/// fling and a constant-speed slide, so it is what
|
||||
/// `List::tick_fling`'s debug line reports: successive frames printing
|
||||
/// a shrinking number is the evidence that the curve is being followed
|
||||
/// at all.
|
||||
pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
||||
let duration = self.duration(velocity);
|
||||
if duration.is_zero() {
|
||||
return 0.0;
|
||||
}
|
||||
let fraction = elapsed.as_secs_f32() / duration.as_secs_f32();
|
||||
android_fling_spline::sample(fraction).velocity_fraction * self.distance(velocity)
|
||||
/ duration.as_secs_f32()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1281,6 +1309,93 @@ mod fling_calculator_tests {
|
||||
total
|
||||
);
|
||||
}
|
||||
|
||||
/// The table itself, against AOSP's own entries. Every number here
|
||||
/// came out of `benches/fling_spline_reference.py`, which is a
|
||||
/// separate hand transcription of `OverScroller.java` and
|
||||
/// `SplineBasedDecay.kt` -- so this is the one test in the file that
|
||||
/// is not the Rust code grading its own homework, and the only kind
|
||||
/// that could have caught the transposed build loop
|
||||
/// `android_fling_spline`'s doc describes.
|
||||
///
|
||||
/// The property that names the old defect directly: the curve is
|
||||
/// **not** the identity. At a tenth of the way through its time a
|
||||
/// fling has covered 27.4% of its distance, and at half its time
|
||||
/// 85.8%. The old table returned 0.100 and 0.500 -- a constant-speed
|
||||
/// slide -- so the two `assert!`s below fail by a factor of three.
|
||||
#[test]
|
||||
fn the_spline_matches_aosps_own_table() {
|
||||
for (t, expected) in [
|
||||
(0.0f32, 0.000023f32),
|
||||
(0.1, 0.274002),
|
||||
(0.25, 0.583811),
|
||||
(0.5, 0.858411),
|
||||
(0.75, 0.971068),
|
||||
(0.9, 0.995811),
|
||||
(1.0, 1.0),
|
||||
] {
|
||||
let got = android_fling_spline::sample(t).distance_fraction;
|
||||
assert!(
|
||||
(got - expected).abs() < 1e-4,
|
||||
"distance fraction at t={t}: got {got}, AOSP says {expected}"
|
||||
);
|
||||
}
|
||||
// Speed falls monotonically to nothing -- the difference between
|
||||
// a fling and a slide, and what the abrupt stop was.
|
||||
let mut last = f32::INFINITY;
|
||||
for step in 0..=100 {
|
||||
let v = android_fling_spline::sample(step as f32 / 100.0).velocity_fraction;
|
||||
assert!(v <= last + 1e-4, "speed rose at t={step}/100: {v} > {last}");
|
||||
last = v;
|
||||
}
|
||||
assert_eq!(android_fling_spline::sample(1.0).velocity_fraction, 0.0);
|
||||
}
|
||||
|
||||
/// The same curve carried through `distance`/`duration` at Iris's own
|
||||
/// phone density (2.55, `docs/bench/iris-phone-v2-2026-09-06.md`),
|
||||
/// again with every number from `benches/fling_spline_reference.py`.
|
||||
/// A fling's *speed* a third of the way through is 4733px/s out of an
|
||||
/// initial 11064 -- what a reader sees as deceleration, and the
|
||||
/// quantity that was constant before this.
|
||||
///
|
||||
/// The sample fractions are deliberately not round: the velocity
|
||||
/// coefficient is piecewise constant across each of the 100 samples,
|
||||
/// so `0.75` sits exactly on a step and the assertion would be about
|
||||
/// which side of it the last float landed rather than about the curve.
|
||||
#[test]
|
||||
fn a_flick_decelerates_the_way_aosp_says_it_does() {
|
||||
let calc = FlingCalculator::new(2.55);
|
||||
let velocity = 11064.0f32;
|
||||
let duration = calc.duration(velocity);
|
||||
assert!(
|
||||
(duration.as_secs_f32() - 1.6357).abs() < 0.01,
|
||||
"duration {duration:?}"
|
||||
);
|
||||
assert!(
|
||||
(calc.distance(velocity) - 6334.2).abs() < 5.0,
|
||||
"distance {}",
|
||||
calc.distance(velocity)
|
||||
);
|
||||
for (fraction, position, speed) in [
|
||||
(0.125f32, 2123.3f32, 9202.1f32),
|
||||
(0.335, 4458.3, 4733.0),
|
||||
(0.505, 5459.0, 2649.6),
|
||||
(0.755, 6158.7, 950.9),
|
||||
] {
|
||||
let at = duration.mul_f32(fraction);
|
||||
let got_position = calc.position_at(velocity, at);
|
||||
let got_speed = calc.velocity_at(velocity, at);
|
||||
assert!(
|
||||
(got_position - position).abs() < 5.0,
|
||||
"position at {fraction} of the fling: got {got_position}, AOSP says {position}"
|
||||
);
|
||||
assert!(
|
||||
(got_speed - speed).abs() < 20.0,
|
||||
"speed at {fraction} of the fling: got {got_speed}, AOSP says {speed}"
|
||||
);
|
||||
}
|
||||
assert_eq!(calc.velocity_at(velocity, duration), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+33
-5
@@ -443,11 +443,12 @@ impl List {
|
||||
/// has no idea a finger came back down, and Android's own `Scroller`
|
||||
/// relies on the view calling `abortAnimation` for the same reason.
|
||||
///
|
||||
/// Density cancels out of the underlying spline as long as velocity
|
||||
/// and the distance it produces share one pixel space (see
|
||||
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
|
||||
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
|
||||
/// it is the correct density for a self-consistent unit system.
|
||||
/// The density handed to `FlingCalculator` is this list's own
|
||||
/// (`self.density`, taken from the painter in `draw`), not `1.0`: it
|
||||
/// does **not** cancel out of the spline -- see `FlingCalculator`'s
|
||||
/// doc, which used to claim the opposite, and the 45-second coast that
|
||||
/// claim produced.
|
||||
///
|
||||
/// **Sets the fling; it does not drive it.** A fling moves only while
|
||||
/// something calls [`Self::tick_fling`] once per frame, and what does
|
||||
/// that in a running app is `UiData::tick_animations`, over the ids
|
||||
@@ -534,6 +535,20 @@ impl List {
|
||||
f.applied = target;
|
||||
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
||||
let velocity = f.velocity;
|
||||
// The evidence that the spline is actually being followed, at the
|
||||
// one granularity where a linear coast and a decelerating one look
|
||||
// different: successive `dy` and `speed` shrinking. It was neither
|
||||
// observable nor observed while `distance_fraction` returned `t`
|
||||
// (`android_fling_spline`'s doc), which is why this is here rather
|
||||
// than the total-travel line the release log already carries.
|
||||
log::debug!(
|
||||
"iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px",
|
||||
elapsed.as_secs_f32(),
|
||||
delta,
|
||||
f.calc.velocity_at(velocity, elapsed),
|
||||
velocity,
|
||||
f.calc.distance(velocity) - target,
|
||||
);
|
||||
self.scroll(delta);
|
||||
|
||||
// Clamp: a fling moving toward the start that has already reached
|
||||
@@ -1788,6 +1803,19 @@ mod tests {
|
||||
w[1]
|
||||
);
|
||||
}
|
||||
// Non-increasing is not deceleration: a fling that coasts at a
|
||||
// constant speed and then stops dead satisfies every `<=` above,
|
||||
// and that is exactly what iris shipped until 2026-09-07
|
||||
// (`android_fling_spline`'s doc). Over the samples collected here
|
||||
// -- the earliest part of the curve, since row 0 leaves the loaded
|
||||
// extents soon after -- AOSP's spline has already lost more than
|
||||
// a fifth of its speed.
|
||||
let (first, last) = (deltas[1], *deltas.last().unwrap());
|
||||
assert!(
|
||||
last < first * 0.8,
|
||||
"fling barely slowed across {} ticks: {first} -> {last}",
|
||||
deltas.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user