Preserve native title line boxes and async focus

This commit is contained in:
justinzeus 2026-11-05 20:26:43 +01:00
parent a8951827ba
commit 43ae716157
9 changed files with 187 additions and 32 deletions

View file

@ -0,0 +1,88 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleItem
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitleReadStatus
import org.justinvisser.wolfskin.design.TitleSheet
import org.justinvisser.wolfskin.design.TitleTrack
import org.justinvisser.wolfskin.design.TitleVersion
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.UUID
/** A delayed service reply exercises the production destination, view-model and Android focus transfer. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class TitleAsyncFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var viewModel: WolfskinTitleViewModel
@After fun dispose() {
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
}
@Test fun loadingToReadyRestoresRealKeyFocusAndSheetReturn() {
val id = UUID.fromString("00000000-0000-0000-0000-000000000012")
val ready = CompletableDeferred<TitlePageData>()
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(id, any()) } returns null
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true))
val person = ShellPerson("justin", "Justin", "J", false)
val route = titleRoute(id.toString())
val shell = ShellState(listOf(person), person.key).apply {
pickerVisible = false
pickerLaunch = false
enterDestination(route)
}
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } }
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
rule.waitForIdle()
assertTrue(shell.navFocused)
press(Key.DirectionDown)
assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused)
val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList())
rule.runOnIdle {
ready.complete(TitlePageData(TitleItem(id.toString(), "A held film", "movie", held = true), versions = listOf(source, source.copy(id = "second", name = "Alternate"))))
}
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Ready && !shell.navFocused }
press(Key.DirectionRight)
val state = requireNotNull(viewModel.title)
assertEquals("version", state.options[state.column].id)
press(Key.DirectionCenter)
assertEquals(TitleSheet.Version, state.sheet)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertNull(state.sheet)
press(Key.DirectionRight)
assertEquals("tracks", state.options[state.column].id)
assertFalse(shell.navFocused)
}
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
}

View file

@ -17,6 +17,8 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.graphics.toArgb
import com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONObject
import org.junit.Before
@ -115,6 +117,30 @@ class TitleCaptureTest {
.put("boundsPx", org.json.JSONArray(listOf(heading.boundsInRoot.left, heading.boundsInRoot.top, heading.boundsInRoot.right, heading.boundsInRoot.bottom)))
File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2))
}
recordTextMetrics(directory, number)
}
private fun recordTextMetrics(directory: File, number: Int) {
val elements = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true)
val records = org.json.JSONArray()
elements.fetchSemanticsNodes().forEachIndexed { index, node ->
if (node.boundsInRoot.width > 0 && node.boundsInRoot.height > 0) {
val layouts = mutableListOf<TextLayoutResult>()
elements[index].performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
records.put(JSONObject().put("text", layout.layoutInput.text.text)
.put("fontSizeSp", layout.layoutInput.style.fontSize.value)
.put("weight", layout.layoutInput.style.fontWeight?.weight)
.put("colourArgb", Integer.toUnsignedString(layout.layoutInput.style.color.toArgb(), 16))
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL)
.put("lineCount", layout.lineCount)
.put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom)))
.put("spans", org.json.JSONArray(layout.layoutInput.text.spanStyles.map { span ->
JSONObject().put("start", span.start).put("end", span.end).put("weight", span.item.fontWeight?.weight)
})))
}
}
File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2))
}
private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) }

View file

@ -33,6 +33,7 @@ import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
@RunWith(RobolectricTestRunner::class)
@ -289,4 +290,15 @@ class TitleKeysTest {
val genre = rule.onNodeWithText("Drama", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() {
render()
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) }
press(Key.DirectionCenter)
val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
}
}

View file

@ -200,16 +200,23 @@ button until Conductarr's trailer field exists. Conductarr sign-in and request
submission/receipt workflows belong to their separate slices; title pages do not
pretend those operations succeeded or fall back to Seerr.
`TitleCaptureTest` writes the 34 approved title/person/options frames and native
heading layout measurements at 1920×1080, density 2.0. The approved typeface ruling
keeps platform Roboto rather than bundling the browser's DejaVu Sans: there is no
per-title compensation and no wrapping-parity claim. `TitleKeysTest` replays
real Android key events from title entry, including modal Back, held OK, nearest
row entry, remembered columns, pending seasons and mouse-wheel re-entry.
`title-press-paths.json` also retains the complete launch paths and their entry
boundaries; their Home prefixes require the Home surface before end-to-end replay.
SDK cache, exact-episode, track-index, identity, locale-layout and trailer-handler
tests run entirely offline with in-process service replies and local art.
`TitleCaptureTest` writes the 34 approved title/person/options frames plus native
text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved
typeface ruling keeps platform Roboto, not the browser's DejaVu Sans: no per-title
compensation and no wrapping-parity claim. Untrimmed CSS line boxes are scoped to
title/person content and sheets; shared nav/library typography is unchanged.
`TitleKeysTest` replays real Android keys from title entry, including modal Back,
held OK, nearest-row entry, remembered columns, pending seasons, wheel re-entry
and native media-info line-box pitch. `TitleAsyncFocusTest` delays the service reply
through the production destination/view-model, then exercises loaded options and
hardware sheet Back through real focus handling.
The approved current gate uses exact title-entry suffixes, not padded launch
paths. `title-press-paths.json` retains the full sequences and entry boundaries.
The full 4/8/15/11 launch replays will be added after the native Home slice merges;
the corresponding title-entry paths are 1/5/6/2. SDK cache, exact-episode,
track-index, identity, locale-layout and trailer-handler tests run offline with
in-process service replies and local art.
## Toolchain and disk use

View file

@ -104,7 +104,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier,
Row(Modifier.align(Alignment.TopEnd)
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2)
.height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))
.padding(horizontal = V.side), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
.padding(horizontal = V.side + V.border), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
Glyph("wifi", C.warn, Modifier.size(V.icon))
Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption)
}

View file

@ -131,15 +131,15 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
Row(Modifier.offset(L.safeSide, D.personTop), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
PersonPortrait(state.person, Modifier.size(L.personPortrait).clip(CircleShape), true)
Column(Modifier.width(L.contentWidth - L.personPortrait - D.personGap)) {
Text(state.person.name, color = C.fg, style = WolfskinTokens.Type.screenTitle, modifier = Modifier.padding(bottom = D.personNameBottom))
Text("Acting · ${state.credits.size} credit${if (state.credits.size == 1) "" else "s"} among this household’s titles", color = C.muted, style = WolfskinTokens.Type.body)
if (state.person.biography.isNotBlank()) Text(state.person.biography, color = C.dim, style = WolfskinTokens.Type.caption, maxLines = 3)
Text(state.person.name, color = C.fg, style = TitleTextStyles.screenTitle, modifier = Modifier.padding(bottom = D.personNameBottom))
Text("Acting · ${state.credits.size} credit${if (state.credits.size == 1) "" else "s"} among this household’s titles", color = C.muted, style = TitleTextStyles.body)
if (state.person.biography.isNotBlank()) Text(state.person.biography, color = C.dim, style = TitleTextStyles.caption, maxLines = 3)
}
}
}
}
item(key = "filmography", contentType = "header") {
Text("Filmography", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
}
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {

View file

@ -70,24 +70,24 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
@Composable
private fun VersionChoices(state: TitleState) {
Text("Version", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle)
SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files")
state.data.versions.forEachIndexed { index, version ->
PlaybackChoice(version.name, listOf(version.video, version.bitrate, version.size).filter { it.isNotBlank() }.joinToString(" · "), version == state.version, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
}
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = WolfskinTokens.Type.caption)
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption)
}
@Composable
private fun TrackChoices(state: TitleState) {
val version = state.version ?: return
Text("Audio and subtitles", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle)
SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}")
Text("Audio", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop))
version.audio.forEachIndexed { index, track ->
PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
}
Text("Subtitles", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop))
val start = version.audio.size
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
version.subtitles.forEachIndexed { index, track ->
@ -107,8 +107,8 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus
.clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(label, color = foreground, style = WolfskinTokens.Type.listTitle)
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = WolfskinTokens.Type.caption)
Text(label, color = foreground, style = TitleTextStyles.listTitle)
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption)
}
TitleRadio(selected, focused)
}
@ -117,7 +117,7 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus
@Composable
private fun MediaInformation(state: TitleState) {
val version = state.version ?: return
Text("Media info", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle)
SheetSubtitle(state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name)
MediaInfoSection("General", listOf("Container" to version.container, "Size" to version.size.ifEmpty { "not known" }, "Bitrate" to version.bitrate, "Runtime" to (state.target?.runtime ?: "not known")))
MediaInfoSection("Video", listOf("Stream" to version.video))
@ -128,11 +128,11 @@ private fun MediaInformation(state: TitleState) {
@Composable
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
Text(label, color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop))
Column(verticalArrangement = Arrangement.spacedBy(D.infoRowGap)) { values.forEach { (name, value) ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
Text(name, color = C.dim, style = D.infoKey, modifier = Modifier.width(D.infoLabelWidth))
Text(value, color = C.fg, style = WolfskinTokens.Type.caption)
Text(name, color = C.dim, style = TitleTextStyles.infoKey, modifier = Modifier.width(D.infoLabelWidth))
Text(value, color = C.fg, style = TitleTextStyles.caption)
}
} }
}
@ -140,7 +140,7 @@ private fun MediaInfoSection(label: String, values: List<Pair<String, String>>)
@Composable
private fun WatchedConfirmation(state: TitleState) {
val verb = if (state.watched) "unwatched" else "watched"
Text("Mark the whole series as $verb?", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle)
SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.")
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
@ -150,7 +150,7 @@ private fun WatchedConfirmation(state: TitleState) {
@Composable
private fun SheetSubtitle(text: String) {
Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.layout { measurable, constraints ->
Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.layout { measurable, constraints ->
val child = measurable.measure(constraints)
val lift = D.sheetSubLift.roundToPx()
layout(child.width, (child.height + lift).coerceAtLeast(0)) { child.placeRelative(0, lift) }
@ -163,9 +163,9 @@ private fun HiddenReceipt(state: TitleState) {
verticalArrangement = Arrangement.spacedBy(D.receiptGap)) {
Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("check", C.fg, Modifier.size(D.receiptIcon))
Text("Hidden from your picks", color = C.fg, style = D.receiptTitle)
Text("Hidden from your picks", color = C.fg, style = TitleTextStyles.receiptTitle)
}
Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = WolfskinTokens.Type.body)
Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = TitleTextStyles.body)
}
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }

View file

@ -201,12 +201,13 @@ private fun TitleReadError(state: TitleState) {
Column(Modifier.offset(L.safeSide, D.errorTop).width(L.stateWidth)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(WolfskinTokens.SavedIndicator.border)
.padding(horizontal = D.errorSide, vertical = D.errorVertical)) {
Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("alert", C.warn, Modifier.size(D.receiptIcon))
Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = D.errorTitle)
Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = TitleTextStyles.errorTitle)
}
Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))
Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }, overArtwork = false) { state.click("header", index) } } }
}
}

View file

@ -0,0 +1,21 @@
package org.justinvisser.wolfskin.design
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
/** Explicit styles retain CSS leading without changing shared nav or library typography. */
internal object TitleTextStyles {
private val lineBoxes = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
private fun TextStyle.withLineBoxes() = copy(lineHeightStyle = lineBoxes)
val body = WolfskinTokens.Type.body.withLineBoxes()
val caption = WolfskinTokens.Type.caption.withLineBoxes()
val label = WolfskinTokens.Type.label.withLineBoxes()
val sheetTitle = WolfskinTokens.Type.sheetTitle.withLineBoxes()
val listTitle = WolfskinTokens.Type.listTitle.withLineBoxes()
val screenTitle = WolfskinTokens.Type.screenTitle.withLineBoxes()
val rowHeading = WolfskinTokens.Type.rowHeading.withLineBoxes()
val errorTitle = WolfskinTokens.Title.errorTitle.withLineBoxes()
val receiptTitle = WolfskinTokens.Title.receiptTitle.withLineBoxes()
val infoKey = WolfskinTokens.Title.infoKey.withLineBoxes()
}