Follow visible card centers and remember Home tracks
Some checks failed
review/design-claude Request changes: separator dot colour, vertical rhythm, focus entry after load

This commit is contained in:
justinzeus 2026-11-05 17:16:06 +01:00
parent 2b65ee7b53
commit b54bcc148c
6 changed files with 73 additions and 8 deletions

View file

@ -1,5 +1,7 @@
package org.justinvisser.wolfskin.design
import kotlin.math.abs
/** Home navigation mutates the shell's per-person view, including each row's remembered column. */
class HomeFocus(
private val shell: ShellState,
@ -15,7 +17,10 @@ class HomeFocus(
shell.navFocused = false
shell.view.row = index
shell.view.home.focusedKey = rows[index].id
shell.view.cards[rows[index].id] = column.coerceIn(0, (rows[index].count - 1).coerceAtLeast(0))
val target = rows[index]
val bounded = column.coerceIn(0, (target.count - 1).coerceAtLeast(0))
shell.view.cards[target.id] = bounded
if (target.kind == HomeRowKind.Shelf) shell.view.home.firstCards[target.id] = target.adjustedFirst(bounded, shell.view.home.firstCards[target.id] ?: 0)
}
fun key(key: ShellKey, down: Boolean, repeat: Int): Boolean {
@ -43,7 +48,7 @@ class HomeFocus(
while (next in rows.indices && !rows[next].focusable) next += direction
if (next in rows.indices) {
val target = rows[next]
val nearest = (column * pitch(current) / pitch(target)).toInt()
val nearest = nearest(current, target)
focus(next, shell.view.cards[target.id] ?: nearest)
} else if (direction < 0) {
shell.view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
@ -84,9 +89,38 @@ class HomeFocus(
}
}
private fun nearest(from: HomeRow, to: HomeRow): Int {
if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt()
val fromFirst = if (from.kind == HomeRowKind.Shelf) shell.view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2
val first = (shell.view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0))
val last = minOf(to.count - 1, first + to.focusSlots - 1)
var best = first
var distance = Float.POSITIVE_INFINITY
for (index in first..last) {
val candidate = abs((index - first) * pitch(to) + to.cardWidth.value / 2 - x)
if (candidate < distance) { best = index; distance = candidate }
}
return best
}
private fun pitch(row: HomeRow): Float = when (row.cardKind) {
HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value
HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value
HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value
}
}
internal val HomeRow.focusSlots: Int get() = when (cardKind) {
HomeCardKind.Poster -> 6
HomeCardKind.Tile -> 4
HomeCardKind.Wide -> 3
}
/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int {
var first = previous
if (column > first + focusSlots - 2) first = column - (focusSlots - 2)
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0))
}

View file

@ -36,6 +36,9 @@ fun HomeScreen(
onDispose { shell.contentInput = null }
}
LaunchedEffect(snapshot.rows, view) {
snapshot.rows.forEach { item ->
view.cards[item.id]?.let { view.cards[item.id] = it.coerceIn(0, (item.count - 1).coerceAtLeast(0)) }
}
val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable }
if (remembered >= 0) view.row = remembered
if (snapshot.rows.none { it.focusable }) { shell.navFocused = true; shell.navIndex = shell.currentTab }
@ -86,7 +89,7 @@ fun HomeScreen(
fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok, true, 0); focus.key(ShellKey.Ok, false, 0) }
when (item.kind) {
HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings else emptyList(), ::activate) { focus.focus(index, it) }
HomeRowKind.Shelf -> HomeShelf(item, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) }
HomeRowKind.Shelf -> HomeShelf(item, view.home, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) }
HomeRowKind.Section -> HomeSection(shell.person, snapshot.savedAt)
HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) }
HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) {

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -31,12 +32,12 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T
/** Every lazy row and card uses a stable source identity and an explicit content type. */
@Composable
fun HomeShelf(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
val width = row.cardWidth
val height = row.cardHeight
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
val slots = if (row.cardKind == HomeCardKind.Poster) 6 else if (row.cardKind == HomeCardKind.Tile) 4 else 3
val first = (column - (slots - 2)).coerceIn(0, (row.count - slots).coerceAtLeast(0))
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
val track = rememberLazyListState(first)
val density = LocalDensity.current
LaunchedEffect(first) {

View file

@ -2,11 +2,13 @@ package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.setValue
/** Focus-follow runs on a new row, never on re-entry to an already-positioned saved viewport. */
class HomeViewMemory {
val ratings = HomeRatingCache()
val firstCards = mutableStateMapOf<String, Int>()
var focusedKey: String? = null
var positionedKey: String? = null
var resetScroll by mutableIntStateOf(0)

View file

@ -132,6 +132,26 @@ class HomeKeysTest {
assertEquals(tail.id, (action as HomeAction.SeeAll).row.id)
}
@Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false }
val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards
val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards)
val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile)
shell.view.cards[posters.id] = 3
rule.setContent { WolfskinTheme {
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} }
} }
press("DOWN")
assertEquals("The third tile is nearest the fourth poster's horizontal center", 2, shell.view.cards[tiles.id])
repeat(4) { press("RIGHT") }
val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left
press("LEFT")
assertEquals("Moving back within the visible middle must not shift the track", left,
rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left, .1f)
press("UP")
assertEquals("A visited row retains its column", 3, shell.view.cards[posters.id])
}
private fun press(name: String) {
val key = when (name) {
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown