From 6f54bae38eb1bf10282457682366e27d10919df3 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 02:59:51 +0100 Subject: [PATCH] Glide card focus and carry key-driven scroll momentum --- .../org/justinvisser/wolfskin/design/Focus.kt | 24 +- .../wolfskin/design/FocusRingHost.kt | 132 ++++++++- .../wolfskin/design/FrameScroll.kt | 75 +++++- .../wolfskin/design/HomeScreen.kt | 4 +- .../wolfskin/design/SettingsList.kt | 5 +- .../wolfskin/design/ShelfScroll.kt | 5 +- .../wolfskin/design/FocusGlideTest.kt | 251 ++++++++++++++++++ .../design/RecoveryToastMotionTest.kt | 112 ++++++++ .../wolfskin/design/ScrollMomentumTest.kt | 144 ++++++++++ 9 files changed, 726 insertions(+), 26 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusGlideTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryToastMotionTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScrollMomentumTest.kt diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt index 9a386180..10b8bab2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt @@ -7,6 +7,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.remember import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -18,6 +20,9 @@ import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp @@ -37,7 +42,7 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier { /** Lift shadows remain specific to cards and buttons. */ enum class FocusLift { None, Card, Button } -/** Every focus ring straddles the surface edge above content, without a background gap. */ +/** Hosted cards keep their lift and share the surface stroke; standalone rings straddle the surface edge. */ @Composable fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier { val reduced = reducedMotion() @@ -55,6 +60,23 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity } } + val host = LocalCardRing.current.takeIf { lift == FocusLift.Card } + if (host != null) { + val owner = remember { Any() } + val density = LocalDensity.current + val scale = WolfskinTokens.Focus.scaleCard + val corner = with(density) { radius.toPx() } * scale + val stroke = with(density) { width.toPx() } * scale + DisposableEffect(host, focused) { + onDispose { if (focused) host.release(owner) } + } + SideEffect { if (focused) host.retainFocus() } + return if (!focused) shadow else shadow.onGloballyPositioned { coordinates -> + val center = coordinates.localToRoot(Offset(coordinates.size.width / 2f, coordinates.size.height / 2f)) + val size = Size(coordinates.size.width * scale, coordinates.size.height * scale) + host.update(owner, CardRingGeometry(Rect(center - Offset(size.width / 2, size.height / 2), size), corner, stroke)) + } + } return shadow.drawWithCache { val stroke = Stroke(width.toPx()) val corner = CornerRadius(radius.toPx()) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt index 6175d6f9..927f7190 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt @@ -1,10 +1,136 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design -import androidx.compose.runtime.Composable +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector4D +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.positionInRoot +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M -/** A shell surface draws one shared card focus ring above its content and below the nav and sheets. */ +private val boundsConverter = TwoWayConverter( + { AnimationVector4D(it.left, it.top, it.right, it.bottom) }, + { Rect(it.v1, it.v2, it.v3, it.v4) }, +) + +internal data class CardRingGeometry(val bounds: Rect, val radius: Float, val stroke: Float) +private data class CardRingTarget(val owner: Any, val geometry: CardRingGeometry) +internal val LocalCardRing = staticCompositionLocalOf { null } + +/** Target geometry follows layout; only the displacement from it carries spring velocity. */ +@Stable +internal class CardRingMotion(private val scope: CoroutineScope) { + var reduced = false + private var target by mutableStateOf(null) + private var retained by mutableStateOf(null) + private val displacement = Animatable(Rect.Zero, boundsConverter) + private val radiusDisplacement = Animatable(0f) + val opacity = Animatable(0f) + private var geometryJob: Job? = null + private var fadeJob: Job? = null + private var releaseJob: Job? = null + private var stroke = Stroke(0f) + + fun geometry(): CardRingGeometry? { + val destination = target?.geometry ?: return retained + if (reduced) return destination + return CardRingGeometry(destination.bounds + displacement.value, + (destination.radius + radiusDisplacement.value).coerceAtLeast(0f), destination.stroke) + } + + fun update(owner: Any, geometry: CardRingGeometry) { + releaseJob?.cancel() + if (stroke.width != geometry.stroke) stroke = Stroke(geometry.stroke) + val previous = target + if (previous?.owner === owner) { + target = CardRingTarget(owner, geometry) + return + } + val current = this.geometry() + val velocity = displacement.velocity + val radiusVelocity = radiusDisplacement.velocity + geometryJob?.cancel() + target = CardRingTarget(owner, geometry) + geometryJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { + displacement.snapTo(if (previous != null && current != null && !reduced) current.bounds - geometry.bounds else Rect.Zero) + radiusDisplacement.snapTo(if (previous != null && current != null && !reduced) current.radius - geometry.radius else 0f) + if (!reduced) coroutineScope { + launch { displacement.animateTo(Rect.Zero, spring(M.navBlobDampingRatio, M.navBlobStiffness), initialVelocity = velocity) } + launch { radiusDisplacement.animateTo(0f, spring(M.navBlobDampingRatio, M.navBlobStiffness), initialVelocity = radiusVelocity) } + } + } + if (previous == null) fade(true) + } + + fun drawRing(scope: DrawScope, origin: Offset) { + val geometry = target?.geometry ?: retained ?: return + val alpha = opacity.value + if (alpha <= 0f) return + val delta = if (target != null && !reduced) displacement.value else Rect.Zero + val radius = geometry.radius + if (target != null && !reduced) radiusDisplacement.value else 0f + scope.drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = alpha), + topLeft = geometry.bounds.topLeft + Offset(delta.left, delta.top) - origin, + size = Size(geometry.bounds.width + delta.right - delta.left, geometry.bounds.height + delta.bottom - delta.top), + cornerRadius = CornerRadius(radius.coerceAtLeast(0f)), style = stroke) + } + + fun retainFocus() { releaseJob?.cancel() } + + fun release(owner: Any) { + // A replacement card registers during layout in the same composition; it must not fade the ring. + releaseJob?.cancel() + releaseJob = scope.launch { + yield() + if (target?.owner === owner) { + retained = geometry() + target = null + geometryJob?.cancel() + fade(false) + } + } + } + + private fun fade(visible: Boolean) { + fadeJob?.cancel() + fadeJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { + opacity.animateTo(if (visible) 1f else 0f, tween(motionDuration(M.focusMove, reduced), easing = M.easeOut)) + } + } +} + +private operator fun Rect.plus(other: Rect) = Rect(left + other.left, top + other.top, right + other.right, bottom + other.bottom) +private operator fun Rect.minus(other: Rect) = Rect(left - other.left, top - other.top, right - other.right, bottom - other.bottom) + +/** One draw-only card ring sits above surface content without changing layout or focus ownership. */ @Composable fun FocusRingHost(content: @Composable () -> Unit) { - content() + val scope = rememberCoroutineScope() + val motion = remember(scope) { CardRingMotion(scope) } + val reduced = reducedMotion() + SideEffect { motion.reduced = reduced } + var origin by remember { mutableStateOf(Offset.Zero) } + Box(Modifier.onPlaced { origin = it.positionInRoot() }.drawWithContent { + drawContent() + motion.drawRing(this, origin) + }) { + CompositionLocalProvider(LocalCardRing provides motion, content = content) + } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt index bab07b25..9390b0cd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt @@ -1,19 +1,72 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.spring import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.withFrameNanos +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.WeakHashMap +import kotlin.math.abs +import kotlin.math.sign +import kotlin.math.sqrt -/** Every frame follows focus with the same bounded scroll tween. */ -internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) { - if (distance == 0f) return - list.scroll { - var previous = 0f - animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, - easing = WolfskinTokens.Motion.easeOut)) { value, _ -> - scrollBy(value - previous) - previous = value +private class ScrollMomentum { + var velocity = 0f + var frameTime = Long.MIN_VALUE + var generation = 0L + fun reset() { velocity = 0f; frameTime = Long.MIN_VALUE } + + fun release(context: CoroutineContext, generation: Long) { + if (this.generation != generation) return + if (context.isActive) { reset(); return } + // A replacement must claim the carry before the next frame, without keeping the old mutation alive. + CoroutineScope(context + NonCancellable).launch(start = CoroutineStart.UNDISPATCHED) { + try { withFrameNanos { } } + finally { if (this@ScrollMomentum.generation == generation) reset() } } } } +// Scroll mutations and animation callbacks run on the UI thread; values never retain their weak keys. +private val scrollMomentum = WeakHashMap() +private val scrollAngularFrequency = sqrt(WolfskinTokens.Motion.navBlobStiffness) + +/** Key-driven frames and shelves share a bounded nav spring and carry same-direction velocity. */ +internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) { + val momentum = scrollMomentum.getOrPut(list) { ScrollMomentum() } + val generation = ++momentum.generation + if (distance == 0f) { momentum.reset(); return } + try { + list.scroll { + if (NetworkArtTransition.reduced) { + momentum.reset() + scrollBy(distance) + } else { + val maximum = scrollAngularFrequency * abs(distance) + val velocity = if (momentum.velocity.sign == distance.sign) momentum.velocity.coerceIn(-maximum, maximum) else 0f + var previous = 0f + // A fresh frame-clock start would insert a stationary frame on every held-key repeat. + AnimationState(0f, velocity, lastFrameTimeNanos = momentum.frameTime).animateTo(distance, + animationSpec = spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness), + sequentialAnimation = momentum.frameTime != Long.MIN_VALUE) { + scrollBy(value - previous) + previous = value + momentum.velocity = this.velocity + momentum.frameTime = lastFrameTimeNanos + } + } + } + } finally { + val context = currentCoroutineContext() + momentum.release(context, generation) + context.ensureActive() + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index f6cc248f..eb4c5645 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -1,7 +1,5 @@ package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -69,7 +67,7 @@ fun HomeScreen( (heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx() }.coerceAtLeast(0f) val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset - view.listState.animateScrollBy(target - current, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) + animateFrameScroll(view.listState, target - current) view.home.positionedKey = key view.home.positionedReset = view.home.resetScroll } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt index fe7adad1..cae18253 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -1,7 +1,5 @@ package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -57,13 +55,12 @@ val SETTINGS_GROUPS = listOf( @Composable fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") { val list = state.view.listState - val reduced = reducedMotion() val density = LocalDensity.current LaunchedEffect(state.settingsRow, state.navFocused) { if (state.navFocused) return@LaunchedEffect val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0) val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset - list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) + animateFrameScroll(list, distance) } Box(modifier.fillMaxSize()) { Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight), diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt index 614caff7..8e72e00f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt @@ -1,7 +1,5 @@ package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.lazy.LazyListState /** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */ @@ -15,7 +13,6 @@ internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int /** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */ internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) { val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset - if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base, - easing = WolfskinTokens.Motion.ease)) + animateFrameScroll(this, distance) if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first) } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusGlideTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusGlideTest.kt new file mode 100644 index 00000000..24fa42d3 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusGlideTest.kt @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Rect +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import kotlin.math.abs +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native ring pixels cover translation, shape morphing, scroll tracking and legacy parity. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class FocusGlideTest { + @get:Rule val rule = createAndroidComposeRule() + private val directory get() = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/E02").apply { mkdirs() } + private val accentArgb = WolfskinTokens.Color.accent.toArgb() + private var pixels = IntArray(0) + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + @Test fun shelfRightAndRowDownGlideAsOneShape() = shelves(false) + @Test fun reducedShelfGeometrySnapsInOneFrame() = shelves(true) + + private fun shelves(reduced: Boolean) { + val row = mutableIntStateOf(0) + val column = mutableIntStateOf(0) + val memory = HomeViewMemory() + val cards = List(5) { HomeTitle("$it", "Film $it", "movie") } + val tiles = HomeRow("tiles", HomeRowKind.Shelf, cards = cards, cardKind = HomeCardKind.Tile) + val posters = tiles.copy(id = "posters", cardKind = HomeCardKind.Poster) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { + Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) { + FocusRingHost { Column(Modifier.padding(top = 40.dp)) { + HomeShelf(tiles, memory, row.intValue == 0, column.intValue, emptyList(), {}, {}) + HomeShelf(posters, memory, row.intValue == 1, column.intValue, emptyList(), {}, {}) + } } + } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + val suffix = if (reduced) "reduced/" else "" + val samples = mutableListOf() + fun frame(phase: String, index: Int): Rect { + val image = capture("$suffix$phase-${index.toString().padStart(2, '0')}") + val bounds = accentBounds(image) + val runs = edgeRuns(image, bounds.centerY()) + samples += "$phase,${index * 16},${bounds.left},${bounds.top},${bounds.width()},${bounds.height()},$runs" + image.recycle() + return bounds + } + val start = frame("shelf-right", 0) + commit { column.intValue = 1 } + val right = List(21) { index -> step(); frame("shelf-right", index + 1) } + commit { row.intValue = 1 } + val down = List(21) { index -> step(); frame("row-down", index) } + commit { row.intValue = -1 } + val fadeBudget = if (reduced) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.focusMove + repeat((fadeBudget + 15) / 16 + 1) { step() } + val hidden = capture("${suffix}nav-exit") + assertTrue("Leaving cards fades the ring completely", accentBounds(hidden).isEmpty) + val lastRing = down.last() + val background = WolfskinTokens.Color.bg.toArgb() + for (y in lastRing.top - 3..lastRing.top + 7) for (shift in listOf(0, 8, 16)) { + val pixel = hidden.getPixel(lastRing.centerX(), y) + assertTrue("No residual ring alpha after the fade deadline", + abs((pixel ushr shift and 255) - (background ushr shift and 255)) <= 2) + } + hidden.recycle() + File(directory, "${suffix}samples.csv").writeText("phase,timeMs,left,top,width,height,edgeRuns\n" + samples.joinToString("\n")) + assertTrue("Ring must travel to the next card", right.last().left > start.left) + monotonic(listOf(start.left) + right.map { it.left }) + assertTrue("Nav spring must be visually settled by 300ms", abs(right.last().left - right[18].left) <= 2) + monotonic(listOf(start.width()) + down.map { it.width() }) + monotonic(listOf(start.height()) + down.map { it.height() }) + if (reduced) { + assertEquals("Reduced horizontal move snaps on frame one", right.last(), right.first()) + assertEquals("Reduced shape change snaps on frame one", down.last(), down.first()) + } else { + assertTrue("Horizontal move must glide, not jump", right.first().left < right.last().left) + assertTrue("Width must morph", down.first().width() != down.last().width()) + assertTrue("Height must morph", down.first().height() != down.last().height()) + } + samples.filter { it.startsWith("shelf-right") }.forEach { assertEquals("Exactly one ring crosses its midline: $it", 2, it.substringAfterLast(',').toInt()) } + } + + @Test fun circleRadiusMorphsOnTheNavSpring() = circleRadius(false) + @Test fun reducedCircleRadiusSnapsInOneFrame() = circleRadius(true) + + private fun circleRadius(reduced: Boolean) { + val selected = mutableIntStateOf(0) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { + Box(Modifier.requiredSize(320.dp).background(WolfskinTokens.Color.bg)) { + FocusRingHost { + for (index in 0..1) key(index) { + val radius = if (index == 0) WolfskinTokens.Radius.card else 60.dp + Box(Modifier.offset(80.dp, 80.dp).size(120.dp) + .focusScale(selected.intValue == index, WolfskinTokens.Focus.scaleCard) + .focusRing(selected.intValue == index, radius, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) + .background(WolfskinTokens.Color.surface, androidx.compose.foundation.shape.RoundedCornerShape(radius))) + } + } + } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + fun topSpan(name: String): Int { + val bitmap = capture(name) + val bounds = accentBounds(bitmap) + val pixels = (bounds.left until bounds.right).filter { accent(bitmap.getPixel(it, bounds.top + 3)) } + bitmap.recycle() + return if (pixels.isEmpty()) 0 else pixels.last() - pixels.first() + 1 + } + val prefix = if (reduced) "reduced/circle" else "circle" + val start = topSpan("$prefix-start") + commit { selected.intValue = 1 } + val spans = List(22) { step(); topSpan("$prefix-$it") } + File(directory, "$prefix-samples.csv").writeText("timeMs,topEdgeSpanPx\n0,$start\n" + + spans.mapIndexed { index, span -> "${(index + 1) * 16},$span" }.joinToString("\n")) + assertTrue("Radius change must change corner pixels", start > spans.last() + 20) + monotonic(listOf(start) + spans) + if (reduced) assertEquals(spans.last(), spans.first()) + else assertTrue("Radius must morph, not jump", spans.first() > spans.last()) + } + + @Test fun posterTileAndSeeAllSettledRingsMatchLegacyPixels() { + val hosted = mutableStateOf(false) + val kind = mutableIntStateOf(0) + rule.setContent { WolfskinTheme { + Box(Modifier.requiredSize(500.dp, 320.dp).background(WolfskinTokens.Color.bg)) { + @Composable fun card() { + val width = if (kind.intValue == 1) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW + val height = if (kind.intValue == 1) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH + val modifier = Modifier.offset(60.dp, 40.dp) + if (kind.intValue == 2) LibrarySeeAllCard(40, true, {}, modifier, width, height, HomeType.listTitle, HomeType.label) + else TitleCard(true, modifier, width, height) { Box(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface)) } + } + if (hosted.value) FocusRingHost { card() } else card() + } + } } + for (shape in 0..2) { + rule.runOnIdle { hosted.value = false; kind.intValue = shape } + rule.mainClock.advanceTimeBy(1000) + val legacy = capture("legacy-$shape") + rule.runOnIdle { hosted.value = true } + rule.mainClock.advanceTimeBy(1000) + val shared = capture("settled-$shape") + assertEquals("Settled ring bounds for shape $shape", accentBounds(legacy), accentBounds(shared)) + var maximumDifference = 0 + for (y in 0 until legacy.height) for (x in 0 until legacy.width) { + val expected = legacy.getPixel(x, y) + val actual = shared.getPixel(x, y) + for (channel in 0..3) { + val shift = channel * 8 + maximumDifference = maxOf(maximumDifference, abs((expected ushr shift and 255) - (actual ushr shift and 255))) + } + } + assertTrue("Settled shape $shape differs by $maximumDifference channel levels", maximumDifference <= 2) + legacy.recycle(); shared.recycle() + } + } + + @Test fun libraryRingTracksFrameScrollAndRapidRetargets() = library(false) + @Test fun reducedLibraryRingTracksFrameScrollAndRapidRetargets() = library(true) + + private fun library(reduced: Boolean) { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("movies"); navFocused = false } + val grid = LibraryGridState("Movies", List(60) { LibraryTitle("$it", "Film $it", "movie") }) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { FocusRingHost { LibraryScreen(shell, grid, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + rule.runOnIdle { grid.key(ShellKey.Down) } + repeat(3) { step() } + rule.runOnIdle { grid.key(ShellKey.Right) } + repeat(3) { step() } + rule.runOnIdle { grid.key(ShellKey.Down) } + repeat(30) { step() } + val image = capture(if (reduced) "reduced/library-settled" else "library-settled") + val ring = accentBounds(image) + image.recycle() + assertFalse(ring.isEmpty) + assertEquals("Scroll target stays on the pinned line", WolfskinTokens.Layout.pinnedLine.value * 2 - WolfskinTokens.Card.posterH.value * (WolfskinTokens.Focus.scaleCard - 1f), ring.top.toFloat(), 5f) + } + + private fun commit(action: () -> Unit) = rule.runOnIdle { action(); Snapshot.sendApplyNotifications() } + private fun step() { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() } + private fun capture(name: String): Bitmap { + val file = File(directory, "$name.png").apply { parentFile!!.mkdirs() } + rule.onRoot().captureRoboImage(file.absolutePath) + return BitmapFactory.decodeFile(file.absolutePath) + } + private fun accent(pixel: Int): Boolean { + for (channel in 0..2) { + val shift = channel * 8 + if (abs((pixel ushr shift and 255) - (accentArgb ushr shift and 255)) > 8) return false + } + return true + } + private fun accentBounds(image: Bitmap): Rect { + val bounds = Rect(image.width, image.height, 0, 0) + val count = image.width * image.height + if (pixels.size < count) pixels = IntArray(count) + image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height) + var index = 0 + for (y in 0 until image.height) for (x in 0 until image.width) if (accent(pixels[index++])) { + bounds.left = minOf(bounds.left, x); bounds.top = minOf(bounds.top, y) + bounds.right = maxOf(bounds.right, x + 1); bounds.bottom = maxOf(bounds.bottom, y + 1) + } + return bounds + } + private fun edgeRuns(image: Bitmap, y: Int): Int { + var runs = 0 + var inside = false + for (x in 0 until image.width) { + val next = accent(image.getPixel(x, y)) + if (next && !inside) runs++ + inside = next + } + return runs + } + private fun monotonic(values: List) { + val direction = if (values.last() >= values.first()) 1 else -1 + values.zipWithNext().forEach { (before, after) -> assertTrue("Geometry reverses or overshoots: $values", (after - before) * direction >= -1) } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryToastMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryToastMotionTest.kt new file mode 100644 index 00000000..be875722 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryToastMotionTest.kt @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.BitmapFactory +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native toast pixels prove retention, interruption and bounded reduced-motion fades. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class RecoveryToastMotionTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun toastFadesInHoldsAndFadesOutWithoutStacking() = exercise(false) + @Test fun reducedToastFadesInHoldsAndFadesOutWithoutStacking() = exercise(true) + + private fun exercise(reduced: Boolean) { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + val message = mutableStateOf(null) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { + Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) { + RecoveryToastHost(message.value) + } + } } } + rule.mainClock.autoAdvance = false + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P02${if (reduced) "/reduced" else ""}").apply { mkdirs() } + val samples = mutableListOf() + var sampleX = 0 + var sampleY = 0 + fun frame(phase: String, index: Int): Int { + val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png") + rule.onRoot().captureRoboImage(file.absolutePath) + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + val pixel = bitmap.getPixel(sampleX, sampleY) + bitmap.recycle() + val brightness = android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) + samples += "$phase,${index * 16},$brightness" + return brightness + } + fun step() { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() } + fun count() = rule.onAllNodesWithTag("recovery-toast", useUnmergedTree = true).fetchSemanticsNodes().size + fun show(value: String?) = rule.runOnIdle { message.value = value; Snapshot.sendApplyNotifications() } + show(RecoveryMessage.StillUnreachable) + step() + var inserted = 0 + while (count() == 0 && inserted++ < 3) step() + val bounds = rule.onNodeWithTag("recovery-toast", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + sampleX = (bounds.left + 40).toInt() + sampleY = (bounds.center.y - 6).toInt() + val entering = List(14) { index -> frame("toast-in", index).also { step() } } + assertTrue("Fade-in must change native toast pixels: $entering", entering.last() > entering.first()) + entering.zipWithNext().forEach { (before, after) -> assertTrue("Fade-in reverses: $entering", after >= before - 1) } + val deadline = (if (reduced) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.base) / 16 + 1 + assertEquals("Fade-in must settle by its token deadline", entering.last(), entering[deadline]) + rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.toast.toLong()) + assertEquals(entering.last(), frame("hold", 0)) + show(RecoveryMessage.PlaybackUnavailable) + step() + assertEquals(1, count()) + rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertExists() + assertEquals("Replacing a visible message must not restart its fade", entering.last(), frame("replace", 0)) + show(RecoveryMessage.StillUnreachable) + step() + show(null) + step() + assertEquals("Retain the row while fading out", 1, count()) + val leaving = List(14) { index -> frame("toast-out", index).also { step() } } + assertTrue("Fade-out must change native toast pixels: $leaving", leaving.first() > leaving.last()) + leaving.zipWithNext().forEach { (before, after) -> assertTrue("Fade-out reverses: $leaving", after <= before + 1) } + assertEquals(leaving.last(), leaving[deadline]) + rule.onNodeWithTag("recovery-toast").assertDoesNotExist() + show(RecoveryMessage.StillUnreachable) + repeat(14) { step() } + show(null) + step(); step(); step() + val interrupted = frame("interrupted", 0) + assertTrue(interrupted < entering.last()) + show(RecoveryMessage.StillUnreachable) + step() + val resumed = List(14) { index -> frame("resume", index).also { step() } } + // Recomposition may consume one final outgoing frame before retargeting. + assertTrue("Re-entry retains current opacity instead of resetting or snapping", + resumed.first() >= leaving[3] - 1 && resumed.first() <= interrupted + 1) + resumed.zipWithNext().forEach { (before, after) -> assertTrue(after >= before - 1) } + assertEquals(entering.last(), resumed.last()) + assertEquals(1, count()) + File(directory, "samples.csv").writeText("phase,timeMs,brightness\n" + samples.joinToString("\n")) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScrollMomentumTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScrollMomentumTest.kt new file mode 100644 index 00000000..3bc5b8c7 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScrollMomentumTest.kt @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Held keys exercise real shelf/frame targets and measure velocity across interrupted scrolls. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ScrollMomentumTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun heldRightCarriesVelocityAndSettlesOnShelfFirst() = exercise(false, false) + @Test fun heldDownCarriesVelocityAndSettlesOnLibraryRow() = exercise(false, true) + @Test fun reducedShelfScrollSnapsInOneFrame() = exercise(true, false) + @Test fun reducedLibraryScrollSnapsInOneFrame() = exercise(true, true) + + private fun exercise(reduced: Boolean, library: Boolean) { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + val shell = ShellState(fixturePeople(), "justin").apply { + pickerVisible = false + if (library) enterDestination("movies") + navFocused = false + } + val row = HomeRow("long", HomeRowKind.Shelf, cards = List(30) { HomeTitle("$it", "Film $it", "movie") }) + shell.view.cards[row.id] = 4 + val grid = LibraryGridState("Movies", List(120) { LibraryTitle("$it", "Film $it", "movie") }) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> + if (library) LibraryScreen(shell, grid, modifier) + else HomeScreen(shell, HomeSnapshot(listOf(row)), modifier) {} + } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + val track = if (library) shell.view.listState else shell.view.home.tracks.getValue(row.id) + val pitch = (WolfskinTokens.Card.posterW + WolfskinTokens.Card.posterGap).value * 2 + val framePitch = (WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + WolfskinTokens.Layout.gridRowGap).value * 2 + fun offset(): Float { + val first = track.firstVisibleItemIndex + if (!library) return first * pitch + track.firstVisibleItemScrollOffset + val top = if (first == 0) 0f else WolfskinTokens.Layout.row_lhead.value * 2 + + if (first < 2) 0f else WolfskinTokens.Layout.row_chipsGrid.value * 2 + framePitch * (first - 2) + return top + track.firstVisibleItemScrollOffset + } + fun target(): Float = if (!library) shell.view.home.firstCards.getValue(row.id) * pitch else + if (grid.index / grid.columns == 0) 0f else (WolfskinTokens.Layout.row_lhead.value + WolfskinTokens.Layout.row_chipsGrid.value - WolfskinTokens.Layout.pinnedLine.value) * 2 + framePitch * (grid.index / grid.columns) + fun press() = rule.runOnIdle { shell.key(if (library) ShellKey.Down else ShellKey.Right); Snapshot.sendApplyNotifications() } + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/E05${if (library) "/library" else ""}${if (reduced) "/reduced" else ""}").apply { mkdirs() } + val samples = mutableListOf() + val offsets = mutableListOf() + val velocities = mutableListOf() + val repeats = mutableListOf() + press() + // Start sampling at the animation's zero-time frame, after composition commits the new focus. + repeat(2) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() } + val singleTarget = target() + for (index in 0..80) { + if (index >= 32 && index <= 47 && (index - 32) % 3 == 0) { repeats += index; press() } + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + val position = offset() + offsets += position + velocities += if (index == 0) position / .016f else (position - offsets[index - 1]) / .016f + samples += "${(index + 1) * 16},$position,${velocities.last()},${target()}" + val file = File(directory, "held-${if (library) "down" else "right"}-${index.toString().padStart(2, '0')}.png") + rule.onRoot().captureRoboImage(file.absolutePath) + } + File(directory, "samples.csv").writeText("timeMs,offsetPx,velocityPxPerSecond,targetPx\n" + samples.joinToString("\n")) + offsets.zipWithNext().forEach { (before, after) -> assertTrue("Track reversed: $offsets", after >= before) } + assertTrue("No overshoot of final target", offsets.all { it <= target() + 1 }) + assertEquals("Final target must reconcile exactly", target(), offsets.last(), 1f) + assertTrue("Single press must reach 95 percent by 200ms: $offsets", offsets[12] >= singleTarget * .95f) + assertEquals("Single press is visually settled by 320ms", singleTarget, offsets[19], 2f) + if (reduced) assertEquals("Reduced scroll lands in one frame", singleTarget, offsets.first(), 1f) + else repeats.drop(2).forEach { index -> + assertTrue("Repeated key must not stop the track", velocities[index] > 0f) + assertTrue("Retarget must carry velocity instead of restarting: ${velocities.subList(index - 1, index + 2)}", velocities[index + 1] >= velocities[index - 1] * .5f) + } + } + + @Test fun cancelledScrollCannotReuseAnAbandonedFrameClock() = cancelledScroll(false) + @Test fun reducedCancelledScrollStillSnapsOnReentry() = cancelledScroll(true) + + private fun cancelledScroll(reduced: Boolean) { + val visible = mutableStateOf(true) + val target = mutableFloatStateOf(0f) + val list = LazyListState() + fun offset() = list.firstVisibleItemIndex * 200f + list.firstVisibleItemScrollOffset + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { + if (visible.value) { + LaunchedEffect(target.floatValue) { animateFrameScroll(list, target.floatValue - offset()) } + LazyRow(Modifier.requiredSize(500.dp, 100.dp), state = list) { + items(30) { Box(Modifier.size(100.dp)) } + } + } else Box(Modifier.requiredSize(500.dp, 100.dp)) + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + rule.runOnIdle { target.floatValue = 600f; Snapshot.sendApplyNotifications() } + repeat(5) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() } + rule.runOnIdle { visible.value = false; Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + rule.mainClock.advanceTimeBy(1000) + val resumedTarget = offset() + 600f + rule.runOnIdle { target.floatValue = resumedTarget; visible.value = true; Snapshot.sendApplyNotifications() } + repeat(3) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() } + if (reduced) assertEquals(resumedTarget, offset(), 1f) + else assertTrue("An abandoned frame timestamp must not turn a fresh scroll into a snap", offset() < resumedTarget - 300f) + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + assertEquals(resumedTarget, offset(), 1f) + } +}