Polish Requests transitions and shell hosts

This commit is contained in:
justinzeus 2026-11-08 01:56:06 +01:00
parent 74b737fb52
commit 2572da00fd
5 changed files with 466 additions and 39 deletions

View file

@ -1,14 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -16,6 +26,13 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.layout.layout
import kotlin.math.roundToInt
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
@ -29,6 +46,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import androidx.compose.runtime.mutableStateOf
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
@ -36,10 +54,19 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */
@Composable
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
val visible = shell.requestsVisible
val fade = rememberSheetFade(visible)
if (!visible && fade.value == 0f) return
val state = shell.requests
val data = state.snapshot
val current = RequestsPanelFrame(state.snapshot, state.focusedId, state.expanded, state.windowStart, state.windowSize,
state.recoveryFocused, state.ratingsVisible, shell.requestsExpansionInput)
val retained = remember { mutableStateOf(current) }
SideEffect { if (visible) retained.value = current }
val frame = if (visible) current else retained.value
val data = frame.data
val ratingsReader = LocalScreenRatings.current
LaunchedEffect(state, state.focusedId, data.signedIn, data.status) {
LaunchedEffect(visible, state, state.focusedId, data.signedIn, data.status) {
if (!visible) return@LaunchedEffect
if (ratingsReader == null) state.awaitFocusedRatings()
else if (data.signedIn) state.focused?.let { item ->
ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values ->
@ -50,8 +77,9 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
}
}
}
Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim"))
Column(modifier.fillMaxHeight().width(L.sheetWidth)
Box(modifier.fillMaxSize().sheetScrim { fade.value }.focusProperties { canFocus = false }
.clickable { if (visible) shell.closeRequests() }.testTag("coming-in-scrim"))
Column(modifier.sheetPanelFade { fade.value }.fillMaxHeight().width(L.sheetWidth)
.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
.drawWithCache {
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
@ -61,9 +89,11 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
onDrawBehind {
drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) }
}
}.pointerInput(Unit) {
}.focusProperties { canFocus = visible }
.then(if (visible) Modifier else Modifier.clearAndSetSemantics { })
.pointerInput(visible) {
awaitPointerEventScope {
while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() }
while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() }
}
}
.padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY)
@ -71,10 +101,10 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg)
when {
data.status == ComingInStatus.Loading && data.items.isEmpty() -> {
LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) {
LazyColumn(Modifier.skeletonPulse(), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() }
}
SheetButton("Close") { shell.closeRequests() }
SheetButton("Close", interactive = visible) { shell.closeRequests() }
}
data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> {
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
@ -86,34 +116,50 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
}
Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted)
}
SheetButton("Try again", primary = true) { shell.retryRequests() }
SheetButton("Try again", primary = true, interactive = visible) { shell.retryRequests() }
}
data.items.isEmpty() -> {
Column(Modifier.padding(top = T.emptyMargin), verticalArrangement = Arrangement.spacedBy(T.receiptGap)) {
Text("Nothing coming in.", style = ComingInTypography.body, color = C.muted)
Text("New requests show up here.", style = ComingInTypography.body, color = C.muted)
}
SheetButton("Close", interactive = visible) { shell.closeRequests() }
}
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
else -> {
if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn)
data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) }
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
val start = state.windowStart
val visibleRows = remember(data, start, state.windowSize) {
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
val start = frame.start
val listState = rememberLazyListState()
val placement = rememberRequestPlacement(frame, listState)
val visibleRows = remember(data, start, frame.windowSize) {
data.ordered.subList(start, (start + frame.windowSize).coerceAtMost(data.ordered.size))
}
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier),
state = listState, verticalArrangement = Arrangement.spacedBy(T.listGap)) {
val keys = visibleRows.map { it.id }.uniqueKeys()
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { _, item ->
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { index, item ->
ComingInRow(item, shell.person, !frame.recoveryFocused && item.id == frame.focusedId, frame.expanded && item.id == frame.focusedId,
frame.ratingsVisible && data.signedIn && item.id == frame.focusedId,
onFocus = { if (visible) state.focus(item.id) }, onClick = { if (visible) { state.focus(item.id); shell.activateRequest() } },
modifier = Modifier.layout { measurable, constraints ->
val row = measurable.measure(constraints)
layout(row.width, row.height) {
row.placeRelative(0, placement.offset(keys[index], listState.layoutInfo))
}
},
readinessProgress = { placement.progress.value }, interactive = visible)
}
}
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() }
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = frame.recoveryFocused, interactive = visible) { shell.retryRequests() }
}
}
}
}
@Composable
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) }
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, interactive: Boolean = true, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = { if (interactive) onClick() }) }
}
@Composable
@ -128,32 +174,95 @@ private fun ComingInSkeleton() {
}
}
private data class RequestsPanelFrame(val data: ComingInSnapshot, val focusedId: String?, val expanded: Boolean,
val start: Int, val windowSize: Int, val recoveryFocused: Boolean, val ratingsVisible: Boolean, val expansionInput: Long)
private class RequestPlacementMemory(var frame: RequestsPanelFrame, var motion: RequestsPlacement? = null)
/** Placement reads one clock; target-offset caching happens only when the list is measured. */
private class RequestsPlacement(val animate: Boolean, private val origins: Map<Any, Int>) {
val progress: Animatable<Float, AnimationVector1D> = Animatable(if (animate) 0f else 1f)
private var measured: LazyListLayoutInfo? = null
private var targets: Map<Any, Int> = emptyMap()
fun offset(key: Any, info: LazyListLayoutInfo): Int {
val progress = progress.value
if (!animate || progress == 1f) return 0
if (measured !== info) {
targets = info.visibleItemsInfo.associate { it.key to it.offset }
measured = info
}
val target = targets[key] ?: return 0
return (((origins[key] ?: target) - target) * (1f - progress)).roundToInt()
}
}
/** Only remote expansion toggles animate; interruptions start at the current visual placement. */
@Composable
private fun rememberRequestPlacement(frame: RequestsPanelFrame, listState: LazyListState): RequestsPlacement {
val previous = remember { RequestPlacementMemory(frame) }
val reduced = reducedMotion()
val motion = remember(frame, reduced) {
val animate = !reduced && previous.frame.let {
it.expansionInput != frame.expansionInput && it.data === frame.data && it.focusedId == frame.focusedId &&
it.start == frame.start && it.expanded != frame.expanded
}
val info = listState.layoutInfo
val origins = if (animate) info.visibleItemsInfo.associate {
it.key to it.offset + (previous.motion?.offset(it.key, info) ?: 0)
} else emptyMap()
RequestsPlacement(animate, origins)
}
SideEffect { previous.frame = frame; previous.motion = motion }
LaunchedEffect(motion) {
if (motion.animate) motion.progress.animateTo(1f, tween(M.lateLineFade, easing = M.easeOut))
}
return motion
}
/** Thumbnail slots do not resize when the text or the readiness block grows. */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
@Composable
fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean,
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier,
readinessProgress: () -> Float = { 1f }, interactive: Boolean = true) {
val foreground = if (focused) C.onFocus else C.fg
val secondary = if (focused) C.onFocus else C.muted
val context = LocalContext.current
val request = remember(item.poster, context) {
item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) }
}
val posterReady = remember(request) { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
val posterState = remember(request) { mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty) }
var collapsedHeight by remember { mutableIntStateOf(0) }
Row(modifier.fillMaxWidth().testTag("coming-row-${item.id}")
.onSizeChanged { if (!expanded) collapsedHeight = it.height }
.drawWithContent {
val progress = readinessProgress()
if (expanded && collapsedHeight > 0 && progress < 1f) {
val bottom = collapsedHeight + (size.height - collapsedHeight) * progress
clipRect(left = -size.width, top = -size.height, right = size.width * 2, bottom = bottom) {
this@drawWithContent.drawContent()
}
} else drawContent()
}.focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
.pointerInput(item.id) {
.then(if (interactive) Modifier.pointerInput(item.id) {
awaitPointerEventScope {
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
}
}.clickable(onClick = onClick)
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"),
} else Modifier).clickable(enabled = interactive, onClick = onClick)
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
.background(if (posterReady.value) Color.Transparent else C.surface3),
onState = { posterReady.value = it is AsyncImagePainter.State.Success },
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
Box(Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
.background(if (posterState.value is AsyncImagePainter.State.Success) Color.Transparent else C.surface3)
.testTag("coming-thumb-${item.id}"), contentAlignment = Alignment.Center) {
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
modifier = Modifier.fillMaxSize(), onState = { posterState.value = it },
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
if (request == null || posterState.value is AsyncImagePainter.State.Error) {
Glyph(if (item.mediaType == "movie") "film" else "tv", C.dim, Modifier.size(T.thumbGlyph))
}
}
Column(Modifier.weight(1f)) {
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)

View file

@ -82,6 +82,8 @@ class ShellState(
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
var recoveryIndex by mutableStateOf(0)
var requestsVisible by mutableStateOf(false)
internal var requestsExpansionInput = 0L
private set
var toast by mutableStateOf<String?>(null)
private val personRequests = mutableMapOf<String, ComingInState>()
val requests: ComingInState get() = personRequests.getValue(person.key)
@ -161,8 +163,12 @@ class ShellState(
when {
key == ShellKey.Back -> closeRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
requests.key(key) -> activateRequest()
key == ShellKey.Ok && requests.snapshot.items.isEmpty() && requests.snapshot.status != ComingInStatus.Unavailable -> closeRequests()
else -> {
val expanded = requests.expanded
if (requests.key(key)) activateRequest()
if ((key == ShellKey.Right || key == ShellKey.Left) && requests.expanded != expanded) requestsExpansionInput++
}
}
return true
}

View file

@ -96,12 +96,14 @@ fun ShellSurface(
if (state.pickerVisible) {
WhosWatching(state)
} else {
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
FocusRingHost {
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
}
NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
if (state.requestsVisible) ComingInSheet(state)
ComingInSheet(state)
overlay()
}
state.toast?.let { RecoveryToast(it) }
RecoveryToastHost(state.toast)
}
}

View file

@ -100,9 +100,9 @@ class RequestsKeysTest {
assertEquals(1, retries)
assertTrue(state.requestsVisible)
rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) }
rule.onNodeWithText("New requests show up here.").assertExists()
send(Key.DirectionDown, Key.DirectionCenter)
rule.onNodeWithText("Nothing coming in.").assertExists()
assertTrue(state.requestsVisible)
assertFalse(state.requestsVisible)
}
@Test fun cachedRatingsRenderInTheTelevisionLocale() {

View file

@ -0,0 +1,310 @@
// 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.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import coil3.intercept.Interceptor
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.awaitCancellation
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
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
/** Native frames exercise Requests transitions without replacing the shell's key or focus policy. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class RequestsPolishTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val reduced = mutableStateOf(false)
@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
rule.mainClock.autoAdvance = false
}
private fun shell(snapshot: ComingInSnapshot = requestsFixture()) = ShellState(fixturePeople(), "justin").apply {
pickerVisible = false
pickerLaunch = false
enterDestination("settings")
settingsRow = 2
requests.update(snapshot)
}
private fun mount(state: ShellState) {
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) {
WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) }
} }
rule.mainClock.advanceTimeBy(32)
rule.waitForIdle()
}
private fun change(block: () -> Unit) {
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
private fun capture(idea: String, name: String): Bitmap {
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$idea").apply { mkdirs() }
val file = File(directory, "$name.png")
rule.onRoot().captureRoboImage(file.absolutePath)
return BitmapFactory.decodeFile(file.absolutePath)
}
private fun csv(idea: String, rows: List<String>) {
File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$idea/samples.csv").writeText(rows.joinToString("\n", postfix = "\n"))
}
private fun brightness(pixel: Int) = android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
private fun monotonic(values: List<Float>, increasing: Boolean) {
assertNotEquals("Transition must change the sampled value", values.first(), values.last())
values.zipWithNext().forEach { (first, second) -> assertTrue("Non-monotonic samples: $values", if (increasing) second >= first - 1f else second <= first + 1f) }
assertTrue("Transition must include an intermediate frame: $values", values.any { it > minOf(values.first(), values.last()) + 1 && it < maxOf(values.first(), values.last()) - 1 })
}
@Test fun requestsPanelFadesWithItsScrim() {
val state = shell(ComingInSnapshot(status = ComingInStatus.Ready))
mount(state)
val samples = mutableListOf("mode,phase,timeMs,scrim,panel")
for (reduce in listOf(false, true)) {
change { reduced.value = reduce; state.openRequests() }
val entering = List(13) { index ->
val image = capture("P06", "requests-${if (reduce) "reduced-" else ""}open-${index.toString().padStart(2, '0')}")
val pixels = brightness(image.getPixel(400, 900)).toFloat() to brightness(image.getPixel(1500, 900)).toFloat()
image.recycle()
samples += "$reduce,open,${index * 16},${pixels.first},${pixels.second}"
rule.mainClock.advanceTimeBy(16)
pixels
}
val row = state.settingsRow
val nav = state.navFocused
change { state.closeRequests() }
assertEquals(row, state.settingsRow)
assertEquals(nav, state.navFocused)
rule.onNodeWithTag("coming-in-scrim").performTouchInput { click(Offset(400f, 900f)) }
val leaving = List(9) { index ->
val image = capture("P06", "requests-${if (reduce) "reduced-" else ""}close-${index.toString().padStart(2, '0')}")
val pixels = brightness(image.getPixel(400, 900)).toFloat() to brightness(image.getPixel(1500, 900)).toFloat()
image.recycle()
samples += "$reduce,close,${index * 16},${pixels.first},${pixels.second}"
rule.mainClock.advanceTimeBy(16)
pixels
}
csv("P06", samples)
monotonic(entering.map { it.first }, false)
monotonic(entering.map { it.second }, true)
monotonic(leaving.map { it.first }, true)
monotonic(leaving.map { it.second }, false)
assertFalse(state.requestsVisible)
assertEquals(row, state.settingsRow)
assertEquals(nav, state.navFocused)
rule.onNodeWithTag("coming-in").assertDoesNotExist()
val end = if (reduce) 5 else 10
assertEquals(entering.last(), entering[end + 1])
assertEquals(leaving.last(), leaving[if (reduce) 6 else 8])
}
}
@Test fun loadingRowsBreatheAsOneGroupAndStopWhenLoaded() {
val state = shell(ComingInSnapshot(status = ComingInStatus.Loading)).apply { openRequests() }
mount(state)
rule.mainClock.advanceTimeBy(240)
val samples = mutableListOf("mode,timeMs,thumb1,thumb2,button")
for (reduce in listOf(false, true)) {
change { reduced.value = reduce }
val close = rule.onNodeWithText("Close").fetchSemanticsNode().boundsInRoot
val times = ((0..1400 step 100).toList() + 350).sorted()
val values = times.mapIndexed { index, time ->
val image = capture("P07", "requests-loading-${if (reduce) "reduced-" else ""}${time.toString().padStart(4, '0')}")
val pixels = listOf(image.getPixel(1240, 240), image.getPixel(1240, 418), image.getPixel(close.center.x.toInt(), close.bottom.toInt() + 8))
image.recycle()
samples += "$reduce,$time,${pixels.joinToString(",")}"
if (index < times.lastIndex) rule.mainClock.advanceTimeBy((times[index + 1] - time).toLong(), ignoreFrameDuration = true)
pixels
}
csv("P07", samples)
assertEquals(values.map { it[0] }, values.map { it[1] })
assertEquals(1, values.map { it[2] }.distinct().size)
if (reduce) assertEquals(1, values.map { it[0] }.distinct().size)
else {
assertNotEquals(values[0][0], values[4][0])
assertNotEquals(values[4][0], values[8][0])
}
}
change { reduced.value = false; state.requests.update(requestsFixture()) }
rule.mainClock.advanceTimeBy(400)
rule.onNodeWithText("Close").assertDoesNotExist()
assertTrue("Loaded panel must leave the recomposer idle", androidx.compose.runtime.Recomposer.runningRecomposers.value.all { !it.hasPendingWork })
}
@Test fun readinessExpansionPushesRowsBelowWithoutRemeasure() {
val state = shell().apply { openRequests(); requests.key(ShellKey.Down) }
mount(state)
// Rating dwell changes layout independently; toggles require settled metadata.
rule.mainClock.advanceTimeBy(M.ratingDwell.toLong())
val samples = mutableListOf("mode,phase,timeMs,focusedTop,followingTop")
for (reduce in listOf(false, true)) {
change { reduced.value = reduce }
val focusedBounds = rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot
fun focusedTop(): Float {
val bounds = rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot
val scale = WolfskinTokens.Focus.scaleRow
val scaleOffset = kotlin.math.abs(bounds.height - focusedBounds.height) * (scale - 1f) / (2f * scale)
assertEquals(focusedBounds.top, bounds.top, scaleOffset + 0.5f)
return bounds.top
}
val collapsed = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
change { state.key(ShellKey.Right) }
val expanding = List(16) { index ->
val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
val fixed = focusedTop()
capture("P09", "expand-${if (reduce) "reduced-" else ""}${index.toString().padStart(2, '0')}").recycle()
samples += "$reduce,expand,${index * 16},$fixed,$top"
rule.mainClock.advanceTimeBy(16)
top
}
change { state.key(ShellKey.Left) }
val collapsing = List(16) { index ->
val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
capture("P09", "collapse-${if (reduce) "reduced-" else ""}${index.toString().padStart(2, '0')}").recycle()
samples += "$reduce,collapse,${index * 16},${focusedTop()},$top"
rule.mainClock.advanceTimeBy(16)
top
}
csv("P09", samples)
assertEquals(collapsed, collapsing.last(), 0.1f)
if (reduce) {
assertTrue(expanding.all { kotlin.math.abs(it - expanding.last()) < 0.1f })
assertTrue(collapsing.all { kotlin.math.abs(it - collapsed) < 0.1f })
} else {
monotonic(listOf(collapsed) + expanding, true)
assertTrue("Expansion must not finish early", expanding[5] < expanding.last() - 1f)
monotonic(listOf(expanding.last()) + collapsing, false)
assertTrue("Collapse must not finish early", collapsing[5] > collapsing.last() + 1f)
}
}
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P09")
for (phase in listOf("expand", "collapse")) {
val animated = BitmapFactory.decodeFile(File(directory, "$phase-15.png").absolutePath)
val snapped = BitmapFactory.decodeFile(File(directory, "$phase-reduced-15.png").absolutePath)
try { assertTrue("Settled $phase pixels must match reduced-motion snapping", animated.sameAs(snapped)) }
finally { animated.recycle(); snapped.recycle() }
}
val collapsedTop = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
change { reduced.value = false; state.key(ShellKey.Right) }
rule.mainClock.advanceTimeBy(48)
change { state.key(ShellKey.Left) }
val retargeted = List(16) { index ->
val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top
capture("P09", "retarget-${index.toString().padStart(2, '0')}").recycle()
samples += "false,retarget,${index * 16},${rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot.top},$top"
rule.mainClock.advanceTimeBy(16)
top
}
assertTrue("An interrupted expand must return from its current placement", retargeted.first() > collapsedTop + 1f)
monotonic(retargeted, false)
assertEquals(collapsedTop, retargeted.last(), 0.1f)
change { reduced.value = false; state.key(ShellKey.Right) }
rule.mainClock.advanceTimeBy(48)
change { state.key(ShellKey.Down) }
val jumped = rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top
rule.mainClock.advanceTimeBy(240)
assertEquals(jumped, rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top, 0.1f)
rule.mainClock.advanceTimeBy(M.ratingDwell.toLong())
change { state.key(ShellKey.Right) }
rule.mainClock.advanceTimeBy(48)
change { state.requests.update(state.requests.snapshot.copy(partial = true)) }
val refreshed = rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top
rule.mainClock.advanceTimeBy(240)
assertEquals(refreshed, rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top, 0.1f)
csv("P09", samples)
}
@Test fun emptyRequestsCloseReturnsToSettingsRow() {
val state = shell(ComingInSnapshot(status = ComingInStatus.Ready)).apply { openRequests() }
mount(state)
rule.mainClock.advanceTimeBy(240)
capture("P10", "requests-empty").recycle()
rule.onNodeWithText("New requests show up here.").assertExists()
val row = state.settingsRow
val nav = state.navFocused
change { state.key(ShellKey.Down); state.key(ShellKey.Ok) }
assertFalse(state.requestsVisible)
assertEquals(row, state.settingsRow)
assertEquals(nav, state.navFocused)
rule.mainClock.advanceTimeBy(160)
change { state.openRequests() }
rule.mainClock.advanceTimeBy(240)
rule.onNodeWithText("Close").performClick()
assertFalse(state.requestsVisible)
assertEquals(row, state.settingsRow)
assertEquals(nav, state.navFocused)
}
@Test fun missingPosterShowsFilmOrTvGlyph() {
val completed = AtomicBoolean(false)
WolfskinImages.configureNetwork(rule.activity) {
add(Interceptor { chain ->
if (chain.request.data == "pending-poster") awaitCancellation()
else chain.proceed().also { if (chain.request.data == "file:///missing-wolfskin-poster.png") completed.set(true) }
})
}
val source = requestsFixture().items.first()
val items = listOf(source.copy(id = "movie", mediaType = "movie", poster = null),
source.copy(id = "tv", mediaType = "tv", poster = null),
source.copy(id = "failed", mediaType = "movie", poster = "file:///missing-wolfskin-poster.png"),
source.copy(id = "pending", poster = "pending-poster"))
val state = shell(ComingInSnapshot(items, ComingInStatus.Ready)).apply { openRequests(); requests.focus("pending") }
mount(state)
rule.waitUntil(5000) { completed.get() }
rule.mainClock.advanceTimeBy(400)
val image = capture("P12", "missing-thumbs")
try {
fun glyph(id: String): List<Int> {
val thumb = rule.onNodeWithTag("coming-thumb-$id", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val left = (thumb.center.x - 20).toInt()
val top = (thumb.center.y - 20).toInt()
return (0 until 40).flatMap { y -> (0 until 40).map { x -> image.getPixel(left + x, top + y) } }
}
val movie = glyph("movie")
val tv = glyph("tv")
val failed = glyph("failed")
val stroke = C.dim.compositeOver(C.surface3).toArgb()
listOf(movie, tv, failed).forEach { pixels ->
assertTrue("The thumbnail centre column must contain the dim glyph stroke", (0 until 40).any { y -> pixels[y * 40 + 20] == stroke })
}
assertNotEquals(movie, tv)
val deltas = movie.zip(failed).map { (first, second) ->
listOf(16, 8, 0).map { shift -> kotlin.math.abs(((first ushr shift) and 255) - ((second ushr shift) and 255)) }
}
assertTrue("Absent and failed posters must render the same film glyph", deltas.flatten().average() < 2)
assertTrue("Only antialiasing differences are allowed", deltas.count { it.max() <= 8 } >= movie.size * 0.99)
assertTrue("Loading remains a plain thumbnail", glyph("pending").all { it == C.surface3.toArgb() })
} finally { image.recycle(); WolfskinImages.configureNetwork(rule.activity) {} }
}
}