Start new Home rows leftmost and gate outgoing Back handlers

This commit is contained in:
justinzeus 2026-11-07 18:52:30 +01:00
parent 5811ab0555
commit c5dbe91076
7 changed files with 82 additions and 32 deletions

View file

@ -1,7 +1,5 @@
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,
@ -38,8 +36,7 @@ class HomeFocus(
while (next in rows.indices && !rows[next].focusable) next += direction
if (next in rows.indices) {
val target = rows[next]
val nearest = nearest(current, target)
focus(next, view.cards[target.id] ?: nearest)
focus(next, view.cards[target.id] ?: 0)
} else if (direction < 0) {
view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
view.home.resetScroll++
@ -77,27 +74,6 @@ class HomeFocus(
else -> Unit
}
}
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) view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2
val first = (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) {

View file

@ -8,6 +8,10 @@ import androidx.compose.runtime.remember
internal val LocalScreenView = compositionLocalOf<RememberedView?> { null }
internal val LocalScreenActive = compositionLocalOf { true }
/** Outgoing entries cannot consume Back while their exit animation remains composed. */
@Composable
fun isScreenEntryActive(): Boolean = LocalScreenActive.current
/** Overlapping navigation entries retain their own viewport; only the current entry owns input. */
@Composable
fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) {

View file

@ -105,13 +105,15 @@ class HomeKeysTest {
assertNull(action)
}
@Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() {
@Test fun rememberedColumnsClampToShortRowsAndTheFinalSeeAll() {
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 long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards)
val short = long.copy(id = "short", cards = cards.take(2), total = 2)
val tail = short.copy(id = "tail", total = 20)
shell.view.cards[long.id] = cards.lastIndex
shell.view.cards[short.id] = cards.lastIndex
shell.view.cards[tail.id] = 2
var action: HomeAction? = null
rule.setContent { WolfskinTheme {
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } }
@ -132,17 +134,18 @@ class HomeKeysTest {
assertEquals(tail.id, (action as HomeAction.SeeAll).row.id)
}
@Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() {
@Test fun visitedMixedWidthRowsKeepColumnsAndTrackWhenMovingInsideTheMiddle() {
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
shell.view.cards[tiles.id] = 2
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])
assertEquals(2, shell.view.cards[tiles.id])
repeat(4) { press("RIGHT") }
val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left
press("LEFT")

View file

@ -0,0 +1,61 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.foundation.lazy.LazyListState
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Home starts new rows at zero and retains visited positions independently of other root tabs. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class HomeRowEntryTest {
private val people = listOf(ShellPerson("justin", "Justin", "J", true))
private val cards = List(12) { HomeTitle("item-$it", "Film $it", "movie") }
@Test fun unvisitedRowsStartLeftmostAcrossCardWidths() {
for (from in HomeCardKind.entries) for (to in HomeCardKind.entries) {
val shell = ShellState(people, "justin").apply { pickerVisible = false; navFocused = false }
val source = HomeRow("source", HomeRowKind.Shelf, cards = cards, cardKind = from)
val target = source.copy(id = "target", cardKind = to)
val focus = HomeFocus(shell, listOf(source, target)) {}
focus.focus(0, 3)
focus.key(ShellKey.Down)
assertEquals("$from to $to", target.id, focus.row?.id)
assertEquals("$from to $to must start leftmost", 0, focus.column)
assertEquals(0, shell.rememberedView("home").home.firstCards[target.id])
}
}
@Test fun visitedColumnsAndFirstVisiblePositionsSurviveRowChangesAndRootTabLeave() {
val shell = ShellState(people, "justin").apply { pickerVisible = false; navFocused = false }
val source = HomeRow("source", HomeRowKind.Shelf, cards = cards)
val rows = listOf(source, source.copy(id = "target", cardKind = HomeCardKind.Tile))
val focus = HomeFocus(shell, rows) {}
focus.focus(0, 8)
focus.key(ShellKey.Left)
val view = shell.rememberedView("home")
val first = view.home.firstCards.getValue(source.id)
val track = LazyListState(first, 19)
view.home.tracks[source.id] = track
focus.key(ShellKey.Down)
focus.key(ShellKey.Up)
assertEquals(7, focus.column)
assertEquals(first, view.home.firstCards[source.id])
shell.focusNav(shell.tabs.indexOf("discover")); shell.key(ShellKey.Ok)
shell.focusNav(shell.tabs.indexOf("home")); shell.key(ShellKey.Ok)
val restored = HomeFocus(shell, rows) {}
assertSame(view, shell.rememberedView("home"))
assertEquals(7, restored.column)
restored.key(ShellKey.Down)
restored.key(ShellKey.Up)
assertEquals(7, restored.column)
assertEquals(first, view.home.firstCards[source.id])
assertSame(track, view.home.tracks[source.id])
assertEquals(first, track.firstVisibleItemIndex)
assertEquals(19, track.firstVisibleItemScrollOffset)
}
}