Round 2 release candidate 3: polish lanes A-E, season reel and trailer return #82

Merged
justinzeus merged 27 commits from fm/tv-round2-candidate3 into fm/tv-round2 2026-11-08 13:58:41 +01:00
9 changed files with 726 additions and 26 deletions
Showing only changes of commit 6f54bae38e - Show all commits

View file

@ -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())

View file

@ -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<Rect, AnimationVector4D>(
{ 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<CardRingMotion?> { 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<CardRingTarget?>(null)
private var retained by mutableStateOf<CardRingGeometry?>(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)
}
}

View file

@ -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<LazyListState, ScrollMomentum>()
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()
}
}

View file

@ -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
}

View file

@ -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),

View file

@ -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)
}

View file

@ -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<ComponentActivity>()
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<String>()
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<Int>) {
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) }
}
}

View file

@ -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<ComponentActivity>()
@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<String?>(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<String>()
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"))
}
}

View file

@ -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<ComponentActivity>()
@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<String>()
val offsets = mutableListOf<Float>()
val velocities = mutableListOf<Float>()
val repeats = mutableListOf<Int>()
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)
}
}