Compare commits
2
Commits
038f6a3832
...
4274b8b8d0
| 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
|
changes. The override is what lets `run-headless.sh --phone` open a window
|
||||||
at your phone's own 1080x2424 and 2.55.
|
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
|
## 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
|
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),
|
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
||||||
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
|
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
|
||||||
|
|
||||||
- [ ] **"Fling still doesn't work."** *(Three defects fixed 2026-09-07;
|
- [ ] **"Fling still doesn't work."** -> on `ed04d4c`, 2026-09-07:
|
||||||
open until the phone says so. **The line to look for:**
|
*"flinging now does technically do something, but it seems to just be
|
||||||
`adb logcat | grep "iris drag release"` -- `samples=1` or `span=0.0ms`
|
linear velocity with an abrupt stop."* **It was exactly that, and the
|
||||||
means the batched samples are not reaching the tracker there, while a
|
arithmetic said so.** `distance_fraction(t)` returned `t` for every `t`
|
||||||
sensible span with `v=` in the thousands and
|
-- a constant-speed slide for the whole duration, then a stop at full
|
||||||
`outcome=Released(Some(…))` means the gesture was measured right and
|
distance -- because two halves of AOSP's spline build loop were
|
||||||
anything still wrong is downstream.)* Second report; the emulator's
|
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),
|
`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
|
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
|
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
|
`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
|
(`FocusHost`: a tap on a focused field requests the IME, idempotent
|
||||||
when it is already shown).
|
when it is already shown).
|
||||||
- [x] **"Message box does not push up the scroll area."** *(Fixed
|
- [ ] **"Message box does not push up the scroll area."**
|
||||||
2026-09-07: height and visibility are two JNI values now. Emulator:
|
**Reopened by the phone on 2026-09-07** -- *"similarly, the keyboard
|
||||||
`iris insets: … bottom=883 ime_bottom=883 ime_visible=true`, composer
|
raising up does not push things upwards"* -- after being ticked on
|
||||||
box `31,2277..1048,2329` -> `31,1457..1048,1509`, and the list follows
|
emulator evidence the day before (`ime_bottom=883`, composer box
|
||||||
because it is `rest(1)` in the same `Span`.)* Since
|
`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
|
`MainActivity` went edge-to-edge (`e12c708`), `adjustResize` no
|
||||||
longer resizes the window, so the app owns the IME inset -- but
|
longer resizes the window, so the app owns the IME inset -- but
|
||||||
`ime_bottom` is passed through JNI as the boolean `1`/`0` (the
|
`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
|
screenshotted the emulator's GLES path, where a resume may not
|
||||||
destroy the surface at all.
|
destroy the surface at all.
|
||||||
|
|
||||||
**Fixed in `ba2afba`, with `clearing_the_atlas_re_renders_cached_text_
|
**Fixed in `ba2afba` and confirmed on the phone (Iris, 2026-09-07:
|
||||||
instead_of_reusing_it` run and passing (2026-09-07). Ticked on the
|
"the resume glyph corruption is fixed").** Closed. The emulator could
|
||||||
code; still wants phone-side confirmation** -- no emulator here has a
|
never have settled it -- no Vulkan adapter here, and the GLES path may
|
||||||
Vulkan adapter, and the GLES path may not destroy the surface at all,
|
not destroy the surface at all -- so the phone was the only place this
|
||||||
so the emulator cannot reproduce the state Iris photographed.
|
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
|
The reading above is right and the mechanism is one step narrower than
|
||||||
"cached text primitives". `IrisViewPeer::surface_changed`
|
"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
|
verification run before a build goes to the phone. Not for iterating
|
||||||
on layout.
|
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
|
### 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,
|
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
|
// untested fallback path is its own defect. `build-apk.sh`'s
|
||||||
// `cargo ndk -P` is kept at the same number.
|
// `cargo ndk -P` is kept at the same number.
|
||||||
minSdk = 29
|
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
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import android.app.Activity;
|
|||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.WindowInsets;
|
import android.view.WindowInsets;
|
||||||
|
import android.view.WindowInsetsAnimation;
|
||||||
import android.widget.FrameLayout;
|
import android.widget.FrameLayout;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
* 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);
|
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) -> {
|
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||||
int left = insets.getSystemWindowInsetLeft();
|
sendInsets((IrisView) v, insets);
|
||||||
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);
|
|
||||||
return 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}"),
|
Some(stats) => format!("{stats}"),
|
||||||
None => "no frames recorded yet".to_string(),
|
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),
|
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||||
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
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
|
/// 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)]
|
#[derive(Default)]
|
||||||
pub struct Shared {
|
pub struct Shared {
|
||||||
pub insets: Insets,
|
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>>>>;
|
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
|
||||||
@@ -102,7 +114,8 @@ extern "system" fn apply_window_insets<'local>(
|
|||||||
ime_visible: jint,
|
ime_visible: jint,
|
||||||
) {
|
) {
|
||||||
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
||||||
shared.borrow_mut().insets = Insets {
|
let mut shared = shared.borrow_mut();
|
||||||
|
shared.insets = Insets {
|
||||||
left,
|
left,
|
||||||
top,
|
top,
|
||||||
right,
|
right,
|
||||||
@@ -110,6 +123,7 @@ extern "system" fn apply_window_insets<'local>(
|
|||||||
ime_bottom,
|
ime_bottom,
|
||||||
ime_visible: ime_visible != 0,
|
ime_visible: ime_visible != 0,
|
||||||
};
|
};
|
||||||
|
shared.updates += 1;
|
||||||
}
|
}
|
||||||
// Insets can change (the keyboard opening) with no resize and no
|
// Insets can change (the keyboard opening) with no resize and no
|
||||||
// touch, so nothing else here would otherwise ask for a frame.
|
// touch, so nothing else here would otherwise ask for a frame.
|
||||||
|
|||||||
@@ -130,6 +130,28 @@ impl AndroidUiState {
|
|||||||
pub fn insets(&self) -> Insets {
|
pub fn insets(&self) -> Insets {
|
||||||
self.shared.borrow().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 {
|
impl HasRoot for AndroidUiState {
|
||||||
|
|||||||
+178
-63
@@ -921,21 +921,38 @@ impl VelocityTracker {
|
|||||||
|
|
||||||
/// Android's fling deceleration curve, ported from AOSP's
|
/// Android's fling deceleration curve, ported from AOSP's
|
||||||
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
||||||
/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and
|
/// Compose's `androidx.compose.animation.AndroidFlingSpline` and
|
||||||
/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a
|
/// `androidx.compose.animation.FlingCalculator` reuse) so a fling here
|
||||||
/// fling here travels the same distance a Compose `LazyColumn`'s own
|
/// travels the same distance a Compose `LazyColumn`'s own
|
||||||
/// `ScrollableDefaults.flingBehavior()` would for the same initial
|
/// `ScrollableDefaults.flingBehavior()` would for the same initial
|
||||||
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
|
/// 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"
|
/// 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
|
/// 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
|
/// **One table, indexed by even steps of *time*.** `SPLINE_POSITION[i]`
|
||||||
/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]):
|
/// is the fraction of the total distance covered at time fraction
|
||||||
/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total
|
/// `i / NB_SAMPLES`, so a lookup brackets `t` between `index / N` and
|
||||||
/// distance/time elapsed at the `i`th of 100 even steps along the curve's
|
/// `(index + 1) / N` -- never between table entries. AOSP builds a second
|
||||||
/// own parameter. A lookup at an arbitrary time fraction interpolates
|
/// table, `SPLINE_TIME`, purely for `adjustDuration` (re-timing a fling
|
||||||
/// between the two bracketing samples.
|
/// 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 {
|
mod android_fling_spline {
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
@@ -948,24 +965,30 @@ mod android_fling_spline {
|
|||||||
const P1: f32 = START_TENSION * INFLEXION;
|
const P1: f32 = START_TENSION * INFLEXION;
|
||||||
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
|
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
|
||||||
|
|
||||||
pub(super) struct Spline {
|
/// What a lookup answers: how far along the fling is, and how fast it
|
||||||
position: [f32; NB_SAMPLES + 1],
|
/// is going there -- AOSP's `distanceCoef`/`velocityCoef` and Compose's
|
||||||
time: [f32; NB_SAMPLES + 1],
|
/// `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 position = [0.0f32; NB_SAMPLES + 1];
|
||||||
let mut time = [0.0f32; NB_SAMPLES + 1];
|
let mut x_min = 0.0f32;
|
||||||
let (mut x_min, mut y_min) = (0.0f32, 0.0f32);
|
for (i, slot) in position.iter_mut().enumerate().take(NB_SAMPLES) {
|
||||||
for i in 0..NB_SAMPLES {
|
|
||||||
let alpha = i as f32 / NB_SAMPLES as f32;
|
let alpha = i as f32 / NB_SAMPLES as f32;
|
||||||
|
|
||||||
let mut x_max = 1.0f32;
|
let mut x_max = 1.0f32;
|
||||||
let (mut x, mut coef);
|
let (mut x, mut coef);
|
||||||
loop {
|
loop {
|
||||||
x = x_min + (x_max - x_min) / 2.0;
|
x = x_min + (x_max - x_min) / 2.0;
|
||||||
coef = 3.0 * x * (1.0 - x);
|
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 {
|
if (tx - alpha).abs() < 1e-5 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -975,50 +998,37 @@ mod android_fling_spline {
|
|||||||
x_min = x;
|
x_min = x;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
|
*slot = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + 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;
|
|
||||||
}
|
}
|
||||||
position[NB_SAMPLES] = 1.0;
|
position[NB_SAMPLES] = 1.0;
|
||||||
time[NB_SAMPLES] = 1.0;
|
position
|
||||||
Spline { position, time }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
/// Sample the curve at `time_fraction` (0..=1 of the fling's total
|
||||||
/// of the fling's total duration). Finds the bracketing samples in
|
/// duration), exactly as AOSP's `SplineOverScroller.update` and
|
||||||
/// `SPLINE_TIME` and interpolates linearly between their matching
|
/// Compose's `AndroidFlingSpline.flingPosition` do.
|
||||||
/// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller
|
pub(super) fn sample(time_fraction: f32) -> SplineSample {
|
||||||
/// .flingPosition` does.
|
let position = SPLINE_POSITION.get_or_init(build);
|
||||||
pub(super) fn distance_fraction(time_fraction: f32) -> f32 {
|
|
||||||
let spline = SPLINE.get_or_init(build);
|
|
||||||
let t = time_fraction.clamp(0.0, 1.0);
|
let t = time_fraction.clamp(0.0, 1.0);
|
||||||
let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1);
|
let index = (t * NB_SAMPLES as f32) as usize;
|
||||||
let t_inf = spline.time[index];
|
if index >= NB_SAMPLES {
|
||||||
let t_sup = spline.time[index + 1];
|
// The end of the fling: all of the distance covered and
|
||||||
let d_inf = spline.position[index];
|
// nothing left moving. AOSP's `distanceCoef = 1f` /
|
||||||
let d_sup = spline.position[index + 1];
|
// `velocityCoef = 0f` defaults, which its
|
||||||
let span = t_sup - t_inf;
|
// `if (index < NB_SAMPLES)` leaves in place.
|
||||||
if span <= 0.0 {
|
return SplineSample {
|
||||||
d_inf
|
distance_fraction: 1.0,
|
||||||
} else {
|
velocity_fraction: 0.0,
|
||||||
d_inf + (d_sup - d_inf) * (t - t_inf) / span
|
};
|
||||||
|
}
|
||||||
|
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
|
/// The signed distance covered by `elapsed` into a fling of this
|
||||||
/// `velocity` that started at `t0` -- what a per-frame ticker
|
/// `velocity` -- what a per-frame ticker (`List::tick_fling`) calls to
|
||||||
/// (`List::tick_fling`) calls to find how far to have scrolled by now.
|
/// find how far to have scrolled by now. Clamped to the full
|
||||||
/// Clamped to the full `distance()` once `elapsed` reaches
|
/// `distance()` once `elapsed` reaches `duration()`, so a caller need
|
||||||
/// `duration()`, so a caller need not special-case "past the end."
|
/// not special-case "past the end."
|
||||||
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
||||||
let duration = self.duration(velocity);
|
let duration = self.duration(velocity);
|
||||||
if duration.is_zero() {
|
if duration.is_zero() {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0);
|
let fraction = elapsed.as_secs_f32() / duration.as_secs_f32();
|
||||||
self.distance(velocity) * android_fling_spline::distance_fraction(fraction)
|
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
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
+33
-5
@@ -443,11 +443,12 @@ impl List {
|
|||||||
/// has no idea a finger came back down, and Android's own `Scroller`
|
/// has no idea a finger came back down, and Android's own `Scroller`
|
||||||
/// relies on the view calling `abortAnimation` for the same reason.
|
/// relies on the view calling `abortAnimation` for the same reason.
|
||||||
///
|
///
|
||||||
/// Density cancels out of the underlying spline as long as velocity
|
/// The density handed to `FlingCalculator` is this list's own
|
||||||
/// and the distance it produces share one pixel space (see
|
/// (`self.density`, taken from the painter in `draw`), not `1.0`: it
|
||||||
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
|
/// does **not** cancel out of the spline -- see `FlingCalculator`'s
|
||||||
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
|
/// doc, which used to claim the opposite, and the 45-second coast that
|
||||||
/// it is the correct density for a self-consistent unit system.
|
/// claim produced.
|
||||||
|
///
|
||||||
/// **Sets the fling; it does not drive it.** A fling moves only while
|
/// **Sets the fling; it does not drive it.** A fling moves only while
|
||||||
/// something calls [`Self::tick_fling`] once per frame, and what does
|
/// something calls [`Self::tick_fling`] once per frame, and what does
|
||||||
/// that in a running app is `UiData::tick_animations`, over the ids
|
/// that in a running app is `UiData::tick_animations`, over the ids
|
||||||
@@ -534,6 +535,20 @@ impl List {
|
|||||||
f.applied = target;
|
f.applied = target;
|
||||||
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
||||||
let velocity = 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);
|
self.scroll(delta);
|
||||||
|
|
||||||
// Clamp: a fling moving toward the start that has already reached
|
// Clamp: a fling moving toward the start that has already reached
|
||||||
@@ -1788,6 +1803,19 @@ mod tests {
|
|||||||
w[1]
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user