Match native updater frames and preserve D-pad focus behavior

This commit is contained in:
justinzeus 2026-11-07 17:55:09 +01:00
parent 2b8c729f62
commit 37ad87d058
16 changed files with 131 additions and 67 deletions

View file

@ -11,6 +11,9 @@ import androidx.tv.material3.Icon
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
val resource = when (name) {
"gear" -> R.drawable.wolfskin_gear
"updates" -> R.drawable.wolfskin_update
"settings-list" -> R.drawable.wolfskin_settings_list
"settings-right" -> R.drawable.wolfskin_settings_chevron
"requests" -> R.drawable.wolfskin_tray
"interface" -> R.drawable.wolfskin_grid
"play", "playback" -> R.drawable.wolfskin_play

View file

@ -40,15 +40,16 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
fun navBoxes(tabs: List<String>): List<NavBox> {
var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap
fun navBoxes(tabs: List<String>, isUpdatesLayout: Boolean = false): List<NavBox> {
val updates = WolfskinTokens.Update
var left = L.safeSide + (if (isUpdatesLayout) updates.wordmarkWidth else S.wordmarkWidth) + L.wordmarkGap + L.navItemGap
return tabs.map { tab ->
val width = when (tab) {
"home" -> S.navHomeWidth
"discover" -> S.navDiscoverWidth
"movies" -> S.navMoviesWidth
"shows" -> S.navShowsWidth
else -> S.navAnimeWidth
"home" -> if (isUpdatesLayout) updates.navHomeWidth else S.navHomeWidth
"discover" -> if (isUpdatesLayout) updates.navDiscoverWidth else S.navDiscoverWidth
"movies" -> if (isUpdatesLayout) updates.navMoviesWidth else S.navMoviesWidth
"shows" -> if (isUpdatesLayout) updates.navShowsWidth else S.navShowsWidth
else -> if (isUpdatesLayout) updates.navAnimeWidth else S.navAnimeWidth
}
NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap }
} + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar)
@ -57,11 +58,11 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
/** There is one blob, including when focus wraps around the nav avatar. */
@Composable
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
val hasSelectedTab = state.route != "settings" && state.route != "updates"
val boxes = remember(state.person.hasAnime, hasSelectedTab) { navBoxes(state.tabs, isUpdatesLayout = !hasSelectedTab) }
val reduced = reducedMotion()
val labelFade = tween<Color>(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f))
val index = if (state.navFocused) state.navIndex else state.currentTab
val hasSelectedTab = state.route != "settings" && state.route != "updates"
val box = boxes[index]
val avatarFocused = state.navFocused && index == state.tabs.size
val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero

View file

@ -9,14 +9,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
@ -40,11 +38,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
import org.justinvisser.wolfskin.design.UpdateTypography as T
/** Settings routes identify real upstream destinations rather than stand-in panels. */
data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false)
data class SettingsGroup(val key: String, val title: String, val description: String)
val SETTINGS_GROUPS = listOf(
SettingsGroup("requests", "Requests", "Coming in: what the household asked for"),
SettingsGroup("updates", "Updates", "Check and install", true),
SettingsGroup("updates", "Updates", "Check and install"),
SettingsGroup("interface", "Interface", "Sign-in · Theme music · Subtitles"),
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Resume"),
SettingsGroup("nextup", "Next up", "Auto-play · Delay · Passout protection"),
@ -74,7 +72,8 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount:
Text("Settings", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg)
Text("This TV · ${state.person.name}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
}
LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop).height(WolfskinTokens.Update.rowsViewport + S.settingsRowGap),
LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop)
.height(WolfskinTokens.Update.rowsViewport + L.settingsRowHeight * (WolfskinTokens.Focus.scaleRow - 1f)),
state = list, contentPadding = PaddingValues(bottom = S.settingsRowGap)) {
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) {
@ -104,7 +103,7 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modi
Text(group.description, Modifier.exactLineBox(T.caption), color = if (focused) C.onFocus else C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
SettingsPill(if (group.key == "requests" || group.key == "updates") "Wolfskin screen" else "upstream screen", focused)
Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron))
Glyph("settings-right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
}
}

View file

@ -1,6 +1,9 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@ -8,16 +11,19 @@ import androidx.compose.ui.text.font.FontWeight
/** The approved Updates frames resolve system-ui to DejaVu Sans; other screens keep their fonts. */
object UpdateTypography {
val family = FontFamily(Font(R.font.update_dejavu_sans), Font(R.font.update_dejavu_sans_bold, FontWeight.Bold))
val screenTitle = WolfskinTokens.Type.screenTitle.copy(fontFamily = family)
val stateTitle = WolfskinTokens.Type.stateTitle.copy(fontFamily = family)
val heading = WolfskinTokens.Update.heading.copy(fontFamily = family)
val nav = WolfskinTokens.Type.nav.copy(fontFamily = family)
val wordmark = WolfskinTokens.Type.wordmark.copy(fontFamily = family)
val listTitle = WolfskinTokens.Type.listTitle.copy(fontFamily = family)
val body = WolfskinTokens.Type.body.copy(fontFamily = family)
val button = WolfskinTokens.Type.button.copy(fontFamily = family)
val label = WolfskinTokens.Type.label.copy(fontFamily = family)
val caption = WolfskinTokens.Type.caption.copy(fontFamily = family)
val avatarLetterNav = WolfskinTokens.Type.avatarLetterNav.copy(fontFamily = family)
val avatarLetterLibHeader = WolfskinTokens.Type.avatarLetterLibHeader.copy(fontFamily = family)
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
private val platform = PlatformTextStyle(includeFontPadding = false)
private fun resolveStyle(style: TextStyle) = style.copy(fontFamily = family, lineHeightStyle = lineBox, platformStyle = platform)
val screenTitle = resolveStyle(WolfskinTokens.Type.screenTitle)
val stateTitle = resolveStyle(WolfskinTokens.Type.stateTitle)
val heading = resolveStyle(WolfskinTokens.Update.heading)
val nav = resolveStyle(WolfskinTokens.Type.nav)
val wordmark = resolveStyle(WolfskinTokens.Type.wordmark)
val listTitle = resolveStyle(WolfskinTokens.Type.listTitle)
val body = resolveStyle(WolfskinTokens.Type.body)
val button = resolveStyle(WolfskinTokens.Type.button)
val label = resolveStyle(WolfskinTokens.Type.label)
val caption = resolveStyle(WolfskinTokens.Type.caption)
val avatarLetterNav = resolveStyle(WolfskinTokens.Type.avatarLetterNav)
val avatarLetterLibHeader = resolveStyle(WolfskinTokens.Type.avatarLetterLibHeader)
}

View file

@ -703,6 +703,12 @@ object WolfskinTokens {
}
object Update {
val avatarJustin = ComposeColor(0xFF7AB8FF)
val wordmarkWidth = 81.796875.dp
val navHomeWidth = 74.6640625.dp
val navDiscoverWidth = 99.0703125.dp
val navMoviesWidth = 85.2578125.dp
val navShowsWidth = 80.578125.dp
val navAnimeWidth = 79.234375.dp
val notesListTop = 14.0.dp
val notesListBottom = 14.0.dp
val notesMarkerOffset = 5.5.dp

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
<path android:pathData="M12,6 L22,16 L12,26" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
</vector>

View file

@ -68,7 +68,7 @@ class ShellCaptureTest {
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 5 }
}
/** Tests and debug builds share the same shell-only projection of the locked fixture. */

View file

@ -46,6 +46,22 @@ class ShellKeysTest {
assertFalse("Requests content, not the nav, must own focus", state.navFocused)
}
@Test fun updatesUseTwoPressesFromRequestsWithoutClassicHandOff() {
val actions = mutableListOf<ShellAction>()
val shell = ShellState(fixturePeople(), "justin", actions::add).apply {
pickerVisible = false
enterDestination("settings")
}
rule.setContent { WolfskinTheme { ShellSurface(shell) } }
rule.onNodeWithTag("wolfskin-shell").performKeyInput {
pressKey(Key.DirectionDown)
pressKey(Key.DirectionCenter)
}
assertEquals(1, shell.settingsRow)
assertNull(shell.handOff)
assertEquals(listOf(ShellAction.Destination("updates")), actions)
}
@Test fun settingsBackBackInTenPresses() {
replay(settings + listOf(Key.Back, Key.Back), 10)
assertEquals("home", state.route)

View file

@ -517,6 +517,12 @@
--discover-ambientInset: 80.0px;
--discover-ambientBlur: 60.0px;
--update-avatarJustin: #7ab8ff;
--update-wordmarkWidth: 163.59375px;
--update-navHomeWidth: 149.328125px;
--update-navDiscoverWidth: 198.140625px;
--update-navMoviesWidth: 170.515625px;
--update-navShowsWidth: 161.15625px;
--update-navAnimeWidth: 158.46875px;
--update-notesListTop: 28.0px;
--update-notesListBottom: 28.0px;
--update-notesMarkerOffset: 11.0px;

View file

@ -2,8 +2,8 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
1b8222d01dc30cbe2591097218a4a93c6ac4b24704cf81b18174b7839eb57e31 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
a951ee1e5c433789e22cc995a7ed279d170b24d5bfbd1672fa9799c9282c6fb8 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
9a4c316e83af3207dd21fa7a3b4a70711460f38b740ee1de3ec9b042b3d4f9a8 tokens.css
27b0e44afaece06f732d24ff847140da6175ea352de620fa6de9d7e9e4c101fa tokens.css
2e5e0476341adec1ec05331af38bc67cd02ba013a0988aea40a70eff21513264 tokens.json
1bedb8e8079b4a954651d8761d18b8be4adb3933370e5f296be27287401ae460 update-tokens.json
19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json

View file

@ -1,6 +1,12 @@
{
"update": {
"avatarJustin": {"$type": "color", "$value": "#7ab8ff"},
"wordmarkWidth": {"$type": "dimension", "px": 163.59375, "dp": 81.796875},
"navHomeWidth": {"$type": "dimension", "px": 149.328125, "dp": 74.6640625},
"navDiscoverWidth": {"$type": "dimension", "px": 198.140625, "dp": 99.0703125},
"navMoviesWidth": {"$type": "dimension", "px": 170.515625, "dp": 85.2578125},
"navShowsWidth": {"$type": "dimension", "px": 161.15625, "dp": 80.578125},
"navAnimeWidth": {"$type": "dimension", "px": 158.46875, "dp": 79.234375},
"notesListTop": {"$type": "dimension", "px": 28, "dp": 14},
"notesListBottom": {"$type": "dimension", "px": 28, "dp": 14},
"notesMarkerOffset": {"$type": "dimension", "px": 11, "dp": 5.5},