diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt index 469789a1..6363f523 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt @@ -1,5 +1,6 @@ package io.unom.punktfunk +import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -30,6 +31,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -241,10 +243,19 @@ internal fun ConnectTakeover( add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel)) if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry)) } - GamepadHintBar( - hints, - Modifier.align(Alignment.BottomCenter).consoleSafeArea().padding(bottom = 28.dp), - ) + // The SAME bottom-start spot every console screen pins its legend at — this takeover sat + // its pill at bottom-CENTRE, so pressing Connect made the one piece of chrome that is + // supposed to read as fixed jump halfway across the screen (second on-glass verdict). + val landscape = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + Box( + Modifier + .align(Alignment.BottomStart) + .consoleLegendInsets(landscape) + .padding(ConsoleLegendInset), + ) { + GamepadHintBar(hints) + } } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt index b1640ee4..590a69a8 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt @@ -295,7 +295,8 @@ fun GamepadAddHostScreen( horizontalArrangement = Arrangement.spacedBy(18.dp), ) { Column( - Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()), + Modifier.weight(1f).fillMaxHeight().widthIn(max = 620.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { ConsoleHeader(title, horizontalInset = false) @@ -316,7 +317,9 @@ fun GamepadAddHostScreen( // compressed by the keyboard; the keyboard sits below it; the legend floats (fixed). Column(Modifier.fillMaxSize().consoleSafeArea().padding(horizontal = ConsoleEdgeInset)) { Column( - Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()), + // Same 620 dp cap as the settings rows: a field stretched across a wide + // landscape phone is a ribbon, not an input. + Modifier.weight(1f).widthIn(max = 620.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { ConsoleHeader(title, horizontalInset = false) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt index f909f884..2d8d2314 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup @@ -51,15 +52,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -75,6 +77,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -95,8 +98,14 @@ import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.deviceBodyVibrator +import androidx.compose.ui.zIndex +import kotlin.math.PI import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min import kotlin.math.roundToInt +import kotlin.math.sin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -473,7 +482,10 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus label = "consoleFocus", ) val background by animateColorAsState( - if (active) ink.accent(0.20f) else ink.glass, + // 0.28, not the 0.20 it launched with: with the shadow and bloom gone (see consoleGlass), + // the tint IS the whole focus statement, and Apple's rows carry accent(0.30) for the same + // reason. Slightly under Apple's because this fill also rides a luminance gradient. + if (active) ink.accent(0.28f) else ink.glass, ConsoleMotion.ease(ConsoleMotion.FOCUS_MS), label = "consoleBg", ) @@ -492,18 +504,21 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus /** * The console's glass: what every row, field, card and tile is cut from, in one place. * - * Four things separate it from the flat translucent fill it replaces, all of them the desktop - * console's `theme::panel()` in Compose terms: + * Two things separate it from a flat translucent fill: * * a vertical LUMINANCE gradient in the fill, so the pane has a lit top and a settled bottom * instead of one even wash; * * a 1 px top-edge HIGHLIGHT that fades down into the border — the cue that reads as "this is a - * physical pane catching the light above it"; - * * a drop SHADOW that grows with focus, so the focused row genuinely sits above its neighbours; - * * a soft accent BLOOM behind it, drawn outside the clip, so focus reads as a lens over the - * backdrop rather than a recolour of the row. + * physical pane catching the light above it". * - * The bloom and shadow are driven by [ConsoleFocusVisuals.focus], so they arrive and leave with the - * same curve as the fill — one focus change, not four independent animations. + * Focus is the FILL and the BORDER brightening — nothing else, which matches the Apple client's + * glass (`GlassStyle.swift`: material + an animatable tint, full stop). Two earlier additions were + * removed after the first on-glass review, and neither may come back: + * * a drop shadow. An elevation shadow is drawn UNDER the surface, and this surface is + * translucent — the shadow showed straight through the fill as a dark rectangle floating + * inside every row and card. + * * an accent bloom drawn outside the clip. Unclipped drawing does not stop at the row's + * neighbours either: in a list the glow painted over the rows above and below, and in the + * carousel it escaped the card entirely. */ @Composable fun Modifier.consoleGlass( @@ -519,7 +534,6 @@ fun Modifier.consoleGlass( ): Modifier { val ink = LocalGamepadInk.current val focus = visuals.focus - val accent = ink.accent val highlight = ink.highlight val fill = visuals.background val border = visuals.border @@ -558,22 +572,6 @@ fun Modifier.consoleGlass( } return this .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } - // BEFORE the clip, so the bloom can spill past the row's own rectangle — a glow that stops - // at the edge is just a brighter border. - .drawBehind { - if (focus <= 0.01f) return@drawBehind - val r = size.height * 1.35f - drawRect( - brush = Brush.radialGradient( - colors = listOf(accent.copy(alpha = 0.18f * focus), Color.Transparent), - center = Offset(size.width / 2f, size.height / 2f), - radius = r, - ), - topLeft = Offset(-r, -r), - size = Size(size.width + 2f * r, size.height + 2f * r), - ) - } - .shadow(elevation = ConsoleGlassLift * focus, shape = shape, clip = false) .clip(shape) .background( Brush.verticalGradient( @@ -594,9 +592,6 @@ fun Modifier.consoleGlass( ) } -/** How far a fully focused console surface lifts off the field. */ -private val ConsoleGlassLift = 8.dp - /** * A MODAL card's surface. Unlike [consoleGlass] this one is near-opaque: a dialog's job is to * occlude the screen it covers, and it carries body text that has to stay readable over a moving @@ -821,6 +816,134 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) } } +// --- The option band --------------------------------------------------------------------------- + +/** + * A choice row's value as a REAL band — the Android port of the Apple client's + * `GamepadOptionBand.swift`, and the fix for "the select field is nowhere near the Apple client": + * the options sit side by side on a drum segment curving about a vertical axis. The current one + * faces you flat; a step rotates the next one in with perspective. The drum's position is ONE + * continuous value driven by a spring, and Compose's `Animatable` retargets with velocity — rapid + * presses accumulate into one accelerating travel instead of five restarted crossfades. + * + * The band is LINEAR, not a ring (the Apple field verdict, inherited whole): a ring showed the + * first option waiting to the right of the last one, which left/right can't reach — a promise the + * navigation doesn't keep. Positions are fixed, the ends are the ends, and A's wrap from the last + * option travels BACK across the list to the first. Neighbours exist only while the drum is + * actually moving — at rest a row shows exactly its value (a resting neighbour under a long label + * rendered as overlapping, unreadable text on Apple, and would here too). + * + * [width] is FIXED by the row: a step must never reflow the row (the free-width value shifted the + * chevrons with every label), and the drum needs its stage even when the facing label is short. + * + * Purely presentational: stepping semantics (clamp with a boundary thud, A cycles wrapping, + * disabled rows refuse) stay in the settings screen's row closures. + */ +@Composable +fun ConsoleOptionBand( + options: List, + selection: Int, + focused: Boolean, + width: Dp, + modifier: Modifier = Modifier, +) { + val ink = LocalGamepadInk.current + val animated = animationsEnabled() + val color = ink.fg(if (focused) 1f else 0.6f) + val drum = remember { Animatable(selection.toFloat()) } + // Where the spring is headed (jumps instantly on a step; only the drum chases it). The + // distance between them is "how mid-flight are we" — the neighbours exist exactly as long as + // the drum is moving. + var target by remember { mutableIntStateOf(selection) } + LaunchedEffect(selection, options.size, animated) { + val old = target + target = selection + // A step (or A's wrap — on a linear band a fast travel back to the start) springs the + // drum; anything else (an external write from the touch settings, a re-derived options + // list) re-seats it — a travel to a value the user didn't step to would read as the UI + // acting on its own. + val wrapped = options.size > 1 && old == options.size - 1 && selection == 0 + if (animated && (abs(selection - old) == 1 || wrapped)) { + drum.animateTo( + selection.toFloat(), + // Apple's `.spring(response: 0.32, dampingFraction: 0.78)`; stiffness is + // (2π / response)² ≈ 385. + spring(dampingRatio = 0.78f, stiffness = 385f), + ) + } else { + drum.snapTo(selection.toFloat()) + } + } + Box( + modifier + .width(width) + .clipToBounds() + // One element to a screen reader — the neighbour texts are rendering, not content. + .clearAndSetSemantics { + contentDescription = options.getOrNull(selection).orEmpty() + }, + contentAlignment = Alignment.Center, + ) { + val rotation = drum.value + val flight = min(1f, abs(rotation - target) * 3f) + val widthPx = with(LocalDensity.current) { width.toPx() } + // Puts the ±1 neighbour ~40 % of the band off-centre, curling toward the edge. + val radius = widthPx * 0.72f + options.forEachIndexed { i, label -> + // Plain signed distance — option i has ONE home and nothing waits beyond the ends. + val d = i - rotation + // Only the facing option at rest; its neighbours join it for the travel. + if (abs(d) < 0.5f || (flight > 0.001f && abs(d) <= 2.5f)) { + val angle = d * DRUM_STEP_RAD + val depth = cos(angle) + val x = radius * sin(angle) + val alpha = max(depth, 0f).let { it * it * it } * + (if (abs(d) < 0.5f) 1f else flight) * drumEdgeFade(x, widthPx) + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = color, + maxLines = 1, + softWrap = false, + modifier = Modifier + // The Apple `fixedSize()`: never let a turning label re-wrap to the + // band's width mid-flight — it clips at the band's edge fade instead. + .wrapContentWidth(unbounded = true) + .zIndex(depth) + .graphicsLayer { + translationX = x + val s = 0.70f + 0.30f * depth + scaleX = s + scaleY = s + // Foreshorten the label as it turns away — what sells the cylinder. + rotationY = Math.toDegrees(angle.toDouble()).toFloat() + cameraDistance = 6f * density + this.alpha = alpha + }, + ) + } + } + } +} + +/** Angular pitch between adjacent options on the drum — Apple's 34°. */ +private const val DRUM_STEP_RAD = 34f * (PI.toFloat() / 180f) + +/** + * The soft edge, per option rather than as a container mask: full strength through the middle of + * the band, dissolving to nothing by an option's rim, so the drum never ends on a hard cut. (The + * Apple file documents why it must not be a mask: a mask rasterises what it covers, which throws + * the 3D projection away every frame — the same trap exists in Compose via `graphicsLayer` alpha + * masking a parent.) + */ +private fun drumEdgeFade(x: Float, width: Float): Float { + val half = width / 2f + if (half <= 0f) return 1f + val fadeStart = half * 0.55f + if (abs(x) <= fadeStart) return 1f + return ((half - abs(x)) / (half - fadeStart)).coerceIn(0f, 1f) +} + // --- Menu haptics ----------------------------------------------------------------------------- /** diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt index 1fece5e7..ea29e0fe 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt @@ -208,7 +208,12 @@ fun GamepadHome( val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp) val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp) val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp) - Box(Modifier.fillMaxSize().consoleSafeArea()) { + // The carousel deliberately IGNORES the safe area (first on-glass verdict): only the + // CENTRED card matters, and it sits mid-screen; the fanned neighbours running under + // the hole punch is ambience, while insetting the pager CLIPPED them at the cutout + // edge — cards visibly cut off is worse than cards behind a camera. The title and the + // legend keep their insets; they are content. + Box(Modifier.fillMaxSize()) { HorizontalPager( state = pagerState, pageSize = PageSize.Fixed(cardWidth), diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 48bb5fb7..ff16e42b 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally @@ -23,15 +24,28 @@ 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.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -59,6 +73,7 @@ import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -116,6 +131,13 @@ internal class GpRow( * row that opens the Controllers view was then advertised as pinning something. */ val actionHint: String = "Open", + /** + * A choice row's full option list + where [value] sits in it — what the [ConsoleOptionBand] + * drum turns through. Null (with [selectedIndex] -1) on everything that is not a stepped + * choice: toggles are a switch, and the flat rows keep the quiet text slip. + */ + val options: List? = null, + val selectedIndex: Int = -1, ) /** @@ -252,7 +274,6 @@ fun GamepadSettingsScreen( // (the value gives a little and springs back). A press always gets an answer, even "no". var stepToken by remember { mutableIntStateOf(0) } var refusalToken by remember { mutableIntStateOf(0) } - val listState = rememberLazyListState() val haptics = rememberConsoleHaptics() val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE @@ -302,82 +323,72 @@ fun GamepadSettingsScreen( // The shoulders work from either place — a real pad never has to visit the strip. onShoulder = { delta -> stepTab(delta) }, ) - // Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the - // screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it. - // +1 accounts for the heading being item 0. - val legendClearancePx = with(LocalDensity.current) { ConsoleLegendClearance.roundToPx() } - LaunchedEffect(focus, tab) { - runCatching { - val itemIndex = focus + 1 - val info = listState.layoutInfo - val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex } - val offScreen = item == null || - item.offset < info.viewportStartOffset || - // The SAME constant the list pads its bottom with, rather than a literal that has - // to be remembered when the legend zone grows — which is exactly how a row ended up - // scrolling to a position the legend then covered. - item.offset + item.size > info.viewportEndOffset - legendClearancePx - if (offScreen) listState.animateScrollToItem(itemIndex) - } - } - - // The section slide: one shared list whose CONTENT slides, rather than an AnimatedContent - // holding two LazyColumns — two lists would mean two scroll states fighting over one cursor. val animated = animationsEnabled() - val tabSlide = remember { Animatable(0f) } - LaunchedEffect(tab, animated) { - if (!animated) { tabSlide.snapTo(0f); return@LaunchedEffect } - tabSlide.snapTo(1f) - tabSlide.animateTo(0f, ConsoleMotion.ease(ConsoleMotion.TAB_MS)) - } - val tabSlidePx = with(LocalDensity.current) { ConsoleMotion.TAB_SLIDE.toPx() } - val hazeState = remember { HazeState() } + val ink = LocalGamepadInk.current + // The list runs to the PHYSICAL bottom edge (see the column's insets below), so the legend + // zone's clearance has to carry the bottom bar inset itself. Landscape's zone is only the + // pill — its detail lives in the side pane — so it clears less. + val bottomInset = with(LocalDensity.current) { + WindowInsets.systemBars.getBottom(this).toDp() + } + val legendClearance = (if (landscape) 92.dp else ConsoleLegendClearance) + bottomInset + val legendClearancePx = with(LocalDensity.current) { legendClearance.roundToPx() } + // The drum's fixed stage: a portrait phone is the one place the full width starves the row's + // label, so it alone narrows it — the Apple band makes the same single exception. 132, not the + // 156 of the first cut: at 156 the LABELS truncated ("Resoluti…"), and a clipped label loses + // meaning where a drum value only loses its tail into the edge fade. + val bandWidth = if (landscape) 220.dp else 132.dp - Box(Modifier.fillMaxSize()) { - // Everything scrolls — including the heading — so nothing is pinned. Vital in landscape, - // where a fixed title + a fixed detail/legend strip ate most of the (short) height. - Box(Modifier.fillMaxSize().hazeSource(hazeState)) { - // The backdrop stays full-bleed under the cutout and the bars — it is ambience. Only - // the CONTENT column takes the safe area. - GamepadFormBackground(Modifier.fillMaxSize()) - Column(Modifier.fillMaxSize().consoleSafeArea()) { - // The strip is PINNED while the rows scroll under it: it is this screen's primary - // navigation now, and a switcher you have to scroll back up to find isn't one. The - // title stays in the scrolling list (landscape has no height to spare, and the - // selected pill already says which section you are in). - ConsoleTabStrip( - titles = GpTab.entries.map { it.title }, - selected = GpTab.entries.indexOf(tab), - onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp), - focused = tabFocused, - ) - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - translationX = tabSlidePx * tabSlide.value * tabDir - alpha = 1f - 0.85f * tabSlide.value - }, - contentPadding = PaddingValues( - start = ConsoleEdgeInset, - end = ConsoleEdgeInset, - top = 8.dp, - // Clears the whole floating legend ZONE — the detail band as well as the pill. - bottom = ConsoleLegendClearance, - ), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - item(key = "__title") { - // "Default settings", not "Settings": this screen edits the base layer only. The - // console honours a host's profile but doesn't edit profiles (design §5.4), so a - // bare "Settings" would quietly imply it changes whatever that host streams with. - ConsoleHeader("Default settings", horizontalInset = false) + /** + * One section's rows as a scrolling pane. A composable local rather than inline because the + * tab transition composes TWO of these at once (incoming and outgoing), and each needs its own + * [LazyListState] — Compose refuses one state attached to two lists, which is why the previous + * cut animated a single list's contents and read as the same fade in every direction. + */ + val tabPane: @Composable (GpTab, Modifier) -> Unit = { paneTab, paneModifier -> + val paneRows = if (paneTab == tab) rows else allRows.filter { it.tab == paneTab } + val paneFocus = if (paneTab == tab) focus else (tabFocus[paneTab] ?: 0) + // Seeded at the restored cursor, so re-entering a section lands where it was left without + // a visible catch-up scroll on the first frame. + val paneListState = rememberLazyListState( + initialFirstVisibleItemIndex = paneFocus.coerceIn(0, paneRows.lastIndex.coerceAtLeast(0)), + ) + // Keep the focused row on screen, but only SCROLL when it's actually off-screen. Only the + // LIVE pane tracks the cursor; the outgoing one is a photograph on its way out. + if (paneTab == tab) { + LaunchedEffect(focus) { + runCatching { + val info = paneListState.layoutInfo + val item = info.visibleItemsInfo.firstOrNull { it.index == focus } + val offScreen = item == null || + item.offset < info.viewportStartOffset || + // The SAME clearance the list pads its bottom with, rather than a literal + // that has to be remembered when the legend zone grows. + item.offset + item.size > info.viewportEndOffset - legendClearancePx + if (offScreen) paneListState.animateScrollToItem(focus) + } } - itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> - val rowFocused = index == focus && !tabFocused + } + LazyColumn( + state = paneListState, + // Capped at the Apple client's 620 row width: a landscape phone is WIDER than it is + // useful, and a settings row stretched across 900 dp reads as a ribbon, not a control. + // Start-aligned (not centred) so the rows and the side detail pane split the screen + // rather than both crowding the middle. + modifier = paneModifier.widthIn(max = 620.dp + ConsoleEdgeInset * 2), + contentPadding = PaddingValues( + start = ConsoleEdgeInset, + end = ConsoleEdgeInset, + top = 8.dp, + // Clears the whole floating legend ZONE, bottom bar included — the list itself + // runs to the screen edge now. + bottom = legendClearance, + ), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed(paneRows, key = { _, r -> r.id }) { index, row -> + val rowFocused = paneTab == tab && index == focus && !tabFocused SettingRowView( row, focused = rowFocused, @@ -385,6 +396,7 @@ fun GamepadSettingsScreen( // Only the focused row can be stepped, so only it needs to answer one. stepToken = if (rowFocused) stepToken else 0, refusalToken = if (rowFocused) refusalToken else 0, + bandWidth = bandWidth, onClick = { // Same inertness as the pad path above — tapping a dimmed row focuses it // (so its detail explains itself) but never flips it. @@ -394,7 +406,127 @@ fun GamepadSettingsScreen( }, ) } - } + } + } + + /** The section switcher with its directional content — shared by both orientations below. */ + val tabbedContent: @Composable (Modifier) -> Unit = { contentModifier -> + AnimatedContent( + targetState = tab, + modifier = contentModifier, + transitionSpec = { + if (!animated) { + fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith + fadeOut(tween(ConsoleMotion.REDUCED_MS)) + } else { + // DIRECTION-driven, with a real exit: the incoming section slides in from the + // side the press pointed at while the outgoing leaves the other way — paging + // along a strip. The previous cut slid a single list's contents 24 dp under an + // 85 % fade, which read as the same crossfade whichever shoulder was pressed. + val dir = tabDir + ( + slideInHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { it / 6 * dir } + + fadeIn(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) + ) togetherWith ( + slideOutHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { -it / 6 * dir } + + fadeOut(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) + ) + } + }, + label = "settingsTab", + ) { t -> + tabPane(t, Modifier.fillMaxHeight()) + } + } + + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + // The backdrop stays full-bleed — it is ambience. The CHROME (strip, rows' start + // edge) takes the safe area on the sides and top only: the LIST deliberately runs to + // the physical bottom of the screen, with the bottom inset folded into its + // contentPadding, so scrolled rows glide off the edge instead of being guillotined at + // an invisible inset line 24 px above it (third on-glass verdict). + GamepadFormBackground(Modifier.fillMaxSize()) + Column( + Modifier + .fillMaxSize() + .windowInsetsPadding( + WindowInsets.systemBars.union(WindowInsets.displayCutout) + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + ), + ) { + // The strip is PINNED while the rows scroll under it: it is this screen's primary + // navigation, and a switcher you have to scroll back up to find isn't one. + Row( + Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ConsoleTabStrip( + titles = GpTab.entries.map { it.title }, + selected = GpTab.entries.indexOf(tab), + onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) }, + modifier = Modifier.weight(1f), + focused = tabFocused, + ) + // The base-layer marker, where a full "Default settings" heading used to eat a + // headline row on EVERY tab. The honesty it carried stays: this screen edits + // the defaults only — the console honours a host's profile but doesn't edit + // profiles (design §5.4) — and this quiet chip at the strip's end says so + // without a second heading repeating the tab pill's own word. + Text( + "Defaults", + style = MaterialTheme.typography.labelMedium, + color = ink.fg(0.45f), + maxLines = 1, + modifier = Modifier.padding(start = 10.dp, end = ConsoleEdgeInset), + ) + } + if (landscape) { + Row(Modifier.fillMaxSize()) { + tabbedContent(Modifier.weight(0.6f)) + // The focused row's description, in the width a wide phone wastes — beside + // the rows instead of floating over the list's tail (the portrait band). + // Presentation only: the row already merges this text into its own + // announcement, so the pane is hidden from a screen reader like the band. + val focusedRow = rows.getOrNull(focus) + AnimatedContent( + targetState = if (tabFocused) null else focusedRow, + transitionSpec = { + fadeIn(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) togetherWith + fadeOut(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) + }, + modifier = Modifier + .weight(0.4f) + .semantics { hideFromAccessibility() }, + label = "sideDetail", + ) { r -> + Column( + Modifier + .fillMaxHeight() + .padding(start = 6.dp, end = ConsoleEdgeInset, top = 22.dp), + ) { + if (r != null && r.detail.isNotBlank()) { + Text( + r.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = ink.fg(0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + r.detail, + style = MaterialTheme.typography.bodyMedium, + color = ink.fg(0.6f), + modifier = Modifier.padding(top = 6.dp), + ) + } + } + } + } + } else { + tabbedContent(Modifier.fillMaxSize()) + } } } @@ -425,13 +557,17 @@ fun GamepadSettingsScreen( .takeIf { padIsGamepad }, ) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ConsoleDetailBand( - // On the strip there is no row to describe, and the pills already name the - // sections — a stale row's description there would describe the wrong thing. - text = if (tabFocused) "" else focused?.detail.orEmpty(), - key = if (tabFocused) "__strip" else focused?.id, - hazeState = hazeState, - ) + // Portrait only: landscape's description lives in the side pane, where the wide + // aspect has room for it — a band AND a pane would say the same thing twice. + if (!landscape) { + ConsoleDetailBand( + // On the strip there is no row to describe, and the pills already name the + // sections — a stale row's description there would describe the wrong thing. + text = if (tabFocused) "" else focused?.detail.orEmpty(), + key = if (tabFocused) "__strip" else focused?.id, + hazeState = hazeState, + ) + } GamepadHintBar( if (tabFocused) listOf( GamepadHint('↔', PadGlyph.Arrow, "Section"), @@ -497,6 +633,8 @@ private fun SettingRowView( adjustDir: Int, stepToken: Int, refusalToken: Int, + /** The option drum's fixed stage — sized by the SCREEN (orientation decides how much a row can spare). */ + bandWidth: Dp, onClick: () -> Unit, ) { val ink = LocalGamepadInk.current @@ -598,24 +736,31 @@ private fun SettingRowView( // A toggle is a switch, not text — the sliding knob + tinting track IS the value. ConsoleSwitch(on = row.toggled, focused = focused) } else { - Text( - "‹ ", - color = ink.fg, + Icon( + Icons.Filled.ChevronLeft, + // Decoration: it says "this value steps", which the row's Switch/Button + // role already says. Left in the tree it is read out on every focused row. + contentDescription = null, + tint = ink.fg, modifier = Modifier - // Decoration: it says "this value steps", which the row's Switch/Button - // role already says. Left in the tree it is read out as punctuation on - // every single focused row. + .size(18.dp) .semantics { hideFromAccessibility() } .graphicsLayer { alpha = chevronAlpha } .offset { IntOffset(minOf(chevronKick.value, 0f).dp.roundToPx(), 0) }, ) - Box( - Modifier.widthIn(min = 96.dp), - contentAlignment = Alignment.CenterEnd, - ) { - // The value slides in the direction it was stepped, so cycling a choice - // reads as motion through a list rather than a text swap — but its slot - // does NOT resize with it (`snap`), which is what used to jiggle the row. + if (row.options != null && row.selectedIndex in row.options.indices) { + // The drum — see ConsoleOptionBand. Its width is FIXED by the row, so a + // step can never reflow the chevrons, and the tabular-figures concern + // dissolves with it: nothing about the row's layout depends on the label. + ConsoleOptionBand( + options = row.options, + selection = row.selectedIndex, + focused = focused, + width = bandWidth, + ) + } else { + // The flat rows (profile pin counts, the empty-catalog placeholder) keep + // the quiet slip: the changed string slides in following the motion. AnimatedContent( targetState = row.value, transitionSpec = { @@ -636,10 +781,7 @@ private fun SettingRowView( ) { value -> Text( value, - // Tabular figures: every digit the same width, so stepping a - // resolution or a bitrate cannot change the text's width. - style = MaterialTheme.typography.bodyMedium - .copy(fontFeatureSettings = "tnum"), + style = MaterialTheme.typography.bodyMedium, color = valueColor, textAlign = TextAlign.End, maxLines = 1, @@ -647,10 +789,12 @@ private fun SettingRowView( ) } } - Text( - " ›", - color = ink.fg, + Icon( + Icons.Filled.ChevronRight, + contentDescription = null, + tint = ink.fg, modifier = Modifier + .size(18.dp) .semantics { hideFromAccessibility() } .graphicsLayer { alpha = chevronAlpha } .offset { IntOffset(maxOf(chevronKick.value, 0f).dp.roundToPx(), 0) }, @@ -699,6 +843,8 @@ internal fun buildSettingsRows( val i = if (idx < 0) 0 else (idx + 1) % options.size options.getOrNull(i)?.let { write(it.first) } }, + options = options.map { it.second }, + selectedIndex = idx, ) } fun toggle( diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index e6497327..142e6c15 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -124,6 +124,16 @@ class ScreenshotTest { fun consoleSettingsLight() = shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") } + /** + * Landscape — the orientation the console actually runs in, and a DIFFERENT layout since the + * on-glass review: rows capped and left-aligned, the focused row's description in a side pane + * on the right instead of the floating band. + */ + @Test + @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") + fun consoleSettingsLandscape() = + shootRoot("console-settings-landscape") { ConsoleSettingsScene() } + // The console home, the screen the living backdrop is most of. The default sdk (36) draws the // real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two // renderings of the same palette can be compared rather than assumed equivalent.