Fix focused card captions and leave loading bands blank
All checks were successful
review/code-claude Blocker fixed on c2f7ccc4, delta reviewed
review/design-claude Blocker fixed on c2f7ccc4, delta reviewed
All checks were successful
review/code-claude Blocker fixed on c2f7ccc4, delta reviewed
review/design-claude Blocker fixed on c2f7ccc4, delta reviewed
This commit is contained in:
parent
fe44a3c24f
commit
c2f7ccc438
11 changed files with 248 additions and 44 deletions
|
|
@ -87,7 +87,7 @@ Native **1920×1080** before/after frames and the frame-budget inventory are pub
|
||||||
|
|
||||||
Film cards show year, duration and a **24 px watched check**. Episodes show their own duration and check, without a year; series show year and check, never an aggregate runtime. Resuming and unwatched cards have no check. Compact metadata omits media-type words, season counts, character roles, ownership and remaining-time prose. Duration rounds total minutes before formatting hours/minutes; narrow layouts tighten metadata gaps from **12 → 8 → 4 px**, never shrink text or ellipsise duration.
|
Film cards show year, duration and a **24 px watched check**. Episodes show their own duration and check, without a year; series show year and check, never an aggregate runtime. Resuming and unwatched cards have no check. Compact metadata omits media-type words, season counts, character roles, ownership and remaining-time prose. Duration rounds total minutes before formatting hours/minutes; narrow layouts tighten metadata gaps from **12 → 8 → 4 px**, never shrink text or ellipsise duration.
|
||||||
|
|
||||||
Movies, Shows, Anime, Home See all and person filmography always show their card-width title/subtitle. Titles are one-line ellipses: **28 px / 32 px line**, medium muted at rest, bold foreground when focused. Metadata is **24 px / 26 px line**. The **96 px** poster caption band starts its text **38 px** below unscaled art. Home poster captions remain focus-only; Home landscape titles stay on the art with focus-only metadata below it. Landscape shelves reserve **64 px** below art. Loading placeholders reserve the same title/subtitle slots and bands; each shelf still shares the motion lane's single pulse layer.
|
Movies, Shows, Anime, Home See all and person filmography always show their card-width title/subtitle. Titles are one-line ellipses: **28 px / 32 px line**, medium muted at rest, bold foreground when focused. Metadata is **24 px / 26 px line**. The **96 px** poster caption band starts its text **38 px** below unscaled art. Home poster captions remain focus-only; Home landscape titles stay on the art with focus-only metadata below it. Landscape shelves reserve **64 px** below art. Loading placeholders reserve identical **blank** caption bands: only art-sized blocks are painted, with no title/subtitle pills. Each shelf still shares the motion lane's single pulse layer.
|
||||||
|
|
||||||
| Geometry at 1920×1080 | Pixels |
|
| Geometry at 1920×1080 | Pixels |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
|
|
@ -100,9 +100,11 @@ Movies, Shows, Anime, Home See all and person filmography always show their card
|
||||||
|
|
||||||
One bottom state line replaces the independent resume bar. Unwatched and Discover have no state line; watched has a full-width **3 px accent** line; resume has proportional **6 px accent** fill on a full-width `color.progressTrack` track. Left/right are zero, with **2 px bottom backing** and **2 px opaque separator** above the fill, clipped by the art's **16 px** radius. Focus scales the art/line by **1.08**, with its **4 px** straddle ring drawn independently above them.
|
One bottom state line replaces the independent resume bar. Unwatched and Discover have no state line; watched has a full-width **3 px accent** line; resume has proportional **6 px accent** fill on a full-width `color.progressTrack` track. Left/right are zero, with **2 px bottom backing** and **2 px opaque separator** above the fill, clipped by the art's **16 px** radius. Focus scales the art/line by **1.08**, with its **4 px** straddle ring drawn independently above them.
|
||||||
|
|
||||||
|
Completed Home progress (`resumeSeconds / runtimeSeconds >= 1`) follows the same state precedence as library/title cards: it is no longer resuming, so a played title uses the 3 px watched line and an unplayed title has no state line.
|
||||||
|
|
||||||
Justin's additional correction is binding even where a reference frame leaks: **the lowest card row must contain card/edge/fill colour, never art**. Clip artwork out of the backing band before painting it; merely overdrawing after a rounded clip can retain poster colours in antialiased corners. The backing is opaque `color.bg` (**#07070a**). Precompose and quantise the track over that backing once to preserve the approved **#3d3d3f** browser pixels instead of native alpha-rounding differences; fill is **#cc9fff**. The approved Annie Hall and Locke treatment frames themselves expose artwork below their inset state line; the native build deliberately makes that row solid.
|
Justin's additional correction is binding even where a reference frame leaks: **the lowest card row must contain card/edge/fill colour, never art**. Clip artwork out of the backing band before painting it; merely overdrawing after a rounded clip can retain poster colours in antialiased corners. The backing is opaque `color.bg` (**#07070a**). Precompose and quantise the track over that backing once to preserve the approved **#3d3d3f** browser pixels instead of native alpha-rounding differences; fill is **#cc9fff**. The approved Annie Hall and Locke treatment frames themselves expose artwork below their inset state line; the native build deliberately makes that row solid.
|
||||||
|
|
||||||
Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances from native surfaces. Visible reference differences are listed in the gallery and remain merge blockers unless explicitly approved.
|
Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers.
|
||||||
|
|
||||||
## Sources and verification
|
## Sources and verification
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -564,15 +564,19 @@ single runtime. Resuming cards have no check. The poster caption band remains
|
||||||
96 px; Home poster captions are focus-only. Home landscapes retain titles on
|
96 px; Home poster captions are focus-only. Home landscapes retain titles on
|
||||||
the art and reserve 64 px for focused metadata below it, making wide/tile shelf
|
the art and reserve 64 px for focused metadata below it, making wide/tile shelf
|
||||||
pitches 522/441 px; poster shelves remain 632 px and grids remain 524 px.
|
pitches 522/441 px; poster shelves remain 632 px and grids remain 524 px.
|
||||||
Loading rows reserve the same caption slots and share one pulse layer.
|
Loading rows reserve the same blank caption bands and share one pulse layer;
|
||||||
|
only art-sized blocks are painted, with no title/subtitle placeholder pills.
|
||||||
|
|
||||||
`CardBottomPixelTest` samples full native bottom rows on four real posters at
|
`CardBottomPixelTest` samples full native bottom rows on four real posters at
|
||||||
rest and individually focused, including white Annie Hall and dark Locke.
|
rest and individually focused, including white Annie Hall and dark Locke.
|
||||||
`CardGeometryTest` measures 210/327 px caption fitting, card-width titles,
|
`CardGeometryTest` measures 210/327 px caption fitting, card-width titles,
|
||||||
24 px checks, row pitches, pinned rows and ring clearances. The
|
24 px checks, row pitches, pinned rows and ring clearances to 0.1 px, and checks
|
||||||
[native option-B proof gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/)
|
that every reserved loading-caption pixel remains background-coloured.
|
||||||
|
`CardCaptionMotionTest` checks visible native glyph pixels when a still-focused
|
||||||
|
caption changes title directly or while moving across a Home shelf.
|
||||||
|
The [native option-B proof gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/)
|
||||||
contains the same illustrative watch-state data as the approved frames and
|
contains the same illustrative watch-state data as the approved frames and
|
||||||
lists reference deviations; unapproved deviations block merge.
|
records renderer/fixture artifacts; unapproved design changes block merge.
|
||||||
`LibrarySpacingRegressionTest` retains the deep focused index across the
|
`LibrarySpacingRegressionTest` retains the deep focused index across the
|
||||||
199↔200-title letter-rail boundary.
|
199↔200-title letter-rail boundary.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ fun CardCaption(title: String, year: Int?, duration: String?, watched: Boolean,
|
||||||
val entryProgress = remember(title) { mutableFloatStateOf(0f) }
|
val entryProgress = remember(title) { mutableFloatStateOf(0f) }
|
||||||
val reduced = reducedMotion()
|
val reduced = reducedMotion()
|
||||||
val rise = with(LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
|
val rise = with(LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
|
||||||
LaunchedEffect(focused, reduced) {
|
LaunchedEffect(alpha, focused, reduced) {
|
||||||
if (focused && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
|
if (focused && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
|
||||||
alpha.animateTo(if (focused) 1f else 0f, tween(motionDuration(if (focused) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) {
|
alpha.animateTo(if (focused) 1f else 0f, tween(motionDuration(if (focused) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) {
|
||||||
if (focused) entryProgress.floatValue = value
|
if (focused) entryProgress.floatValue = value
|
||||||
|
|
|
||||||
|
|
@ -54,16 +54,18 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
|
||||||
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index")
|
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index")
|
||||||
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
|
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
|
||||||
if (row.skeleton) {
|
if (row.skeleton) {
|
||||||
PosterCaptionSkeleton(width, height, caption, row.cardKind == HomeCardKind.Poster)
|
PosterCaptionSkeleton(width, height, caption)
|
||||||
} else if (title == null) {
|
} else if (title == null) {
|
||||||
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
|
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
|
||||||
} else TitleCard(selected, cardModifier.clickable { onCard(index) }, width, height,
|
} else {
|
||||||
watched = !row.catalogue && title.played,
|
val progress = if (row.catalogue || title.runtimeSeconds == null || title.runtimeSeconds <= 0 || title.resumeSeconds <= 0) 0f else (title.resumeSeconds / title.runtimeSeconds).toFloat()
|
||||||
progress = if (row.catalogue || title.runtimeSeconds == null || title.runtimeSeconds <= 0 || title.resumeSeconds <= 0) 0f else (title.resumeSeconds / title.runtimeSeconds).toFloat()) {
|
TitleCard(selected, cardModifier.clickable { onCard(index) }, width, height,
|
||||||
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
|
watched = !row.catalogue && title.played, progress = if (progress >= 1f) 0f else progress) {
|
||||||
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
|
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
|
||||||
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
|
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
|
||||||
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
|
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
|
||||||
|
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,18 +56,12 @@ private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.c
|
||||||
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loading and real grid rows reserve the identical two-line caption band. */
|
/** Loading reserves the ready caption band without painting unapproved text placeholders. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PosterCaptionSkeleton(width: androidx.compose.ui.unit.Dp = K.posterW,
|
internal fun PosterCaptionSkeleton(width: androidx.compose.ui.unit.Dp = K.posterW,
|
||||||
height: androidx.compose.ui.unit.Dp = K.posterH, band: androidx.compose.ui.unit.Dp = K.posterCaptionBand,
|
height: androidx.compose.ui.unit.Dp = K.posterH, band: androidx.compose.ui.unit.Dp = K.posterCaptionBand) {
|
||||||
showTitle: Boolean = true) {
|
|
||||||
Column(Modifier.width(width)) {
|
Column(Modifier.width(width)) {
|
||||||
SkeletonBlock(width, height)
|
SkeletonBlock(width, height)
|
||||||
Box(Modifier.height(band)) {
|
Spacer(Modifier.height(band))
|
||||||
Column(Modifier.offset(y = L.captionOffset)) {
|
|
||||||
if (showTitle) SkeletonBlock(width, with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.CardMetadata.titleRest.lineHeight.toDp() })
|
|
||||||
SkeletonBlock(width / 2, with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.CardMetadata.subtitle.lineHeight.toDp() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -187,13 +187,11 @@ object WolfskinTokens {
|
||||||
val tileH = 112.5.dp
|
val tileH = 112.5.dp
|
||||||
val tileGap = 20.0.dp
|
val tileGap = 20.0.dp
|
||||||
val tilePitch = 220.0.dp
|
val tilePitch = 220.0.dp
|
||||||
val tileCaptionBand = 32.0.dp
|
|
||||||
const val tileSlots = 4
|
const val tileSlots = 4
|
||||||
val wideW = 272.0.dp
|
val wideW = 272.0.dp
|
||||||
val wideH = 153.0.dp
|
val wideH = 153.0.dp
|
||||||
val wideGap = 20.0.dp
|
val wideGap = 20.0.dp
|
||||||
val widePitch = 292.0.dp
|
val widePitch = 292.0.dp
|
||||||
val wideCaptionBand = 32.0.dp
|
|
||||||
const val wideSlots = 3
|
const val wideSlots = 3
|
||||||
val entryW = 128.0.dp
|
val entryW = 128.0.dp
|
||||||
val entryH = 72.0.dp
|
val entryH = 72.0.dp
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -83,4 +84,52 @@ class CardBottomPixelTest {
|
||||||
bitmap.recycle()
|
bitmap.recycle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun completedHomeProgressUsesTheSameWatchedStripAsLibraryCards() {
|
||||||
|
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 path = "/library/assets/movie-annie-hall-1977-poster.jpg"
|
||||||
|
val fraction = mutableStateOf(.5f)
|
||||||
|
val art = mapOf(path to fixtureArt(path)!!)
|
||||||
|
val memory = HomeViewMemory()
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) {
|
||||||
|
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||||
|
val title = HomeTitle("home", "Home film", "movie", runtimeSeconds = 100,
|
||||||
|
poster = path, played = true, resumeSeconds = fraction.value * 100.0)
|
||||||
|
HomeShelf(HomeRow("progress", HomeRowKind.Shelf, "Progress", listOf(title)),
|
||||||
|
memory, false, 0, emptyList(), {}, {})
|
||||||
|
LibraryPoster(LibraryTitle("reference", "Reference", "movie", poster = path,
|
||||||
|
played = true, progress = fraction.value), false, false, false,
|
||||||
|
Modifier.offset(240.dp, 42.dp), ::fixtureArt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() }
|
||||||
|
for (progress in listOf(.5f, 1f, 1.5f)) {
|
||||||
|
rule.runOnIdle { fraction.value = progress }
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.waitForIdle()
|
||||||
|
val home = rule.onNodeWithTag("home-card:progress:0").fetchSemanticsNode().boundsInRoot
|
||||||
|
val reference = rule.onNodeWithTag("poster-reference").fetchSemanticsNode().boundsInRoot
|
||||||
|
assertEquals(reference.width, home.width, .1f)
|
||||||
|
assertEquals(reference.bottom, home.bottom, .1f)
|
||||||
|
val file = File(directory, "home-progress-${(progress * 100).toInt()}.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
try {
|
||||||
|
val bottom = home.bottom.toInt()
|
||||||
|
val band = if (progress >= 1f) 7 else 10
|
||||||
|
for (y in bottom - band until bottom) for (x in 0 until home.width.toInt()) {
|
||||||
|
assertEquals("Home/library strip progress=$progress ($x,$y)",
|
||||||
|
bitmap.getPixel(reference.left.toInt() + x, y), bitmap.getPixel(home.left.toInt() + x, y))
|
||||||
|
}
|
||||||
|
val separator = if (progress >= 1f) WolfskinTokens.Color.bg else WolfskinTokens.Color.accent
|
||||||
|
assertEquals("Completion changes the resume strip to the 3px watched strip",
|
||||||
|
separator.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 7))
|
||||||
|
assertEquals(WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 4))
|
||||||
|
} finally { bitmap.recycle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
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.*
|
||||||
|
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.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
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 org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
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
|
||||||
|
|
||||||
|
/** Changing a shelf's selected identity must restart its focus-only caption reveal. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class CardCaptionMotionTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun changingTitleWhileStillFocusedRestoresOpaqueCaptionPixels() {
|
||||||
|
val title = mutableStateOf("Initial caption")
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||||
|
CardCaption(title.value, 2007, "2h 56m", true, true,
|
||||||
|
Modifier.offset(40.dp, 80.dp).width(128.dp).testTag("animated"), focusOnly = true)
|
||||||
|
CardCaption(title.value, 2007, "2h 56m", true, true,
|
||||||
|
Modifier.offset(40.dp, 180.dp).width(128.dp).testTag("reference"))
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
for ((index, selected) in listOf("Initial caption", "Second caption", "Third caption").withIndex()) {
|
||||||
|
update { title.value = selected }
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
assertCaptionPixels("component-$index",
|
||||||
|
rule.onNodeWithTag("animated").fetchSemanticsNode().boundsInRoot,
|
||||||
|
rule.onNodeWithTag("reference").fetchSemanticsNode().boundsInRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun movingAcrossAFocusedHomeShelfKeepsEachSelectedCaptionVisible() {
|
||||||
|
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
|
||||||
|
val cards = listOf(HomeTitle("first", "First shelf caption", "movie", 2007, 10560, poster = path),
|
||||||
|
HomeTitle("second", "Second shelf caption", "movie", 2008, 11520, poster = path))
|
||||||
|
val row = HomeRow("captions", HomeRowKind.Shelf, "Shelf", cards)
|
||||||
|
val column = mutableStateOf(0)
|
||||||
|
val memory = HomeViewMemory()
|
||||||
|
val art = mapOf(path to fixtureArt(path)!!)
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
CompositionLocalProvider(LocalTitleBitmaps provides art) {
|
||||||
|
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||||
|
Box(Modifier.testTag("home-shelf")) {
|
||||||
|
HomeShelf(row, memory, true, column.value, emptyList(), {}, {})
|
||||||
|
}
|
||||||
|
val selected = cards[column.value]
|
||||||
|
CardCaption(selected.title, selected.year, selected.runtime, false, true,
|
||||||
|
Modifier.offset(48.dp, 400.dp).width(128.dp).testTag("reference"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
for ((step, selected) in listOf(0, 1, 0).withIndex()) {
|
||||||
|
update { column.value = selected }
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
val title = cards[selected].title
|
||||||
|
val actual = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("home-shelf")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||||
|
val reference = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("reference")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||||
|
assertCaptionPixels("home-$step", actual, reference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun update(block: () -> Unit) {
|
||||||
|
rule.runOnIdle { block(); Snapshot.sendApplyNotifications() }
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertCaptionPixels(name: String, actual: Rect, reference: Rect) {
|
||||||
|
assertEquals(reference.width, actual.width, .1f)
|
||||||
|
assertEquals(reference.height, actual.height, .1f)
|
||||||
|
rule.waitForIdle()
|
||||||
|
val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() }
|
||||||
|
val file = File(directory, "$name.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
try {
|
||||||
|
val foreground = WolfskinTokens.Color.fg.toArgb()
|
||||||
|
var compared = 0
|
||||||
|
for (y in 0 until reference.height.toInt()) for (x in 0 until reference.width.toInt()) {
|
||||||
|
if (bitmap.getPixel(reference.left.toInt() + x, reference.top.toInt() + y) == foreground) {
|
||||||
|
assertEquals("$name opaque title pixel ($x,$y)", foreground,
|
||||||
|
bitmap.getPixel(actual.left.toInt() + x, actual.top.toInt() + y))
|
||||||
|
compared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue("The reference must contain opaque title glyphs", compared >= 24)
|
||||||
|
} finally { bitmap.recycle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -64,23 +69,23 @@ class CardGeometryTest {
|
||||||
val first = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
|
val first = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
|
||||||
val second = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
|
val second = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
|
||||||
// Focus scales around the unchanged slot centre.
|
// Focus scales around the unchanged slot centre.
|
||||||
assertEquals(372f, first.top + (first.height - 384f) / 2, 1f)
|
assertEquals(372f, first.top + (first.height - 384f) / 2, .1f)
|
||||||
assertEquals(524f, second.top - (first.top + (first.height - 384f) / 2), 1f)
|
assertEquals(524f, second.top - (first.top + (first.height - 384f) / 2), .1f)
|
||||||
for (index in 0..5) {
|
for (index in 0..5) {
|
||||||
val caption = rule.onNodeWithText(titles[index].title, useUnmergedTree = true).fetchSemanticsNode()
|
val caption = rule.onNodeWithText(titles[index].title, useUnmergedTree = true).fetchSemanticsNode()
|
||||||
assertEquals(256f, caption.boundsInRoot.width, .1f)
|
assertEquals(256f, caption.boundsInRoot.width, .1f)
|
||||||
assertEquals(794f, caption.boundsInRoot.top, 1f)
|
assertEquals(794f, caption.boundsInRoot.top, .1f)
|
||||||
}
|
}
|
||||||
val focusedCaption = rule.onNodeWithText(titles[0].title, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
val focusedCaption = rule.onNodeWithText(titles[0].title, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||||
assertEquals(20.48f, focusedCaption.top - first.bottom - first.height * .04f - 2f * 1.08f, 1f)
|
assertEquals(20.48f, focusedCaption.top - first.bottom - first.height * .04f - 2f * 1.08f, .1f)
|
||||||
assertEquals(26.48f, second.top - first.height * .04f - 2f * 1.08f - (focusedCaption.top + 58f), 1f)
|
assertEquals(26.48f, second.top - first.height * .04f - 2f * 1.08f - (focusedCaption.top + 58f), .1f)
|
||||||
rule.runOnIdle { kotlinx.coroutines.runBlocking { shell.view.listState.scrollToItem(0, 204) } }
|
rule.runOnIdle { kotlinx.coroutines.runBlocking { shell.view.listState.scrollToItem(0, 204) } }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
val pinnedFirst = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
|
val pinnedFirst = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
|
||||||
val pinnedSecond = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
|
val pinnedSecond = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
|
||||||
assertEquals(168f, pinnedFirst.top, 1f)
|
assertEquals(168f, pinnedFirst.top, .1f)
|
||||||
assertEquals(692f, pinnedSecond.top, 1f)
|
assertEquals(692f, pinnedSecond.top, .1f)
|
||||||
assertEquals(1076f, pinnedSecond.bottom, 1f)
|
assertEquals(1076f, pinnedSecond.bottom, .1f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun homePosterWideAndTileShelvesReserveTheirCaptionBands() {
|
@Test fun homePosterWideAndTileShelvesReserveTheirCaptionBands() {
|
||||||
|
|
@ -128,4 +133,37 @@ class CardGeometryTest {
|
||||||
rule.onNodeWithContentDescription("Watched").assertDoesNotExist()
|
rule.onNodeWithContentDescription("Watched").assertDoesNotExist()
|
||||||
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
|
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun loadingCaptionBandsReserveSpaceWithoutPaintingBars() {
|
||||||
|
val slots = listOf("poster" to Triple(128.dp, 192.dp, 48.dp),
|
||||||
|
"wide" to Triple(272.dp, 153.dp, 32.dp), "tile" to Triple(200.dp, 112.5.dp, 32.dp))
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||||
|
Row(Modifier.offset(48.dp, 42.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||||
|
slots.forEach { (name, geometry) ->
|
||||||
|
Box(Modifier.testTag("loading-$name")) {
|
||||||
|
PosterCaptionSkeleton(geometry.first, geometry.second, geometry.third)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
rule.waitForIdle()
|
||||||
|
val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() }
|
||||||
|
val file = File(directory, "loading-bands.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
try {
|
||||||
|
for ((name, geometry) in slots) {
|
||||||
|
val bounds = rule.onNodeWithTag("loading-$name").fetchSemanticsNode().boundsInRoot
|
||||||
|
val band = geometry.third.value * 2
|
||||||
|
assertEquals((geometry.second.value + geometry.third.value) * 2, bounds.height, .1f)
|
||||||
|
for (y in (bounds.bottom - band).toInt() until bounds.bottom.toInt()) {
|
||||||
|
for (x in bounds.left.toInt() until bounds.right.toInt()) {
|
||||||
|
assertEquals("$name reserved caption band ($x,$y)", WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally { bitmap.recycle() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1224,15 +1224,13 @@
|
||||||
"w": 400,
|
"w": 400,
|
||||||
"h": 225,
|
"h": 225,
|
||||||
"gap": 40,
|
"gap": 40,
|
||||||
"pitch": 440,
|
"pitch": 440
|
||||||
"captionBand": 64
|
|
||||||
},
|
},
|
||||||
"dp": {
|
"dp": {
|
||||||
"w": 200.0,
|
"w": 200.0,
|
||||||
"h": 112.5,
|
"h": 112.5,
|
||||||
"gap": 20.0,
|
"gap": 20.0,
|
||||||
"pitch": 220.0,
|
"pitch": 220.0
|
||||||
"captionBand": 32.0
|
|
||||||
},
|
},
|
||||||
"ratio": "16:9",
|
"ratio": "16:9",
|
||||||
"fullSlotsPerRow": 4,
|
"fullSlotsPerRow": 4,
|
||||||
|
|
@ -1245,15 +1243,13 @@
|
||||||
"w": 544,
|
"w": 544,
|
||||||
"h": 306,
|
"h": 306,
|
||||||
"gap": 40,
|
"gap": 40,
|
||||||
"pitch": 584,
|
"pitch": 584
|
||||||
"captionBand": 64
|
|
||||||
},
|
},
|
||||||
"dp": {
|
"dp": {
|
||||||
"w": 272.0,
|
"w": 272.0,
|
||||||
"h": 153.0,
|
"h": 153.0,
|
||||||
"gap": 20.0,
|
"gap": 20.0,
|
||||||
"pitch": 292.0,
|
"pitch": 292.0
|
||||||
"captionBand": 32.0
|
|
||||||
},
|
},
|
||||||
"ratio": "16:9",
|
"ratio": "16:9",
|
||||||
"fullSlotsPerRow": 3,
|
"fullSlotsPerRow": 3,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token
|
||||||
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
|
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
|
||||||
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
|
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
|
||||||
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
|
||||||
c04055d064795e5c7d7eef74756fe1386337002e66ef5e6597951a10cd7952dd src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
|
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
|
||||||
d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css
|
d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css
|
||||||
fe84a56f7e570e1ff8c0ed0b347955b7d5421a75719fbbeffe251811ea2e1ae7 tokens.json
|
ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue