Gamepad UI: section tabs, background palettes, and a backdrop that moves everywhere #66
@@ -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 }
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
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<GamepadHint>, 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),
|
||||
) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Double, Double, Double>): Triple<Double, Double, Double> {
|
||||
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]
|
||||
}
|
||||
}
|
||||
@@ -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<GpTab, Int>() }
|
||||
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<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
id: String, tab: GpTab, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {})
|
||||
|
||||
@@ -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<Double>) -> 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<Double>] = [
|
||||
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<Double>
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: 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<Item: Identifiable, Row: View>: 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<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
}
|
||||
input.onConfirm = { activate() }
|
||||
input.onBack = onBack
|
||||
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T: Equatable>(
|
||||
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<Bool>, 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Double>) -> SIMD3<Double> {
|
||||
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<Double>, hueDegrees: Double, saturation: Double
|
||||
) -> SIMD3<Double> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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<Double>] = [
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RowId> {
|
||||
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<MenuPulse> {
|
||||
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<MenuPulse> {
|
||||
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<Hint> {
|
||||
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<RowId> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Toast>,
|
||||
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<String>,
|
||||
@@ -99,8 +109,8 @@ impl Shell {
|
||||
stack: Vec<Screen>,
|
||||
) -> Result<Shell> {
|
||||
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::<u8>(), 12) };
|
||||
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<PadInfo> = 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=<dir> 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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -84,6 +84,9 @@ pub(crate) struct MenuList {
|
||||
bump: Spring,
|
||||
scroll: f64,
|
||||
focus: Vec<f64>,
|
||||
/// 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<MenuPulse>) {
|
||||
@@ -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<f64> = labels
|
||||
.iter()
|
||||
.map(|l| f64::from(fonts.measure(l, W::SemiBold, size)) + 2.0 * pad_x)
|
||||
.collect();
|
||||
let total: f64 = widths.iter().sum::<f64>() + 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::<f64>() + 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 {
|
||||
|
||||
Reference in New Issue
Block a user