iris: the keyboard reopens, the IME's height reaches the layout, and a fling actually moves
Items 1-3 of Iris's 22:16 phone report, plus the two defects that were hiding behind item 1 and only became visible once the first one was fixed. Emulator evidence and the numbers are in docs/RUST.md. **Keyboard reopen.** `attr.rs`'s already-focused branch calls `focus_gained` on a tap that stays inside `DRAG_SLOP` -- what Android's own `EditText` does, `showSoftInput` being idempotent. Dismissing the IME leaves the field focused, so the only branch that requested it never ran again. Negative control run: without this one call the second tap leaves `mInputShown=false`. Swipes across and out of the focused field still summon nothing. **IME height.** `MainActivity` sends `getInsets(ime()).bottom` and `isVisible(ime())` as two values; the height used to be sent *as* the boolean, so nothing had a number to pad by. `Insets`/`WindowInsets` carry both, `bench_client` reads the boolean for its state machine and the height for `Composer::set_bottom_inset`, and the list follows because it is `rest(1)` in the same `Span`. **Fling.** Three defects, in the order they were found: 1. `on_touch_event` read only each `MotionEvent`'s final position, so a batched 120Hz flick fed the tracker one sample and `velocity()` answered 0.0. Historical samples are replayed through the sensor pass now, `CursorState::time` carries each sample's own time (so a replay loop's speed cannot become the measured velocity -- the winit backend sets it too), the press is a sample as AOSP's own tracker does, and `iris drag release:` logs the decision for the phone's logcat. 2. Nothing advanced a fling between input events: `tick_fling`'s only caller was the benchmark's own loop, so the bench flung and a finger never did. iris has one animation mechanism now -- `Widget::tick`, `UiData::animate`/`tick_animations`, called by both backends before the draw and re-requesting a frame while it answers true. 3. With flings finally animating, one lasted 45 seconds: `List::fling` hardcoded density 1.0 against physical-pixel velocities, and `FlingCalculator`'s coefficient used the scroll friction where AOSP uses its 0.84 tuning constant -- 56x, inside an exponential. Emulator: 1.62s for v=11064, against AOSP's own 1.586s. **Two pre-existing faults found on the way.** `MOVE_CHAIN_LIMIT` was 16 and the composer's chain is 17, so every debug build aborted on a tap of the composer and every release build silently drew and hit-tested that subtree short; it is 64 in both the CPU walk and shader.wgsl, and the assert prints the chain so a cycle and a deep tree can be told apart. And `minSdk` is 29, since `getEventTimeNanos` is API 29 and a missing JNI method is a crash rather than a degraded fling. Every new invariant carries its guard: sample times non-decreasing in `on_touch_event`, and tests confirmed to fail without their fix for the press-seeded velocity, the animation registration and the AOSP magnitudes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba2afbaedb
commit
ed04d4c735
23 files changed
+735
-87
No files matched your search
+1
-1
@@ -95,7 +95,7 @@ members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"]
|
|||||||
# buildable. Cross-compile it from its own directory (its own single-crate
|
# buildable. Cross-compile it from its own directory (its own single-crate
|
||||||
# workspace, since it has no `[workspace]` table of its own and this
|
# workspace, since it has no `[workspace]` table of its own and this
|
||||||
# exclusion stops it inheriting this one): `cd android-app && cargo ndk
|
# exclusion stops it inheriting this one): `cd android-app && cargo ndk
|
||||||
# -t x86_64 -P 26 build`.
|
# -t x86_64 -P 29 build`.
|
||||||
exclude = ["android-app"]
|
exclude = ["android-app"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.iris.android.demo"
|
applicationId = "dev.iris.android.demo"
|
||||||
minSdk = 26
|
// 29, not 26: `iris::android::view`'s touch handler dates each
|
||||||
|
// sample with `MotionEvent.getEventTimeNanos` and
|
||||||
|
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
|
||||||
|
// method there is a hard crash on the first touch rather than a
|
||||||
|
// degraded fling. Raised deliberately rather than guarded at
|
||||||
|
// runtime: nothing this app is built for runs below 29, and an
|
||||||
|
// untested fallback path is its own defect. `build-apk.sh`'s
|
||||||
|
// `cargo ndk -P` is kept at the same number.
|
||||||
|
minSdk = 29
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public final class IrisView extends RustView {
|
|||||||
protected native long newViewPeer(Context context);
|
protected native long newViewPeer(Context context);
|
||||||
|
|
||||||
native void applyWindowInsetsNative(
|
native void applyWindowInsetsNative(
|
||||||
long peer, int left, int top, int right, int bottom, int imeBottom);
|
long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
|
||||||
|
|
||||||
native void unregisterInsetsNative(long peer);
|
native void unregisterInsetsNative(long peer);
|
||||||
|
|
||||||
@@ -35,8 +35,9 @@ public final class IrisView extends RustView {
|
|||||||
super(context);
|
super(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
void applyWindowInsets(int left, int top, int right, int bottom, int imeBottom) {
|
void applyWindowInsets(
|
||||||
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom);
|
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
|
||||||
|
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -55,30 +55,33 @@ public final class MainActivity extends Activity {
|
|||||||
int top = insets.getSystemWindowInsetTop();
|
int top = insets.getSystemWindowInsetTop();
|
||||||
int right = insets.getSystemWindowInsetRight();
|
int right = insets.getSystemWindowInsetRight();
|
||||||
int bottom = insets.getSystemWindowInsetBottom();
|
int bottom = insets.getSystemWindowInsetBottom();
|
||||||
// The manifest declares adjustResize (AGENTS.md: without it the
|
// **Two separate answers, because they are separate questions**
|
||||||
// keyboard pans the whole window instead of resizing it), and
|
// (Iris's phone, 2026-09-06: "message box does not push up the
|
||||||
// under adjustResize the window itself shrinks to make room for
|
// scroll area"). `isVisible(ime())` says whether the keyboard is
|
||||||
// the keyboard -- which is exactly the condition under which
|
// up; `getInsets(ime()).bottom` says how tall it is. An earlier
|
||||||
// WindowInsets.Type.ime()'s own *inset amount* reports zero: it
|
// pass sent the boolean *as* the height (0 or 1) because under
|
||||||
// measures how much of the window the keyboard overlaps, and
|
// plain `adjustResize` the window shrinks to make room and the
|
||||||
// resize already made that overlap zero by construction. That
|
// ime inset therefore measures a zero overlap by construction --
|
||||||
// numeric inset is not a usable "is the keyboard open" signal
|
// true then, and no longer true now: `setDecorFitsSystemWindows
|
||||||
// here (found while root-causing why bench_client.rs's keyboard
|
// (false)` above makes this an edge-to-edge window, which is
|
||||||
// phase and auto-diagnostics never fired on the emulator despite
|
// exactly the case where the system stops resizing and hands the
|
||||||
// the keyboard visibly opening -- RUST.md's P0 box). What does
|
// app the real overlap instead. Sending 1 for it left the Rust
|
||||||
// survive adjustResize is the boolean isVisible() answer, set
|
// side padding the composer by one physical pixel, so the
|
||||||
// from the platform's own start/end of the transition over a
|
// keyboard covered the bar and the transcript alike.
|
||||||
// different path than the inset amount -- the same fact
|
//
|
||||||
// AGENTS.md's "Things that have bitten" already names for the
|
// The visibility is still sent in its own right rather than
|
||||||
// Compose side's identical trap. Passed through as a 0/1 stand-
|
// inferred from `height > 0`: the two disagree during the
|
||||||
// in for the ime_bottom pixel amount, since nothing on the Rust
|
// keyboard's slide-in and -out (visible, height still climbing),
|
||||||
// side reads it as a real pixel value -- only `> 0.0`.
|
// 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 imeBottom = 0;
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
int imeVisible = 0;
|
||||||
&& insets.isVisible(WindowInsets.Type.ime())) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
imeBottom = 1;
|
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||||
|
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
|
||||||
}
|
}
|
||||||
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
|
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
|
||||||
return insets;
|
return insets;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ export ANDROID_NDK_HOME="$NDK_DIR"
|
|||||||
rm -rf app/src/main/jniLibs
|
rm -rf app/src/main/jniLibs
|
||||||
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
|
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
|
||||||
else
|
else
|
||||||
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
GRADLE_TASK="assembleDebug"
|
GRADLE_TASK="assembleDebug"
|
||||||
|
|||||||
@@ -408,7 +408,12 @@ impl AndroidAppState for BenchClient {
|
|||||||
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
let ime_visible = insets.ime_bottom > 0.0;
|
// The platform's own answer, not `ime_bottom > 0.0` -- see
|
||||||
|
// `iris::android::WindowInsets::ime_bottom`. The height is still
|
||||||
|
// climbing while the keyboard slides in, so a frame or two of a
|
||||||
|
// real opening reads as "closed" when the boolean is inferred from
|
||||||
|
// it, and `shown_events`/`hidden_events` below count transitions.
|
||||||
|
let ime_visible = insets.ime_visible;
|
||||||
|
|
||||||
let mut ime = self.ime_state.lock().unwrap();
|
let mut ime = self.ime_state.lock().unwrap();
|
||||||
if ime_visible && !ime.visible {
|
if ime_visible && !ime.visible {
|
||||||
|
|||||||
@@ -80,11 +80,17 @@ var<storage> masks: array<Mask>;
|
|||||||
@group(3) @binding(1)
|
@group(3) @binding(1)
|
||||||
var<storage> move_offsets: array<MoveOffset>;
|
var<storage> move_offsets: array<MoveOffset>;
|
||||||
|
|
||||||
// A move chain more than this deep means something else is wrong (an
|
// The bound on the parent walk, kept in step with `MOVE_CHAIN_LIMIT` in
|
||||||
// accidental cycle) -- kept in step with `MOVE_CHAIN_LIMIT` in
|
// render_state.rs, which walks the identical chain on the CPU side for
|
||||||
// render_state.rs, which walks the identical bound on the CPU side for
|
// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
|
||||||
// hit-testing. Bounded so a malformed chain cannot hang the GPU.
|
// hang the GPU -- not a claim about how deep a real tree gets. It was 16
|
||||||
const MOVE_CHAIN_LIMIT: u32 = 16u;
|
// and that was too small: the transcript screen's composer field sits 17
|
||||||
|
// slots below the root, measured 2026-09-07 on this checkout's emulator
|
||||||
|
// by tapping it (the CPU walk's own debug assert names the chain now).
|
||||||
|
// Past the bound both walks simply stop summing, so the widget draws and
|
||||||
|
// hit-tests short by whatever the outer slots held, with nothing on
|
||||||
|
// screen to say so.
|
||||||
|
const MOVE_CHAIN_LIMIT: u32 = 64u;
|
||||||
|
|
||||||
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
|
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
|
||||||
/// the vertex stage (a primitive's own corners) and the fragment stage (its
|
/// the vertex stage (a primitive's own corners) and the fragment stage (its
|
||||||
|
|||||||
@@ -24,6 +24,46 @@ pub struct UiData {
|
|||||||
/// id (never reallocated), so a retained descendant's `parent` index
|
/// id (never reallocated), so a retained descendant's `parent` index
|
||||||
/// never goes stale -- see LAYOUT.md section 2.
|
/// never goes stale -- see LAYOUT.md section 2.
|
||||||
pub move_offsets: TrackedArena<MoveOffset, u32>,
|
pub move_offsets: TrackedArena<MoveOffset, u32>,
|
||||||
|
/// Every widget whose [`crate::Widget::tick`] should run before the
|
||||||
|
/// next frame -- today, a `List` coasting through a fling. Added by
|
||||||
|
/// [`Self::animate`] when the animation starts and removed by
|
||||||
|
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so
|
||||||
|
/// a stopped animation costs nothing and a dropped widget cannot be
|
||||||
|
/// ticked (`get_dyn_mut` answers `None` and it is dropped the same
|
||||||
|
/// way).
|
||||||
|
animating: Vec<WidgetId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiData {
|
||||||
|
/// Ask for `id`'s [`crate::Widget::tick`] to run every frame until it
|
||||||
|
/// says it is done. Idempotent -- registering an already-animating
|
||||||
|
/// widget is the ordinary case (a second fling before the first
|
||||||
|
/// settled) and must not tick it twice per frame.
|
||||||
|
pub fn animate(&mut self, id: WidgetId) {
|
||||||
|
if !self.animating.contains(&id) {
|
||||||
|
self.animating.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tick every registered widget to `now`, drop the ones that finished,
|
||||||
|
/// and say whether any is still going -- which is a backend's cue to
|
||||||
|
/// ask for another frame. Called once per frame *before* the draw, so
|
||||||
|
/// what the frame draws is this instant's position rather than the
|
||||||
|
/// previous one's.
|
||||||
|
pub fn tick_animations(&mut self, now: std::time::Instant) -> bool {
|
||||||
|
// Taken out and put back rather than iterated in place: `tick`
|
||||||
|
// needs `&mut` on the widget arena this list lives beside, and a
|
||||||
|
// widget is free to register another one while ticking.
|
||||||
|
let mut registered = std::mem::take(&mut self.animating);
|
||||||
|
registered.retain(|&id| match self.widgets.get_dyn_mut(id) {
|
||||||
|
Some(widget) => widget.tick(now),
|
||||||
|
None => false,
|
||||||
|
});
|
||||||
|
for id in registered {
|
||||||
|
self.animate(id);
|
||||||
|
}
|
||||||
|
!self.animating.is_empty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait UiRsc {
|
pub trait UiRsc {
|
||||||
|
|||||||
@@ -60,10 +60,17 @@ pub struct UiRenderState {
|
|||||||
pub(super) shape_count: u64,
|
pub(super) shape_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A move chain more than this deep would mean something else is wrong
|
/// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
|
||||||
/// (an accidental cycle) -- see `resolve_move` in shader.wgsl, which walks
|
/// which walks the identical chain and must be kept in step with this
|
||||||
/// the identical bound and must be kept in step with this constant.
|
/// constant. It exists so a cyclic `parent` link cannot hang either walk,
|
||||||
pub const MOVE_CHAIN_LIMIT: usize = 16;
|
/// not as a statement about how deep a real tree gets: it was 16, and the
|
||||||
|
/// transcript screen's composer field turned out to sit **17** slots below
|
||||||
|
/// the root (measured 2026-09-07 on this checkout's emulator, by tapping
|
||||||
|
/// the composer in a debug build -- the assert in `resolve_move_chain`
|
||||||
|
/// prints the chain). A chain past the bound is not reported anywhere at
|
||||||
|
/// run time; both walks just stop summing, so the widget is drawn and hit
|
||||||
|
/// tested short by whatever the outer slots held.
|
||||||
|
pub const MOVE_CHAIN_LIMIT: usize = 64;
|
||||||
|
|
||||||
impl UiRenderState {
|
impl UiRenderState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -708,26 +715,56 @@ impl UiRenderState {
|
|||||||
/// pixel delta along the parent chain starting at `slot`. Both walks
|
/// pixel delta along the parent chain starting at `slot`. Both walks
|
||||||
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
|
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
|
||||||
/// about where the chain ends.
|
/// about where the chain ends.
|
||||||
fn resolve_move_chain(&self, mut slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
||||||
let offsets = &rsc.ui().move_offsets;
|
let offsets = &rsc.ui().move_offsets;
|
||||||
let mut delta = Vec2::ZERO;
|
let mut delta = Vec2::ZERO;
|
||||||
|
let mut at = slot;
|
||||||
for i in 0..MOVE_CHAIN_LIMIT {
|
for i in 0..MOVE_CHAIN_LIMIT {
|
||||||
let entry = &offsets[slot.idx()];
|
let entry = &offsets[at.idx()];
|
||||||
delta.x += entry.delta[0];
|
delta.x += entry.delta[0];
|
||||||
delta.y += entry.delta[1];
|
delta.y += entry.delta[1];
|
||||||
if entry.parent == MoveOffset::NONE_PARENT {
|
if entry.parent == MoveOffset::NONE_PARENT {
|
||||||
return delta;
|
return delta;
|
||||||
}
|
}
|
||||||
slot = Id::preset(entry.parent);
|
at = Id::preset(entry.parent);
|
||||||
|
// The chain itself, not just the fact that it was too long: a
|
||||||
|
// cycle and a tree genuinely nested deeper than the shader can
|
||||||
|
// follow are different faults with different fixes, and the
|
||||||
|
// slot numbers are the only thing that tells them apart.
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
i + 1 < MOVE_CHAIN_LIMIT,
|
i + 1 < MOVE_CHAIN_LIMIT,
|
||||||
"move offset chain exceeded MOVE_CHAIN_LIMIT; a widget's `parent` link is \
|
"move offset chain exceeded MOVE_CHAIN_LIMIT ({MOVE_CHAIN_LIMIT}): {chain} -- a \
|
||||||
probably cyclic"
|
repeated slot means a `parent` link is cyclic, all-distinct slots mean the tree \
|
||||||
|
nests deeper than shader.wgsl's own walk of the same bound",
|
||||||
|
chain = Self::move_chain_debug(slot, offsets)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
delta
|
delta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice
|
||||||
|
/// `MOVE_CHAIN_LIMIT` so a cycle shows up as a repeated slot number
|
||||||
|
/// rather than as a chain that merely stops. Only ever called from the
|
||||||
|
/// failed assertion above.
|
||||||
|
fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut at = slot;
|
||||||
|
for _ in 0..MOVE_CHAIN_LIMIT * 2 {
|
||||||
|
let entry = &offsets[at.idx()];
|
||||||
|
parts.push(format!(
|
||||||
|
"{}({}, {})",
|
||||||
|
at.idx(),
|
||||||
|
entry.delta[0],
|
||||||
|
entry.delta[1]
|
||||||
|
));
|
||||||
|
if entry.parent == MoveOffset::NONE_PARENT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
at = Id::preset(entry.parent);
|
||||||
|
}
|
||||||
|
parts.join(" -> ")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
|
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
|
||||||
let region = self.resolved_region(id, rsc)?;
|
let region = self.resolved_region(id, rsc)?;
|
||||||
Some(region.to_px(self.output_size))
|
Some(region.to_px(self.output_size))
|
||||||
|
|||||||
@@ -41,6 +41,25 @@ pub trait Widget: Any {
|
|||||||
fn access_role(&self) -> accesskit::Role {
|
fn access_role(&self) -> accesskit::Role {
|
||||||
accesskit::Role::Unknown
|
accesskit::Role::Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advance whatever this widget is animating to `now`, and say whether
|
||||||
|
/// it is still animating afterwards. Default: nothing is, so a widget
|
||||||
|
/// opts in by overriding this *and* by something calling
|
||||||
|
/// [`crate::UiData::animate`] with its id when the animation starts --
|
||||||
|
/// which is that animation's path out, since the driver
|
||||||
|
/// ([`crate::UiData::tick_animations`]) drops every id whose `tick`
|
||||||
|
/// answers `false`.
|
||||||
|
///
|
||||||
|
/// Called once per frame, before the frame's draw, by whichever
|
||||||
|
/// backend owns the surface; a `true` answer is what makes that
|
||||||
|
/// backend ask for another frame. So this is the only thing in iris
|
||||||
|
/// that moves without an input event, and a widget that animates
|
||||||
|
/// without registering simply never moves -- which is exactly how a
|
||||||
|
/// finger fling looked on Iris's phone before this existed.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn tick(&mut self, now: std::time::Instant) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for () {
|
impl Widget for () {
|
||||||
|
|||||||
@@ -45,11 +45,21 @@ pub struct Insets {
|
|||||||
pub top: i32,
|
pub top: i32,
|
||||||
pub right: i32,
|
pub right: i32,
|
||||||
pub bottom: i32,
|
pub bottom: i32,
|
||||||
/// The keyboard's own inset (`WindowInsetsCompat.Type.ime()`), separate
|
/// The keyboard's own inset (`WindowInsets.Type.ime()`), in physical
|
||||||
/// from `bottom` (the system bars): a layout wants to know about the
|
/// pixels, separate from `bottom` (the system bars): a layout wants to
|
||||||
/// keyboard specifically, since it usually means "make room" rather
|
/// know about the keyboard specifically, since it usually means "make
|
||||||
/// than "stay clear of a corner".
|
/// room" rather than "stay clear of a corner".
|
||||||
pub ime_bottom: i32,
|
pub ime_bottom: i32,
|
||||||
|
/// `WindowInsets.isVisible(ime())` -- whether the keyboard is up, which
|
||||||
|
/// is **not** the same question as `ime_bottom > 0` and is why the two
|
||||||
|
/// are carried separately. They disagree for the frames the keyboard
|
||||||
|
/// spends sliding: visible, with a height still on its way to the full
|
||||||
|
/// one. Anything asking "make how much room" reads `ime_bottom`;
|
||||||
|
/// anything asking "is the keyboard up" reads this. See
|
||||||
|
/// `MainActivity.java`'s comment for the history -- the height used to
|
||||||
|
/// be sent *as* this boolean, which is what left the composer padded by
|
||||||
|
/// one pixel on Iris's phone.
|
||||||
|
pub ime_visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -89,6 +99,7 @@ extern "system" fn apply_window_insets<'local>(
|
|||||||
right: jint,
|
right: jint,
|
||||||
bottom: jint,
|
bottom: jint,
|
||||||
ime_bottom: jint,
|
ime_bottom: 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 {
|
shared.borrow_mut().insets = Insets {
|
||||||
@@ -97,6 +108,7 @@ extern "system" fn apply_window_insets<'local>(
|
|||||||
right,
|
right,
|
||||||
bottom,
|
bottom,
|
||||||
ime_bottom,
|
ime_bottom,
|
||||||
|
ime_visible: ime_visible != 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Insets can change (the keyboard opening) with no resize and no
|
// Insets can change (the keyboard opening) with no resize and no
|
||||||
@@ -115,7 +127,7 @@ pub fn register_native_methods<'local, 'other_local>(
|
|||||||
&[
|
&[
|
||||||
NativeMethod {
|
NativeMethod {
|
||||||
name: "applyWindowInsetsNative".into(),
|
name: "applyWindowInsetsNative".into(),
|
||||||
sig: "(JIIIII)V".into(),
|
sig: "(JIIIIII)V".into(),
|
||||||
fn_ptr: apply_window_insets as *mut c_void,
|
fn_ptr: apply_window_insets as *mut c_void,
|
||||||
},
|
},
|
||||||
NativeMethod {
|
NativeMethod {
|
||||||
|
|||||||
+114
-10
@@ -7,9 +7,9 @@ use android_view::{
|
|||||||
jni::{
|
jni::{
|
||||||
JNIEnv, JavaVM,
|
JNIEnv, JavaVM,
|
||||||
objects::{GlobalRef, JValue},
|
objects::{GlobalRef, JValue},
|
||||||
sys::jint,
|
sys::{jint, jlong},
|
||||||
},
|
},
|
||||||
ndk::event::{Keycode, MotionAction},
|
ndk::event::{Axis, Keycode, MotionAction},
|
||||||
};
|
};
|
||||||
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
|
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
|
||||||
// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified
|
// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified
|
||||||
@@ -20,7 +20,7 @@ use std::{
|
|||||||
marker::{PhantomData, Sized},
|
marker::{PhantomData, Sized},
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::Instant,
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -195,7 +195,12 @@ pub struct WindowInsets {
|
|||||||
pub top: f32,
|
pub top: f32,
|
||||||
pub right: f32,
|
pub right: f32,
|
||||||
pub bottom: f32,
|
pub bottom: f32,
|
||||||
|
/// How much of the window the keyboard covers, in physical pixels --
|
||||||
|
/// what a layout pads by. See `insets::Insets::ime_visible` for why
|
||||||
|
/// "is the keyboard up" is a separate field rather than this one
|
||||||
|
/// compared against zero.
|
||||||
pub ime_bottom: f32,
|
pub ime_bottom: f32,
|
||||||
|
pub ime_visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WindowInsets {
|
impl WindowInsets {
|
||||||
@@ -206,6 +211,7 @@ impl WindowInsets {
|
|||||||
right: insets.right as f32,
|
right: insets.right as f32,
|
||||||
bottom: insets.bottom as f32,
|
bottom: insets.bottom as f32,
|
||||||
ime_bottom: insets.ime_bottom as f32,
|
ime_bottom: insets.ime_bottom as f32,
|
||||||
|
ime_visible: insets.ime_visible,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,6 +290,12 @@ pub struct IrisViewPeer<State: AndroidAppState> {
|
|||||||
pub(super) render: UiRenderState,
|
pub(super) render: UiRenderState,
|
||||||
pub(super) state: State,
|
pub(super) state: State,
|
||||||
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
|
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
|
||||||
|
/// `(an Instant, the input-event nanosecond stamp it was taken at)`,
|
||||||
|
/// captured from the first `MotionEvent` this view receives and never
|
||||||
|
/// changed after -- how `on_touch_event` dates every touch sample. Its
|
||||||
|
/// path out is the peer's own drop: it holds nothing but two numbers
|
||||||
|
/// and is meaningless to any other view.
|
||||||
|
input_clock: Option<(Instant, jlong)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
|
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
|
||||||
@@ -307,12 +319,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Common tail for every callback that might have changed the cursor,
|
/// One pointer sample through the sensors, plus the platform calls a
|
||||||
/// the text focus, or the widget tree: run the sensors that touch
|
/// handler can only ask for by raising a flag. Split out of
|
||||||
/// input feeds, then ask for a frame if the result needs drawing.
|
/// [`Self::after_input`] because a batched `MotionEvent` carries
|
||||||
/// Mirrors `default::DefaultApp::window_event`'s tail, split across
|
/// several samples that all belong to the same *frame*
|
||||||
/// android-view's several entry points instead of winit's one.
|
/// (`on_touch_event`): each one is a real input frame the widgets must
|
||||||
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) {
|
/// see, but only the last one ends the frame and asks for a redraw.
|
||||||
|
fn run_input_frame(&mut self, ctx: &mut CallbackCtx) {
|
||||||
let window_size = self.window_size();
|
let window_size = self.window_size();
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
let cursor = ui_state.cursor.clone();
|
let cursor = ui_state.cursor.clone();
|
||||||
@@ -332,6 +345,15 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
if let Some(url) = ui_state.pending_open_url.take() {
|
if let Some(url) = ui_state.pending_open_url.take() {
|
||||||
super::platform::open_url(&mut ctx.env, &ctx.view, &url);
|
super::platform::open_url(&mut ctx.env, &ctx.view, &url);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common tail for every callback that might have changed the cursor,
|
||||||
|
/// the text focus, or the widget tree: run the sensors that touch
|
||||||
|
/// input feeds, then ask for a frame if the result needs drawing.
|
||||||
|
/// Mirrors `default::DefaultApp::window_event`'s tail, split across
|
||||||
|
/// android-view's several entry points instead of winit's one.
|
||||||
|
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) {
|
||||||
|
self.run_input_frame(ctx);
|
||||||
|
|
||||||
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
|
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
|
||||||
// doesn't move cursor forward": Gboard needs `updateSelection`
|
// doesn't move cursor forward": Gboard needs `updateSelection`
|
||||||
@@ -383,12 +405,14 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
// is actually fed have to reach the log -- "the composer
|
// is actually fed have to reach the log -- "the composer
|
||||||
// floats at launch" is unanswerable from a screenshot alone.
|
// floats at launch" is unanswerable from a screenshot alone.
|
||||||
log::info!(
|
log::info!(
|
||||||
"iris insets: left={} top={} right={} bottom={} ime_bottom={} window={:?}",
|
"iris insets: left={} top={} right={} bottom={} ime_bottom={} \
|
||||||
|
ime_visible={} window={:?}",
|
||||||
physical.left,
|
physical.left,
|
||||||
physical.top,
|
physical.top,
|
||||||
physical.right,
|
physical.right,
|
||||||
physical.bottom,
|
physical.bottom,
|
||||||
physical.ime_bottom,
|
physical.ime_bottom,
|
||||||
|
physical.ime_visible,
|
||||||
self.window_size(),
|
self.window_size(),
|
||||||
);
|
);
|
||||||
self.state.android_state_mut().last_insets = current_insets;
|
self.state.android_state_mut().last_insets = current_insets;
|
||||||
@@ -414,6 +438,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
// both count. See `iris_core::FrameReport`'s own doc for exactly
|
// both count. See `iris_core::FrameReport`'s own doc for exactly
|
||||||
// what this does and does not measure.
|
// what this does and does not measure.
|
||||||
let frame_start = Instant::now();
|
let frame_start = Instant::now();
|
||||||
|
// Anything moving on its own -- today a `List` coasting through a
|
||||||
|
// fling -- is advanced here, before the draw, and asks for the
|
||||||
|
// next frame at the end of this one. See
|
||||||
|
// `UiData::tick_animations`; `default/mod.rs`'s
|
||||||
|
// `RedrawRequested` arm is the same two lines for winit.
|
||||||
|
let animating = self.rsc.ui.tick_animations(frame_start);
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
self.render.update(&ui_state.root, &mut self.rsc);
|
self.render.update(&ui_state.root, &mut self.rsc);
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
@@ -446,6 +476,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
.android_state_mut()
|
.android_state_mut()
|
||||||
.frame_report
|
.frame_report
|
||||||
.record_split(frame_start.elapsed(), submit_to_present);
|
.record_split(frame_start.elapsed(), submit_to_present);
|
||||||
|
// A frame callback is one-shot, so an animation that wants
|
||||||
|
// another frame has to say so every frame -- unlike `after_input`,
|
||||||
|
// which only has to ask when input dirtied something.
|
||||||
|
if animating {
|
||||||
|
ctx.view.post_frame_callback(&mut ctx.env);
|
||||||
|
}
|
||||||
let ui_state = self.state.android_state();
|
let ui_state = self.state.android_state();
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"render(): after update active={} root_px={:?}",
|
"render(): after update active={} root_px={:?}",
|
||||||
@@ -554,7 +590,67 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
// -- see `AndroidUiState::content_scale`'s field comment.
|
// -- see `AndroidUiState::content_scale`'s field comment.
|
||||||
let x = event.x(&mut ctx.env);
|
let x = event.x(&mut ctx.env);
|
||||||
let y = event.y(&mut ctx.env);
|
let y = event.y(&mut ctx.env);
|
||||||
|
// The event's own clock, converted through one anchor taken on the
|
||||||
|
// first touch this view ever sees. Android reports sample times in
|
||||||
|
// the `SystemClock.uptimeMillis()` base, which is the same
|
||||||
|
// `CLOCK_MONOTONIC` an `Instant` reads, so a single
|
||||||
|
// `(Instant, nanos)` pair converts every later sample exactly.
|
||||||
|
// Anchoring **once** rather than per event is what keeps the times
|
||||||
|
// ordered: a fresh `Instant::now()` per event, minus each sample's
|
||||||
|
// age inside it, can date a later event's first historical sample
|
||||||
|
// before the previous event's last one whenever delivery jitters by
|
||||||
|
// more than the batch spans -- and `VelocityTracker::add_sample`'s
|
||||||
|
// debug assert would rightly fire on that. See `CursorState::time`.
|
||||||
|
let event_time = event.event_time_nanos(&mut ctx.env);
|
||||||
|
let (anchor_at, anchor_nanos) =
|
||||||
|
*self.input_clock.get_or_insert((Instant::now(), event_time));
|
||||||
|
let at = |sample_time: jlong| {
|
||||||
|
anchor_at + Duration::from_nanos(sample_time.saturating_sub(anchor_nanos).max(0) as u64)
|
||||||
|
};
|
||||||
|
|
||||||
|
// **Historical samples first.** A flick on a 120Hz screen is
|
||||||
|
// delivered as one or two `MotionEvent`s with the intermediate
|
||||||
|
// positions batched inside them, so reading only `x()`/`y()` threw
|
||||||
|
// away every sample but the last: the velocity tracker saw one
|
||||||
|
// `Pan` for the whole gesture, `VelocityTracker::velocity` answers
|
||||||
|
// 0.0 below two samples, and the release therefore flung at zero --
|
||||||
|
// Iris's phone, twice ("fling still doesn't work"), while a
|
||||||
|
// `ui-trace` swipe, which is many evenly-spaced events, flung fine.
|
||||||
|
// Replayed one at a time through the sensors rather than summarised,
|
||||||
|
// so the arbiter, the tracker and any other sensor all see the same
|
||||||
|
// motion the finger actually made; only the last sample ends the
|
||||||
|
// frame (`after_input`).
|
||||||
|
if matches!(action, MotionAction::Move) {
|
||||||
|
let history = event.history_size(&mut ctx.env);
|
||||||
|
// Android documents the historical samples as oldest first and
|
||||||
|
// the event's own sample as the newest of the batch; everything
|
||||||
|
// downstream (`VelocityTracker`, `DragArbiter`'s long-press
|
||||||
|
// clock) assumes it, so say so here rather than at each reader.
|
||||||
|
let mut previous = anchor_nanos;
|
||||||
|
for pos in 0..history {
|
||||||
|
let hx = event.historical_axis(&mut ctx.env, Axis::X, 0, pos);
|
||||||
|
let hy = event.historical_axis(&mut ctx.env, Axis::Y, 0, pos);
|
||||||
|
let ht = event.historical_event_time_nanos(&mut ctx.env, pos);
|
||||||
|
debug_assert!(
|
||||||
|
ht >= previous,
|
||||||
|
"historical sample {pos} of {history} is dated {ht}ns, before the {previous}ns \
|
||||||
|
sample ahead of it -- the input clock is not what this assumes"
|
||||||
|
);
|
||||||
|
previous = ht;
|
||||||
|
let ui_state = self.state.android_state_mut();
|
||||||
|
ui_state.cursor.pos = vec2(hx, hy);
|
||||||
|
ui_state.cursor.time = at(ht);
|
||||||
|
self.run_input_frame(ctx);
|
||||||
|
}
|
||||||
|
debug_assert!(
|
||||||
|
event_time >= previous,
|
||||||
|
"the event's own sample is dated {event_time}ns, before its last historical \
|
||||||
|
sample at {previous}ns"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
|
ui_state.cursor.time = at(event_time);
|
||||||
match action {
|
match action {
|
||||||
MotionAction::Down => {
|
MotionAction::Down => {
|
||||||
ui_state.cursor.pos = vec2(x, y);
|
ui_state.cursor.pos = vec2(x, y);
|
||||||
@@ -564,6 +660,13 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
MotionAction::Move => {
|
MotionAction::Move => {
|
||||||
ui_state.cursor.pos = vec2(x, y);
|
ui_state.cursor.pos = vec2(x, y);
|
||||||
}
|
}
|
||||||
|
// `Cancel` ends the gesture the same way `Up` does, and must:
|
||||||
|
// a release that never arrives leaves whichever widget took
|
||||||
|
// pointer capture holding it forever, with every later touch
|
||||||
|
// delivered to a drag nobody is performing. Confirmed present
|
||||||
|
// before this pass rather than assumed -- it was one of the
|
||||||
|
// three suspects listed for the phone's missing fling, and it
|
||||||
|
// is not the cause.
|
||||||
MotionAction::Up | MotionAction::Cancel => {
|
MotionAction::Up | MotionAction::Cancel => {
|
||||||
ui_state.cursor.pos = vec2(x, y);
|
ui_state.cursor.pos = vec2(x, y);
|
||||||
ui_state.cursor.buttons.left.update(false);
|
ui_state.cursor.buttons.left.update(false);
|
||||||
@@ -894,6 +997,7 @@ pub fn new_peer<'local, State: AndroidAppState>(
|
|||||||
render,
|
render,
|
||||||
state,
|
state,
|
||||||
task_recv,
|
task_recv,
|
||||||
|
input_clock: None,
|
||||||
};
|
};
|
||||||
let id = android_view::register_view_peer(peer);
|
let id = android_view::register_view_peer(peer);
|
||||||
super::insets::register(id, shared);
|
super::insets::register(id, shared);
|
||||||
|
|||||||
+29
-4
@@ -18,9 +18,14 @@ pub trait FocusHost {
|
|||||||
/// side effect the way a real double-click timer does.
|
/// side effect the way a real double-click timer does.
|
||||||
fn recent_click(&mut self) -> bool;
|
fn recent_click(&mut self) -> bool;
|
||||||
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>);
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>);
|
||||||
/// Called after a `TextEdit` becomes the focus target, with the region
|
/// Called on every tap that should put the IME on `id`: the tap that
|
||||||
/// it was hit in (`None` when the widget could not be located, which
|
/// *makes* a `TextEdit` the focus target, and any later tap on one that
|
||||||
/// happens for one it was just deselected from).
|
/// already is. `region` is where it was hit (`None` when the widget
|
||||||
|
/// could not be located, which happens for one it was just deselected
|
||||||
|
/// from). Implementations must be idempotent -- both backends' calls
|
||||||
|
/// (`showSoftInput`, `set_ime_cursor_area`) already are, which is what
|
||||||
|
/// lets the repeat tap be handled by the same call rather than by a
|
||||||
|
/// second "re-show" entry point beside it.
|
||||||
fn focus_gained(&mut self, region: Option<PixelRegion>);
|
fn focus_gained(&mut self, region: Option<PixelRegion>);
|
||||||
/// Whether `id` is the current focus target -- what [`select`] uses to
|
/// Whether `id` is the current focus target -- what [`select`] uses to
|
||||||
/// tell a fresh press (which must wait to see whether it becomes a tap
|
/// tell a fresh press (which must wait to see whether it becomes a tap
|
||||||
@@ -155,10 +160,30 @@ fn on_press(
|
|||||||
ctx.text.press_origin = None;
|
ctx.text.press_origin = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if matches!(sense, CursorSense::PressEnd(_)) {
|
let ended = matches!(sense, CursorSense::PressEnd(_));
|
||||||
|
if ended {
|
||||||
ctx.text.press_origin = None;
|
ctx.text.press_origin = None;
|
||||||
}
|
}
|
||||||
ctx.select(pos, size, true, false);
|
ctx.select(pos, size, true, false);
|
||||||
|
// A tap on a field that is *already* focused asks for the
|
||||||
|
// keyboard again (Iris's phone, 2026-09-06: "I can't reopen
|
||||||
|
// keyboard by tapping on message box after it already
|
||||||
|
// happened once"). Dismissing the IME -- back gesture, or
|
||||||
|
// its own hide button -- takes the keyboard away but leaves
|
||||||
|
// the field focused, so without this the one branch that
|
||||||
|
// requests it (the unfocused one below) never runs again
|
||||||
|
// and the field is permanently unable to summon it.
|
||||||
|
// Android's own `EditText` does exactly this: every tap on
|
||||||
|
// a focused field calls `showSoftInput`, which is a no-op
|
||||||
|
// when the keyboard is already up.
|
||||||
|
//
|
||||||
|
// Gated on the same tap-vs-drag test the unfocused branch
|
||||||
|
// uses, not on `PressEnd` alone, so a drag-to-select that
|
||||||
|
// happens to finish inside the field does not summon a
|
||||||
|
// keyboard the reader was not asking for.
|
||||||
|
if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP {
|
||||||
|
state.focus_gained(render.window_region(&id, &*rsc));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
|
// `CursorState::time` is the sample's own time on every backend. winit
|
||||||
|
// carries no timestamp on a pointer event, so the moment it is handed to
|
||||||
|
// us is the closest measurement available here -- which is also what the
|
||||||
|
// drag code used to do for itself with `Instant::now()`, before Android's
|
||||||
|
// batched samples made the difference matter (see `sense::CursorState`).
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
use std::time::Instant;
|
||||||
use winit::{
|
use winit::{
|
||||||
event::{MouseButton, MouseScrollDelta, WindowEvent},
|
event::{MouseButton, MouseScrollDelta, WindowEvent},
|
||||||
keyboard::{Key, NamedKey},
|
keyboard::{Key, NamedKey},
|
||||||
@@ -21,8 +27,10 @@ impl Input {
|
|||||||
WindowEvent::CursorMoved { position, .. } => {
|
WindowEvent::CursorMoved { position, .. } => {
|
||||||
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32) / scale_factor;
|
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32) / scale_factor;
|
||||||
self.cursor.exists = true;
|
self.cursor.exists = true;
|
||||||
|
self.cursor.time = Instant::now();
|
||||||
}
|
}
|
||||||
WindowEvent::MouseInput { state, button, .. } => {
|
WindowEvent::MouseInput { state, button, .. } => {
|
||||||
|
self.cursor.time = Instant::now();
|
||||||
let buttons = &mut self.cursor.buttons;
|
let buttons = &mut self.cursor.buttons;
|
||||||
let pressed = state.is_pressed();
|
let pressed = state.is_pressed();
|
||||||
match button {
|
match button {
|
||||||
@@ -44,6 +52,7 @@ impl Input {
|
|||||||
delta.y = 0.0;
|
delta.y = 0.0;
|
||||||
}
|
}
|
||||||
self.cursor.scroll_delta = delta;
|
self.cursor.scroll_delta = delta;
|
||||||
|
self.cursor.time = Instant::now();
|
||||||
}
|
}
|
||||||
WindowEvent::CursorLeft { .. } => {
|
WindowEvent::CursorLeft { .. } => {
|
||||||
self.cursor.exists = false;
|
self.cursor.exists = false;
|
||||||
|
|||||||
@@ -267,9 +267,21 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
match &event {
|
match &event {
|
||||||
WindowEvent::CloseRequested => event_loop.exit(),
|
WindowEvent::CloseRequested => event_loop.exit(),
|
||||||
WindowEvent::RedrawRequested => {
|
WindowEvent::RedrawRequested => {
|
||||||
|
// Before the draw, so this frame shows this instant's
|
||||||
|
// position (`UiData::tick_animations`' own doc), and the
|
||||||
|
// window is asked for another frame while anything is
|
||||||
|
// still moving -- the winit half of what
|
||||||
|
// `IrisViewPeer::render`'s `post_frame_callback` does on
|
||||||
|
// Android. Nothing else in iris moves without an input
|
||||||
|
// event.
|
||||||
|
let animating = rsc.ui_mut().tick_animations(std::time::Instant::now());
|
||||||
|
let ui_state = state.default_state_mut();
|
||||||
render.update(&ui_state.root, rsc);
|
render.update(&ui_state.root, rsc);
|
||||||
ui_state.renderer.update(&mut rsc.ui, render);
|
ui_state.renderer.update(&mut rsc.ui, render);
|
||||||
ui_state.renderer.draw();
|
ui_state.renderer.draw();
|
||||||
|
if animating {
|
||||||
|
ui_state.window.request_redraw();
|
||||||
|
}
|
||||||
// I4 (RUST.md): only produces a `TreeUpdate` when the named
|
// I4 (RUST.md): only produces a `TreeUpdate` when the named
|
||||||
// set actually changed this frame -- see `AccessTree`'s doc
|
// set actually changed this frame -- see `AccessTree`'s doc
|
||||||
// comment. `render` reflects the draw that just happened,
|
// comment. `render` reflects the draw that just happened,
|
||||||
|
|||||||
+276
-14
@@ -95,12 +95,39 @@ impl CursorSense {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CursorState {
|
pub struct CursorState {
|
||||||
pub pos: Vec2,
|
pub pos: Vec2,
|
||||||
pub exists: bool,
|
pub exists: bool,
|
||||||
pub buttons: CursorButtons,
|
pub buttons: CursorButtons,
|
||||||
pub scroll_delta: Vec2,
|
pub scroll_delta: Vec2,
|
||||||
|
/// When this pointer state was *sampled*, from the platform's own
|
||||||
|
/// input clock -- not when the handler reading it happened to run.
|
||||||
|
///
|
||||||
|
/// It exists because Android batches touch samples: a flick on a
|
||||||
|
/// 120Hz screen arrives as one or two `MotionEvent`s carrying the
|
||||||
|
/// intermediate positions as *historical* samples
|
||||||
|
/// (`getHistoricalX`/`getHistoricalEventTime`), which
|
||||||
|
/// `IrisViewPeer::on_touch_event` replays through the sensor pass one
|
||||||
|
/// at a time. Every one of those replays happens within the same few
|
||||||
|
/// microseconds, so a gesture timing itself with `Instant::now()`
|
||||||
|
/// would see a span of nearly zero across the whole flick and divide
|
||||||
|
/// by it -- the velocity would be an artefact of how fast we looped,
|
||||||
|
/// which is exactly the inferred-as-measured number UI_RULES.md
|
||||||
|
/// forbids. Carrying the sample's own time makes the span real.
|
||||||
|
pub time: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CursorState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
pos: Vec2::ZERO,
|
||||||
|
exists: false,
|
||||||
|
buttons: CursorButtons::default(),
|
||||||
|
scroll_delta: Vec2::ZERO,
|
||||||
|
time: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
@@ -732,6 +759,18 @@ impl DragGesture {
|
|||||||
match sense {
|
match sense {
|
||||||
CursorSense::PressStart(_) => {
|
CursorSense::PressStart(_) => {
|
||||||
self.velocity.reset();
|
self.velocity.reset();
|
||||||
|
// The press itself is a sample: nothing has moved yet, but
|
||||||
|
// *when* the finger went down is real and measured, and
|
||||||
|
// without it a gesture whose whole motion arrives in one
|
||||||
|
// frame has a single sample and therefore no time span to
|
||||||
|
// divide by -- `velocity` answers 0.0 and the release does
|
||||||
|
// not fling. Batched touch delivery makes that shape
|
||||||
|
// ordinary rather than rare (see `CursorState::time`), and
|
||||||
|
// `VELOCITY_WINDOW` trims this entry back out the moment
|
||||||
|
// the gesture is long enough not to need it, so a slow
|
||||||
|
// drag's velocity is still its recent motion and not its
|
||||||
|
// whole history.
|
||||||
|
self.velocity.add_sample(0.0, now);
|
||||||
self.arbiter.press_start(pos_window, now, already_selected);
|
self.arbiter.press_start(pos_window, now, already_selected);
|
||||||
self.dispatch(render, id, pos_window, now)
|
self.dispatch(render, id, pos_window, now)
|
||||||
}
|
}
|
||||||
@@ -743,6 +782,20 @@ impl DragGesture {
|
|||||||
} else {
|
} else {
|
||||||
GestureOutcome::Released(None)
|
GestureOutcome::Released(None)
|
||||||
};
|
};
|
||||||
|
// The one line that settles "why did that flick not fling"
|
||||||
|
// from a logcat, which is the only instrument available on
|
||||||
|
// Iris's phone (this-machine-android: system tracing does
|
||||||
|
// not work there). Every input to the decision is here, so
|
||||||
|
// a zero velocity can be told apart from a gesture that
|
||||||
|
// never reached `Panning` at all -- the two look identical
|
||||||
|
// on screen and had to be guessed between twice.
|
||||||
|
log::info!(
|
||||||
|
"iris drag release: samples={} span={:.1}ms v={:.0} outcome={:?}",
|
||||||
|
self.velocity.sample_count(),
|
||||||
|
self.velocity.span().as_secs_f32() * 1000.0,
|
||||||
|
self.velocity.velocity(),
|
||||||
|
outcome,
|
||||||
|
);
|
||||||
self.arbiter.release();
|
self.arbiter.release();
|
||||||
render.release_pointer();
|
render.release_pointer();
|
||||||
outcome
|
outcome
|
||||||
@@ -752,6 +805,7 @@ impl DragGesture {
|
|||||||
// landed outside whichever hit region first noticed it.
|
// landed outside whichever hit region first noticed it.
|
||||||
_ if self.arbiter.is_idle() => {
|
_ if self.arbiter.is_idle() => {
|
||||||
self.velocity.reset();
|
self.velocity.reset();
|
||||||
|
self.velocity.add_sample(0.0, now);
|
||||||
self.arbiter.press_start(pos_window, now, already_selected);
|
self.arbiter.press_start(pos_window, now, already_selected);
|
||||||
self.dispatch(render, id, pos_window, now)
|
self.dispatch(render, id, pos_window, now)
|
||||||
}
|
}
|
||||||
@@ -835,6 +889,22 @@ impl VelocityTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many samples are currently inside the window, and how long they
|
||||||
|
/// span. Reported beside the velocity in `DragGesture`'s release log,
|
||||||
|
/// because a `v=0` on its own cannot say whether the gesture was slow
|
||||||
|
/// or whether the tracker was simply never fed -- which is exactly the
|
||||||
|
/// distinction the phone's missing fling turned on.
|
||||||
|
pub fn sample_count(&self) -> usize {
|
||||||
|
self.samples.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn span(&self) -> Duration {
|
||||||
|
match (self.samples.front(), self.samples.back()) {
|
||||||
|
(Some(&(first, _)), Some(&(last, _))) => last.duration_since(first),
|
||||||
|
_ => Duration::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The estimated speed, in units-per-second, over whatever samples
|
/// The estimated speed, in units-per-second, over whatever samples
|
||||||
/// currently fall inside the tracking window: total motion divided by
|
/// currently fall inside the tracking window: total motion divided by
|
||||||
/// the elapsed time between the oldest and newest sample still held.
|
/// the elapsed time between the oldest and newest sample still held.
|
||||||
@@ -844,13 +914,7 @@ impl VelocityTracker {
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
|
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
|
||||||
let span = self
|
let span = self.span().as_secs_f32();
|
||||||
.samples
|
|
||||||
.back()
|
|
||||||
.unwrap()
|
|
||||||
.0
|
|
||||||
.duration_since(self.samples.front().unwrap().0)
|
|
||||||
.as_secs_f32();
|
|
||||||
if span <= 0.0 { 0.0 } else { total / span }
|
if span <= 0.0 { 0.0 } else { total / span }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -964,6 +1028,17 @@ mod android_fling_spline {
|
|||||||
/// friction of `0.84` per frame at 60Hz corresponds to
|
/// friction of `0.84` per frame at 60Hz corresponds to
|
||||||
/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`).
|
/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`).
|
||||||
const FLING_FRICTION: f32 = 0.015;
|
const FLING_FRICTION: f32 = 0.015;
|
||||||
|
/// AOSP's own look-and-feel tuning constant, the argument
|
||||||
|
/// `SplineOverScroller`'s constructor passes to `computeDeceleration` when
|
||||||
|
/// it builds `mPhysicalCoeff` -- *not* the scroll friction, which is a
|
||||||
|
/// different number used a different place in the same formula. This was
|
||||||
|
/// `FLING_FRICTION` here until 2026-09-07, making the coefficient 56x too
|
||||||
|
/// small, which put an `ln` of a 56x-too-large ratio through
|
||||||
|
/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds**
|
||||||
|
/// instead of 1.6. Nothing could see it while a finger fling never
|
||||||
|
/// animated at all (`List::fling`'s doc), which is why two defects had to
|
||||||
|
/// be fixed before either was visible.
|
||||||
|
const FLING_TUNING: f32 = 0.84;
|
||||||
fn deceleration_rate() -> f32 {
|
fn deceleration_rate() -> f32 {
|
||||||
(0.78f32.ln()) / (0.9f32.ln())
|
(0.78f32.ln()) / (0.9f32.ln())
|
||||||
}
|
}
|
||||||
@@ -975,11 +1050,15 @@ const GRAVITY_EARTH: f32 = 9.80665;
|
|||||||
/// ported the same way Compose's `FlingCalculator` is, including its
|
/// ported the same way Compose's `FlingCalculator` is, including its
|
||||||
/// `density`-dependent physical coefficient (`computeDeceleration`,
|
/// `density`-dependent physical coefficient (`computeDeceleration`,
|
||||||
/// `GravityEarth * 39.37 * density * 160 * friction`). Density and
|
/// `GravityEarth * 39.37 * density * 160 * friction`). Density and
|
||||||
/// velocity/distance units cancel algebraically as long as velocity and
|
/// `density` is physical pixels per `dp`, and the velocity handed in has
|
||||||
/// the returned distance share one pixel space (physical or logical) --
|
/// to be in those same physical pixels -- which is what a touch event
|
||||||
/// [`crate::widget::List::fling`] relies on exactly that cancellation to
|
/// carries. It does **not** cancel out: `duration` is
|
||||||
/// avoid needing a display density of its own, since iris's `List`
|
/// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the
|
||||||
/// already works in logical (density-independent) pixels throughout.
|
/// wrong density changes how long a fling lasts exponentially rather than
|
||||||
|
/// scaling it. An earlier version of this comment claimed the opposite and
|
||||||
|
/// `List::fling` passed `1.0`; on a 2.75-density screen that gave a
|
||||||
|
/// one-second flick a 45-second coast (measured 2026-09-07). `List` reads
|
||||||
|
/// its density from the painter now.
|
||||||
pub struct FlingCalculator {
|
pub struct FlingCalculator {
|
||||||
physical_coefficient: f32,
|
physical_coefficient: f32,
|
||||||
}
|
}
|
||||||
@@ -987,7 +1066,7 @@ pub struct FlingCalculator {
|
|||||||
impl FlingCalculator {
|
impl FlingCalculator {
|
||||||
pub fn new(density: f32) -> Self {
|
pub fn new(density: f32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_FRICTION,
|
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_TUNING,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1147,6 +1226,39 @@ mod fling_calculator_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The absolute numbers, against AOSP's own formula worked by hand --
|
||||||
|
/// the one thing every other test here cannot see, because they all
|
||||||
|
/// compare this calculator with itself (monotonic, signed, integrates
|
||||||
|
/// to the closed form) and so pass just as happily with a coefficient
|
||||||
|
/// 56x out. That is exactly the state this file was in: an ordinary
|
||||||
|
/// flick lasted 30 seconds on the emulator and every test was green.
|
||||||
|
///
|
||||||
|
/// `SplineOverScroller` at ppi = 2.75*160 = 440:
|
||||||
|
/// `mPhysicalCoeff = 9.80665 * 39.37 * 440 * 0.84 = 142,698`;
|
||||||
|
/// `l = ln(0.35 * v / (0.015 * mPhysicalCoeff))`;
|
||||||
|
/// `duration = exp(l / (DECELERATION_RATE - 1))`.
|
||||||
|
/// For v = 3000 px/s that is 0.592s and 621px; for 11444 px/s,
|
||||||
|
/// 1.586s.
|
||||||
|
#[test]
|
||||||
|
fn a_flick_lasts_what_aosps_own_formula_says_it_does() {
|
||||||
|
let calc = FlingCalculator::new(2.75);
|
||||||
|
let slow = calc.duration(3000.0).as_secs_f32();
|
||||||
|
assert!(
|
||||||
|
(slow - 0.592).abs() < 0.02,
|
||||||
|
"3000px/s at density 2.75 should settle in ~0.59s, got {slow}s"
|
||||||
|
);
|
||||||
|
let distance = calc.distance(3000.0);
|
||||||
|
assert!(
|
||||||
|
(distance - 621.5).abs() < 5.0,
|
||||||
|
"3000px/s at density 2.75 should travel ~621px, got {distance}"
|
||||||
|
);
|
||||||
|
let fast = calc.duration(11444.0).as_secs_f32();
|
||||||
|
assert!(
|
||||||
|
(fast - 1.586).abs() < 0.05,
|
||||||
|
"11444px/s at density 2.75 should settle in ~1.59s, got {fast}s"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn position_at_is_monotonic_and_clamped_past_the_end() {
|
fn position_at_is_monotonic_and_clamped_past_the_end() {
|
||||||
let calc = FlingCalculator::new(1.0);
|
let calc = FlingCalculator::new(1.0);
|
||||||
@@ -1410,3 +1522,153 @@ mod drag_arbiter_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`DragGesture`] end to end, at the shape Android actually delivers a
|
||||||
|
/// flick in. The arbiter and the tracker each behave correctly on their
|
||||||
|
/// own (the two modules above); what these cover is the join between them
|
||||||
|
/// at release, which is where the phone's missing fling lived.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod drag_gesture_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
static BASE: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||||
|
|
||||||
|
fn t(ms: u64) -> Instant {
|
||||||
|
*BASE + Duration::from_millis(ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `UiRenderState` with nothing in it. `DragGesture` only ever calls
|
||||||
|
/// `capture_pointer`/`release_pointer` on it, which are bookkeeping on
|
||||||
|
/// a `Cell` and need no widget tree behind them.
|
||||||
|
fn render() -> UiRenderState {
|
||||||
|
UiRenderState::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The id `capture_pointer` records. Any id will do -- nothing here
|
||||||
|
/// resolves it -- so it comes from a real (empty) widget registry
|
||||||
|
/// rather than being fabricated.
|
||||||
|
fn some_id(ui: &mut UiData) -> WidgetId {
|
||||||
|
ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The phone's shape.** A 120Hz flick reaches the app as very few
|
||||||
|
/// `MotionEvent`s, so before `on_touch_event` replayed the historical
|
||||||
|
/// samples inside them a whole gesture could be press, one move past
|
||||||
|
/// the slop, release. That released at `v=0` -- `velocity()` needs two
|
||||||
|
/// samples and the single `Pan` frame was the only one -- so the list
|
||||||
|
/// stopped dead under the finger while the same gesture driven as many
|
||||||
|
/// evenly-spaced `ui-trace` events flung perfectly. The press is a
|
||||||
|
/// sample now, so even this minimum still carries a real speed.
|
||||||
|
#[test]
|
||||||
|
fn a_flick_delivered_as_one_move_frame_still_releases_with_a_velocity() {
|
||||||
|
let mut ui = UiData::default();
|
||||||
|
let id = some_id(&mut ui);
|
||||||
|
let r = render();
|
||||||
|
let mut g = DragGesture::new();
|
||||||
|
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::ZERO,
|
||||||
|
t(0),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 100.0),
|
||||||
|
t(8),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let out = g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressEnd(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 100.0),
|
||||||
|
t(16),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// (100 - DRAG_SLOP) px over the 8ms between the press and the one
|
||||||
|
// move that arrived: a real measurement of what was delivered, not
|
||||||
|
// an estimate of what the finger "probably" did in between.
|
||||||
|
let expected = (100.0 - DRAG_SLOP) / 0.008;
|
||||||
|
match out {
|
||||||
|
GestureOutcome::Released(Some(v)) => {
|
||||||
|
assert!((v - expected).abs() < 1.0, "expected ~{expected}, got {v}");
|
||||||
|
}
|
||||||
|
other => panic!("expected a released pan, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other half of the same join, and the case the fix had no
|
||||||
|
/// reason to touch: a press and release with no motion at all is a
|
||||||
|
/// tap, and must not acquire a velocity from the seeded press sample.
|
||||||
|
#[test]
|
||||||
|
fn a_tap_is_still_a_tap_and_flings_nothing() {
|
||||||
|
let mut ui = UiData::default();
|
||||||
|
let id = some_id(&mut ui);
|
||||||
|
let r = render();
|
||||||
|
let mut g = DragGesture::new();
|
||||||
|
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::ZERO,
|
||||||
|
t(0),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let out = g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressEnd(CursorButton::Left),
|
||||||
|
Vec2::ZERO,
|
||||||
|
t(20),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(out, GestureOutcome::Tapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A long-press selection released while the finger was still moving
|
||||||
|
/// must not fling either -- `Released(None)`, never the tracked
|
||||||
|
/// velocity. Also untouched by the press-seeding above, which is why
|
||||||
|
/// it is checked here rather than assumed.
|
||||||
|
#[test]
|
||||||
|
fn a_selection_release_carries_no_velocity() {
|
||||||
|
let mut ui = UiData::default();
|
||||||
|
let id = some_id(&mut ui);
|
||||||
|
let r = render();
|
||||||
|
let mut g = DragGesture::new();
|
||||||
|
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::ZERO,
|
||||||
|
t(0),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// Held still past LONG_PRESS, which is what starts a selection.
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::ZERO,
|
||||||
|
t(0) + LONG_PRESS,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let out = g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressEnd(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 50.0),
|
||||||
|
t(0) + LONG_PRESS + Duration::from_millis(10),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(out, GestureOutcome::Released(None));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ fn cursor_at(pos: Vec2) -> CursorState {
|
|||||||
exists: true,
|
exists: true,
|
||||||
buttons: Default::default(),
|
buttons: Default::default(),
|
||||||
scroll_delta: Vec2::ZERO,
|
scroll_delta: Vec2::ZERO,
|
||||||
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+99
-1
@@ -225,6 +225,21 @@ pub struct List {
|
|||||||
/// (headless tests, a caller driving `tick_fling` by hand as
|
/// (headless tests, a caller driving `tick_fling` by hand as
|
||||||
/// `bench_client.rs`'s scripted phases do).
|
/// `bench_client.rs`'s scripted phases do).
|
||||||
redraw: Option<Arc<dyn RequestRedraw>>,
|
redraw: Option<Arc<dyn RequestRedraw>>,
|
||||||
|
/// Physical pixels per `dp`, copied from the painter on every `draw`
|
||||||
|
/// -- what [`Self::fling`] hands `FlingCalculator`. 1.0 until this
|
||||||
|
/// list has been drawn once, which is also the only state in which a
|
||||||
|
/// fling is impossible (`fling` needs an anchor, and an anchor comes
|
||||||
|
/// from a draw).
|
||||||
|
///
|
||||||
|
/// It has to be the real one: the deceleration constant is
|
||||||
|
/// `GRAVITY * 39.37 * density * 160 * friction`, and the velocity fed
|
||||||
|
/// in is in the same physical pixels the touch events arrive in, so a
|
||||||
|
/// hardcoded 1.0 against a 2.75-density screen does not cancel out --
|
||||||
|
/// it makes the fling last exponentially too long. Measured on this
|
||||||
|
/// checkout's emulator, 2026-09-07, once flings could animate at all:
|
||||||
|
/// a flick that should coast for about a second ran for **45
|
||||||
|
/// seconds**.
|
||||||
|
density: f32,
|
||||||
/// Whether the last `draw` found no more content above the topmost
|
/// Whether the last `draw` found no more content above the topmost
|
||||||
/// visible row (its top edge at or past the viewport's own top, with
|
/// visible row (its top edge at or past the viewport's own top, with
|
||||||
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
|
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
|
||||||
@@ -261,6 +276,7 @@ impl List {
|
|||||||
last_viewport_len: 0.0,
|
last_viewport_len: 0.0,
|
||||||
fling: None,
|
fling: None,
|
||||||
redraw: None,
|
redraw: None,
|
||||||
|
density: 1.0,
|
||||||
at_start: false,
|
at_start: false,
|
||||||
at_end: false,
|
at_end: false,
|
||||||
pending_tap: None,
|
pending_tap: None,
|
||||||
@@ -423,6 +439,26 @@ impl List {
|
|||||||
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
|
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
|
||||||
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
|
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
|
||||||
/// it is the correct density for a self-consistent unit system.
|
/// it is the correct density for a self-consistent unit system.
|
||||||
|
/// **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
|
||||||
|
/// `UiData::animate` was given. So a caller starting a fling from a
|
||||||
|
/// gesture registers the list in the same breath:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// list(ui).fling(-velocity);
|
||||||
|
/// let id = list.id();
|
||||||
|
/// ui.ui_mut().animate(id);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Split that way because the two halves have different owners: the
|
||||||
|
/// velocity is the list's business, and whether anything animates at
|
||||||
|
/// all is the frame loop's. Missing the second call is what a finger
|
||||||
|
/// fling did on Iris's phone for two builds -- the velocity was right
|
||||||
|
/// and nothing ever advanced it, which looks exactly like a list that
|
||||||
|
/// stops dead under the finger. A caller driving frames itself
|
||||||
|
/// (`bench_client.rs`'s fling phase, the headless tests) calls
|
||||||
|
/// `tick_fling` directly instead and does not register.
|
||||||
pub fn fling(&mut self, velocity_px_per_s: f32) {
|
pub fn fling(&mut self, velocity_px_per_s: f32) {
|
||||||
// A NaN/inf velocity (a `VelocityTracker::velocity()` divide-by-
|
// A NaN/inf velocity (a `VelocityTracker::velocity()` divide-by-
|
||||||
// near-zero span, or a caller passing a raw device value straight
|
// near-zero span, or a caller passing a raw device value straight
|
||||||
@@ -436,7 +472,7 @@ impl List {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.fling = Some(Fling {
|
self.fling = Some(Fling {
|
||||||
calc: FlingCalculator::new(1.0),
|
calc: FlingCalculator::new(self.density),
|
||||||
velocity: velocity_px_per_s,
|
velocity: velocity_px_per_s,
|
||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
applied: 0.0,
|
applied: 0.0,
|
||||||
@@ -876,8 +912,20 @@ impl List {
|
|||||||
const GENEROUS_PADDING: f32 = 100_000.0;
|
const GENEROUS_PADDING: f32 = 100_000.0;
|
||||||
|
|
||||||
impl Widget for List {
|
impl Widget for List {
|
||||||
|
/// A `List` animates exactly one thing, a fling
|
||||||
|
/// ([`Self::tick_fling`]). The registration that makes this run is
|
||||||
|
/// `UiData::animate` beside the `fling` call -- see `fling`'s own doc.
|
||||||
|
fn tick(&mut self, now: Instant) -> bool {
|
||||||
|
self.tick_fling(now)
|
||||||
|
}
|
||||||
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
let axis = self.axis;
|
let axis = self.axis;
|
||||||
|
// Learned from the frame rather than passed in: a fling's
|
||||||
|
// deceleration is a physical quantity and needs the real display
|
||||||
|
// density, and `draw` is where this widget meets the only thing
|
||||||
|
// that knows it. See `fling`.
|
||||||
|
self.density = painter.density();
|
||||||
let output_len = painter.output_size().axis(axis);
|
let output_len = painter.output_size().axis(axis);
|
||||||
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
||||||
|
|
||||||
@@ -1573,6 +1621,56 @@ mod tests {
|
|||||||
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The half `fling` itself does not do: a registered list is advanced
|
||||||
|
/// by the frame loop's own driver, and unregisters itself when the
|
||||||
|
/// fling settles. Written against `UiData::tick_animations` rather
|
||||||
|
/// than `tick_fling` because the defect it pins is exactly the gap
|
||||||
|
/// between the two -- a fling with a correct velocity that nothing
|
||||||
|
/// ever advanced, which is what a finger fling did on the phone.
|
||||||
|
#[test]
|
||||||
|
fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||||
|
let before = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.anchor_position_display();
|
||||||
|
|
||||||
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
|
||||||
|
rsc.ui.animate(list_weak.id());
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut animating = true;
|
||||||
|
let mut steps = 0;
|
||||||
|
while animating && steps < 600 {
|
||||||
|
animating = rsc
|
||||||
|
.ui
|
||||||
|
.tick_animations(start + std::time::Duration::from_millis(steps * 16));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
steps += 1;
|
||||||
|
}
|
||||||
|
assert!(!animating, "the driver never stopped within 600 frames");
|
||||||
|
assert!(steps > 1, "the fling settled without ever moving");
|
||||||
|
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||||
|
assert_ne!(
|
||||||
|
before,
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.anchor_position_display(),
|
||||||
|
"the list is where it started -- the fling was registered but never applied"
|
||||||
|
);
|
||||||
|
// Nothing left registered, so the next frame costs nothing: the
|
||||||
|
// path out of `animate` is the `false` answer, not a caller
|
||||||
|
// remembering to remove it.
|
||||||
|
assert!(!rsc.ui.tick_animations(start));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fling_distance_is_positive_toward_the_end() {
|
fn fling_distance_is_positive_toward_the_end() {
|
||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
// these methods should "not require any context" (require unit) because they're in core
|
// these methods should "not require any context" (require unit) because they're in core
|
||||||
widget_trait! {
|
widget_trait! {
|
||||||
@@ -111,7 +110,7 @@ widget_trait! {
|
|||||||
let id = ctx.widget.id();
|
let id = ctx.widget.id();
|
||||||
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
|
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
|
||||||
ctx.widget(rsc)
|
ctx.widget(rsc)
|
||||||
.drag(ctx.data.render, id, sense, pos, Instant::now());
|
.drag(ctx.data.render, id, sense, pos, ctx.data.cursor.time);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.add(state)
|
.add(state)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ pub mod tool;
|
|||||||
use client_core::transcript_fold::TranscriptRow as FoldedRow;
|
use client_core::transcript_fold::TranscriptRow as FoldedRow;
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use selection::Selection;
|
use selection::Selection;
|
||||||
use std::{cell::RefCell, rc::Rc, time::Instant};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
pub struct TranscriptScreen {
|
pub struct TranscriptScreen {
|
||||||
/// The transcript's own `List` -- exposed so a caller can read
|
/// The transcript's own `List` -- exposed so a caller can read
|
||||||
@@ -412,7 +412,7 @@ where
|
|||||||
row,
|
row,
|
||||||
ctx.data.cursor.pos,
|
ctx.data.cursor.pos,
|
||||||
ctx.data.sense,
|
ctx.data.sense,
|
||||||
Instant::now(),
|
ctx.data.cursor.time,
|
||||||
ctx.data.render,
|
ctx.data.render,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ use crate::tool::ToolRow;
|
|||||||
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
||||||
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use std::{cell::RefCell, rc::Rc, time::Instant};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
/// The gap drawn between two markdown blocks of one message. A block used
|
/// The gap drawn between two markdown blocks of one message. A block used
|
||||||
/// to be separated by the blank line `markdown::render_markdown` put in
|
/// to be separated by the blank line `markdown::render_markdown` put in
|
||||||
@@ -237,7 +237,7 @@ where
|
|||||||
Some((key, pos, size)),
|
Some((key, pos, size)),
|
||||||
cursor,
|
cursor,
|
||||||
ctx.data.sense,
|
ctx.data.sense,
|
||||||
Instant::now(),
|
ctx.data.cursor.time,
|
||||||
ctx.data.render,
|
ctx.data.render,
|
||||||
);
|
);
|
||||||
// A *tap*, decided by the same `DragArbiter` the pan and
|
// A *tap*, decided by the same `DragArbiter` the pan and
|
||||||
|
|||||||
@@ -294,7 +294,14 @@ impl Selection {
|
|||||||
// happened to end with the finger still moving, and never a
|
// happened to end with the finger still moving, and never a
|
||||||
// tap/long-press that never left `Undecided` -- exactly what
|
// tap/long-press that never left `Undecided` -- exactly what
|
||||||
// `DragGesture`'s `Some(v)` already encodes.
|
// `DragGesture`'s `Some(v)` already encodes.
|
||||||
GestureOutcome::Released(Some(v)) => list(ui).fling(-v),
|
GestureOutcome::Released(Some(v)) => {
|
||||||
|
list(ui).fling(-v);
|
||||||
|
// The half that actually makes it move -- see
|
||||||
|
// `List::fling`'s doc. Without it the velocity is
|
||||||
|
// computed, stored, and never advanced by anything.
|
||||||
|
let id = list.id();
|
||||||
|
ui.ui_mut().animate(id);
|
||||||
|
}
|
||||||
// A tap is nobody's business here -- `row.rs` reads it from
|
// A tap is nobody's business here -- `row.rs` reads it from
|
||||||
// the returned outcome and follows a link if one was under
|
// the returned outcome and follows a link if one was under
|
||||||
// the finger.
|
// the finger.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ use crate::selection::Selection;
|
|||||||
use client_core::tool_summary::{ToolInput, parse_tool_input};
|
use client_core::tool_summary::{ToolInput, parse_tool_input};
|
||||||
use client_core::transcript_fold::{ToolState, TranscriptItem};
|
use client_core::transcript_fold::{ToolState, TranscriptItem};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc, time::Instant};
|
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
|
||||||
|
|
||||||
/// A card's own fill: Surface 0, what Material's filled `Card` resolves to
|
/// A card's own fill: Surface 0, what Material's filled `Card` resolves to
|
||||||
/// under `Theme.kt`'s scheme. One step *above* the page, so a card reads
|
/// under `Theme.kt`'s scheme. One step *above* the page, so a card reads
|
||||||
@@ -202,7 +202,7 @@ fn on_tap<Rsc: HasEvents>(
|
|||||||
None,
|
None,
|
||||||
ctx.data.cursor.pos,
|
ctx.data.cursor.pos,
|
||||||
ctx.data.sense,
|
ctx.data.sense,
|
||||||
Instant::now(),
|
ctx.data.cursor.time,
|
||||||
ctx.data.render,
|
ctx.data.render,
|
||||||
);
|
);
|
||||||
if outcome == GestureOutcome::Tapped {
|
if outcome == GestureOutcome::Tapped {
|
||||||
|
|||||||
Reference in new issue
Block a user