diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index 43a66b76..4c759e7b 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -98,6 +99,13 @@ fun App(forceGamepadUi: Boolean = false) { } } + // The console backdrop's colour family, published once from the live settings rather than + // threaded through every screen that draws a backdrop. Because it is read from the SAME + // `settings` state the gamepad settings screen writes, stepping the Background row recolours + // the field behind that very row. + CompositionLocalProvider( + LocalGamepadPalette provides GamepadPalette.named(settings.uiPalette), + ) { AnimatedContent( targetState = session, transitionSpec = { @@ -201,8 +209,16 @@ fun App(forceGamepadUi: Boolean = false) { } } } + } } +/** + * The console backdrop's colour family for everything under [App] — provided from the live + * settings so a change on the gamepad settings screen recolours every backdrop at once. Defaults + * to the brand violet, which is also what a preview or a test composition gets. + */ +val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") } + /** Which console screen the gamepad shell is showing. */ private enum class GamepadScreen { Home, Settings, Library } 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 e7e4cefb..d33c084e 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 @@ -14,6 +14,8 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -23,6 +25,9 @@ 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.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -31,7 +36,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -86,32 +93,53 @@ private val auroraBlobs = listOf( AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue ) +/** The deep base the field sits on — and, scaled, the [calm] lift that flattens it. */ +private val auroraBase = Color(0xFF131126) + /** - * The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops, - * finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation - * of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role. + * The living console backdrop: soft brand-family blobs drifting over a deep base on slow, seamless + * loops, finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose + * approximation of the Apple client's MeshGradient aurora — same colour family, same "ambience, + * never content" role, and the same [GamepadPalette] setting recolours both. + * + * [calm] is what the FORM screens wear: the pools dim onto the base so the glass rows keep real + * colour and luminance without the launcher's contrast. Motion is identical either way on purpose — + * only the contrast differs, so moving between screens can't make the field jump. + * + * Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the + * same courtesy the Apple client pays Reduce Motion. */ @Composable -fun GamepadAuroraBackground(modifier: Modifier = Modifier) { +fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) { + val palette = LocalGamepadPalette.current + val animated = animationsEnabled() val transition = rememberInfiniteTransition(label = "aurora") // A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap // so the field never visibly jumps when the animation restarts. - val angle by transition.animateFloat( + val swept by transition.animateFloat( initialValue = 0f, targetValue = (2 * PI).toFloat(), animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart), label = "angle", ) + val angle = if (animated) swept else 0f + // Tinting is per-frame-cheap but not free, and the palette changes about once a year. + val blobs = remember(palette.id) { auroraBlobs.map { it to palette.tint(it.color) } } + val base = remember(palette.id) { palette.tint(auroraBase) } Canvas(modifier) { - drawRect(Color.Black) + drawRect(if (calm) base else Color.Black) val span = max(size.width, size.height) - for (b in auroraBlobs) { + for ((b, tinted) in blobs) { val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height val r = span * b.radiusFrac + // Calm scales each blob's contribution rather than dimming the whole canvas: the base + // stays put and only the pools come down to meet it, which is the same "lower the + // contrast, keep the colour" the desktop console's `calm` uniform does. + val alpha = if (calm) b.alpha * 0.62f else b.alpha drawCircle( brush = Brush.radialGradient( - colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent), + colors = listOf(tinted.copy(alpha = alpha), Color.Transparent), center = Offset(cx, cy), radius = r, ), @@ -120,10 +148,15 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) { blendMode = BlendMode.Plus, ) } - // Cinematic vignette: pool light centre, sink the corners. + // Cinematic vignette: pool light centre, sink the corners. Halved under calm: a launcher's + // cards sit in the pooled centre, but a form screen's rows run out toward the edges, where + // crushing to black just eats them. (Matches the Apple client and the desktop console.) drawRect( Brush.radialGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)), + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = if (calm) 0.22f else 0.44f), + ), center = Offset(size.width / 2, size.height / 2), radius = span * 0.92f, ), @@ -141,33 +174,96 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) { } /** - * The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet - * (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass - * rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground. + * `false` when the user has turned animations off system-wide (Developer options' animator duration + * scale, or the accessibility "Remove animations" switch, which sets the same global). Read once + * per composition — it needs a settings trip to the system, and it changes about never. + */ +@Composable +private fun animationsEnabled(): Boolean { + val context = LocalContext.current + return remember { + runCatching { + android.provider.Settings.Global.getFloat( + context.contentResolver, + android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) != 0f + }.getOrDefault(true) + } +} + +/** + * The backdrop for the console FORM screens (settings, add-host). It used to be a STILL deep-indigo + * base with two soft glows; it is now the launcher's own living field at `calm`, which keeps that + * colour and luminance under the glass rows, honours the palette setting on every screen rather + * than only the launcher, and leaves nothing in the console UI backed by a static image. Mirrors + * the Apple client's GamepadFormBackground, which made the same substitution. */ @Composable fun GamepadFormBackground(modifier: Modifier = Modifier) { - Canvas(modifier) { - val span = max(size.width, size.height) - drawRect(Color(0xFF131126)) - drawCircle( - brush = Brush.radialGradient( - colors = listOf(Color(0xE6635AAE), Color.Transparent), - center = Offset(size.width * 0.24f, size.height * 0.12f), - radius = span * 0.7f, - ), - center = Offset(size.width * 0.24f, size.height * 0.12f), - radius = span * 0.7f, - ) - drawCircle( - brush = Brush.radialGradient( - colors = listOf(Color(0xBF343E96), Color.Transparent), - center = Offset(size.width * 0.82f, size.height * 0.9f), - radius = span * 0.7f, - ), - center = Offset(size.width * 0.82f, size.height * 0.9f), - radius = span * 0.7f, - ) + GamepadAuroraBackground(modifier, calm = true) +} + +/** + * The horizontal section switcher above a console list. Purely presentational — the SCREEN owns + * which tab is selected and what the shoulders do. Scrollable so a narrow phone in landscape never + * has to squeeze the pills, and the selected one is always brought into view whether it was reached + * by shoulder button or tap. + */ +@Composable +fun ConsoleTabStrip( + titles: List, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + /** + * The strip itself holds the cursor (the caller moved focus UP out of its list). Draws a ring + * on the selected pill so it's clear left/right now walks sections rather than values — the + * route a D-pad remote, which has no shoulder buttons, needs. + */ + focused: Boolean = false, +) { + val listState = rememberLazyListState() + LaunchedEffect(selected) { + runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) } + } + LazyRow( + state = listState, + modifier = modifier, + contentPadding = PaddingValues(horizontal = ConsoleEdgeInset), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed(titles) { i, title -> + val active = i == selected + val background by animateColorAsState( + if (active) Color(0xD96656F2) else Color(0x14FFFFFF), + tween(180), + label = "tabBg", + ) + val ink by animateColorAsState( + Color.White.copy(alpha = if (active) 1f else 0.55f), + tween(180), + label = "tabInk", + ) + val ring by animateColorAsState( + Color.White.copy(alpha = if (active && focused) 0.85f else 0f), + tween(180), + label = "tabRing", + ) + Text( + title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = ink, + maxLines = 1, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(background) + .border(1.5.dp, ring, RoundedCornerShape(50)) + .clickable { onSelect(i) } + .padding(horizontal = 14.dp, vertical = 7.dp), + ) + } } } @@ -176,7 +272,7 @@ fun GamepadFormBackground(modifier: Modifier = Modifier) { * sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind * it cross-fades between screens. */ -val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp) +val ConsoleLegendInset = PaddingValues(start = 24.dp, end = 24.dp, bottom = 24.dp) /** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */ val ConsoleEdgeInset = 24.dp @@ -471,7 +567,12 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze Row( modifier = frosted .border(1.dp, Color.White.copy(alpha = 0.14f), shape) - .padding(horizontal = 16.dp, vertical = 10.dp), + .padding(horizontal = 16.dp, vertical = 10.dp) + // The pill still hugs its content when it fits; when it doesn't (a narrow phone, or a + // screen whose legend grew a cell) it scrolls rather than running off the edge and + // silently eating the last hint — which is exactly what the settings screen's new + // Section cell did on a 360 dp phone. + .horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(11.dp), ) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt index dd98af48..fe64b01f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt @@ -152,8 +152,9 @@ fun GamepadNavEffect( * keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant * stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick * returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X, - * [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels - * one layer": close the keyboard, then the screen). + * [onSecondary] is Y, and [onShoulder] is L1 (-1) / R1 (+1) — a step SIDEWAYS out of the list, which + * the settings screen uses for its section tabs. B is left to MainActivity's BACK remap → the + * screen's BackHandler (so B "peels one layer": close the keyboard, then the screen). */ @Composable fun GamepadNavEffect2D( @@ -162,6 +163,7 @@ fun GamepadNavEffect2D( onActivate: () -> Unit, onTertiary: () -> Unit = {}, onSecondary: () -> Unit = {}, + onShoulder: (Int) -> Unit = {}, ) { val activity = LocalContext.current as? MainActivity ?: return val state = remember { NavInputState() } @@ -169,6 +171,7 @@ fun GamepadNavEffect2D( val currentOnActivate by rememberUpdatedState(onActivate) val currentOnTertiary by rememberUpdatedState(onTertiary) val currentOnSecondary by rememberUpdatedState(onSecondary) + val currentOnShoulder by rememberUpdatedState(onShoulder) DisposableEffect(active) { // Stable probe refs so onDispose only releases the slot if WE still own it — during a @@ -196,7 +199,10 @@ fun GamepadNavEffect2D( KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true } KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true } KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true } - else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler) + // Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs. + KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) currentOnShoulder(-1); true } + KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) currentOnShoulder(1); true } + else -> false // B → MainActivity (remapped to BACK → BackHandler) } } if (active) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt new file mode 100644 index 00000000..3db75d01 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt @@ -0,0 +1,84 @@ +package io.unom.punktfunk + +import androidx.compose.ui.graphics.Color +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +// The console (gamepad) UI's background colour families. +// +// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale +// applied to the ONE field GamepadAuroraBackground already draws, so every palette inherits its +// structure (dark base, bright drifting pools) and the brand default is exactly the shipped look — +// `violet` is the identity transform. +// +// The table and the `tint` maths are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the +// Apple client's `GamepadPalette.swift` under the same ids, so the shared `ui_palette` setting +// names the same colour family on every client. Keep the three copies in step: a palette added +// here without the others is a value the other clients will silently render as Violet. + +/** + * One background colour family. [hueDegrees] rotates about the grey axis (positive runs + * red → green → blue) and [saturation] scales saturation about luminance. + */ +class GamepadPalette( + /** The stored `ui_palette` value ([Settings.uiPalette]). */ + val id: String, + /** What the settings row shows. */ + val name: String, + val hueDegrees: Double, + val saturation: Double, +) { + /** True for the identity transform, so the default path skips the per-colour work. */ + val isIdentity: Boolean get() = hueDegrees == 0.0 && saturation == 1.0 + + /** Apply this palette to one packed sRGB colour, keeping its alpha. */ + fun tint(c: Color): Color { + if (isIdentity) return c + val (r, g, b) = tint(Triple(c.red.toDouble(), c.green.toDouble(), c.blue.toDouble())) + return Color(r.toFloat(), g.toFloat(), b.toFloat(), c.alpha) + } + + /** + * Rotate `c` about the grey axis by [hueDegrees] (Rodrigues — the same rotation, in the same + * orientation, that the desktop console's shader uses for its ±8° warm/cool sway) and scale + * its saturation about luminance. Clamped, because a large rotation can push a channel out of + * gamut. + */ + fun tint(c: Triple): Triple { + val (r, g, b) = c + val a = Math.toRadians(hueDegrees) + val cs = cos(a) + val sn = sin(a) + val invSqrt3 = 1.0 / sqrt(3.0) + val grey = (r + g + b) / 3.0 * (1.0 - cs) + // The `sn` term is cross(k, c) with k = (1,1,1)/√3. + val rr = r * cs + (b - g) * invSqrt3 * sn + grey + val rg = g * cs + (r - b) * invSqrt3 * sn + grey + val rb = b * cs + (g - r) * invSqrt3 * sn + grey + val luma = 0.2126 * rr + 0.7152 * rg + 0.0722 * rb + fun mix(v: Double) = (luma + (v - luma) * saturation).coerceIn(0.0, 1.0) + return Triple(mix(rr), mix(rg), mix(rb)) + } + + companion object { + /** + * The six shipped palettes, in cycling order: the brand violet, then cool → warm, then + * the neutral. + */ + val ALL = listOf( + GamepadPalette("violet", "Violet", 0.0, 1.0), + GamepadPalette("tide", "Tide", -70.0, 1.0), + GamepadPalette("forest", "Forest", -130.0, 0.9), + GamepadPalette("ember", "Ember", 105.0, 1.0), + GamepadPalette("rose", "Rose", 60.0, 0.95), + GamepadPalette("graphite", "Graphite", 0.0, 0.12), + ) + + /** + * The palette stored under [id], falling back to the brand default — an unknown name is a + * palette a newer client shipped, not a reason to draw nothing. + */ + fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0] + } +} 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 b085163c..48911910 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 @@ -39,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -63,10 +64,35 @@ import io.unom.punktfunk.kit.security.KnownHostStore // The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView: // the couch-relevant subset of the touch settings restyled as a console page and fully navigable with // a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it, -// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings. +// L1/R1 change SECTION, B closes. Both write the same SharedPreferences, so values round-trip with +// the touch settings. +// +// The rows are split across SECTION TABS ([GpTab]) — a shoulder press on a pad, a tap on a phone. +// They used to be one long scroll with inline `Group · Subgroup` headers, which on a TV meant +// walking past Display and Audio to reach the controller settings. The tab names match the desktop +// console's and the Apple client's, so a setting is found under the same word wherever you look. + +/** + * The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names + * match `pf-console-ui`'s `TABS` and the Apple client's `GpSettingsTab`. + */ +enum class GpTab(val title: String) { + STREAM("Stream"), + VIDEO("Video"), + AUDIO("Audio"), + CONTROLLER("Controller"), + INTERFACE("Interface"), + PROFILES("Profiles"), +} internal class GpRow( val id: String, + val tab: GpTab, + /** + * A sub-heading above this row, for the few tabs that hold more than one group. Most rows have + * none: the tab pill already names the section, and repeating it would be a second label + * saying the same word. + */ val header: String?, val label: String, val value: String, @@ -133,10 +159,34 @@ fun GamepadSettingsScreen( // path there is this screen's own Controller-optimized UI toggle, which swaps in the standard // interface remote-navigably. The strings branch on it. val tv = remember { isTvDevice(context) } - val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + + val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + buildProfileRows(profiles, savedHosts, tv) { pinProfile = it } + // Which section is showing, and where each one's focus was when it was last left — a detour + // into another tab shouldn't lose your place. + var tab by remember { mutableStateOf(GpTab.STREAM) } + // True while the STRIP holds the cursor rather than the list. Up from the first row moves + // here and Down goes back — the only route to the sections on a D-pad remote, which has no + // shoulder buttons at all (and is exactly what a TV box ships with). + var tabFocused by remember { mutableStateOf(false) } + val tabFocus = remember { mutableStateMapOf() } + val rows = allRows.filter { it.tab == tab } var focus by remember { mutableIntStateOf(0) } - if (focus > rows.lastIndex) focus = rows.lastIndex + if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0) + + // L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle). + fun selectTab(next: GpTab) { + if (next == tab) return + tabFocus[tab] = focus + tab = next + // Clamp: a tab's length follows the hardware and the catalog, so a remembered index can + // outlive the row it pointed at. + focus = (tabFocus[next] ?: 0) + .coerceIn(0, (allRows.count { it.tab == next } - 1).coerceAtLeast(0)) + } + fun stepTab(delta: Int) { + val all = GpTab.entries + selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size]) + } // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the // value text slides in its AnimatedContent, so the motion matches the button press. var adjustDir by remember { mutableIntStateOf(1) } @@ -151,20 +201,28 @@ fun GamepadSettingsScreen( active = navActive && pinProfile == null, onDirection = { dir -> when (dir) { - NavDir.UP -> if (focus > 0) focus-- - NavDir.DOWN -> if (focus < rows.lastIndex) focus++ - // A disabled row is INERT, not just dim — the step is refused instead of writing a - // setting that has nothing to act on (see `liveRow`). - NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) } - NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) } + NavDir.UP -> if (focus > 0) focus-- else tabFocused = true + NavDir.DOWN -> if (tabFocused) tabFocused = false else if (focus < rows.lastIndex) focus++ + // On the strip, left/right walks sections; on a row it steps the value. A disabled + // row is INERT, not just dim — the step is refused instead of writing a setting + // that has nothing to act on (see `liveRow`). + NavDir.LEFT -> + if (tabFocused) stepTab(-1) else { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) } + NavDir.RIGHT -> + if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) } } }, - onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() }, + // A on the strip drops into the section you picked, which is what "confirm" means there. + onActivate = { + if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() } + }, + // 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. - LaunchedEffect(focus) { + LaunchedEffect(focus, tab) { runCatching { val itemIndex = focus + 1 val info = listState.layoutInfo @@ -183,9 +241,21 @@ fun GamepadSettingsScreen( // where a fixed title + a fixed detail/legend strip ate most of the (short) height. Box(Modifier.fillMaxSize().hazeSource(hazeState)) { GamepadFormBackground(Modifier.fillMaxSize()) + Column(Modifier.fillMaxSize().systemBarsPadding()) { + // 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().systemBarsPadding(), + modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { @@ -196,12 +266,19 @@ fun GamepadSettingsScreen( ConsoleHeader("Default settings", horizontalInset = false) } itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> - SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = { - // Same inertness as the pad path above — tapping a dimmed row focuses it (so - // its detail explains itself) but never flips it. - if (focus != index) focus = index - else if (row.enabled) { adjustDir = 1; row.activate() } - }) + SettingRowView( + row, + focused = index == focus && !tabFocused, + adjustDir = adjustDir, + onClick = { + // Same inertness as the pad path above — tapping a dimmed row focuses it + // (so its detail explains itself) but never flips it. + tabFocused = false + if (focus != index) focus = index + else if (row.enabled) { adjustDir = 1; row.activate() } + }, + ) + } } } } @@ -218,8 +295,23 @@ fun GamepadSettingsScreen( // a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet" // placeholder does nothing at all — advertising ↔/A on those would be a lie. val focused = rows.getOrNull(focus) + // The shoulders always change section, so that cell leads on every row. Tappable too, + // like the others — a user without a working pad can still reach every tab. + // Advertise the shoulders only where they EXIST: a TV remote has none (its route is Up + // into the strip) and a touch user taps a pill, so on those the cell would be both a + // lie and the reason a 360 dp legend runs out of room. Defaults to the pad case off an + // Activity (preview/tests), like GamepadHintBar's own glyph choice. + val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true + val sections = listOfNotNull( + GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) }) + .takeIf { padIsGamepad }, + ) GamepadHintBar( - when { + if (tabFocused) listOf( + GamepadHint('↔', Color(0xFF9A93C7), "Section"), + PadGlyph.hint('A', "Open") { tabFocused = false }, + PadGlyph.hint('B', "Done", onClick = onBack), + ) else sections + when { focused != null && !focused.enabled -> listOf( PadGlyph.hint('B', "Done", onClick = onBack), ) @@ -353,7 +445,8 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick /** Build the console settings rows from the current [Settings], writing through [update]. * [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the - * AV1 codec entry (see `codecOptionsFor`). */ + * AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one + * tab at a time. */ internal fun buildSettingsRows( s: Settings, hasBodyVibrator: Boolean, @@ -361,12 +454,12 @@ internal fun buildSettingsRows( update: (Settings) -> Unit, ): List { fun choice( - id: String, header: String?, label: String, detail: String, + id: String, tab: GpTab, header: String?, label: String, detail: String, options: List>, current: T, enabled: Boolean = true, write: (T) -> Unit, ): GpRow { val idx = options.indexOfFirst { it.first == current } return GpRow( - id, header, label, + id, tab, header, label, value = options.getOrNull(idx)?.second ?: "—", detail = detail, enabled = enabled, @@ -385,10 +478,10 @@ internal fun buildSettingsRows( ) } fun toggle( - id: String, header: String?, label: String, detail: String, + id: String, tab: GpTab, header: String?, label: String, detail: String, value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit, ): GpRow = GpRow( - id, header, label, + id, tab, header, label, value = if (value) "On" else "Off", detail = detail, enabled = enabled, @@ -397,36 +490,13 @@ internal fun buildSettingsRows( toggled = value, ) - // Grouped and ordered by the cross-client category map (General / Display / Audio / - // Controllers), with the same sub-section names the touch settings and the desktop clients use, - // so a setting sits in the same place whichever surface you found it on. The ROWS stay the - // couch-relevant subset: a pad can't drive a touch-input picker, and adding one for the sake of - // symmetry would be parity in name only. + // Grouped by the cross-client tab map (Stream / Video / Audio / Controller / Interface / + // Profiles), so a setting sits under the same word whichever client you found it on. The ROWS + // stay the couch-relevant subset: a pad can't drive a touch-input picker, and adding one for + // the sake of symmetry would be parity in name only. return listOf( choice( - "hud", "General · Statistics", "Statistics overlay", - "How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " + - "A 3-finger tap cycles the tiers live.", - STATS_VERBOSITY_OPTIONS, s.statsVerbosity, - ) { update(s.copy(statsVerbosity = it)) }, - toggle( - "autoWake", "General · Session", "Auto-wake on connect", - "Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.", - s.autoWakeEnabled, - ) { update(s.copy(autoWakeEnabled = it)) }, - toggle( - "library", "General · Library", "Game library", - "Browse a paired host's games with Y (experimental).", - s.libraryEnabled, - ) { update(s.copy(libraryEnabled = it)) }, - toggle( - "gamepadUI", "General · Interface", "Controller-optimized UI", - "Turn off to use the touch interface even with a controller connected.", - s.gamepadUiEnabled, - ) { update(s.copy(gamepadUiEnabled = it)) }, - - choice( - "resolution", "Display · Resolution", "Resolution", + "resolution", GpTab.STREAM, null, "Resolution", "The host creates a virtual display at exactly this size — no scaling. " + "Custom sizes are typed in the touch settings.", // A custom size (typed in the touch settings) leads the list so it stays visible and @@ -440,55 +510,56 @@ internal fun buildSettingsRows( s.width to s.height, ) { (w, h) -> update(s.copy(width = w, height = h)) }, choice( - "refresh", null, "Refresh rate", "Frame rate the host renders and streams at.", + "refresh", GpTab.STREAM, null, "Refresh rate", + "Frame rate the host renders and streams at.", REFRESH_OPTIONS, s.hz, ) { update(s.copy(hz = it)) }, - choice( - "bitrate", "Display · Quality", "Bitrate", + "bitrate", GpTab.STREAM, null, "Bitrate", "Automatic uses the host's default. A host's options (Up on its tile) can measure the " + "link and set an informed value.", BITRATE_OPTIONS, s.bitrateKbps, ) { update(s.copy(bitrateKbps = it)) }, choice( - "codec", null, "Video codec", - "A preference — the host falls back if it can't encode this one.", - codecOptionsFor(s.codec, av1Capable), s.codec, - ) { update(s.copy(codec = it)) }, - toggle( - "hdr", null, "10-bit HDR", - "HDR10 — engages when the host sends HDR content and this display supports it.", - s.hdrEnabled, - ) { update(s.copy(hdrEnabled = it)) }, - - toggle( - "lowLatency", "Display · Decoding", "Low-latency mode", - "The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.", - s.lowLatencyMode, - ) { update(s.copy(lowLatencyMode = it)) }, - - choice( - "compositor", "Display · Host output", "Compositor", + "compositor", GpTab.STREAM, "Host output", "Compositor", "Which compositor drives the virtual output — honored only if available on the host.", COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor, ) { update(s.copy(compositor = it)) }, choice( - "audio", "Audio", "Audio channels", "The speaker layout requested from the host.", + "codec", GpTab.VIDEO, null, "Video codec", + "A preference — the host falls back if it can't encode this one.", + codecOptionsFor(s.codec, av1Capable), s.codec, + ) { update(s.copy(codec = it)) }, + toggle( + "hdr", GpTab.VIDEO, null, "10-bit HDR", + "HDR10 — engages when the host sends HDR content and this display supports it.", + s.hdrEnabled, + ) { update(s.copy(hdrEnabled = it)) }, + toggle( + "lowLatency", GpTab.VIDEO, "Decoding", "Low-latency mode", + "The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.", + s.lowLatencyMode, + ) { update(s.copy(lowLatencyMode = it)) }, + + choice( + "audio", GpTab.AUDIO, null, "Audio channels", + "The speaker layout requested from the host.", AUDIO_CHANNEL_OPTIONS, s.audioChannels, ) { update(s.copy(audioChannels = it)) }, toggle( - "mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.", + "mic", GpTab.AUDIO, null, "Microphone", + "Send this device's microphone to the host's virtual mic.", s.micEnabled, ) { update(s.copy(micEnabled = it)) }, toggle( - "echoCancel", null, "Echo cancellation", + "echoCancel", GpTab.AUDIO, null, "Echo cancellation", "Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.", s.echoCancel, ) { update(s.copy(echoCancel = it)) }, toggle( - "padForward", "Controllers", "Forward controllers", + "padForward", GpTab.CONTROLLER, null, "Forward controllers", "Send this device's controllers to the host. Turn it off when your controller " + "already reaches the host another way — USB passthrough such as VirtualHere — " + "so games don't see two of them.", @@ -499,18 +570,18 @@ internal fun buildSettingsRows( // had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so // the pad rows kept stepping settings that had nothing to act on. choice( - "padType", null, "Controller type", + "padType", GpTab.CONTROLLER, null, "Controller type", "The virtual pad the host creates — Automatic matches this controller.", GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding, ) { update(s.copy(gamepad = it)) }, choice( - "systemButtons", null, "Guide button", + "systemButtons", GpTab.CONTROLLER, null, "Guide button", "Where the guide (Xbox/PS) and share presses go while streaming — Automatic " + "sends them to the host whenever this device delivers them.", SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding, ) { update(s.copy(systemButtons = it)) }, choice( - "guideGesture", null, "Hold Select for guide", + "guideGesture", GpTab.CONTROLLER, null, "Hold Select for guide", "Hold Select alone to press the host's guide button — keep holding for a " + "Gaming-Mode host's quick-access menu. A Select tap still goes through.", GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding, @@ -518,7 +589,7 @@ internal fun buildSettingsRows( ) + listOfNotNull( if (hasBodyVibrator) { toggle( - "phoneRumble", null, "Rumble on this phone", + "phoneRumble", GpTab.CONTROLLER, null, "Rumble on this phone", "Also play controller 1's rumble on this phone's own vibration motor — " + "for clip-on pads without rumble motors.", s.rumbleOnPhone, @@ -530,7 +601,7 @@ internal fun buildSettingsRows( // NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has // nothing to do with this device's motor, and a TV box is where it matters most. toggle( - "sc2", null, "Steam Controller 2 passthrough", + "sc2", GpTab.CONTROLLER, "Passthrough", "Steam Controller 2 passthrough", "Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " + "it as-is — Steam on the host drives it like the physical pad.", s.sc2Capture, enabled = s.gamepadForwarding, @@ -540,20 +611,53 @@ internal fun buildSettingsRows( // back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate // reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's. toggle( - "dsCapture", null, "DualSense / DualShock passthrough (USB)", + "dsCapture", GpTab.CONTROLLER, null, "DualSense / DualShock passthrough (USB)", "Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " + "triggers, lightbar and gyro.", s.dsCapture, enabled = s.gamepadForwarding, ) { update(s.copy(dsCapture = it)) }, + + // The palette leads Interface: it is the one row whose effect you can see while you step + // it (the backdrop behind this very list recolours), so it wants to be the first thing + // found in the section. + choice( + "palette", GpTab.INTERFACE, null, "Background", + "The colour family this backdrop drifts through — it changes as you step, so pick by " + + "looking. Appearance only.", + GamepadPalette.ALL.map { it.id to it.name }, + GamepadPalette.named(s.uiPalette).id, + ) { update(s.copy(uiPalette = it)) }, + choice( + "hud", GpTab.INTERFACE, null, "Statistics overlay", + "How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " + + "A 3-finger tap cycles the tiers live.", + STATS_VERBOSITY_OPTIONS, s.statsVerbosity, + ) { update(s.copy(statsVerbosity = it)) }, + toggle( + "autoWake", GpTab.INTERFACE, null, "Auto-wake on connect", + "Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.", + s.autoWakeEnabled, + ) { update(s.copy(autoWakeEnabled = it)) }, + toggle( + "library", GpTab.INTERFACE, null, "Game library", + "Browse a paired host's games with Y (experimental).", + s.libraryEnabled, + ) { update(s.copy(libraryEnabled = it)) }, + toggle( + "gamepadUI", GpTab.INTERFACE, null, "Controller-optimized UI", + "Turn off to use the touch interface even with a controller connected.", + s.gamepadUiEnabled, + ) { update(s.copy(gamepadUiEnabled = it)) }, ) } + /** * The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4): * one row per catalog profile, valued with how many saved hosts pin it, activating into the * pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard * interface, so an empty catalog shows one dimmed placeholder explaining where they come from - * instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points + * instead of a dead-looking empty tab. On a TV that phrasing changes: "touch interface" points * nowhere useful on a touchless device, so the strings name the actual route — the * Controller-optimized UI toggle a few rows up, which swaps the standard interface in * (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists). @@ -574,7 +678,8 @@ private fun buildProfileRows( return listOf( GpRow( id = "noProfiles", - header = "Profiles", + tab = GpTab.PROFILES, + header = null, label = "No profiles yet", value = "", detail = "Profiles bundle stream settings for different uses — pinned ones become " + @@ -586,12 +691,13 @@ private fun buildProfileRows( ), ) } - return profiles.mapIndexed { i, p -> + return profiles.map { p -> // Counted straight off the host records, so it agrees with what the carousel renders. val pins = savedHosts.count { p.id in it.pinnedProfileIds } GpRow( id = "profile:${p.id}", - header = if (i == 0) "Profiles" else null, + tab = GpTab.PROFILES, + header = null, label = p.name, value = when (pins) { 0 -> "Not pinned" diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 6ebca6f3..83bf4f61 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -105,6 +105,16 @@ data class Settings( * client's `libraryEnabled`. */ val libraryEnabled: Boolean = true, + /** + * Which colour family the console (gamepad) UI's living backdrop drifts through — the + * cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`, + * `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the + * desktop console's and the Apple client's under the same names. Presentation only: nothing + * about a stream depends on it, so it is a device preference and never part of a profile. + * An unknown value reads as the default rather than failing — a newer client may have shipped + * a palette this build doesn't know. + */ + val uiPalette: String = "violet", /** * "Low-latency mode" — the master switch over the latency pipeline: the async decode loop * (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking @@ -284,6 +294,7 @@ class SettingsStore(context: Context) { ?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER, gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true), libraryEnabled = prefs.getBoolean(K_LIBRARY, true), + uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet", lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true), presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency", smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0), @@ -323,6 +334,7 @@ class SettingsStore(context: Context) { .putString(K_TOUCH_MODE, s.touchMode.name) .putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled) .putBoolean(K_LIBRARY, s.libraryEnabled) + .putString(K_UI_PALETTE, s.uiPalette) .putBoolean(K_LOW_LATENCY, s.lowLatencyMode) .putString(K_PRESENT_PRIORITY, s.presentPriority) .putInt(K_SMOOTH_BUFFER, s.smoothBuffer) @@ -361,6 +373,7 @@ class SettingsStore(context: Context) { const val K_TOUCH_MODE = "touch_mode" const val K_GAMEPAD_UI = "gamepad_ui_enabled" const val K_LIBRARY = "library_enabled" + const val K_UI_PALETTE = "ui_palette" /** * Bumped AGAIN to restart every install at the new default (ON). History: the original diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt new file mode 100644 index 00000000..40b40b98 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt @@ -0,0 +1,132 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +// The console UI's background palettes. These assertions are the CONTRACT the Rust +// (`pf-console-ui::library::tint`) and Swift (`GamepadPalette.tint`) ports have to reproduce — the +// same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette` value +// names the same colour family on every client. +class GamepadPaletteTest { + /** The brightest pool of the field — the colour a palette is judged by. */ + private val violetPool = Triple(0.49, 0.39, 0.95) + + /** + * The brand default must be the IDENTITY transform. Every existing install already sees the + * shipped violet backdrop, and a palette table that quietly restyled it would be a regression + * dressed as a feature. + */ + @Test + fun violetIsTheUntouchedShippedField() { + val violet = GamepadPalette.named("violet") + assertEquals("violet", GamepadPalette.ALL.first().id) + assertTrue(violet.isIdentity) + assertEquals(violetPool, violet.tint(violetPool)) + // An unknown name is a newer client's palette, not an error. + assertEquals("violet", GamepadPalette.named("chartreuse").id) + assertEquals("violet", GamepadPalette.named("").id) + } + + /** The ids and their order are the cross-client contract (strip order, and the L1/R1 cycle). */ + @Test + fun tableMatchesTheOtherClients() { + assertEquals( + listOf("violet", "tide", "forest", "ember", "rose", "graphite"), + GamepadPalette.ALL.map { it.id }, + ) + assertEquals( + listOf("Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"), + GamepadPalette.ALL.map { it.name }, + ) + } + + /** + * A rotation moves the hue while roughly holding luminance, and the saturation scale collapses + * toward grey — the same four checks the Rust and Swift tests make. + */ + @Test + fun tintRotatesHueAndScalesSaturation() { + assertTrue(violetPool.third > violetPool.first && violetPool.third > violetPool.second) + + // +105° (Ember) turns the blue-dominant pool red-dominant… + val ember = GamepadPalette.named("ember").tint(violetPool) + assertTrue("$ember should be warm", ember.first > ember.third) + // …−130° (Forest) turns it green-dominant… + val forest = GamepadPalette.named("forest").tint(violetPool) + assertTrue("$forest", forest.second > forest.first && forest.second > forest.third) + // …and −70° (Tide) lands on a cyan whose green and blue both beat red. + val tide = GamepadPalette.named("tide").tint(violetPool) + assertTrue("$tide", tide.second > tide.first && tide.third > tide.first) + + // Graphite's saturation scale leaves the channels nearly equal… + val grey = GamepadPalette.named("graphite").tint(violetPool) + val channels = listOf(grey.first, grey.second, grey.third) + assertTrue("$grey", channels.max() - channels.min() < 0.08) + // …at about the source's luminance (it desaturates, it doesn't dim). + val luma = 0.2126 * violetPool.first + 0.7152 * violetPool.second + 0.0722 * violetPool.third + assertEquals(luma, grey.second, 0.05) + } + + /** + * Every palette stays in gamut on every colour the field is built from — an out-of-range + * channel would clamp differently on each platform's rasteriser. + */ + @Test + fun everyPaletteStaysInGamut() { + val field = listOf( + Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72), Triple(0.30, 0.26, 0.74), + Triple(0.42, 0.20, 0.54), Triple(0.49, 0.39, 0.95), Triple(0.28, 0.31, 0.84), + Triple(0.16, 0.26, 0.64), Triple(0.45, 0.23, 0.60), Triple(0.53, 0.31, 0.75), + Triple(0.35, 0.35, 0.91), Triple(0.19, 0.28, 0.70), Triple(0.22, 0.18, 0.54), + Triple(0.24, 0.20, 0.58), + ) + for (palette in GamepadPalette.ALL) { + for (c in field) { + val t = palette.tint(c) + for (v in listOf(t.first, t.second, t.third)) { + assertTrue("${palette.id} $c → $t", v in 0.0..1.0) + } + } + } + } + + /** + * Every settings row lands in exactly one tab — a row missing from the tab map is a setting + * that became unreachable on a TV, which is precisely what this screen exists to prevent. + */ + @Test + fun everySettingsRowHasATab() { + val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {} + assertTrue(rows.isNotEmpty()) + assertEquals(rows.size, rows.map { it.id }.toSet().size) + // Profiles is built separately (from the catalog), so no settings row claims it. + assertTrue(rows.none { it.tab == GpTab.PROFILES }) + for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) { + assertTrue("$t is empty", rows.any { it.tab == t }) + } + } + + /** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */ + @Test + fun backgroundRowStepsTheSharedKey() { + var s = Settings() + fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it } + fun palette() = rows().first { it.id == "palette" } + + assertEquals("violet", s.uiPalette) + assertEquals("Violet", palette().value) + assertTrue("already the first = thud", !palette().adjust(-1)) + assertTrue(palette().adjust(1)) + assertEquals(GamepadPalette.ALL[1].id, s.uiPalette) + + // A from the last entry wraps home. + s = s.copy(uiPalette = GamepadPalette.ALL.last().id) + palette().activate() + assertEquals("violet", s.uiPalette) + + // A store written by a newer client shows the palette that is actually drawing. + s = s.copy(uiPalette = "chartreuse") + assertEquals("Violet", palette().value) + } +} 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 3fe894ea..70dd0e57 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 @@ -106,6 +106,9 @@ class ScreenshotTest { @Test fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() } + @Test + fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() } + @Test fun trust() = shootScreen("trust") { HostsScene() diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index a7845699..43562f37 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -31,6 +31,7 @@ import io.unom.punktfunk.BrandDark import io.unom.punktfunk.ConnectModal import io.unom.punktfunk.ConnectPhase import io.unom.punktfunk.ConnectTakeover +import io.unom.punktfunk.GamepadSettingsScreen import io.unom.punktfunk.Settings import io.unom.punktfunk.TouchMode import io.unom.punktfunk.SettingsCategory @@ -406,3 +407,13 @@ internal fun WakeTimedOutScene() = @Composable internal fun ConnectConsoleScene() = ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {}) + +/** + * The real console settings screen — the section tab strip, the glass rows, the focused row's + * unfolded detail, and the living (calmed) backdrop behind them. The touch [SettingsScene] can't + * stand in for it: this is a different screen with different navigation, and the strip is the part + * a layout regression would eat first. + */ +@Composable +internal fun ConsoleSettingsScene() = + GamepadSettingsScreen(initial = SHOT_SETTINGS, onChange = {}, onBack = {}) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index be9c6940..80c5390e 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -122,12 +122,21 @@ struct GamepadHintBar: View { } } -/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black -/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an -/// animated `MeshGradient` — a continuous silk of colour whose control points wander on slow, -/// out-of-phase sinusoids — finished with an elliptical vignette (pools light in the centre, sinks -/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting -/// radial-blob field, unchanged, so nothing regresses. +/// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind +/// the cards, never as content. On iOS 18 / macOS 15+ it's an animated `MeshGradient` — a continuous +/// silk of colour whose control points wander on slow, out-of-phase sinusoids — finished with an +/// elliptical vignette (pools light in the centre, sinks the corners) and a top/bottom legibility +/// scrim. Older OSes fall back to the original drifting radial-blob field, unchanged, so nothing +/// regresses. +/// +/// `calm` is what the FORM screens (settings, add-host) wear: the same living field with its pools +/// dimmed onto its own corner colour, so those screens keep real colour under their Liquid Glass +/// rows without the launcher's contrast. They used to sit on a still gradient; nothing in the +/// gamepad UI is backed by a static image now. Motion is identical in both modes on purpose — only +/// the contrast differs, so a screen change can't make the field jump. +/// +/// `GamepadPalette` recolours the whole thing (the shared `ui_palette` setting) by transforming the +/// COLOURS, not by stacking a filter — see GamepadPalette.swift for why. /// /// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/ /// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably @@ -136,35 +145,52 @@ struct GamepadHintBar: View { /// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's /// header). Honors Reduce Motion by freezing the field at a fixed phase. struct GamepadScreenBackground: View { + /// Quiet the field for a form screen (see the type comment). + var calm = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" var body: some View { + let palette = GamepadPalette.named(paletteID) Group { if reduceMotion { - composite(at: 0) + composite(at: 0, palette: palette) } else { // 30 Hz is plenty for a field that drifts centimetres per minute, and halves the // redraw cost of a battery-fed couch device vs. the display's native rate. TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in - composite(at: context.date.timeIntervalSinceReferenceDate) + composite(at: context.date.timeIntervalSinceReferenceDate, palette: palette) } } } .ignoresSafeArea() } - /// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the - /// title/hints legibility scrim. - private func composite(at t: TimeInterval) -> some View { + /// The colour field under a very slow warm/cool hue sway, the calm flattening, an elliptical + /// vignette, and the title/hints legibility scrim — in that order, matching the console + /// shader's `composite` so the two platforms' backdrops stay the same picture. + private func composite(at t: TimeInterval, palette: GamepadPalette) -> some View { ZStack { Color.black - colorField(at: t) + colorField(at: t, palette: palette) // ±8° over ~5 min — the whole field very slowly warms and cools. .hueRotation(.degrees(sin(t * 0.021) * 8)) + // Calm = col·0.6 + corner·0.4: over black, `.opacity` IS the multiply… + .opacity(calm ? 0.6 : 1) + if calm { + // …and a plusLighter wash of the palette's own corner colour IS the add. Chosen so + // a corner lands exactly where it was and the bright pools come down to meet it. + Self.color(palette.tint(Self.cornerRGB)) + .opacity(0.4) + .blendMode(.plusLighter) + } // Cinematic vignette: darker toward the edges so the cards sit in the pooled light. // Soft (extends past the frame) so the corners deepen rather than crush to black. + // Halved under calm: a launcher's cards sit in the pooled centre, but a form screen's + // rows run out toward the edges, where crushing to black just eats them. EllipticalGradient( - colors: [.clear, .black.opacity(0.42)], + colors: [.clear, .black.opacity(calm ? 0.21 : 0.42)], center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15) // Legibility grounding for the pinned title (top) and hint pill (bottom). This one // darkens the aurora itself (it's the backdrop's bottom layer — nothing behind it to @@ -180,33 +206,45 @@ struct GamepadScreenBackground: View { } } - @ViewBuilder private func colorField(at t: TimeInterval) -> some View { + @ViewBuilder private func colorField(at t: TimeInterval, palette: GamepadPalette) -> some View { if #available(iOS 18, macOS 15, tvOS 18, *) { MeshGradient( width: 4, height: 4, points: Self.meshPoints(at: t), - colors: Self.meshColors, + colors: Self.meshColors(palette), smoothsColors: true) } else { - LegacyBlobField(t: t) + LegacyBlobField(t: t, palette: palette) } } // MARK: - MeshGradient aurora (iOS 18 / macOS 15+) + static func color(_ c: SIMD3) -> Color { + Color(red: c.x, green: c.y, blue: c.z) + } + + /// The corner colour — the four pinned corners AND the calm lift's base. + static let cornerRGB = SIMD3(0.075, 0.060, 0.160) + /// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry /// mid-tone violets, and the four interior points hold the bright brand family — a violet and a /// blue-violet up top, a magenta-violet and a violet below — so warm pools on the left, cool on - /// the right, and the silk shifts temperature as those interior points drift. - private static let meshColors: [Color] = { - let corner = Color(red: 0.075, green: 0.060, blue: 0.160) - return [ - corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner, - Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64), - Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70), - corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner, - ] - }() + /// the right, and the silk shifts temperature as those interior points drift. A palette rotates + /// the whole grid; `violet` is the identity, so this array IS what the default draws. + private static let baseMeshRGB: [SIMD3] = [ + cornerRGB, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), cornerRGB, + SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64), + SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), + cornerRGB, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), cornerRGB, + ] + + /// `baseMeshRGB` under a palette. Recomputed per frame rather than cached — sixteen `tint` + /// calls at 30 Hz costs nothing next to rasterising the mesh, and the obvious cache would be + /// mutable global state on a type SwiftUI is free to evaluate off the main actor. + private static func meshColors(_ palette: GamepadPalette) -> [Color] { + baseMeshRGB.map { color(palette.tint($0)) } + } /// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh /// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black @@ -233,15 +271,18 @@ struct GamepadScreenBackground: View { } /// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field — four -/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see -/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+). +/// soft colour blobs on slow Lissajous paths, additively blended. Geometry and motion are verbatim +/// so older OSes see exactly the aurora they shipped with (the mesh path is the upgrade for OS +/// 18/15+); only the blob COLOURS now pass through the palette, so an older device honours the +/// setting too instead of being stuck on violet. private struct LegacyBlobField: View { let t: TimeInterval + let palette: GamepadPalette /// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds /// (rad/s — periods of 30–90 s), and a radius that slowly breathes. private struct Blob { - let color: Color + let rgb: SIMD3 let center: CGPoint let drift: CGSize let speed: (x: Double, y: Double) @@ -252,19 +293,19 @@ private struct LegacyBlobField: View { } private static let blobs: [Blob] = [ - Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet + Blob(rgb: SIMD3(0.53, 0.47, 0.96), // brand violet center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10), speed: (0.111, 0.083), phase: (0.0, 1.9), radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52), - Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo + Blob(rgb: SIMD3(0.24, 0.20, 0.72), // deep indigo center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14), speed: (0.071, 0.096), phase: (2.4, 0.7), radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55), - Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum + Blob(rgb: SIMD3(0.62, 0.30, 0.80), // plum center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09), speed: (0.089, 0.067), phase: (4.1, 3.2), radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42), - Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue + Blob(rgb: SIMD3(0.22, 0.38, 0.86), // cool blue center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08), speed: (0.059, 0.104), phase: (1.2, 5.0), radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38), @@ -287,9 +328,10 @@ private struct LegacyBlobField: View { let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y)) let r = side * blob.radius * (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x))) + let color = GamepadScreenBackground.color(palette.tint(blob.rgb)) return Circle() .fill(RadialGradient( - colors: [blob.color, blob.color.opacity(0)], + colors: [color, color.opacity(0)], center: .center, startRadius: 0, endRadius: r / 2)) .frame(width: r, height: r) .position(x: x * size.width, y: y * size.height) @@ -330,27 +372,16 @@ struct GamepadTrayScrim: View { } } -/// The calm backdrop for the gamepad UI's form screens (settings, add-host) — NOT the launcher's -/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid -/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo -/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens, -/// so the rows read as glass while the screen stays restful. +/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair +/// of glows over a deep indigo base — deliberately not near-black, because Liquid Glass refracts +/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own +/// living field at `calm`, which keeps that luminance under the glass, keeps the palette setting +/// honoured on every screen rather than only the launcher, and leaves nothing in the gamepad UI +/// backed by a static image. Kept as its own type because that is what the form screens ask for by +/// name; the console (`pf-console-ui`) made the same substitution behind its `Bg::Form`. struct GamepadFormBackground: View { var body: some View { - ZStack { - Color(red: 0.075, green: 0.062, blue: 0.150) - // Violet lift top-leading, cooler indigo bottom-trailing — resolution-independent - // (fraction radii) so the glow scale tracks the window on any screen. - EllipticalGradient( - colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear], - center: UnitPoint(x: 0.26, y: 0.14), - startRadiusFraction: 0, endRadiusFraction: 0.78) - EllipticalGradient( - colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear], - center: UnitPoint(x: 0.82, y: 0.9), - startRadiusFraction: 0, endRadiusFraction: 0.78) - } - .ignoresSafeArea() + GamepadScreenBackground(calm: true) } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift index bf916fc4..e156eb40 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift @@ -35,6 +35,10 @@ struct GamepadMenuList: View where Item.ID: Hasha let onActivate: (Item) -> Void /// B → back/dismiss; nil disables it. var onBack: (() -> Void)? + /// L1 (`-1`) / R1 (`+1`) — a step SIDEWAYS out of the list: the settings screen's section + /// tabs. Wired on tvOS too, where the focus engine owns up/down but leaves the shoulders + /// to the poll. nil ⇒ the shoulders do nothing. + var onShoulder: ((Int) -> Void)? /// Whether this list currently owns controller input — same handoff contract as /// GamepadCarousel's `isActive` (a covered screen must stop polling the shared pad). var isActive: Bool = true @@ -159,6 +163,7 @@ struct GamepadMenuList: View where Item.ID: Hasha case .up, .down: break } } + input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) } #else input.onMove = { direction in switch direction { @@ -170,6 +175,7 @@ struct GamepadMenuList: View where Item.ID: Hasha } input.onConfirm = { activate() } input.onBack = onBack + input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) } #endif } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 09274f1f..355c72d7 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -11,7 +11,13 @@ // the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable // with one button. Toggles read left = off, right = on — refusing a no-op with the same thud. // -// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager +// The rows are split across SECTION TABS (`GpSettingsTab`) — L1/R1 on a pad, a tap elsewhere. They +// used to be one long scroll with inline group headers, which meant thumbing past Video and Audio +// to reach the controller settings; a tab is one shoulder press, and each tab remembers where its +// focus was. The tab names match the desktop console's and the Android client's, so a setting is +// found under the same word wherever you look for it. +// +// The trailing Profiles tab (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager // for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an // in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with // one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned. @@ -27,6 +33,17 @@ import GameController import CoreHaptics #endif +/// The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names +/// match `pf-console-ui`'s `TABS` and the Android client's `GpTab`. +enum GpSettingsTab: String, CaseIterable, Hashable { + case stream = "Stream" + case video = "Video" + case audio = "Audio" + case controller = "Controller" + case interface = "Interface" + case profiles = "Profiles" +} + struct GamepadSettingsView: View { @Environment(\.dismiss) private var dismiss /// The saved-host store — the pin picker writes `setPinned` through it and the profile rows @@ -55,6 +72,9 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true + /// The gamepad UI's background colour family — the backdrop BEHIND this screen re-colours as + /// the row steps, which is why the picker lives here and not in a sheet. + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" @AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true @AppStorage(DefaultsKey.presentPriority) private var presentPriority = SettingsOptions.presentPriorityDefault @@ -74,12 +94,23 @@ struct GamepadSettingsView: View { #if os(iOS) /// `.compact` in a landscape phone window — tighter chrome so more rows fit. @Environment(\.verticalSizeClass) private var vSizeClass + /// `.regular` only on an iPad-class window — see `showsSectionHint`. + @Environment(\.horizontalSizeClass) private var hSizeClass private var compact: Bool { vSizeClass == .compact } #else private let compact = false // no size classes on macOS; the sheet is sized generously #endif @State private var focusID: String? + /// The section showing. The pin picker ignores it — that layer replaces the whole list. + @State private var tab: GpSettingsTab = .stream + /// Where each tab's focus was when it was last left, so a detour doesn't lose your place. + @State private var tabFocus: [GpSettingsTab: String] = [:] + @Namespace private var tabHighlight + #if os(tvOS) + /// Real focus on the strip — the tvOS route to the sections (see `tabStrip`). + @FocusState private var focusedTab: GpSettingsTab? + #endif /// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per /// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows. @State private var pinTarget: StreamProfile? @@ -93,7 +124,8 @@ struct GamepadSettingsView: View { focusID: $focusID, onAdjust: { row, delta in adjust(id: row.id, by: delta) }, onActivate: { activate(id: $0.id) }, - onBack: { back() } + onBack: { back() }, + onShoulder: { step(tabBy: $0) } ) { row, focused in rowView(row, focused: focused) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth) @@ -101,14 +133,19 @@ struct GamepadSettingsView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - Text(title) - .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) - .padding(.top, gamepadTitleTopPadding(compact: compact)) - .padding(.bottom, compact ? 4 : 8) - .frame(maxWidth: .infinity) - .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } - .background { GamepadTrayScrim(edge: .top) } + VStack(spacing: compact ? 4 : 8) { + Text(title) + .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } + // The picker is one layer deeper — its rows aren't sections of anything, so the + // strip would be a control that does nothing while it's up. + if pinTarget == nil { tabStrip } + } + .padding(.top, gamepadTitleTopPadding(compact: compact)) + .padding(.bottom, compact ? 4 : 8) + .background { GamepadTrayScrim(edge: .top) } } .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 8) { @@ -127,8 +164,9 @@ struct GamepadSettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) .background { GamepadTrayScrim(edge: .bottom) } } - // No aurora here — the settings read as clean Liquid Glass over a quiet dark base, so the - // glass rows are the only material on the screen. + // The launcher's living field, calmed (GamepadFormBackground) — the glass rows keep real + // colour and luminance to lens without the launcher's contrast, and the palette setting + // applies here too, so this screen previews the row you're stepping. .background { GamepadFormBackground() } .onAppear { gamepads.refresh() @@ -137,6 +175,101 @@ struct GamepadSettingsView: View { .onDisappear { gamepads.stopDiscovery() } } + /// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to + /// squeeze six pills — the selected one is always scrolled into view, whether it was reached + /// by shoulder button, tap, or (tvOS) the focus engine. + private var tabStrip: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 6) { + ForEach(GpSettingsTab.allCases, id: \.self) { t in + #if os(tvOS) + // Focusable, because L1/R1 is NOT a route here: a Siri Remote has no + // extended gamepad profile, so it never reaches GamepadMenuList's poll. + // As focusable Buttons the pills are simply above the rows, and moving + // focus up onto one switches section — the standard tvOS tab bar. + Button { select(tab: t) } label: { pill(t) } + .buttonStyle(ConsoleBareButtonStyle()) + .focused($focusedTab, equals: t) + .id(t) + #else + pill(t) + .contentShape(Capsule()) + .onTapGesture { select(tab: t) } + .id(t) + #endif + } + } + .padding(.horizontal, 24) + } + .scrollIndicators(.never) + .animation(.smooth(duration: 0.22), value: tab) + .onChange(of: tab) { _, t in + withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(t) } + } + #if os(tvOS) + .onChange(of: focusedTab) { _, t in + // Focus IS selection on a tab bar; nil means focus dropped back into the rows. + if let t { select(tab: t) } + } + #endif + } + } + + private func pill(_ t: GpSettingsTab) -> some View { + let selected = t == tab + return Text(t.rawValue) + .font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote)) + .foregroundStyle(selected ? .white : .white.opacity(0.55)) + .padding(.horizontal, 13) + .padding(.vertical, 7) + .background { + // One shared capsule that MOVES between pills, rather than one per pill fading + // in and out — the highlight travels the way the press did. + if selected { + Capsule() + .fill(Color.brand.opacity(0.85)) + .matchedGeometryEffect(id: "tab", in: tabHighlight) + } + } + } + + /// Whether the legend advertises the shoulder shortcut. Held back on an iPhone, whose legend + /// is already at its width and would push "Done" off the edge — the strip is visible and + /// tappable there anyway. Never on tvOS: a Siri Remote has no shoulders, and its route to the + /// sections is the focus engine (see `tabStrip`). + private var showsSectionHint: Bool { + #if os(tvOS) + false + #elseif os(iOS) + hSizeClass == .regular + #else + true + #endif + } + + /// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle). + private func step(tabBy delta: Int) { + guard pinTarget == nil else { return } + let all = GpSettingsTab.allCases + guard let i = all.firstIndex(of: tab) else { return } + let n = all.count + select(tab: all[((i + delta) % n + n) % n]) + } + + private func select(tab next: GpSettingsTab) { + guard next != tab else { return } + tabFocus[tab] = focusID + // Restore where this tab was, if that row is still in it (a row can come and go with the + // hardware it depends on); otherwise the focus list seeds its first row. Resolved against + // `allRows` rather than `rows` so it doesn't depend on `tab`'s write being visible yet. + let landing = tabFocus[next].flatMap { id in + allRows.contains { $0.tab == next && $0.id == id } ? id : nil + } + tab = next + focusID = landing + } + /// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc /// rides the cancel action. private var closeButton: some View { @@ -166,12 +299,19 @@ struct GamepadSettingsView: View { /// layer" rule), and a hostless picker has nothing to pin, so only Back remains. private var hints: [GamepadHint] { guard pinTarget != nil else { + // The shoulders change section, so that cell leads — where it fits and where the + // shoulders exist at all (see `showsSectionHint`). + let sections: [GamepadHint] = showsSectionHint + ? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"), + text: "Section")] + : [] // A dimmed row takes neither, so offering them would be the same lie the row itself // used to tell — only Done remains, and the detail line says what to turn on first. guard rows.first(where: { $0.id == focusID })?.enabled ?? true else { - return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")] + return sections + + [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")] } - return [ + return sections + [ .init(glyph: "arrow.left.and.right", text: "Adjust"), .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"), .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"), @@ -201,15 +341,9 @@ struct GamepadSettingsView: View { private func rowView(_ row: Row, focused: Bool) -> some View { let m = GamepadFormMetrics.self + // No section header: the tab strip names the section now, and repeating it above the + // first row of every tab was just a second label saying the same word. return VStack(alignment: .leading, spacing: 6) { - if let header = row.header { - Text(header) - .font(.geist(m.headerFont, .semibold, relativeTo: .caption)) - .tracking(1.4) - .foregroundStyle(.white.opacity(0.45)) - .padding(.leading, m.rowHPad) - .padding(.top, 14) - } HStack(spacing: 14) { Image(systemName: row.icon) .font(.system(size: m.iconFont)) @@ -276,8 +410,9 @@ struct GamepadSettingsView: View { private struct Row: Identifiable { let id: String - /// Section header drawn above this row (the first row of each group carries it). - var header: String? + /// Which section tab this row belongs to. Every row has exactly one, and `rows` shows + /// only the current tab's — see `allRows`. + var tab: GpSettingsTab = .stream let icon: String let label: String let value: String @@ -313,10 +448,17 @@ struct GamepadSettingsView: View { row.activate() } + /// What the focus list actually shows: the current tab's rows — or the pin picker's, which + /// replaces the whole list while it's up (same screen, one layer deeper, so the focus list's + /// controller wiring and the tvOS focus engine carry over as is). private var rows: [Row] { - // The pin picker replaces the whole list while it's up — same screen, one layer deeper, - // so the focus list's controller wiring (and the tvOS focus engine) carries over as is. if let profile = pinTarget { return pinRows(for: profile) } + return allRows.filter { $0.tab == tab } + } + + /// Every row on the screen, tagged with its section. Built as one list (not per tab) so the + /// platform-conditional insertions below can still place a row RELATIVE to another by id. + private var allRows: [Row] { let resolution = resolutionOptions let refresh = SettingsOptions.refreshRates(including: hz) .map { (label: "\($0) Hz", tag: $0) } @@ -324,7 +466,7 @@ struct GamepadSettingsView: View { let controllers = SettingsOptions.controllerOptions(gamepads) var list: [Row] = [ choiceRow( - id: "resolution", header: "Stream", icon: "aspectratio", + id: "resolution", tab: .stream, icon: "aspectratio", label: "Resolution", detail: "The host creates a virtual display at exactly this size — no scaling.", options: resolution, current: "\(width)x\(height)" @@ -335,53 +477,48 @@ struct GamepadSettingsView: View { height = parts[1] }, choiceRow( - id: "refresh", icon: "gauge.with.needle", label: "Refresh rate", + id: "refresh", tab: .stream, icon: "gauge.with.needle", label: "Refresh rate", detail: "Rates this display can actually show.", options: refresh, current: hz ) { hz = $0 }, choiceRow( - id: "bitrate", icon: "speedometer", label: "Bitrate", + id: "bitrate", tab: .stream, icon: "speedometer", label: "Bitrate", detail: "Automatic uses the host's default (20 Mbps). " + "Run a speed test from the touch UI for an informed value.", options: bitrate, current: bitrateKbps ) { bitrateKbps = $0 }, choiceRow( - id: "compositor", icon: "macwindow", label: "Compositor", + id: "compositor", tab: .stream, icon: "macwindow", label: "Compositor", detail: "Which compositor drives the virtual output — honored only if " + "available on the host.", options: SettingsOptions.compositors, current: compositor ) { compositor = $0 }, - toggleRow( - id: "autoWake", icon: "power", label: "Auto-wake on connect", - detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before " - + "streaming. Off connects straight through.", - value: $autoWakeEnabled), - choiceRow( - id: "codec", header: "Video", icon: "film", label: "Video codec", + id: "codec", tab: .video, icon: "film", label: "Video codec", detail: "A preference — the host falls back if it can't encode this one " + "(10-bit and 4:4:4 are HEVC-only).", options: SettingsOptions.codecs, current: codec ) { codec = $0 }, toggleRow( - id: "hdr", icon: "sun.max", label: "10-bit HDR", + id: "hdr", tab: .video, icon: "sun.max", label: "10-bit HDR", detail: "HDR10 — engages when the host sends HDR content and this display " + "supports it.", value: $hdrEnabled), toggleRow( - id: "chroma", icon: "textformat", label: "Full chroma (4:4:4)", + id: "chroma", tab: .video, icon: "textformat", label: "Full chroma (4:4:4)", detail: "Sharper text and UI at more bandwidth — needs host opt-in and " + "hardware decode.", value: $enable444), choiceRow( - id: "presentPriority", icon: "rectangle.stack", label: "Prioritize", + id: "presentPriority", tab: .video, icon: "rectangle.stack", label: "Prioritize", detail: "Lowest latency shows each frame the moment the display can take it; " + "Smoothness buffers a few frames to even out network hiccups. Applies " + "from the next session.", options: SettingsOptions.presentPriorities, current: presentPriority ) { presentPriority = $0 }, choiceRow( - id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer", + id: "smoothBuffer", tab: .video, icon: "square.stack.3d.up", + label: "Smoothness buffer", detail: "How many frames Smoothness holds — each adds about a refresh of " + "display latency and absorbs about a refresh of jitter. Only applies " + "when prioritizing smoothness.", @@ -389,22 +526,22 @@ struct GamepadSettingsView: View { ) { smoothBuffer = $0 }, choiceRow( - id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels", + id: "audio", tab: .audio, icon: "speaker.wave.2", label: "Audio channels", detail: "The speaker layout requested from the host.", options: SettingsOptions.audioChannels, current: audioChannels ) { audioChannels = $0 }, toggleRow( - id: "mic", icon: "mic", label: "Microphone", + id: "mic", tab: .audio, icon: "mic", label: "Microphone", detail: "Send this device's microphone to the host's virtual mic.", value: $micEnabled), toggleRow( - id: "echoCancel", icon: "waveform", label: "Echo cancellation", + id: "echoCancel", tab: .audio, icon: "waveform", label: "Echo cancellation", detail: "Cancel the audio this device plays out of the mic signal — stops " + "speaker setups feeding the game back to the host.", value: $echoCancel), toggleRow( - id: "padForward", header: "Controller", icon: "gamecontroller", + id: "padForward", tab: .controller, icon: "gamecontroller", label: "Forward controllers", detail: "Send this device's controllers to the host. Turn it off when your " + "controller already reaches the host another way — USB passthrough such " @@ -415,26 +552,28 @@ struct GamepadSettingsView: View { // `.disabled(!effective.gamepadForwarding)`. This screen could not express it until // `Row.enabled` existed, so it alone left them live and steppable. choiceRow( - id: "pad", icon: "gamecontroller", label: "Use controller", + id: "pad", tab: .controller, icon: "gamecontroller", label: "Use controller", detail: "Which pad is forwarded to the host, as player 1.", options: controllers, current: gamepads.preferredID, enabled: gamepadForwarding ) { gamepads.preferredID = $0 }, choiceRow( - id: "padType", icon: "dpad", label: "Controller type", + id: "padType", tab: .controller, icon: "dpad", label: "Controller type", detail: "The virtual pad the host creates — Automatic matches this controller.", options: SettingsOptions.padTypes, current: gamepadType, enabled: gamepadForwarding ) { gamepadType = $0 }, choiceRow( - id: "systemButtons", icon: "house.circle", label: "Guide button", + id: "systemButtons", tab: .controller, icon: "house.circle", + label: "Guide button", detail: "Where the guide (Xbox/PS) and share presses go while streaming — " + "Automatic sends them to the host whenever this device delivers them.", options: SettingsOptions.systemButtons, current: systemButtons, enabled: gamepadForwarding ) { systemButtons = $0 }, choiceRow( - id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide", + id: "guideGesture", tab: .controller, icon: "hand.point.up.left", + label: "Hold Select for guide", detail: "Hold Select alone to press the host's guide button — keep holding " + "for a Gaming-Mode host's quick-access menu. A tap still goes through.", options: SettingsOptions.guideGestures, current: guideGesture, @@ -442,33 +581,47 @@ struct GamepadSettingsView: View { ) { guideGesture = $0 }, choiceRow( - id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay", + id: "palette", tab: .interface, icon: "paintpalette", label: "Background", + detail: "The colour family this backdrop drifts through — it changes as you " + + "step, so pick by looking. Appearance only.", + options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) }, + current: GamepadPalette.named(paletteID).id + ) { paletteID = $0 }, + toggleRow( + id: "autoWake", tab: .interface, icon: "power", label: "Auto-wake on connect", + detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before " + + "streaming. Off connects straight through.", + value: $autoWakeEnabled), + choiceRow( + id: "hud", tab: .interface, icon: "chart.bar", label: "Statistics overlay", detail: "How much to show while streaming — Compact is a one-line pill, " + "Detailed adds the latency stage breakdown.", options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw ) { statsVerbosityRaw = $0 }, choiceRow( - id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position", + id: "hudPlacement", tab: .interface, icon: "rectangle.inset.topright.filled", + label: "Overlay position", detail: "Which corner the statistics overlay sits in.", options: SettingsOptions.hudPlacements, current: hudPlacement ) { hudPlacement = $0 }, toggleRow( - id: "library", icon: "square.grid.2x2", label: "Game library", + id: "library", tab: .interface, icon: "square.grid.2x2", label: "Game library", detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).", value: $libraryEnabled), toggleRow( - id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI", + id: "gamepadUI", tab: .interface, icon: "hand.tap", + label: "Controller-optimized UI", detail: "Turn off to use the touch interface even with a controller connected.", value: $gamepadUIEnabled), ] #if os(macOS) // The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside - // the Video group) — macOS only, mirroring the touch SettingsView's Presentation row + // the Video tab) — macOS only, mirroring the touch SettingsView's Presentation row // (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent). if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) { list.insert( toggleRow( - id: "windowedSafePresent", icon: "macwindow.badge.plus", + id: "windowedSafePresent", tab: .video, icon: "macwindow.badge.plus", label: "Safe windowed presentation", detail: "Windowed streams present in step with the compositor — avoids a " + "macOS display-driver crash on high-refresh displays, at a small " @@ -478,14 +631,14 @@ struct GamepadSettingsView: View { } #endif #if os(iOS) - // The device-rumble mirror slots in after "Controller type" (staying inside the - // Controller group — the next row carries the "Interface" header). iPhone only in - // practice: hidden where the device itself can't play haptics (iPad). + // The device-rumble mirror slots in after "Controller type", inside the Controller tab. + // iPhone only in practice: hidden where the device itself can't play haptics (iPad). if CHHapticEngine.capabilitiesForHardware().supportsHaptics, let at = list.firstIndex(where: { $0.id == "padType" }) { list.insert( toggleRow( - id: "deviceRumble", icon: "iphone.radiowaves.left.and.right", + id: "deviceRumble", tab: .controller, + icon: "iphone.radiowaves.left.and.right", label: "Rumble on this iPhone", detail: "Also play player 1's rumble on the phone's own Taptic Engine — " + "for clip-on pads without rumble motors.", @@ -505,17 +658,17 @@ struct GamepadSettingsView: View { private var profileRows: [Row] { guard !profiles.profiles.isEmpty else { return [Row( - id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3", + id: "noProfiles", tab: .profiles, icon: "slider.horizontal.3", label: "No profiles yet", value: "", detail: emptyCatalogDetail, adjustable: false, adjust: { _ in false }, activate: {})] } - return profiles.profiles.enumerated().map { i, profile in + return profiles.profiles.map { profile in let pins = store.hosts .filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count return Row( - id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil, + id: "profile-\(profile.id)", tab: .profiles, icon: "slider.horizontal.3", label: profile.name, value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")", detail: profileDetail, @@ -537,7 +690,8 @@ struct GamepadSettingsView: View { private func pinRows(for profile: StreamProfile) -> [Row] { guard !store.hosts.isEmpty else { return [Row( - id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet", + id: "noHosts", tab: .profiles, icon: "desktopcomputer", + label: "No saved hosts yet", value: "", detail: "Pair with a host first, then pin this profile to it.", adjustable: false, @@ -547,7 +701,7 @@ struct GamepadSettingsView: View { let hostID = host.id let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id) return Row( - id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer", + id: "pinHost-\(hostID.uuidString)", tab: .profiles, icon: "desktopcomputer", label: host.displayName, value: pinned ? "Pinned" : "Off", detail: "A pinned profile appears as its own card on the host — one press " @@ -609,13 +763,13 @@ struct GamepadSettingsView: View { // MARK: - Row builders private func choiceRow( - id: String, header: String? = nil, icon: String, label: String, detail: String, + id: String, tab: GpSettingsTab, icon: String, label: String, detail: String, options: [(label: String, tag: T)], current: T, enabled: Bool = true, write: @escaping (T) -> Void ) -> Row { let index = options.firstIndex { $0.tag == current } return Row( - id: id, header: header, icon: icon, label: label, + id: id, tab: tab, icon: icon, label: label, value: index.map { options[$0].label } ?? "—", detail: detail, enabled: enabled, @@ -638,11 +792,11 @@ struct GamepadSettingsView: View { } private func toggleRow( - id: String, header: String? = nil, icon: String, label: String, detail: String, + id: String, tab: GpSettingsTab, icon: String, label: String, detail: String, value: Binding, enabled: Bool = true ) -> Row { Row( - id: id, header: header, icon: icon, label: label, + id: id, tab: tab, icon: icon, label: label, value: value.wrappedValue ? "On" : "Off", detail: detail, enabled: enabled, diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 56f97896..79bb74f6 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -179,6 +179,14 @@ public enum DefaultsKey { /// layout (the console launcher, gamepad-navigable settings, a coverflow-style library) /// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`. public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled" + /// Which colour family the gamepad UI's living backdrop drifts through — a + /// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/ + /// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the + /// Android client carry the same table under the same names. Presentation only, so it is + /// a device preference and never part of a stream profile. An unknown value reads as the + /// default rather than failing — a newer client may have shipped a palette this build + /// doesn't know. + public static let uiPalette = "punktfunk.uiPalette" /// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this /// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where /// the phone body is the only actuator in the player's hands. Off by default (opt-in); read diff --git a/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift b/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift new file mode 100644 index 00000000..c5c1b976 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift @@ -0,0 +1,79 @@ +// The gamepad UI's background colour families. +// +// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale +// applied to the ONE field GamepadScreenBackground already draws, so every palette inherits its +// structure (dark corners, bright interior pools, warm-left/cool-right) and the brand default is +// exactly the shipped look — `violet` is the identity transform. +// +// The table and the `tint` math are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the +// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so the shared `ui_palette` +// setting names the same colour family on every client. Keep the three copies in step: a palette +// added here without the others is a value the other clients will silently render as Violet. +// +// It lives in PunktfunkShared rather than next to the views because that is the target the tests +// can reach — the arithmetic below is the part that has to agree across three languages. + +import Foundation +import simd + +public struct GamepadPalette: Identifiable, Equatable, Sendable { + /// The stored `ui_palette` value (`DefaultsKey.uiPalette`). + public let id: String + /// What the settings row shows. + public let name: String + /// Hue rotation about the grey axis, degrees — positive runs red → green → blue. + public let hueDegrees: Double + /// Saturation scale about luminance; 1 keeps the source saturation. + public let saturation: Double + + /// The six shipped palettes, in cycling order: the brand violet, then cool → warm, then the + /// neutral. + public static let all: [GamepadPalette] = [ + GamepadPalette(id: "violet", name: "Violet", hueDegrees: 0, saturation: 1.0), + GamepadPalette(id: "tide", name: "Tide", hueDegrees: -70, saturation: 1.0), + GamepadPalette(id: "forest", name: "Forest", hueDegrees: -130, saturation: 0.9), + GamepadPalette(id: "ember", name: "Ember", hueDegrees: 105, saturation: 1.0), + GamepadPalette(id: "rose", name: "Rose", hueDegrees: 60, saturation: 0.95), + GamepadPalette(id: "graphite", name: "Graphite", hueDegrees: 0, saturation: 0.12), + ] + + /// The palette stored under `id`, falling back to the brand default — an unknown name is a + /// palette a newer client shipped, not a reason to draw nothing. + public static func named(_ id: String) -> GamepadPalette { + all.first { $0.id == id } ?? all[0] + } + + /// `true` for the identity transform, so the default path can skip the per-colour work. + public var isIdentity: Bool { hueDegrees == 0 && saturation == 1 } + + /// Apply this palette to one RGB triple. + public func tint(_ c: SIMD3) -> SIMD3 { + guard !isIdentity else { return c } + return GamepadPalette.tint(c, hueDegrees: hueDegrees, saturation: saturation) + } + + /// Rotate `c` about the grey axis by `hueDegrees` (Rodrigues — the same rotation the field's + /// own ±8° warm/cool sway uses, in the same orientation) and scale its saturation about + /// luminance. Clamped, because a large rotation can push a channel out of gamut. + /// + /// Deliberately computed here rather than left to SwiftUI's `.hueRotation`: that modifier's + /// exact behaviour is the framework's, and the Rust and Kotlin clients have no equivalent — + /// doing the arithmetic on the COLOURS keeps the three implementations identical. + public static func tint( + _ c: SIMD3, hueDegrees: Double, saturation: Double + ) -> SIMD3 { + let a = hueDegrees * .pi / 180 + let cs = cos(a) + let sn = sin(a) + let invSqrt3 = 1 / 3.0.squareRoot() + let grey = (c.x + c.y + c.z) / 3 * (1 - cs) + // The `sn` term is cross(k, c) with k = (1,1,1)/√3. + let rot = SIMD3( + c.x * cs + (c.z - c.y) * invSqrt3 * sn + grey, + c.y * cs + (c.x - c.z) * invSqrt3 * sn + grey, + c.z * cs + (c.y - c.x) * invSqrt3 * sn + grey) + let luma = 0.2126 * rot.x + 0.7152 * rot.y + 0.0722 * rot.z + func mix(_ v: Double) -> Double { min(max(luma + (v - luma) * saturation, 0), 1) } + return SIMD3(mix(rot.x), mix(rot.y), mix(rot.z)) + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift new file mode 100644 index 00000000..8b2ab4f7 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift @@ -0,0 +1,81 @@ +// The gamepad UI's background palettes. These assertions are the CONTRACT the Rust +// (`pf-console-ui::library::tint`) and Kotlin (`GamepadPalette.tint`) ports have to reproduce — +// the same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette` +// value names the same colour family on every client. + +import XCTest +import simd +@testable import PunktfunkShared + +final class GamepadPaletteTests: XCTestCase { + /// The brightest interior pool of the mesh field — the colour a palette is judged by. + private let violetPool = SIMD3(0.49, 0.39, 0.95) + + /// The brand default must be the IDENTITY transform. Every existing install already sees the + /// shipped violet backdrop, and a palette table that quietly restyled it would be a + /// regression dressed as a feature. + func testVioletIsTheUntouchedShippedField() { + let violet = GamepadPalette.named("violet") + XCTAssertEqual(GamepadPalette.all.first?.id, "violet") + XCTAssertTrue(violet.isIdentity) + XCTAssertEqual(violet.tint(violetPool), violetPool) + // An unknown name is a newer client's palette, not an error. + XCTAssertEqual(GamepadPalette.named("chartreuse").id, "violet") + XCTAssertEqual(GamepadPalette.named("").id, "violet") + } + + /// The ids and their order are the cross-client contract (the strip order, and the order + /// L1/R1 and A cycle through). + func testTableMatchesTheOtherClients() { + XCTAssertEqual( + GamepadPalette.all.map(\.id), + ["violet", "tide", "forest", "ember", "rose", "graphite"]) + XCTAssertEqual( + GamepadPalette.all.map(\.name), + ["Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"]) + } + + /// A rotation moves the hue while roughly holding luminance, and the saturation scale + /// collapses toward grey — the same four checks the Rust test makes. + func testTintRotatesHueAndScalesSaturation() { + XCTAssertTrue(violetPool.z > violetPool.x && violetPool.z > violetPool.y, "blue-dominant") + + // +105° (Ember) turns the blue-dominant pool red-dominant… + let ember = GamepadPalette.named("ember").tint(violetPool) + XCTAssertGreaterThan(ember.x, ember.z, "\(ember) should be warm") + // …−130° (Forest) turns it green-dominant… + let forest = GamepadPalette.named("forest").tint(violetPool) + XCTAssertTrue(forest.y > forest.x && forest.y > forest.z, "\(forest)") + // …and −70° (Tide) lands on a cyan whose green and blue both beat red. + let tide = GamepadPalette.named("tide").tint(violetPool) + XCTAssertTrue(tide.y > tide.x && tide.z > tide.x, "\(tide)") + + // Graphite's saturation scale leaves the channels nearly equal… + let grey = GamepadPalette.named("graphite").tint(violetPool) + let spread = max(grey.x, grey.y, grey.z) - min(grey.x, grey.y, grey.z) + XCTAssertLessThan(spread, 0.08, "\(grey)") + // …at about the source's luminance (it desaturates, it doesn't dim). + let luma = 0.2126 * violetPool.x + 0.7152 * violetPool.y + 0.0722 * violetPool.z + XCTAssertEqual(grey.y, luma, accuracy: 0.05) + } + + /// Every palette stays in gamut on every colour the field is built from — an out-of-range + /// channel would clamp differently on each platform's rasteriser. + func testEveryPaletteStaysInGamut() { + let field: [SIMD3] = [ + SIMD3(0.075, 0.060, 0.160), SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), + SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), + SIMD3(0.16, 0.26, 0.64), SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), + SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), SIMD3(0.22, 0.18, 0.54), + SIMD3(0.24, 0.20, 0.58), + ] + for palette in GamepadPalette.all { + for c in field { + let t = palette.tint(c) + for v in [t.x, t.y, t.z] { + XCTAssertTrue((0...1).contains(v), "\(palette.id) \(c) → \(t)") + } + } + } + } +} diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 3b10a192..1aeaecd2 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -1111,6 +1111,15 @@ pub struct Settings { /// Experimental: the game-library browser ("Browse library…" on saved cards) — /// mirrors the Apple client's "Show game library" toggle, default off. pub library_enabled: bool, + /// Which colour family the gamepad UI's living backdrop drifts through — the shared + /// `ui_palette` key (`"violet"` = the brand default, then `tide`/`forest`/`ember`/ + /// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android + /// clients' twins). Presentation only: nothing about a stream depends on it, which is + /// why it is a device preference and never part of a settings profile. An unknown + /// name reads as the default rather than erroring — a newer client may have shipped a + /// palette this binary doesn't know. + #[serde(default = "default_ui_palette")] + pub ui_palette: String, /// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the /// Apple client's "Auto-wake on connect"). Default ON — that was the unconditional /// behavior before this became a setting. Off is for hosts reached over a VPN, where @@ -1195,6 +1204,10 @@ fn default_true() -> bool { true } +fn default_ui_palette() -> String { + "violet".into() +} + fn default_pad_speaker() -> String { "pad".into() } @@ -1303,6 +1316,7 @@ impl Default for Settings { stats_verbosity: None, fullscreen_on_stream: true, library_enabled: false, + ui_palette: default_ui_palette(), auto_wake: true, invert_scroll: false, speaker_device: String::new(), diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index d74fec31..cd6abe37 100644 --- a/crates/pf-console-ui/src/library.rs +++ b/crates/pf-console-ui/src/library.rs @@ -203,17 +203,118 @@ pub const MESH_INTERIOR: [(f64, f64, f64, f64, f64, f64); 4] = [ (0.667, 0.667, 0.12, 0.047, 0.061, 5.0), ]; -/// The mesh gradient as SkSL, palette + motion baked into the source (only time and -/// resolution are uniforms). A smooth bicubic blend of the 16 colours — a separable +// --- Background palettes ------------------------------------------------------------------- + +/// One background colour family for the console's living backdrop. A palette is NOT a second +/// hand-tuned 16-colour grid: it is a hue rotation + saturation scale applied to +/// [`MESH_COLORS`], so every palette inherits the field's structure (dark corners, bright +/// interior pools, warm-left/cool-right) and the brand default is exactly the shipped look — +/// `violet` is the identity transform. The Apple and Android clients carry the same table and +/// the same [`tint`] math, so a palette reads as the same colour family on every client. +pub struct Palette { + /// The stored `ui_palette` value (see `trust::Settings::ui_palette`). + pub id: &'static str, + /// What the settings row shows. + pub name: &'static str, + /// Hue rotation about the grey axis, degrees — positive runs red → green → blue. + pub hue_deg: f64, + /// Saturation scale about luminance; `1.0` keeps the source saturation. + pub sat: f64, +} + +/// The six shipped palettes, in cycling order (the brand violet first, then cool → warm, +/// then the neutral). Adding one here adds it to every console settings screen; the Apple +/// and Android tables must gain the same entry to keep the `ui_palette` key portable. +pub const PALETTES: [Palette; 6] = [ + Palette { + id: "violet", + name: "Violet", + hue_deg: 0.0, + sat: 1.0, + }, + Palette { + id: "tide", + name: "Tide", + hue_deg: -70.0, + sat: 1.0, + }, + Palette { + id: "forest", + name: "Forest", + hue_deg: -130.0, + sat: 0.9, + }, + Palette { + id: "ember", + name: "Ember", + hue_deg: 105.0, + sat: 1.0, + }, + Palette { + id: "rose", + name: "Rose", + hue_deg: 60.0, + sat: 0.95, + }, + Palette { + id: "graphite", + name: "Graphite", + hue_deg: 0.0, + sat: 0.12, + }, +]; + +/// The palette stored under `id`, falling back to the brand default — an unknown name is a +/// palette a newer client shipped, not a reason to draw nothing. +pub fn palette(id: &str) -> &'static Palette { + PALETTES.iter().find(|p| p.id == id).unwrap_or(&PALETTES[0]) +} + +/// Rotate `(r, g, b)` about the grey axis by `deg` (Rodrigues — the same rotation the shader +/// already uses for the ±8° warm/cool sway) and scale its saturation about luminance. Clamped, +/// because a large rotation can push a channel out of gamut. Ported verbatim to Swift and +/// Kotlin: keep the three copies in step or the palettes drift apart between clients. +pub fn tint(c: (f64, f64, f64), deg: f64, sat: f64) -> (f64, f64, f64) { + let (r, g, b) = c; + let a = deg.to_radians(); + let (sn, cs) = a.sin_cos(); + let inv_sqrt3 = 1.0 / 3.0f64.sqrt(); + let grey = (r + g + b) / 3.0 * (1.0 - cs); + // The `sn` term is `cross(k, c)` with k = (1,1,1)/√3 — the SAME orientation the shader's + // own `hue()` uses, so a palette rotation and the ±8° sway agree on which way is warmer. + let rot = ( + r * cs + (b - g) * inv_sqrt3 * sn + grey, + g * cs + (r - b) * inv_sqrt3 * sn + grey, + b * cs + (g - r) * inv_sqrt3 * sn + grey, + ); + let luma = 0.2126 * rot.0 + 0.7152 * rot.1 + 0.0722 * rot.2; + let mix = |v: f64| (luma + (v - luma) * sat).clamp(0.0, 1.0); + (mix(rot.0), mix(rot.1), mix(rot.2)) +} + +impl Palette { + /// [`MESH_COLORS`] under this palette's transform. + pub fn mesh_colors(&self) -> [(f64, f64, f64); 16] { + core::array::from_fn(|i| tint(MESH_COLORS[i], self.hue_deg, self.sat)) + } +} + +/// The mesh gradient as SkSL, palette + motion baked into the source (resolution, time and +/// the calm mix are uniforms). A smooth bicubic blend of the 16 colours — a separable /// cubic-Bézier basis in x then y, C∞ and edge-to-edge, the fragment-shader analogue of /// SwiftUI's `MeshGradient(smoothsColors: true)`. The four interior points drive a /// bounded (weighted-average) domain warp so the bright pools drift; then the whole field /// gets the ±8°/~5-min hue sway, an elliptical vignette, and the vertical legibility scrim, /// all matching the Swift `composite(at:)`. Runs on the GPU at full rate. -pub fn mesh_sksl() -> String { +/// +/// `u_tc.y` is the CALM mix, 0 → 1: at 1 the same living field is flattened toward its own +/// corner colour (`u_lift`), which is how the form screens (settings, add-host, pair) stay +/// restful while still drifting — the motion never changes speed, only the contrast, so the +/// crossfade between a launcher screen and a form screen can't make the field jump. +pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> String { // Colours as `float3(r, g, b)` literals, indices 0..15 (row-major 4×4). let c = |i: usize| { - let (r, g, b) = MESH_COLORS[i]; + let (r, g, b) = colors[i]; format!("float3({r}, {g}, {b})") }; // The four interior-point domain-warp accumulators. Displacement matches Swift `wob()`: @@ -224,14 +325,18 @@ pub fn mesh_sksl() -> String { warp.push_str(&format!( " q = uv - float2({bx}, {by});\n\ ww = exp(-dot(q, q) / (2.0 * 0.30 * 0.30));\n\ - d = float2({amp} * sin(u_t * {sx} + {ph}), \ - {amp} * cos(u_t * {sy} + {ph} * 1.3));\n\ + d = float2({amp} * sin(tt * {sx} + {ph}), \ + {amp} * cos(tt * {sy} + {ph} * 1.3));\n\ wsum += d * ww; wtot += ww;\n", )); } format!( "uniform float2 u_res;\n\ - uniform float u_t;\n\ + // x = seconds since the shell started, y = the calm mix (0 launcher, 1 form).\n\ + uniform float2 u_tc;\n\ + // rgb = the palette's corner colour scaled for the calm lift; a is unused (float4\n\ + // so the uniform block stays 16-byte aligned under any packing rule).\n\ + uniform float4 u_lift;\n\ \n\ // Cubic-Bézier basis over four control values — the smooth 4-point blend per axis.\n\ float bz(float t, float a, float b, float c, float d) {{\n\ @@ -250,6 +355,7 @@ pub fn mesh_sksl() -> String { }}\n\ \n\ half4 main(float2 xy) {{\n\ + \x20 float tt = u_tc.x; float calm = u_tc.y;\n\ \x20 float2 uv = xy / u_res;\n\ \x20 // Interior control points wander → bounded domain warp (pools follow them).\n\ \x20 float2 wsum = float2(0.0); float wtot = 0.0; float2 q; float ww; float2 d;\n\ @@ -263,11 +369,18 @@ pub fn mesh_sksl() -> String { \x20 float3 r3 = bz3(uv.x, {c12}, {c13}, {c14}, {c15});\n\ \x20 float3 col = bz3(uv.y, r0, r1, r2, r3);\n\ \n\ - \x20 col = hue(col, sin(u_t * 0.021) * 0.1396263);\n\ + \x20 col = hue(col, sin(tt * 0.021) * 0.1396263);\n\ + \n\ + \x20 // Calm: flatten the field toward its own corner colour — the pools dim and the\n\ + \x20 // corners lift, so a form screen keeps real colour under its glass rows while\n\ + \x20 // losing the launcher's contrast. Motion is untouched (see the doc comment).\n\ + \x20 col = mix(col, col * 0.60 + u_lift.rgb, calm);\n\ \n\ \x20 // Elliptical vignette: clear at r=0.25 → black·0.42 at r=1.15 (aspect-fit ellipse).\n\ + \x20 // Halved under calm: a launcher's cards sit in the pooled centre, but a form\n\ + \x20 // screen's rows run out toward the edges, where crushing to black just eats them.\n\ \x20 float2 e = (xy / u_res - 0.5) * 2.0;\n\ - \x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * 0.42;\n\ + \x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * mix(0.42, 0.21, calm);\n\ \x20 col *= 1.0 - vig;\n\ \n\ \x20 // Vertical legibility scrim: black 0.38/0.06/0.08/0.40 at 0/0.32/0.68/1.\n\ @@ -482,10 +595,56 @@ mod tests { /// 16 colours baked in, the five bicubic evals and four interior warp terms present). #[test] fn mesh_sksl_shape() { - let src = mesh_sksl(); + let src = mesh_sksl(&MESH_COLORS); assert!(src.matches("float3(").count() >= 16, "16 colours baked"); assert_eq!(src.matches("bz3(").count(), 6); // 1 definition + 5 call sites assert_eq!(src.matches("wtot +=").count(), 4); // one per interior point assert_eq!(src.matches('{').count(), src.matches('}').count()); } + + /// The brand default must be the IDENTITY transform — the shipped violet backdrop is + /// what every existing install already sees, and a palette table that quietly restyled + /// it would be a regression dressed as a feature. + #[test] + fn violet_is_the_untouched_shipped_field() { + assert_eq!(PALETTES[0].id, "violet"); + for (a, b) in palette("violet").mesh_colors().iter().zip(&MESH_COLORS) { + assert!((a.0 - b.0).abs() < 1e-9, "{a:?} vs {b:?}"); + assert!((a.1 - b.1).abs() < 1e-9, "{a:?} vs {b:?}"); + assert!((a.2 - b.2).abs() < 1e-9, "{a:?} vs {b:?}"); + } + // An unknown name is a newer client's palette, not an error. + assert_eq!(palette("chartreuse").id, "violet"); + assert_eq!(palette("").id, "violet"); + } + + /// The transform's two knobs do what they claim: a rotation moves the hue while holding + /// roughly the same luminance, and the saturation scale collapses toward grey. These are + /// the numbers the Swift and Kotlin ports have to reproduce. + #[test] + fn tint_rotates_hue_and_scales_saturation() { + let violet = MESH_COLORS[5]; // the brightest interior pool: blue dominates + assert!(violet.2 > violet.0 && violet.2 > violet.1); + // +105° (Ember) turns the blue-dominant pool red-dominant. + let ember = tint(violet, 105.0, 1.0); + assert!(ember.0 > ember.2, "{ember:?} should be warm"); + // −130° (Forest) turns it green-dominant. + let forest = tint(violet, -130.0, 1.0); + assert!(forest.1 > forest.0 && forest.1 > forest.2, "{forest:?}"); + // Graphite's saturation scale leaves the three channels nearly equal… + let grey = tint(violet, 0.0, 0.12); + let spread = grey.0.max(grey.1).max(grey.2) - grey.0.min(grey.1).min(grey.2); + assert!(spread < 0.08, "{grey:?} spread {spread}"); + // …at about the source's luminance (it desaturates, it doesn't dim). + let luma = 0.2126 * violet.0 + 0.7152 * violet.1 + 0.0722 * violet.2; + assert!((grey.1 - luma).abs() < 0.05, "{grey:?} vs luma {luma}"); + // Every palette stays in gamut on every mesh colour. + for p in &PALETTES { + for c in p.mesh_colors() { + for v in [c.0, c.1, c.2] { + assert!((0.0..=1.0).contains(&v), "{} {c:?}", p.id); + } + } + } + } } diff --git a/crates/pf-console-ui/src/screens.rs b/crates/pf-console-ui/src/screens.rs index a7e2730e..9a839ffe 100644 --- a/crates/pf-console-ui/src/screens.rs +++ b/crates/pf-console-ui/src/screens.rs @@ -21,9 +21,10 @@ use skia_safe::{Canvas, Rect}; /// What a screen draws over (the shell crossfades between them on push/pop). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Bg { - /// The living mesh aurora (home, library). + /// The living mesh aurora at full contrast (home, library). Aurora, - /// The quiet indigo form backdrop (settings, add-host, pair). + /// The SAME living mesh, calmed — dimmed pools, lifted corners (settings, add-host, + /// pair). Not a second backdrop: the shell chases one `calm` uniform between the two. Form, } diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 52f53702..4fe1d83b 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -2,13 +2,17 @@ //! restyled as glass rows and fully controller-navigable (the Swift //! `GamepadSettingsView`, re-homed): up/down moves focus, left/right steps the focused //! value (clamped — the boundary thud tells the thumb it's the last option), A cycles -//! forward wrapping, B closes. Every change persists immediately; the desktop shells -//! read the same file, so values round-trip freely. +//! forward wrapping, L1/R1 change SECTION, B closes. Every change persists immediately; +//! the desktop shells read the same file, so values round-trip freely. +//! +//! The rows are split across tabs (see [`TABS`]). They used to be one 30-row scroll with +//! inline headers, which on a Deck meant thumbing past Video and Audio to reach the pad +//! settings; a tab is one shoulder press, and each tab remembers where its cursor was. use crate::glyphs::{Hint, HintKey}; use crate::screens::{Ctx, Outbox, Screen}; use crate::theme::{Fonts, DIM, W}; -use crate::widgets::{ListMsg, MenuList, RowSpec}; +use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode}; use skia_safe::{Canvas, Rect}; @@ -51,6 +55,10 @@ enum RowId { Fullscreen, AutoWake, Library, + /// The gamepad UI's background colour family — see [`crate::library::PALETTES`]. The + /// backdrop behind this very row re-colours as it steps, which is the whole reason the + /// picker lives on a screen rather than in a dialog. + Palette, } // The couch-relevant subset grew 2026-07-31: this screen is the ONLY settings editor in @@ -58,39 +66,77 @@ enum RowId { // scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo // cancellation all were). Still deliberately smaller than the desktop dialogs — device // pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the -// trailing Profiles section) but created and edited only in the desktop app (design §5.4). -const ROWS: [RowId; 29] = [ - RowId::Resolution, - RowId::Refresh, - RowId::RenderScale, - RowId::Bitrate, - RowId::Compositor, - RowId::Codec, - RowId::Decoder, - RowId::Hdr, - RowId::Chroma444, - RowId::PresentPriority, - RowId::SmoothBuffer, - RowId::Vsync, - RowId::AllowVrr, - RowId::Audio, - RowId::Mic, - RowId::EchoCancel, - RowId::PadForward, - RowId::Pad, - RowId::PadType, - RowId::SystemButtons, - RowId::GuideGesture, - RowId::Touch, - RowId::Mouse, - RowId::InvertScroll, - RowId::Shortcuts, - RowId::Stats, - RowId::Fullscreen, - RowId::AutoWake, - RowId::Library, +// trailing Profiles tab) but created and edited only in the desktop app (design §5.4). +// +// The tab names are shared with the Apple and Android gamepad settings, so a setting is +// found under the same word on every client. Profiles is the trailing tab and is built +// from the catalog at render time, which is why it carries no rows here. +const TABS: [(&str, &[RowId]); 7] = [ + ( + "Stream", + &[ + RowId::Resolution, + RowId::Refresh, + RowId::RenderScale, + RowId::Bitrate, + RowId::Compositor, + ], + ), + ( + "Video", + &[ + RowId::Codec, + RowId::Decoder, + RowId::Hdr, + RowId::Chroma444, + RowId::PresentPriority, + RowId::SmoothBuffer, + RowId::Vsync, + RowId::AllowVrr, + ], + ), + ("Audio", &[RowId::Audio, RowId::Mic, RowId::EchoCancel]), + ( + "Controller", + &[ + RowId::PadForward, + RowId::Pad, + RowId::PadType, + RowId::SystemButtons, + RowId::GuideGesture, + ], + ), + ( + "Input", + &[ + RowId::Touch, + RowId::Mouse, + RowId::InvertScroll, + RowId::Shortcuts, + ], + ), + ( + "Interface", + &[ + RowId::Palette, + RowId::Stats, + RowId::Fullscreen, + RowId::AutoWake, + RowId::Library, + ], + ), + ("Profiles", &[]), ]; +/// The index of the trailing Profiles tab (built from the catalog, not from [`TABS`]). +const PROFILES_TAB: usize = TABS.len() - 1; + +/// How many sections the strip shows — for the shell's raster test, which walks all of them. +/// `cfg(test)` because nothing in a shipping build needs the count: a plain `cargo build` would +/// otherwise warn it dead, and this crate's lanes treat warnings as errors. +#[cfg(test)] +pub(crate) const TAB_COUNT: usize = TABS.len(); + const RESOLUTIONS: [(u32, u32); 6] = [ (0, 0), // native (1280, 720), @@ -169,6 +215,12 @@ const GUIDE_GESTURE: [(&str, &str); 3] = [("auto", "Automatic"), ("on", "On"), ( pub(crate) struct SettingsScreen { list: MenuList, + strip: TabStrip, + /// Which of [`TABS`] is showing. + tab: usize, + /// Where each tab's cursor was when it was last left. Coming back to Controller after a + /// detour through Video should land where you were, not at the top. + tab_cursors: [usize; TABS.len()], /// The profile catalog's `(id, name)` pairs, loaded once at construction — the console /// can't create profiles (design §5.4: the desktop app does), so the list is stable /// for the screen's lifetime. @@ -189,20 +241,37 @@ impl SettingsScreen { fn with_profiles(profiles: Vec<(String, String)>) -> SettingsScreen { SettingsScreen { list: MenuList::new(), + strip: TabStrip::new(), + tab: 0, + tab_cursors: [0; TABS.len()], profiles, } } - /// The full row list: the fixed settings rows, then the Profiles section — one row - /// per catalog profile, or the explainer placeholder while there are none. + /// The rows of the CURRENT tab. Profiles is built from the catalog: one row per + /// profile, or the explainer placeholder while there are none. fn row_ids(&self) -> Vec { - let mut ids = ROWS.to_vec(); - if self.profiles.is_empty() { - ids.push(RowId::NoProfiles); - } else { - ids.extend((0..self.profiles.len()).map(RowId::Profile)); + if self.tab != PROFILES_TAB { + return TABS[self.tab].1.to_vec(); } - ids + if self.profiles.is_empty() { + vec![RowId::NoProfiles] + } else { + (0..self.profiles.len()).map(RowId::Profile).collect() + } + } + + /// L1/R1 — move one tab, wrapping (the strip is a ring, like A's value cycle), keeping + /// each tab's own cursor. + fn switch_tab(&mut self, delta: i32) -> Option { + self.tab_cursors[self.tab] = self.list.cursor; + let n = TABS.len() as i32; + self.tab = (self.tab as i32 + delta).rem_euclid(n) as usize; + // Clamp the remembered cursor: the Profiles tab's length follows the catalog. + let len = self.row_ids().len(); + self.list + .jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1))); + Some(MenuPulse::Move) } pub(crate) fn menu( @@ -211,9 +280,14 @@ impl SettingsScreen { ctx: &mut Ctx, fx: &mut Outbox, ) -> Option { - if ev == MenuEvent::Back { - fx.pop(); - return None; + match ev { + MenuEvent::Back => { + fx.pop(); + return None; + } + MenuEvent::JumpBack => return self.switch_tab(-1), + MenuEvent::JumpForward => return self.switch_tab(1), + _ => {} } let ids = self.row_ids(); let (msg, pulse) = self.list.menu(ev, ids.len()); @@ -271,18 +345,22 @@ impl SettingsScreen { } pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec { - match self.row_ids()[self.list.cursor] { - RowId::Profile(_) => vec![ + let ids = self.row_ids(); + // The shoulders always change section, so that hint leads on every row. + let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")]; + hints.extend(match ids.get(self.list.cursor) { + Some(RowId::Profile(_)) => vec![ Hint::new(HintKey::Confirm, "Pin to hosts…"), Hint::new(HintKey::Back, "Done"), ], - RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")], - _ => vec![ + Some(RowId::NoProfiles) | None => vec![Hint::new(HintKey::Back, "Done")], + Some(_) => vec![ Hint::new(HintKey::Adjust, "Adjust"), Hint::new(HintKey::Confirm, "Change"), Hint::new(HintKey::Back, "Done"), ], - } + }); + hints } pub(crate) fn render( @@ -294,11 +372,23 @@ impl SettingsScreen { fonts: &Fonts, ctx: &mut Ctx, ) { - // The focused row's explainer sits in a reserved band under the list. + // The tab strip takes the top band, the focused row's explainer a reserved band + // under the list; the rows get what's between. let detail_h = 34.0 * k; + let strip_h = TAB_STRIP_H * k; + let labels: Vec<&str> = TABS.iter().map(|(name, _)| *name).collect(); + self.strip.render( + canvas, + Rect::from_ltrb(rect.left, rect.top, rect.right, rect.top + strip_h as f32), + &labels, + self.tab, + fonts, + k, + dt, + ); let list_rect = Rect::from_ltrb( rect.left, - rect.top, + rect.top + strip_h as f32, rect.right, rect.bottom - detail_h as f32, ); @@ -309,7 +399,7 @@ impl SettingsScreen { .collect(); self.list .render(canvas, list_rect, &rows, fonts, k, dt, true); - let detail = detail(ids[self.list.cursor]); + let detail = ids.get(self.list.cursor).copied().map_or("", detail); fonts.centered( canvas, detail, @@ -335,7 +425,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { .filter(|h| h.pin.as_ref().is_some_and(|p| &p.id == pid)) .count(); return RowSpec { - header: (i == 0).then_some("Profiles"), + header: None, label: name.clone(), value: Some(match pins { 0 => "Not pinned".into(), @@ -349,9 +439,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { }; } RowId::NoProfiles => { - let mut row = RowSpec::action("No profiles yet", false); - row.header = Some("Profiles"); - return row; + return RowSpec::action("No profiles yet", false); } _ => {} } @@ -372,7 +460,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { }; let (header, label, value): (Option<&'static str>, &str, String) = match id { RowId::Resolution => ( - Some("Stream"), + None, "Resolution", if s.match_window { "Match window".into() @@ -416,11 +504,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { "Compositor", label_for(&COMPOSITORS, &s.compositor).into(), ), - RowId::Codec => ( - Some("Video"), - "Video codec", - label_for(&CODECS, &s.codec).into(), - ), + RowId::Codec => (None, "Video codec", label_for(&CODECS, &s.codec).into()), RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()), RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()), RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()), @@ -441,7 +525,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { RowId::Vsync => (None, "V-Sync", on_off(s.vsync).into()), RowId::AllowVrr => (None, "Follow variable refresh", on_off(s.allow_vrr).into()), RowId::Audio => ( - Some("Audio"), + None, "Audio channels", AUDIO .iter() @@ -452,7 +536,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()), RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()), RowId::PadForward => ( - Some("Controller"), + None, "Forward controllers", on_off(s.gamepad_forwarding).into(), ), @@ -483,11 +567,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { "Hold Select for guide", label_for(&GUIDE_GESTURE, &s.guide_gesture).into(), ), - RowId::Touch => ( - Some("Touchscreen"), - "Touch mode", - s.touch_mode().label().into(), - ), + RowId::Touch => (None, "Touch mode", s.touch_mode().label().into()), RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()), RowId::InvertScroll => (None, "Invert scroll", on_off(s.invert_scroll).into()), RowId::Shortcuts => ( @@ -495,8 +575,13 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { "Capture system shortcuts", on_off(s.inhibit_shortcuts).into(), ), + RowId::Palette => ( + None, + "Background", + crate::library::palette(&s.ui_palette).name.into(), + ), RowId::Stats => ( - Some("Interface"), + None, "Statistics overlay", s.stats_verbosity().label().into(), ), @@ -603,6 +688,10 @@ fn detail(id: RowId) -> &'static str { "Alt+Tab, Super and friends reach the host while input is captured. \ Off, they act on this device instead." } + RowId::Palette => { + "The colour family this backdrop drifts through — it changes as you step, so \ + pick by looking. Appearance only; nothing about a stream depends on it." + } RowId::Stats => { "How much the overlay shows: Compact (one line) → Normal → Detailed. \ Ctrl+Alt+Shift+S cycles it live while streaming." @@ -766,6 +855,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { step_option(cur, StatsVerbosity::ALL.len(), delta, wrap) .map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i])) } + RowId::Palette => { + let all = &crate::library::PALETTES; + let cur = all.iter().position(|p| p.id == s.ui_palette); + step_option(cur, all.len(), delta, wrap).map(|i| s.ui_palette = all[i].id.to_string()) + } RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap), RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap), RowId::Library => toggle(&mut s.library_enabled, delta, wrap), @@ -1071,19 +1165,18 @@ mod tests { ("p1".into(), "Work".into()), ("p2".into(), "Game".into()), ]); + s.tab = PROFILES_TAB; let ids = s.row_ids(); - assert_eq!(ids.len(), ROWS.len() + 2); - assert_eq!(ids[ROWS.len()], RowId::Profile(0)); + assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]); let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles); - assert_eq!(spec.header, Some("Profiles")); + assert_eq!(spec.header, None, "the tab pill names the section"); assert_eq!(spec.label, "Work"); assert_eq!(spec.value.as_deref(), Some("Pinned to 1 host")); let spec = row_spec(RowId::Profile(1), &ctx, &s.profiles); - assert_eq!(spec.header, None, "only the first row carries the header"); assert_eq!(spec.value.as_deref(), Some("Not pinned")); - s.list.cursor = ROWS.len(); // onto "Work" + s.list.cursor = 0; // onto "Work" let mut fx = Outbox::default(); s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); assert!( @@ -1118,10 +1211,10 @@ mod tests { t: 0.0, }; let mut s = SettingsScreen::with_profiles(Vec::new()); + s.tab = PROFILES_TAB; let ids = s.row_ids(); - assert_eq!(*ids.last().unwrap(), RowId::NoProfiles); + assert_eq!(ids, vec![RowId::NoProfiles]); let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles); - assert_eq!(spec.header, Some("Profiles")); assert!(!spec.enabled); s.list.cursor = ids.len() - 1; @@ -1130,4 +1223,103 @@ mod tests { assert!(matches!(pulse, Some(MenuPulse::Boundary))); assert!(fx.nav.is_none()); } + + /// Every row the screen knows about must live in exactly one tab — a row missing from + /// [`TABS`] is a setting that became unreachable in Gaming Mode, which is precisely + /// what this screen exists to prevent. + #[test] + fn every_row_has_exactly_one_tab() { + let mut seen: Vec = Vec::new(); + for (_, rows) in &TABS { + for id in *rows { + assert!(!seen.contains(id), "{id:?} is in two tabs"); + seen.push(*id); + } + } + // The pre-tab flat list, plus the palette row this change added. + assert_eq!(seen.len(), 30, "{seen:?}"); + assert!(seen.contains(&RowId::Palette)); + // The catalog rows belong to the trailing tab, which builds them at render time. + assert!(TABS[PROFILES_TAB].1.is_empty()); + assert_eq!(TABS[PROFILES_TAB].0, "Profiles"); + } + + /// L1/R1 wrap around the strip and each tab keeps its own cursor, so a detour into + /// another section doesn't lose your place. + #[test] + fn shoulders_cycle_tabs_and_keep_each_cursor() { + let (mut settings, pads) = ctx_parts(); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &[], + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + let mut s = SettingsScreen::with_profiles(Vec::new()); + let mut fx = Outbox::default(); + assert_eq!(s.tab, 0); + s.list.cursor = 3; // "Bitrate", in Stream + s.menu(MenuEvent::JumpForward, &mut ctx, &mut fx); + assert_eq!(s.tab, 1); + assert_eq!(s.list.cursor, 0, "a fresh tab starts at its first row"); + s.list.cursor = 2; // "10-bit HDR", in Video + s.menu(MenuEvent::JumpBack, &mut ctx, &mut fx); + assert_eq!((s.tab, s.list.cursor), (0, 3), "Stream kept its place"); + // Backwards off the first tab wraps to the last… + s.menu(MenuEvent::JumpBack, &mut ctx, &mut fx); + assert_eq!(s.tab, PROFILES_TAB); + // …whose (catalog-built) length clamps a remembered cursor that no longer fits. + assert_eq!(s.list.cursor, 0); + s.menu(MenuEvent::JumpForward, &mut ctx, &mut fx); + assert_eq!(s.tab, 0); + // Switching sections is navigation, never a settings write. + assert!(fx.nav.is_none() && fx.cmds.is_empty()); + } + + /// The palette row steps the shared `ui_palette` key through the table and wraps on A, + /// like every other choice row. + #[test] + fn palette_row_steps_the_shared_key() { + let (mut settings, pads) = ctx_parts(); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &[], + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + assert_eq!(ctx.settings.ui_palette, "violet", "the brand default ships"); + assert_eq!( + row_spec(RowId::Palette, &ctx, &[]).value.as_deref(), + Some("Violet") + ); + assert!( + !adjust(RowId::Palette, -1, false, &mut ctx), + "already the first = thud" + ); + assert!(adjust(RowId::Palette, 1, false, &mut ctx)); + assert_eq!(ctx.settings.ui_palette, crate::library::PALETTES[1].id); + // A from the last entry wraps home. + ctx.settings.ui_palette = crate::library::PALETTES + .last() + .expect("non-empty") + .id + .to_string(); + assert!(adjust(RowId::Palette, 1, true, &mut ctx)); + assert_eq!(ctx.settings.ui_palette, "violet"); + // A store written by a newer client shows that client's value, not a blank row. + ctx.settings.ui_palette = "chartreuse".into(); + assert_eq!( + row_spec(RowId::Palette, &ctx, &[]).value.as_deref(), + Some("Violet"), + "an unknown palette reads as the default it actually draws" + ); + } } diff --git a/crates/pf-console-ui/src/shell.rs b/crates/pf-console-ui/src/shell.rs index 6994109f..f92cc23c 100644 --- a/crates/pf-console-ui/src/shell.rs +++ b/crates/pf-console-ui/src/shell.rs @@ -11,7 +11,7 @@ use crate::anim::Progress; use crate::glyphs::GlyphStyle; -use crate::library::{mesh_sksl, LibraryShared}; +use crate::library::{mesh_sksl, palette, LibraryShared}; use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus}; use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen}; use anyhow::{anyhow, Result}; @@ -81,7 +81,17 @@ pub(crate) struct Shell { wake_optimistic: bool, toast: Option, mesh: RuntimeEffect, - /// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition. + /// The `ui_palette` the compiled `mesh` bakes. The settings screen can change the palette + /// mid-frame-loop, so [`Self::sync`] recompiles when this falls out of step — the backdrop + /// re-colours under the cursor as the row is stepped, which is the whole point of putting + /// the picker on a screen the backdrop is behind. + mesh_palette: String, + /// The palette's corner colour × 0.4 — the calm lift, precomputed with `mesh`. Chosen so + /// `col*0.6 + lift` leaves a corner EXACTLY where it was and pulls the bright pools down + /// to it: the form screens lose the launcher's contrast, not its colour. + mesh_lift: [f32; 3], + /// 0 = launcher aurora, 1 = the calm form field — chased, so the backdrop settles into + /// (or out of) calm alongside the screen transition. bg_mix: f64, glyphs: GlyphStyle, chip: Option, @@ -99,8 +109,8 @@ impl Shell { stack: Vec, ) -> Result { anyhow::ensure!(!stack.is_empty(), "the console needs a root screen"); - let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None) - .map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?; + let settings = trust::Settings::load(); + let (mesh, mesh_lift) = build_mesh(&settings.ui_palette)?; let bg_mix = match stack.last().expect("non-empty").background() { Bg::Aurora => 0.0, Bg::Form => 1.0, @@ -112,7 +122,8 @@ impl Shell { library, bus, actions: VecDeque::new(), - settings: trust::Settings::load(), + mesh_palette: settings.ui_palette.clone(), + settings, hosts: Vec::new(), hosts_gen: u64::MAX, device_name: opts.device_name, @@ -123,6 +134,7 @@ impl Shell { wake_optimistic: false, toast: None, mesh, + mesh_lift, bg_mix, glyphs: GlyphStyle::Keyboard, chip: None, @@ -188,6 +200,26 @@ impl Shell { // --- Model sync (hosts, pairing, wake) — before input and before render -------------- fn sync(&mut self) { + // The settings screen writes `ui_palette` straight into `self.settings`; recompiling + // here is what makes the backdrop re-colour live under the row being stepped. A + // rejected compile keeps the palette that IS drawing — the field never goes black + // because someone picked a colour. + if self.settings.ui_palette != self.mesh_palette { + match build_mesh(&self.settings.ui_palette) { + Ok((mesh, lift)) => { + self.mesh = mesh; + self.mesh_lift = lift; + self.mesh_palette = self.settings.ui_palette.clone(); + } + Err(e) => { + tracing::warn!( + "console: {} palette rejected: {e}", + self.settings.ui_palette + ); + self.mesh_palette = self.settings.ui_palette.clone(); + } + } + } if self.console.hosts_gen() != self.hosts_gen { (self.hosts, self.hosts_gen) = self.console.hosts_snapshot(); } @@ -432,12 +464,25 @@ impl Shell { } } - fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64) { - let uniforms: [f32; 3] = [w as f32, h as f32, t as f32]; - // SAFETY: `uniforms` is a local `[f32; 3]` — exactly 12 bytes — and `f32` has no padding or + /// The living backdrop. `calm` 0 = the launcher's aurora, 1 = the quiet field the form + /// screens sit on; the shell chases it, so there is only ever ONE backdrop pass — the + /// former aurora-over-static-form crossfade is now a single uniform. + fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64, calm: f64) { + // Laid out to match the SkSL block: u_res (float2), u_tc (float2), u_lift (float4). + let uniforms: [f32; 8] = [ + w as f32, + h as f32, + t as f32, + calm as f32, + self.mesh_lift[0], + self.mesh_lift[1], + self.mesh_lift[2], + 0.0, + ]; + // SAFETY: `uniforms` is a local `[f32; 8]` — exactly 32 bytes — and `f32` has no padding or // invalid bit patterns, so reading it as bytes is sound; the slice is copied by // `Data::new_copy` before `uniforms` goes out of scope. - let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::(), 12) }; + let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::(), 32) }; match self.mesh.make_shader(Data::new_copy(bytes), &[], None) { Some(shader) => { let mut paint = Paint::default(); @@ -451,5 +496,30 @@ impl Shell { } } +/// Compile the mesh shader for a palette, returning it with its precomputed calm lift. +/// `uniform_size` is checked rather than assumed: the byte buffer [`Shell::draw_aurora`] +/// hands Skia is hand-packed, and a silent layout change would feed the field garbage +/// instead of failing. +fn build_mesh(palette_id: &str) -> Result<(RuntimeEffect, [f32; 3])> { + let p = palette(palette_id); + let colors = p.mesh_colors(); + let effect = RuntimeEffect::make_for_shader(mesh_sksl(&colors), None) + .map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?; + anyhow::ensure!( + effect.uniform_size() == 32, + "mesh uniform block is {} bytes, expected 32 (u_res, u_tc, u_lift)", + effect.uniform_size() + ); + let corner = colors[0]; + Ok(( + effect, + [ + (corner.0 * 0.4) as f32, + (corner.1 * 0.4) as f32, + (corner.2 * 0.4) as f32, + ], + )) +} + #[cfg(test)] mod tests; diff --git a/crates/pf-console-ui/src/shell/overlays.rs b/crates/pf-console-ui/src/shell/overlays.rs index e8fa7b44..24601855 100644 --- a/crates/pf-console-ui/src/shell/overlays.rs +++ b/crates/pf-console-ui/src/shell/overlays.rs @@ -166,7 +166,7 @@ impl Shell { canvas.save_layer_alpha_f(None, appear as f32); // Opaque aurora — the same living backdrop the home wears, so the takeover reads as the // console taking over rather than a card popping up. - self.draw_aurora(canvas, w, h, t); + self.draw_aurora(canvas, w, h, t, 0.0); // A soft pool of shade under the centre seats the white text against a bright aurora. let mut vignette = Paint::default(); vignette.set_shader(gradient_shader::radial( diff --git a/crates/pf-console-ui/src/shell/render.rs b/crates/pf-console-ui/src/shell/render.rs index 09a89019..c9c99eec 100644 --- a/crates/pf-console-ui/src/shell/render.rs +++ b/crates/pf-console-ui/src/shell/render.rs @@ -8,7 +8,7 @@ use crate::screens::{Bg, Ctx, Screen}; use crate::theme::{white, Fonts, PanelStroke, W, WHITE}; use pf_client_core::gamepad::PadInfo; use pf_client_core::trust; -use skia_safe::{Canvas, Color4f, Rect}; +use skia_safe::{Canvas, Rect}; use std::time::Instant; use super::{Motion, Shell, BOTTOM_BAND, TOP_BAND}; @@ -67,7 +67,9 @@ impl Shell { } }; - // Backdrop crossfade follows the top screen. + // The backdrop settles into (or out of) calm with the screen transition. It is the + // SAME living field either way — a form screen quiets it, it doesn't replace it — + // so this is one shader pass with a chased uniform, not two stacked backdrops. let bg_target = match self.stack.last().expect("non-empty").background() { Bg::Aurora => 0.0, Bg::Form => 1.0, @@ -76,16 +78,7 @@ impl Shell { if (self.bg_mix - bg_target).abs() < 0.005 { self.bg_mix = bg_target; } - if self.bg_mix < 1.0 { - self.draw_aurora(canvas, w, h, t); - } else { - canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0)); - } - if self.bg_mix > 0.0 { - canvas.save_layer_alpha_f(None, self.bg_mix as f32); - crate::theme::draw_form_background(canvas, w, h); - canvas.restore(); - } + self.draw_aurora(canvas, w, h, t, self.bg_mix); // The screens, through the transition choreography. let content = Rect::from_ltrb( diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index d49e1a44..2f196e79 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -167,6 +167,47 @@ fn wake_gates_input_in_the_same_press() { assert!(s.handle_menu(MenuEvent::Move(MenuDir::Left)).is_some()); } +/// Every settings tab actually RASTERS. The eyeball dump below is `#[ignore]`d, so without +/// this nothing in the normal gate ever ran the tab strip's layout arithmetic or a settings +/// screen's rows — a bad index there would only surface on a Deck. CPU raster: the SkSL +/// backdrop, the layers and the text all run without a GPU. +#[test] +fn every_settings_tab_rasters() { + let fonts = crate::theme::build_fonts().unwrap(); + let (w, h) = (1280u32, 800u32); + let pads: Vec = Vec::new(); + let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); + let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]); + s.handle_menu(MenuEvent::Tertiary); // X → Settings + + let mut frame = |s: &mut Shell| { + s.render( + surface.canvas(), + w, + h, + &fonts, + Some("Xbox Wireless Controller"), + Some(GamepadPref::Xbox360), + &pads, + ); + }; + // One lap of the strip — R1 wraps back to where it started. Every tab's rows fit on an + // 800-tall window at once, so ONE frame per tab draws all of them; the cursor is walked to + // the end first (input only, no render) so the focused and unfocused row paths both run. + // Deliberately frugal: a full-screen SkSL field on the CPU costs the better part of a second + // per frame in a debug build, and this test's job is to catch a panic, not to look pretty. + for _ in 0..crate::screens::settings::TAB_COUNT { + for _ in 0..12 { + s.handle_menu(MenuEvent::Move(MenuDir::Down)); + } + frame(&mut s); + s.handle_menu(MenuEvent::JumpForward); + } + // A narrow window is the case the strip has to shrink for (the pills are laid out from + // measured text, so a too-small width must clamp rather than lay out off-screen). + s.render(surface.canvas(), 640, 400, &fonts, None, None, &pads); +} + /// Render every console scene to PNGs for the eyeball pass (ignored; run with /// `PF_CONSOLE_DUMP= cargo test -p pf-console-ui --release -- --ignored dump`). /// CPU raster — the SkSL aurora, layers and text all run without a GPU. @@ -208,6 +249,26 @@ fn dump_console_screens() { dump(&mut s, 3, 25, "02-transition", true); dump(&mut s, 40, 8, "03-settings", true); + // The Interface tab (5 shoulder presses along) leads with the Background row, so this frame + // shows both the strip mid-list and the palette picker… + for _ in 0..5 { + s.handle_menu(MenuEvent::JumpForward); + } + dump(&mut s, 40, 8, "03b-settings-interface", true); + // …and cycling it three times lands on Ember, which is the whole point: the CALM backdrop + // behind these rows recolours live. + for _ in 0..3 { + s.handle_menu(MenuEvent::Confirm); + } + dump(&mut s, 40, 8, "03c-settings-ember", true); + // Back to the brand default and the first tab so the later scenes look like they always did. + for _ in 0..3 { + s.handle_menu(MenuEvent::Confirm); + } + for _ in 0..5 { + s.handle_menu(MenuEvent::JumpBack); + } + // Add Host with the keyboard tray up (keyboard glyph style: no pad). s.handle_menu(MenuEvent::Back); dump(&mut s, 40, 8, "_back", true); diff --git a/crates/pf-console-ui/src/theme.rs b/crates/pf-console-ui/src/theme.rs index 194fe600..fb59b0e9 100644 --- a/crates/pf-console-ui/src/theme.rs +++ b/crates/pf-console-ui/src/theme.rs @@ -112,59 +112,12 @@ pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alph } // --- The form backdrop (settings / add-host / pair) -------------------------------------- - -/// The calm backdrop for the form screens — NOT the launcher's aurora (this stays still -/// and quiet), and deliberately not near-black: a deep indigo base plus two soft static -/// glows give the glass rows real color to sit on. A light top/bottom scrim grounds the -/// pinned title and hint bar (the Swift build blurs a tray instead; same job). -pub(crate) fn draw_form_background(canvas: &Canvas, w: f64, h: f64) { - let (wf, hf) = (w as f32, h as f32); - canvas.draw_rect( - Rect::from_wh(wf, hf), - &Paint::new(Color4f::new(0.075, 0.062, 0.150, 1.0), None), - ); - // Violet lift top-leading, cooler indigo bottom-trailing — elliptical (window - // aspect) via a unit-radius radial gradient under a scale. - for (cx, cy, color, alpha) in [ - (0.26, 0.14, Color4f::new(0.40, 0.31, 0.68, 1.0), 0.9f32), - (0.82, 0.90, Color4f::new(0.20, 0.24, 0.58, 1.0), 0.75), - ] { - let mut paint = Paint::default(); - let c = Color4f::new(color.r, color.g, color.b, alpha); - paint.set_shader(gradient_shader::radial( - Point::new(0.0, 0.0), - 0.78, - gradient_shader::GradientShaderColors::Colors(&[ - c.to_color(), - Color4f::new(color.r, color.g, color.b, 0.0).to_color(), - ]), - None, - TileMode::Clamp, - None, - None, - )); - canvas.save(); - canvas.translate((wf * cx, hf * cy)); - canvas.scale((wf, hf)); - canvas.draw_rect(Rect::from_ltrb(-1.0, -1.0, 1.0, 1.0), &paint); - canvas.restore(); - } - let mut scrim = Paint::default(); - scrim.set_shader(gradient_shader::linear( - (Point::new(0.0, 0.0), Point::new(0.0, hf)), - gradient_shader::GradientShaderColors::Colors(&[ - Color4f::new(0.0, 0.0, 0.0, 0.30).to_color(), - Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(), - Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(), - Color4f::new(0.0, 0.0, 0.0, 0.32).to_color(), - ]), - Some(&[0.0, 0.22, 0.74, 1.0][..]), - TileMode::Clamp, - None, - None, - )); - canvas.draw_rect(Rect::from_wh(wf, hf), &scrim); -} +// +// There isn't one any more. The form screens used to sit on a STATIC deep-indigo field +// drawn here, crossfaded over the launcher's aurora; they now wear the same living mesh at +// `calm = 1` (see `library::mesh_sksl` and `Shell::draw_aurora`), which keeps the glass rows +// on real colour, keeps the console's one backdrop palette-themed everywhere, and means no +// screen in the gamepad UI is ever backed by a still image. /// The loading/connecting spinner: a rotating 270° arc driven by the shell clock. pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) { diff --git a/crates/pf-console-ui/src/widgets.rs b/crates/pf-console-ui/src/widgets.rs index 8ce4bb74..b5f9fa0b 100644 --- a/crates/pf-console-ui/src/widgets.rs +++ b/crates/pf-console-ui/src/widgets.rs @@ -84,6 +84,9 @@ pub(crate) struct MenuList { bump: Spring, scroll: f64, focus: Vec, + /// Next render, seat the scroll and the focus ease instantly instead of chasing — see + /// [`MenuList::jump_to`]. + snap: bool, } impl MenuList { @@ -93,9 +96,18 @@ impl MenuList { bump: Spring::rest(0.0), scroll: 0.0, focus: Vec::new(), + snap: true, } } + /// Move the cursor WITHOUT the scroll gliding there. For a tab switch, where the whole + /// row set is replaced: chasing would sweep the viewport through rows that no longer + /// exist, which reads as a glitch rather than as motion. + pub(crate) fn jump_to(&mut self, cursor: usize) { + self.cursor = cursor; + self.snap = true; + } + /// Route a menu event. Up/down move focus (Boundary = recoil), left/right become /// [`ListMsg::Adjust`], A becomes [`ListMsg::Activate`]. B is the SCREEN's. pub(crate) fn menu(&mut self, ev: MenuEvent, len: usize) -> (ListMsg, Option) { @@ -136,10 +148,19 @@ impl MenuList { dt: f64, active: bool, ) { + if self.snap { + // A replaced row set has no shared history with the old one — start every row's + // focus ease from scratch so the new cursor is simply THERE. + self.focus.clear(); + } self.focus.resize(rows.len(), 0.0); for (i, f) in self.focus.iter_mut().enumerate() { let target = if active && i == self.cursor { 1.0 } else { 0.0 }; - *f = approach(*f, target, dt, 0.06); + *f = if self.snap { + target + } else { + approach(*f, target, dt, 0.06) + }; } self.bump.step(0.0, BUMP_K, BUMP_C, dt); self.bump.settle(0.0, 0.3, 4.0); @@ -160,7 +181,11 @@ impl MenuList { // The scroll chases the focused row into the middle band, clamped to content. let focused_center = tops.get(self.cursor).map_or(0.0, |t| (t + ROW_H / 2.0) * k); let target = (focused_center - view_h / 2.0).clamp(0.0, (content_h - view_h).max(0.0)); - self.scroll = approach(self.scroll, target, dt, 0.08); + self.scroll = if std::mem::take(&mut self.snap) { + target + } else { + approach(self.scroll, target, dt, 0.08) + }; let row_w = (ROW_MAX_W * k).min(f64::from(rect.width()) - 48.0 * k); let x0 = f64::from(rect.left) + (f64::from(rect.width()) - row_w) / 2.0; @@ -272,6 +297,98 @@ impl MenuList { } } +// --- Tab strip --------------------------------------------------------------------------- + +/// The strip's design height, including the air under it before the first row. +pub(crate) const TAB_STRIP_H: f64 = 46.0; + +/// The horizontal section switcher above a menu list. Purely presentational — the SCREEN +/// owns which tab is selected and what the shoulders do; this draws the pills and slides +/// one highlight between them, so switching sections reads as travel rather than a swap. +pub(crate) struct TabStrip { + /// Chased highlight geometry `(x, width)` in device px. `None` until the first render, + /// so a freshly opened screen doesn't animate its highlight in from x = 0. + indicator: Option<(f64, f64)>, +} + +impl TabStrip { + pub(crate) fn new() -> TabStrip { + TabStrip { indicator: None } + } + + /// Draw the pills centered in `rect`'s top band. Returns nothing — the caller already + /// knows the band is [`TAB_STRIP_H`] tall. + #[allow(clippy::too_many_arguments)] // the crate's render signature, same as MenuList's + pub(crate) fn render( + &mut self, + canvas: &Canvas, + rect: Rect, + labels: &[&str], + selected: usize, + fonts: &Fonts, + k: f64, + dt: f64, + ) { + if labels.is_empty() { + return; + } + let size = 13.0 * k; + let pad_x = 13.0 * k; + let gap = 7.0 * k; + let pill_h = 30.0 * k; + let widths: Vec = labels + .iter() + .map(|l| f64::from(fonts.measure(l, W::SemiBold, size)) + 2.0 * pad_x) + .collect(); + let total: f64 = widths.iter().sum::() + gap * (labels.len() - 1) as f64; + let mut x = f64::from(rect.left) + (f64::from(rect.width()) - total) / 2.0; + let top = f64::from(rect.top) + 2.0 * k; + + // Where the highlight wants to be, then the eased position it actually draws at. + let sel = selected.min(labels.len() - 1); + let target = ( + x + widths[..sel].iter().sum::() + gap * sel as f64, + widths[sel], + ); + let (ix, iw) = match self.indicator { + None => target, + Some((cx, cw)) => ( + approach(cx, target.0, dt, 0.07), + approach(cw, target.1, dt, 0.07), + ), + }; + self.indicator = Some((ix, iw)); + crate::theme::panel( + canvas, + Rect::from_xywh(ix as f32, top as f32, iw as f32, pill_h as f32), + (pill_h / 2.0 / k) as f32, + Some(brand(0.85)), + PanelStroke::Plain(0.22), + k as f32, + ); + + let baseline = top + pill_h / 2.0 + size * 0.36; + for (i, label) in labels.iter().enumerate() { + // Fade each label toward white by how much the highlight actually covers it, so + // the two labels a sliding highlight passes between light up together. + let pill_x = x; + let overlap = (pill_x + widths[i]).min(ix + iw) - pill_x.max(ix); + let covered = (overlap / widths[i]).clamp(0.0, 1.0) as f32; + let tw = f64::from(fonts.measure(label, W::SemiBold, size)); + fonts.draw( + canvas, + label, + pill_x + (widths[i] - tw) / 2.0, + baseline, + W::SemiBold, + size, + white(0.5 + 0.5 * covered), + ); + x += widths[i] + gap; + } + } +} + /// Middle-of-nowhere helper: drop chars from the FRONT until the tail fits. fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> String { if f64::from(fonts.measure(text, w, size)) <= max_w {