Candidate 3: merge polish-lane-e
This commit is contained in:
commit
f29ecaace0
17 changed files with 903 additions and 29 deletions
|
|
@ -30,6 +30,7 @@
|
||||||
"receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" },
|
"receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" },
|
||||||
"receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" },
|
"receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" },
|
||||||
"receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" },
|
"receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" },
|
||||||
|
"thumbGlyph": { "$type": "dimension", "px": 40, "dp": 20, "source": "ui-polish P12, approved 2026-11-07: film/TV glyph centred in a request thumbnail without a poster" },
|
||||||
"buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" },
|
"buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" },
|
||||||
"skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" },
|
"skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" },
|
||||||
"skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" },
|
"skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" },
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.foundation.hoverable
|
import androidx.compose.foundation.hoverable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
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.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
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.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.unit.Dp
|
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. */
|
/** Lift shadows remain specific to cards and buttons. */
|
||||||
enum class FocusLift { None, Card, Button }
|
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
|
@Composable
|
||||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
|
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
|
||||||
val reduced = reducedMotion()
|
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
|
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 {
|
return shadow.drawWithCache {
|
||||||
val stroke = Stroke(width.toPx())
|
val stroke = Stroke(width.toPx())
|
||||||
val corner = CornerRadius(radius.toPx())
|
val corner = CornerRadius(radius.toPx())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,72 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.animate
|
import androidx.compose.animation.core.AnimationState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.animateTo
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
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. */
|
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) {
|
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
|
||||||
if (distance == 0f) return
|
val momentum = scrollMomentum.getOrPut(list) { ScrollMomentum() }
|
||||||
|
val generation = ++momentum.generation
|
||||||
|
if (distance == 0f) { momentum.reset(); return }
|
||||||
|
try {
|
||||||
list.scroll {
|
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
|
var previous = 0f
|
||||||
animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll,
|
// A fresh frame-clock start would insert a stationary frame on every held-key repeat.
|
||||||
easing = WolfskinTokens.Motion.easeOut)) { value, _ ->
|
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)
|
scrollBy(value - previous)
|
||||||
previous = value
|
previous = value
|
||||||
|
momentum.velocity = this.velocity
|
||||||
|
momentum.frameTime = lastFrameTimeNanos
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
val context = currentCoroutineContext()
|
||||||
|
momentum.release(context, generation)
|
||||||
|
context.ensureActive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
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.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
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()
|
(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)
|
}.coerceAtLeast(0f)
|
||||||
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
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.positionedKey = key
|
||||||
view.home.positionedReset = view.home.resetScroll
|
view.home.positionedReset = view.home.resetScroll
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||||
|
|
||||||
|
/** One pending playback start per surface; the player opens only after the page has faded to black. */
|
||||||
|
@Stable
|
||||||
|
class PlaybackStart {
|
||||||
|
/** The control that reads "Starting…" while the stream is prepared, or null. */
|
||||||
|
var starting by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Set once the player is ready; the veil runs it exactly once, when the screen is black. */
|
||||||
|
var launch by mutableStateOf<(() -> Unit)?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun begin(control: String) { starting = control; launch = null }
|
||||||
|
fun fail() { starting = null; launch = null }
|
||||||
|
fun ready(open: () -> Unit) { launch = open }
|
||||||
|
internal fun finish() { starting = null; launch = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hand-off is one black rectangle drawn over the page, never a second screen layer. */
|
||||||
|
@Composable
|
||||||
|
fun PlaybackHandoffVeil(start: PlaybackStart, modifier: Modifier = Modifier) {
|
||||||
|
val pending = start.launch
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
val alpha = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(pending, reduced) {
|
||||||
|
if (pending == null) {
|
||||||
|
alpha.snapTo(0f)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
alpha.animateTo(1f, tween(motionDuration(M.base, reduced), easing = M.ease))
|
||||||
|
start.finish()
|
||||||
|
pending()
|
||||||
|
}
|
||||||
|
if (pending != null || alpha.value > 0f) Box(modifier.fillMaxSize().drawBehind { drawRect(Color.Black.copy(alpha = alpha.value)) })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.CompositingStrategy
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||||
|
|
||||||
|
/** Side panels enter over sheetIn and leave over sheetOut on the shared ease-out; reduced motion caps both at the fade limit. */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberSheetFade(visible: Boolean, initial: Float = 0f): State<Float> {
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
val progress = remember { Animatable(initial) }
|
||||||
|
LaunchedEffect(visible, reduced) {
|
||||||
|
progress.animateTo(if (visible) 1f else 0f,
|
||||||
|
tween(motionDuration(if (visible) M.sheetIn else M.sheetOut, reduced), easing = M.easeOut))
|
||||||
|
}
|
||||||
|
return progress.asState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The scrim stays a draw-only colour, so a fading sheet never adds a full-screen layer. */
|
||||||
|
internal fun Modifier.sheetScrim(progress: () -> Float): Modifier = drawBehind {
|
||||||
|
val scrim = WolfskinTokens.Color.sheetScrim
|
||||||
|
drawRect(scrim.copy(alpha = scrim.alpha * progress()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bounded panel modulates each draw, so its outside shadow fades with it and no offscreen buffer is allocated. */
|
||||||
|
internal fun Modifier.sheetPanelFade(progress: () -> Float): Modifier = graphicsLayer {
|
||||||
|
alpha = progress()
|
||||||
|
compositingStrategy = CompositingStrategy.ModulateAlpha
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Focus and selection colours retarget over focusMove on the ease-out; reduced motion caps the fade. */
|
||||||
|
@Composable
|
||||||
|
internal fun animatedFocusColor(target: Color, label: String = "focus-colour"): Color {
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
return animateColorAsState(target, tween(motionDuration(M.focusMove, reduced), easing = M.easeOut), label = label).value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pending action keeps its button's resting width: the resting content fades out while the busy label fades in over base. */
|
||||||
|
@Composable
|
||||||
|
internal fun BusyLabelSwap(busy: Boolean, busyLabel: String, color: Color, style: TextStyle, content: @Composable () -> Unit) {
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
val progress = remember { Animatable(if (busy) 1f else 0f) }
|
||||||
|
LaunchedEffect(busy, reduced) {
|
||||||
|
progress.animateTo(if (busy) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease))
|
||||||
|
}
|
||||||
|
val showLabel = busy || progress.value > 0f
|
||||||
|
Layout(content = {
|
||||||
|
Box(Modifier.graphicsLayer { alpha = 1f - progress.value }.then(if (busy) Modifier.clearAndSetSemantics { } else Modifier)) { content() }
|
||||||
|
if (showLabel) Text(busyLabel, Modifier.graphicsLayer { alpha = progress.value }, color = color, style = style, maxLines = 1, softWrap = false)
|
||||||
|
}) { measurables, constraints ->
|
||||||
|
val resting = measurables[0].measure(constraints)
|
||||||
|
val label = measurables.getOrNull(1)?.measure(Constraints())
|
||||||
|
layout(resting.width, resting.height) {
|
||||||
|
resting.place(0, 0)
|
||||||
|
if (label != null) label.place((resting.width - label.width) / 2, (resting.height - label.height) / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.dropShadow
|
import androidx.compose.ui.draw.dropShadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.CompositingStrategy
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.shadow.Shadow
|
import androidx.compose.ui.graphics.shadow.Shadow
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
|
|
@ -16,14 +26,31 @@ import androidx.tv.material3.Text
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||||
|
|
||||||
|
/** Callers pass their current message; a cleared message fades out with its last text instead of vanishing. */
|
||||||
|
@Composable
|
||||||
|
fun RecoveryToastHost(message: String?, modifier: Modifier = Modifier) {
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
var last by remember { mutableStateOf(message) }
|
||||||
|
val opacity = remember { Animatable(0f) }
|
||||||
|
SideEffect { if (message != null) last = message }
|
||||||
|
LaunchedEffect(message != null, reduced) {
|
||||||
|
opacity.animateTo(if (message != null) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease))
|
||||||
|
}
|
||||||
|
val text = message ?: last ?: return
|
||||||
|
if (message == null && opacity.value == 0f) return
|
||||||
|
RecoveryToast(text, modifier) { opacity.value }
|
||||||
|
}
|
||||||
|
|
||||||
/** Recovery messages remain transient and never take focus from the action that failed. */
|
/** Recovery messages remain transient and never take focus from the action that failed. */
|
||||||
@Composable
|
@Composable
|
||||||
fun RecoveryToast(message: String, modifier: Modifier = Modifier) {
|
fun RecoveryToast(message: String, modifier: Modifier = Modifier, opacity: () -> Float = { 1f }) {
|
||||||
Box(modifier.fillMaxSize()) {
|
Box(modifier.fillMaxSize()) {
|
||||||
val shape = androidx.compose.runtime.remember { RoundedCornerShape(WolfskinTokens.Radius.panel) }
|
val shape = remember { RoundedCornerShape(WolfskinTokens.Radius.panel) }
|
||||||
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop)
|
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop)
|
||||||
.widthIn(max = H.toastWidth)
|
.widthIn(max = H.toastWidth)
|
||||||
|
.graphicsLayer { alpha = opacity(); compositingStrategy = CompositingStrategy.ModulateAlpha }
|
||||||
.dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity),
|
.dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity),
|
||||||
offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY)))
|
offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY)))
|
||||||
.background(C.surface2, shape)
|
.background(C.surface2, shape)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -57,13 +55,12 @@ val SETTINGS_GROUPS = listOf(
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
|
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
|
||||||
val list = state.view.listState
|
val list = state.view.listState
|
||||||
val reduced = reducedMotion()
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
LaunchedEffect(state.settingsRow, state.navFocused) {
|
LaunchedEffect(state.settingsRow, state.navFocused) {
|
||||||
if (state.navFocused) return@LaunchedEffect
|
if (state.navFocused) return@LaunchedEffect
|
||||||
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
|
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
|
||||||
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
|
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()) {
|
Box(modifier.fillMaxSize()) {
|
||||||
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
|
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
|
||||||
/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */
|
/** 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. */
|
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
|
||||||
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
||||||
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
||||||
if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base,
|
animateFrameScroll(this, distance)
|
||||||
easing = WolfskinTokens.Motion.ease))
|
|
||||||
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,8 @@ object WolfskinTokens {
|
||||||
const val focusMove = 180
|
const val focusMove = 180
|
||||||
const val captionDelay = 60
|
const val captionDelay = 60
|
||||||
const val captionOut = 100
|
const val captionOut = 100
|
||||||
|
const val sheetIn = 160
|
||||||
|
const val sheetOut = 100
|
||||||
const val artReveal = 160
|
const val artReveal = 160
|
||||||
const val heroFade = 320
|
const val heroFade = 320
|
||||||
const val heroCopyOut = 120
|
const val heroCopyOut = 120
|
||||||
|
|
@ -319,6 +321,7 @@ object WolfskinTokens {
|
||||||
val receiptPaddingX = 15.0.dp
|
val receiptPaddingX = 15.0.dp
|
||||||
val receiptGap = 5.0.dp
|
val receiptGap = 5.0.dp
|
||||||
val receiptGlyph = 18.0.dp
|
val receiptGlyph = 18.0.dp
|
||||||
|
val thumbGlyph = 20.0.dp
|
||||||
val buttonMargin = 6.0.dp
|
val buttonMargin = 6.0.dp
|
||||||
val skeletonTitleWidth = 180.0.dp
|
val skeletonTitleWidth = 180.0.dp
|
||||||
val skeletonTitleHeight = 17.0.dp
|
val skeletonTitleHeight = 17.0.dp
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -142,6 +142,8 @@
|
||||||
--motion-focusMove: 180ms;
|
--motion-focusMove: 180ms;
|
||||||
--motion-captionDelay: 60ms;
|
--motion-captionDelay: 60ms;
|
||||||
--motion-captionOut: 100ms;
|
--motion-captionOut: 100ms;
|
||||||
|
--motion-sheetIn: 160ms;
|
||||||
|
--motion-sheetOut: 100ms;
|
||||||
--motion-artReveal: 160ms;
|
--motion-artReveal: 160ms;
|
||||||
--motion-heroFade: 320ms;
|
--motion-heroFade: 320ms;
|
||||||
--motion-heroCopyOut: 120ms;
|
--motion-heroCopyOut: 120ms;
|
||||||
|
|
@ -192,6 +194,7 @@
|
||||||
--requests-receiptPaddingX: 30.0px;
|
--requests-receiptPaddingX: 30.0px;
|
||||||
--requests-receiptGap: 10.0px;
|
--requests-receiptGap: 10.0px;
|
||||||
--requests-receiptGlyph: 36.0px;
|
--requests-receiptGlyph: 36.0px;
|
||||||
|
--requests-thumbGlyph: 40.0px;
|
||||||
--requests-buttonMargin: 12.0px;
|
--requests-buttonMargin: 12.0px;
|
||||||
--requests-skeletonTitleWidth: 360.0px;
|
--requests-skeletonTitleWidth: 360.0px;
|
||||||
--requests-skeletonTitleHeight: 34.0px;
|
--requests-skeletonTitleHeight: 34.0px;
|
||||||
|
|
|
||||||
|
|
@ -1408,6 +1408,8 @@
|
||||||
"focusMove": { "$type": "duration", "$value": "180ms" },
|
"focusMove": { "$type": "duration", "$value": "180ms" },
|
||||||
"captionDelay": { "$type": "duration", "$value": "60ms" },
|
"captionDelay": { "$type": "duration", "$value": "60ms" },
|
||||||
"captionOut": { "$type": "duration", "$value": "100ms" },
|
"captionOut": { "$type": "duration", "$value": "100ms" },
|
||||||
|
"sheetIn": { "$type": "duration", "$value": "160ms", "source": "ui-polish P06, approved 2026-11-07: side panel and its scrim fade in, easeOut" },
|
||||||
|
"sheetOut": { "$type": "duration", "$value": "100ms", "source": "ui-polish P06, approved 2026-11-07: side panel and its scrim fade out, easeOut" },
|
||||||
"artReveal": { "$type": "duration", "$value": "160ms" },
|
"artReveal": { "$type": "duration", "$value": "160ms" },
|
||||||
"heroFade": { "$type": "duration", "$value": "320ms" },
|
"heroFade": { "$type": "duration", "$value": "320ms" },
|
||||||
"heroCopyOut": { "$type": "duration", "$value": "120ms" },
|
"heroCopyOut": { "$type": "duration", "$value": "120ms" },
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
|
||||||
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
|
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
|
||||||
72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json
|
72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json
|
||||||
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
9562bd6e6d8fb4d6ef380380e621d6c32401387560437eb4f999ebecbcda160e shell-tokens.json
|
||||||
42399062a6a22566e26adb71eaa4052b28be93d28d0eb46d9d4b87f3142a2944 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
68a94b81bb0300aa05d241f11d88ff4843caa0b1b16e0ed09f62674b336e69fe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
|
98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
|
||||||
731ff25d6ec3ba12d0840523a1ebd65a81b3474cf74040f13bbd1f54985a3053 tokens.css
|
e93d868d6fdf9ad939f1fd18c452732fb3afa76bbba8fe6fdd0c1007aeff3b01 tokens.css
|
||||||
12d33b7b5740443e068cbb06528d88c42ad3bb1df0d4d45dd6ee6797d3dfc401 tokens.json
|
e729efd2efd6bc52a35480038d64ff0199ce166e6e794332e9cfa7dc84ed98c9 tokens.json
|
||||||
19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json
|
19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue