diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index f0c78e66..d24bcf08 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -109,8 +109,10 @@ fun App(forceGamepadUi: Boolean = false) { // 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. + val palette = GamepadPalette.named(settings.uiPalette) CompositionLocalProvider( - LocalGamepadPalette provides GamepadPalette.named(settings.uiPalette), + LocalGamepadPalette provides palette, + LocalGamepadInk provides GamepadInk.of(palette), ) { AnimatedContent( targetState = session, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt index 1b3ad31a..1b92b249 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt @@ -191,6 +191,7 @@ internal fun ConnectTakeover( onCancel: () -> Unit, onRetry: () -> Unit, ) { + val ink = LocalGamepadInk.current val copy = connectCopy(phase) val timedOut = phase is ConnectPhase.WakeTimedOut @@ -212,7 +213,7 @@ internal fun ConnectTakeover( Icon( Icons.Filled.Bedtime, contentDescription = null, - tint = Color.White.copy(alpha = 0.9f), + tint = ink.fg(0.9f), modifier = Modifier.size(46.dp), ) } @@ -221,14 +222,14 @@ internal fun ConnectTakeover( } Text( copy.title, - color = Color.White, + color = ink.fg, fontWeight = FontWeight.Bold, fontSize = 24.sp, textAlign = TextAlign.Center, ) Text( copy.subtitle, - color = Color.White.copy(alpha = 0.65f), + color = ink.fg(0.65f), fontSize = 14.sp, textAlign = TextAlign.Center, fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default, @@ -249,6 +250,7 @@ internal fun ConnectTakeover( */ @Composable private fun PulsingSpinner() { + val ink = LocalGamepadInk.current val transition = rememberInfiniteTransition(label = "connectPulse") val pulse by transition.animateFloat( initialValue = 0f, @@ -262,14 +264,14 @@ private fun PulsingSpinner() { for (i in 0..1) { val p = (pulse + i * 0.5f) % 1f drawCircle( - color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f), + color = ink.accent.copy(alpha = (1f - p) * 0.35f), radius = maxR * (0.42f + p * 0.58f), style = Stroke(width = 2.dp.toPx()), ) } } CircularProgressIndicator( - color = Color.White, + color = ink.fg, strokeWidth = 3.dp, modifier = Modifier.size(54.dp), ) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt index 7da0c8a2..00e2cf5a 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt @@ -79,6 +79,7 @@ fun GamepadAddHostScreen( suggestedMacs: List = emptyList(), onSave: ((KnownHost) -> Unit)? = null, ) { + val ink = LocalGamepadInk.current val context = LocalContext.current val isTv = remember { isTvDevice(context) } val isEdit = editHost != null @@ -245,7 +246,7 @@ fun GamepadAddHostScreen( Text( "Hosts on this network appear automatically — add one by address for everything else.", style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.55f), + color = ink.fg(0.55f), modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp), ) } @@ -306,6 +307,7 @@ private fun TvAddHostForm( onAdd: () -> Unit, onDismiss: () -> Unit, ) { + val ink = LocalGamepadInk.current BackHandler(onBack = onDismiss) val firstFocus = remember { FocusRequester() } Box(Modifier.fillMaxSize()) { @@ -319,11 +321,11 @@ private fun TvAddHostForm( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White) + Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = ink.fg) Text( "Hosts on this network appear automatically — add one by address for everything else.", style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.55f), + color = ink.fg(0.55f), ) OutlinedTextField( value = name, onValueChange = onName, singleLine = true, @@ -362,6 +364,7 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row] @Composable private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused || editing, editing = editing) val shape = RoundedCornerShape(14.dp) Row( @@ -375,25 +378,26 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () - .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White) + Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = ink.fg) Spacer(Modifier.weight(1f)) Text( f.value.ifEmpty { f.placeholder }, style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White, + color = if (f.value.isEmpty()) ink.fg(0.35f) else ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (editing) Text(" |", color = Color(0xFF8678F5)) + if (editing) Text(" |", color = ink.accent) } } @Composable private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) val labelColor by animateColorAsState( - if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f), + if (enabled) ink.accent else ink.fg(0.35f), tween(160), label = "addLabel", ) @@ -425,6 +429,7 @@ private fun KeyboardGrid( bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over onKey: (Int, Int) -> Unit, ) { + val ink = LocalGamepadInk.current val shape = RoundedCornerShape(20.dp) val gap = if (compact) 5.dp else 7.dp Column( @@ -433,7 +438,7 @@ private fun KeyboardGrid( .widthIn(max = 640.dp) .clip(shape) .background(Color(0x1FFFFFFF)) - .border(1.dp, Color.White.copy(alpha = 0.12f), shape) + .border(1.dp, ink.fg(0.12f), shape) .padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset), verticalArrangement = Arrangement.spacedBy(gap), ) { @@ -454,14 +459,15 @@ private fun KeyboardGrid( @Composable private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + val ink = LocalGamepadInk.current // Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the // trailing key must have faded before the cursor is two keys away — quick, but no longer a snap. val bg by animateColorAsState( - if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF), + if (focused) ink.accent else ink.glass, tween(90), label = "keyBg", ) - val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg") + val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg") Box( modifier = modifier .height(if (compact) 34.dp else 44.dp) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt index d33c084e..6e6219b5 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt @@ -72,9 +72,12 @@ import kotlin.math.sin // connected-controller status chip. One look across every screen is what makes the console UI read // as a coherent mode rather than a set of themed pages. -/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */ +/** + * One drifting blob of the aurora field: where it sits, how far it wanders, and how fast. Integer + * [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at + * draw time, so the field always shows several of that palette's tones at once. + */ private class AuroraBlob( - val color: Color, val baseX: Float, val baseY: Float, val driftX: Float, @@ -87,35 +90,33 @@ private class AuroraBlob( ) private val auroraBlobs = listOf( - AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet - AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo - AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum - AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue + AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), + AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), + AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), + AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), ) -/** 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 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. + * The living console backdrop: soft blobs from the palette's ramp drifting over its ground 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 families, 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. + * [calm] is what the FORM screens wear: the pools dim onto the ground 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, calm: Boolean = false) { + val ink = LocalGamepadInk.current 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. + // 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 swept by transition.animateFloat( initialValue = 0f, targetValue = (2 * PI).toFloat(), @@ -123,39 +124,45 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false 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) } + val tones = palette.blobColors + val ground = palette.groundColor + // Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's + // strength bleaches the chroma straight out of the gradient, so a pale palette gets under + // half — the same scrim strength the desktop console's shader carries. + val scrim = if (palette.light) ink.fg else Color.Black + val strength = if (palette.light) 0.45f else 1f Canvas(modifier) { - drawRect(if (calm) base else Color.Black) + drawRect(ground) val span = max(size.width, size.height) - for ((b, tinted) in blobs) { + for ((i, b) in auroraBlobs.withIndex()) { 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. + // Calm scales each blob's contribution rather than dimming the whole canvas: the + // ground 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(tinted.copy(alpha = alpha), Color.Transparent), + colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent), center = Offset(cx, cy), radius = r, ), center = Offset(cx, cy), radius = r, - blendMode = BlendMode.Plus, + // Additive only works over a DARK ground; over a pale one every blob + // saturates to white and the field turns grey. Pale palettes tint instead. + blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus, ) } - // 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.) + // Cinematic vignette: pool light centre, settle the corners toward the scrim. 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 them just eats the list. drawRect( Brush.radialGradient( colors = listOf( Color.Transparent, - Color.Black.copy(alpha = if (calm) 0.22f else 0.44f), + scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength), ), center = Offset(size.width / 2, size.height / 2), radius = span * 0.92f, @@ -164,10 +171,10 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false // Top/bottom legibility scrim for the pinned title + hint bar. drawRect( Brush.verticalGradient( - 0.0f to Color.Black.copy(alpha = 0.40f), - 0.30f to Color.Black.copy(alpha = 0.05f), - 0.70f to Color.Black.copy(alpha = 0.06f), - 1.0f to Color.Black.copy(alpha = 0.42f), + 0.0f to scrim.copy(alpha = 0.40f * strength), + 0.30f to scrim.copy(alpha = 0.05f * strength), + 0.70f to scrim.copy(alpha = 0.06f * strength), + 1.0f to scrim.copy(alpha = 0.42f * strength), ), ) } @@ -223,6 +230,7 @@ fun ConsoleTabStrip( */ focused: Boolean = false, ) { + val ink = LocalGamepadInk.current val listState = rememberLazyListState() LaunchedEffect(selected) { runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) } @@ -236,17 +244,18 @@ fun ConsoleTabStrip( itemsIndexed(titles) { i, title -> val active = i == selected val background by animateColorAsState( - if (active) Color(0xD96656F2) else Color(0x14FFFFFF), + if (active) ink.accent(0.85f) else ink.glass, tween(180), label = "tabBg", ) - val ink by animateColorAsState( - Color.White.copy(alpha = if (active) 1f else 0.55f), + // Not `ink` — that name is the palette's, and shadowing it here cost a compile. + val labelColor by animateColorAsState( + if (active) ink.onAccent else ink.fg(0.55f), tween(180), label = "tabInk", ) val ring by animateColorAsState( - Color.White.copy(alpha = if (active && focused) 0.85f else 0f), + ink.fg(if (active && focused) 0.85f else 0f), tween(180), label = "tabRing", ) @@ -254,7 +263,7 @@ fun ConsoleTabStrip( title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, - color = ink, + color = labelColor, maxLines = 1, modifier = Modifier .clip(RoundedCornerShape(50)) @@ -283,6 +292,7 @@ val ConsoleEdgeInset = 24.dp */ @Composable fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) { + val ink = LocalGamepadInk.current // `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a // LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way. val h = if (horizontalInset) ConsoleEdgeInset else 0.dp @@ -290,7 +300,7 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp), @@ -347,21 +357,22 @@ class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: C */ @Composable fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals { + val ink = LocalGamepadInk.current val scale by animateFloatAsState( targetValue = if (active) 1f else 0.98f, animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow), label = "consoleScale", ) val background by animateColorAsState( - if (active) Color(0x336656F2) else Color(0x14FFFFFF), + if (active) ink.accent(0.20f) else ink.glass, tween(160), label = "consoleBg", ) val border by animateColorAsState( when { - editing -> Color(0xB38678F5) - active -> Color.White.copy(alpha = 0.28f) - else -> Color.White.copy(alpha = 0.06f) + editing -> ink.accent(0.70f) + active -> ink.fg(0.28f) + else -> ink.fg(0.06f) }, tween(160), label = "consoleBorder", @@ -376,18 +387,19 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus */ @Composable fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current val travel by animateFloatAsState( targetValue = if (on) 1f else 0f, animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f), label = "switchKnob", ) val track by animateColorAsState( - if (on) Color(0xFF6656F2) else Color(0x26FFFFFF), + if (on) ink.accent else Color(0x26FFFFFF), tween(200), label = "switchTrack", ) val outline by animateColorAsState( - Color.White.copy(alpha = if (focused) 0.45f else 0.15f), + ink.fg(if (focused) 0.45f else 0.15f), tween(160), label = "switchOutline", ) @@ -409,7 +421,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) .offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) } .size(knob) .clip(CircleShape) - .background(Color.White), + .background(ink.fg), ) } } @@ -417,6 +429,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) /** A round face-button badge: a coloured disc with the button letter, like a controller's face. */ @Composable fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) { + val ink = LocalGamepadInk.current Box( modifier = Modifier .size(size) @@ -426,7 +439,7 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit ) { Text( glyph.toString(), - color = Color.White, + color = ink.fg, fontWeight = FontWeight.Bold, fontSize = (size.value * 0.52f).sp, textAlign = TextAlign.Center, @@ -437,11 +450,12 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit /** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */ @Composable private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { + val ink = LocalGamepadInk.current Box( modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A), contentAlignment = Alignment.Center, ) { - Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape)) + Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, ink.fg, CircleShape)) } } @@ -506,6 +520,7 @@ internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) */ @Composable internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) { + val ink = LocalGamepadInk.current Box( Modifier.size(size).clip(CircleShape).background(PadButtonFace), contentAlignment = Alignment.Center, @@ -517,17 +532,17 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u val corner = RoundedCornerShape(2.dp) Box( Modifier.size(size * 0.32f).align(Alignment.TopEnd) - .border(1.4.dp, Color.White.copy(alpha = 0.9f), corner), + .border(1.4.dp, ink.fg(0.9f), corner), ) Box( Modifier.size(size * 0.32f).align(Alignment.BottomStart) .clip(corner).background(PadButtonFace) - .border(1.4.dp, Color.White.copy(alpha = 0.9f), corner), + .border(1.4.dp, ink.fg(0.9f), corner), ) } Gamepad.PadStyle.NINTENDO -> Text( "−", - color = Color.White, + color = ink.fg, fontWeight = FontWeight.Bold, fontSize = (size.value * 0.62f).sp, textAlign = TextAlign.Center, @@ -536,7 +551,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u Modifier .size(width = size * 0.58f, height = size * 0.30f) .clip(RoundedCornerShape(50)) - .border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)), + .border(1.6.dp, ink.fg(0.9f), RoundedCornerShape(50)), ) } } @@ -548,6 +563,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u */ @Composable fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, hazeState: HazeState? = null) { + val ink = LocalGamepadInk.current // On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses: // A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the // home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters / @@ -560,13 +576,13 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze // With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent // scrim below) + a light tint; otherwise fall back to a solid frosted fill. val frosted = if (hazeState != null) { - modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A)) + modifier.clip(shape).hazeEffect(hazeState).background(ink.shade(0.25f)) } else { - modifier.clip(shape).background(Color(0x8C14122A)) + modifier.clip(shape).background(ink.shade(0.55f)) } Row( modifier = frosted - .border(1.dp, Color.White.copy(alpha = 0.14f), shape) + .border(1.dp, ink.fg(0.14f), shape) .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 @@ -598,7 +614,7 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze Text( h.text, style = MaterialTheme.typography.labelLarge, - color = Color.White.copy(alpha = 0.9f), + color = ink.fg(0.9f), maxLines = 1, softWrap = false, // never char-wrap a label when several hints crowd a narrow pill ) @@ -610,24 +626,25 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze /** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */ @Composable fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current Row( modifier = modifier .clip(RoundedCornerShape(50)) - .background(Color.White.copy(alpha = 0.08f)) + .background(ink.fg(0.08f)) .padding(horizontal = 12.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Filled.SportsEsports, contentDescription = null, - tint = Color.White.copy(alpha = 0.75f), + tint = ink.fg(0.75f), modifier = Modifier.size(16.dp), ) Spacer(Modifier.width(7.dp)) Text( name, style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.75f), + color = ink.fg(0.75f), maxLines = 1, ) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt index a3f27ec2..c9affdb8 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -85,6 +85,7 @@ fun GamepadDialog( actions: List, body: @Composable ColumnScope.() -> Unit, ) { + val ink = LocalGamepadInk.current // Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels // like "Request access" without the cramped-row wrapping a horizontal layout caused). var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) } @@ -117,11 +118,11 @@ fun GamepadDialog( .heightIn(max = maxCardHeight) .clip(RoundedCornerShape(24.dp)) .background(Color(0xF01A1730)) - .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp)) .padding(28.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White) + Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg) Column( Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -139,6 +140,7 @@ fun GamepadDialog( @OptIn(ExperimentalFoundationApi::class) @Composable private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val scale by animateFloatAsState( if (focused) 1.02f else 1f, spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow), @@ -152,19 +154,19 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab // Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping. val bg by animateColorAsState( when { - focused -> Color(0xFF6656F2) - primary -> Color(0x336656F2) - else -> Color(0x14FFFFFF) + focused -> ink.accent + primary -> ink.accent(0.20f) + else -> ink.glass }, tween(160), label = "btnBg", ) val fg by animateColorAsState( when { - !enabled -> Color.White.copy(alpha = 0.35f) - focused -> Color.White - primary -> Color(0xFF8678F5) - else -> Color.White.copy(alpha = 0.85f) + !enabled -> ink.fg(0.35f) + focused -> ink.fg + primary -> ink.accent + else -> ink.fg(0.85f) }, tween(160), label = "btnFg", @@ -198,7 +200,8 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab /** Body text helper — a dimmed paragraph. */ @Composable private fun DialogText(text: String) { - Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f)) + val ink = LocalGamepadInk.current + Text(text, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f)) } /** @@ -271,6 +274,7 @@ fun GamepadPinHostsDialog( onToggle: (KnownHost) -> Unit, onDismiss: () -> Unit, ) { + val ink = LocalGamepadInk.current // 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS // Done, so it starts focused). var focus by remember { mutableIntStateOf(0) } @@ -304,7 +308,7 @@ fun GamepadPinHostsDialog( .heightIn(max = maxCardHeight) .clip(RoundedCornerShape(24.dp)) .background(Color(0xF01A1730)) - .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp)) .padding(28.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { @@ -312,7 +316,7 @@ fun GamepadPinHostsDialog( "Pin “$profileName”", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -350,6 +354,7 @@ fun GamepadPinHostsDialog( @OptIn(ExperimentalFoundationApi::class) @Composable private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused) // Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short // landscape window pulls itself into view. @@ -376,7 +381,7 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -518,6 +523,7 @@ fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, on @Composable fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { + val ink = LocalGamepadInk.current GamepadDialog( title = "Waiting for approval", onDismiss = onCancel, @@ -525,8 +531,8 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { ) { val deviceName = Build.MODEL ?: "this device" Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White) - Text("Approve this device on $hostLabel.", color = Color.White) + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg) + Text("Approve this device on $hostLabel.", color = ink.fg) } DialogText( "Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " + @@ -542,6 +548,7 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { */ @Composable fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) { + val ink = LocalGamepadInk.current val scope = rememberCoroutineScope() val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) } var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button @@ -587,16 +594,16 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: Column( Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight) .clip(RoundedCornerShape(24.dp)) - .background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp)) .verticalScroll(rememberScrollState()) .padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(18.dp), ) { - Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White) + Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg) Text( "Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.", - style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f), textAlign = TextAlign.Center, ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) } @@ -615,13 +622,14 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: @Composable private fun PinSlot(value: Int, focused: Boolean) { + val ink = LocalGamepadInk.current val shape = RoundedCornerShape(12.dp) Box( Modifier.size(54.dp, 66.dp).clip(shape) - .background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF)) - .border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape), + .background(if (focused) ink.accent(0.20f) else ink.glass) + .border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape), contentAlignment = Alignment.Center, ) { - Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace) + Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt index d42a4643..b590e094 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt @@ -247,9 +247,10 @@ fun GamepadHome( /** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */ @Composable private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current val shape = RoundedCornerShape(26.dp) val wash = if (tile.filled) { - Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A))) + Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A))) } else { Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF))) } @@ -258,7 +259,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { .fillMaxWidth() .clip(shape) .background(wash) - .border(1.dp, Color.White.copy(alpha = 0.16f), shape) + .border(1.dp, ink.fg(0.16f), shape) .padding(22.dp), ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { @@ -269,7 +270,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { Icon( Icons.Filled.Lock, contentDescription = "Paired", - tint = Color.White.copy(alpha = 0.7f), + tint = ink.fg(0.7f), modifier = Modifier.padding(end = 6.dp).size(15.dp), ) } @@ -286,14 +287,14 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { tile.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( tile.subtitle, style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.55f), + color = ink.fg(0.55f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -302,9 +303,10 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { @Composable private fun MonogramBadge(tile: HomeTile) { + val ink = LocalGamepadInk.current val shape = RoundedCornerShape(15.dp) val fill = if (tile.filled) { - Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5))) + Brush.verticalGradient(listOf(ink.accent, ink.accent)) } else { Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2))) } @@ -316,18 +318,18 @@ private fun MonogramBadge(tile: HomeTile) { tile.connecting -> CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, - color = Color.White, + color = ink.fg, ) tile.isAdd -> Icon( Icons.Filled.Add, contentDescription = null, - tint = if (tile.filled) Color.White else Color(0xFF8678F5), + tint = if (tile.filled) ink.fg else ink.accent, ) else -> Text( tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = if (tile.filled) Color.White else Color(0xFF8678F5), + color = if (tile.filled) ink.fg else ink.accent, ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt new file mode 100644 index 00000000..a4546749 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt @@ -0,0 +1,89 @@ +package io.unom.punktfunk + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color + +// The ink the console (gamepad) UI draws with under the chosen background palette. +// +// The console screens were white-on-dark throughout with the brand violet hardcoded as the accent. +// Both had to become palette-derived at once: a pale field needs dark text or it is unreadable, +// and a violet focus wash on a copper field is exactly the clash this exists to fix. +// +// Published as a CompositionLocal rather than passed down, so a leaf (a row, a hint pill, a card) +// can ask for the right colour without every caller in between knowing about palettes. The Apple +// client uses an environment value and `pf-console-ui` a thread-local for the same reason. + +/** Everything about the console's look that follows the chosen palette. */ +class GamepadInk( + /** Primary text/glyph colour. */ + val fg: Color, + /** Focus wash, selected tab pill, switch track — the palette's own accent. */ + val accent: Color, + /** What reads ON the accent (a filled pill's label, a switch knob). */ + val onAccent: Color, + /** The base fill every glass surface starts from, at its resting opacity. */ + val glass: Color, + /** What a wash laid UNDER text tends toward: black on a dark field, white on a pale one. */ + val shade: Color, + /** + * How hard those washes go. A pale field needs far less — mixing toward white at the dark + * field's strength bleaches the chroma straight out of the gradient. + */ + val shadeScale: Float, + /** True when the field is pale, for the few places that branch rather than blend. */ + val isLight: Boolean, +) { + /** The foreground at [alpha]. */ + fun fg(alpha: Float): Color = fg.copy(alpha = alpha) + + /** The accent at [alpha]. */ + fun accent(alpha: Float): Color = accent.copy(alpha = alpha) + + /** A wash under text: [alpha] is the dark-field strength, scaled for a pale one. */ + fun shade(alpha: Float): Color = shade.copy(alpha = alpha * shadeScale) + + companion object { + fun of(p: GamepadPalette): GamepadInk { + val accent = p.accentColor + // Chosen by luminance, not by `light`: an accent is picked for contrast against the + // GLASS, not against the field. + val accentLuma = + 0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third + val onAccent = if (accentLuma > 0.55) Color.Black else Color.White + if (!p.light) { + return GamepadInk( + fg = Color.White, + accent = accent, + onAccent = onAccent, + glass = Color.White.copy(alpha = 0.08f), + shade = Color.Black, + shadeScale = 1f, + isLight = false, + ) + } + val (gr, gg, gb) = p.ground + return GamepadInk( + // Tinted toward the palette's own ground so it doesn't read as a foreign grey. + fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()), + accent = accent, + onAccent = onAccent, + // More body than the dark glass carries: white frost over a bright gradient has + // far less separating it from its backdrop than dark glass over a dark one. + glass = Color.White.copy(alpha = 0.55f), + shade = Color.White, + shadeScale = 0.45f, + isLight = true, + ) + } + + /** The shipped dark look — what a preview or a test composition gets. */ + val DARK = of(GamepadPalette.named("violet")) + } +} + +/** + * The ink of the palette currently drawing, for everything under [App]. Provided from the live + * settings alongside [LocalGamepadPalette], so a change on the gamepad settings screen re-inks + * every console surface at once. + */ +val LocalGamepadInk = compositionLocalOf { GamepadInk.DARK } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt index 3db75d01..9e4a04d2 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt @@ -1,78 +1,194 @@ 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. +// The console (gamepad) UI's background colour families, and the ink each one calls for. // -// 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. +// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The +// field samples that ramp so several tones show at once and pool into each other, the way a real +// gradient poster does. An earlier version rotated ONE field's hue per palette, which is why every +// non-default palette read flat and monotone. // -// 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. +// A palette also owns the UI sitting on it: [accent] is the focus wash / selected pill / switch +// colour, and [light] flips the ink so a pale field gets dark text instead of white. +// +// The table, [ramp] and [CELL_RAMP] are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the +// Apple client's `GamepadPalette.swift` under the same ids, so one `ui_palette` value is one look +// on every client. Keep the three copies in step: a palette added here without the others is a +// value the other clients 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. - */ +/** One background colour family. */ 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. + * The colour ramp, dark end first. Empty = the brand default's explicit field, kept + * bit-identical to what every install already sees. */ - fun tint(c: Triple): Triple { - val (r, g, b) = c - val a = Math.toRadians(hueDegrees) - val cs = cos(a) - val sn = sin(a) - val invSqrt3 = 1.0 / sqrt(3.0) - val grey = (r + g + b) / 3.0 * (1.0 - cs) - // The `sn` term is cross(k, c) with k = (1,1,1)/√3. - val rr = r * cs + (b - g) * invSqrt3 * sn + grey - val rg = g * cs + (r - b) * invSqrt3 * sn + grey - val rb = b * cs + (g - r) * invSqrt3 * sn + grey - val luma = 0.2126 * rr + 0.7152 * rg + 0.0722 * rb - fun mix(v: Double) = (luma + (v - luma) * saturation).coerceIn(0.0, 1.0) - return Triple(mix(rr), mix(rg), mix(rb)) + val stops: List>, + /** The field's ground — what it settles onto and what the calm mix lifts toward. */ + val ground: Triple, + /** The UI accent: focus wash, selected tab pill, switch track. */ + val accent: Triple, + /** A pale field: the UI flips to dark ink and the legibility scrims go white. */ + val light: Boolean, +) { + /** Four drifting blob colours, spread across the ramp so the field shows several hues. */ + val blobColors: List by lazy { + val s = stops.ifEmpty { VIOLET_BLOBS } + (0..3).map { color(ramp(s, 0.15 + 0.25 * it)) } } + /** The field's ground as a Compose colour. */ + val groundColor: Color by lazy { color(ground) } + + /** The accent as a Compose colour. */ + val accentColor: Color by lazy { color(accent) } + companion object { /** - * The six shipped palettes, in cycling order: the brand violet, then cool → warm, then - * the neutral. + * Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept + * here so the three ports stay one table even though this client approximates the field + * with blobs. + */ + val CELL_RAMP = listOf( + 0.10, -0.06, 0.04, -0.12, + -0.08, 0.14, -0.10, 0.06, + 0.06, -0.12, 0.16, -0.04, + -0.10, 0.08, -0.06, 0.12, + ) + + /** The brand default's blob ramp — the colours the pre-palette field used. */ + private val VIOLET_BLOBS = listOf( + Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80), + Triple(0.22, 0.38, 0.86), Triple(0.53, 0.47, 0.96), + ) + + /** + * The twelve shipped palettes: the brand default, five more dark fields, then six pale + * ones. Cycling order runs dark → light, so stepping the row walks the range one way. */ 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), + // --- dark fields (white ink) --- + GamepadPalette( + "violet", "Violet", emptyList(), + ground = Triple(0.075, 0.060, 0.160), + accent = Triple(0.525, 0.471, 0.961), light = false, + ), + GamepadPalette( + // Deep indigo climbing through violet into a hot magenta. + "nebula", "Nebula", + listOf( + Triple(0.07, 0.05, 0.20), Triple(0.26, 0.14, 0.54), Triple(0.52, 0.20, 0.72), + Triple(0.82, 0.26, 0.62), Triple(0.98, 0.46, 0.68), + ), + ground = Triple(0.055, 0.040, 0.135), + accent = Triple(0.95, 0.42, 0.72), light = false, + ), + GamepadPalette( + // Ink-blue water: teal → cerulean → a violet undertow. + "abyss", "Abyss", + listOf( + Triple(0.02, 0.10, 0.17), Triple(0.04, 0.28, 0.42), Triple(0.07, 0.46, 0.63), + Triple(0.16, 0.38, 0.78), Triple(0.26, 0.22, 0.58), + ), + ground = Triple(0.018, 0.070, 0.130), + accent = Triple(0.26, 0.76, 0.92), light = false, + ), + GamepadPalette( + // Banked coals: plum embers → crimson → burnt orange → gold. + "ember", "Ember", + listOf( + Triple(0.16, 0.03, 0.10), Triple(0.45, 0.06, 0.12), Triple(0.72, 0.18, 0.06), + Triple(0.90, 0.42, 0.08), Triple(0.95, 0.68, 0.18), + ), + ground = Triple(0.090, 0.035, 0.040), + accent = Triple(0.98, 0.62, 0.26), light = false, + ), + GamepadPalette( + // Forest floor into moss and a lime break. + "moss", "Moss", + listOf( + Triple(0.03, 0.11, 0.09), Triple(0.06, 0.27, 0.20), Triple(0.09, 0.45, 0.31), + Triple(0.28, 0.61, 0.28), Triple(0.58, 0.77, 0.31), + ), + ground = Triple(0.025, 0.085, 0.070), + accent = Triple(0.48, 0.86, 0.46), light = false, + ), + GamepadPalette( + // Neutral, but never flat: barely-there saturation that still travels from a cool + // charcoal to a warm stone. + "graphite", "Graphite", + listOf( + Triple(0.06, 0.07, 0.11), Triple(0.15, 0.18, 0.25), Triple(0.30, 0.31, 0.35), + Triple(0.45, 0.42, 0.38), Triple(0.60, 0.56, 0.49), + ), + ground = Triple(0.055, 0.055, 0.070), + accent = Triple(0.78, 0.80, 0.86), light = false, + ), + // --- pale fields (dark ink) --- + GamepadPalette( + // The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom. + "holo", "Holo", + listOf( + Triple(0.99, 0.72, 0.90), Triple(0.80, 0.60, 0.98), Triple(0.58, 0.62, 0.99), + Triple(0.55, 0.86, 0.98), Triple(0.94, 0.98, 1.00), + ), + ground = Triple(0.96, 0.92, 0.99), + accent = Triple(0.42, 0.28, 0.86), light = true, + ), + GamepadPalette( + // The poster sunset: periwinkle → magenta → scarlet → tangerine → gold. + "sunset", "Sunset", + listOf( + Triple(0.55, 0.45, 0.92), Triple(0.86, 0.31, 0.66), Triple(0.97, 0.26, 0.34), + Triple(0.99, 0.51, 0.18), Triple(1.00, 0.80, 0.22), + ), + ground = Triple(0.98, 0.74, 0.34), + accent = Triple(0.64, 0.13, 0.44), light = true, + ), + GamepadPalette( + // Peach into blush and lilac — the softest of the set. + "bloom", "Bloom", + listOf( + Triple(1.00, 0.86, 0.72), Triple(0.99, 0.73, 0.79), Triple(0.95, 0.65, 0.89), + Triple(0.82, 0.68, 0.96), Triple(0.73, 0.79, 0.99), + ), + ground = Triple(0.99, 0.90, 0.89), + accent = Triple(0.72, 0.24, 0.55), light = true, + ), + GamepadPalette( + // First light: pale gold → coral → lilac. + "dawn", "Dawn", + listOf( + Triple(1.00, 0.92, 0.70), Triple(1.00, 0.80, 0.62), Triple(0.99, 0.66, 0.62), + Triple(0.90, 0.62, 0.78), Triple(0.77, 0.69, 0.95), + ), + ground = Triple(1.00, 0.93, 0.82), + accent = Triple(0.82, 0.33, 0.28), light = true, + ), + GamepadPalette( + // Sea glass: mint → aqua → a pale sky. + "mint", "Mint", + listOf( + Triple(0.82, 0.98, 0.90), Triple(0.62, 0.94, 0.88), Triple(0.55, 0.88, 0.95), + Triple(0.63, 0.82, 0.99), Triple(0.82, 0.87, 1.00), + ), + ground = Triple(0.90, 0.98, 0.96), + accent = Triple(0.04, 0.42, 0.40), light = true, + ), + GamepadPalette( + // Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn. + "opal", "Opal", + listOf( + Triple(0.98, 0.92, 0.96), Triple(0.87, 0.93, 0.99), Triple(0.91, 0.99, 0.95), + Triple(0.99, 0.96, 0.88), Triple(0.94, 0.90, 0.99), + ), + ground = Triple(0.97, 0.96, 0.99), + accent = Triple(0.36, 0.32, 0.44), light = true, + ), ) /** @@ -80,5 +196,23 @@ class GamepadPalette( * palette a newer client shipped, not a reason to draw nothing. */ fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0] + + /** Sample an ordered colour ramp at [t] ∈ [0, 1] (linear between neighbouring stops). */ + fun ramp( + stops: List>, + t: Double, + ): Triple { + if (stops.isEmpty()) return Triple(0.0, 0.0, 0.0) + if (stops.size == 1) return stops[0] + val x = t.coerceIn(0.0, 1.0) * (stops.size - 1) + val i = x.toInt().coerceAtMost(stops.size - 2) + val f = x - i + val (ar, ag, ab) = stops[i] + val (br, bg, bb) = stops[i + 1] + return Triple(ar + (br - ar) * f, ag + (bg - ag) * f, ab + (bb - ab) * f) + } + + fun color(c: Triple): Color = + Color(c.first.toFloat(), c.second.toFloat(), c.third.toFloat()) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 48911910..7da32711 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -346,6 +346,7 @@ fun GamepadSettingsScreen( @Composable private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) // The chevrons keep their layout slot and only fade, so the value never jumps sideways when @@ -357,7 +358,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick label = "chevrons", ) val valueColor by animateColorAsState( - Color.White.copy(alpha = if (focused) 1f else 0.6f), + ink.fg(if (focused) 1f else 0.6f), tween(160), label = "valueColor", ) @@ -366,7 +367,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick Text( row.header.uppercase(), style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.45f), + color = ink.fg(0.45f), letterSpacing = 1.4.sp, modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp), ) @@ -392,7 +393,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick fontWeight = FontWeight.SemiBold, // A disabled row (the "No profiles yet" placeholder) dims but stays focusable, // so its detail line can still explain what would go here. - color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f), + color = ink.fg(if (row.enabled) 1f else 0.45f), maxLines = 1, ) Spacer(Modifier.weight(1f)) @@ -400,7 +401,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick // A toggle is a switch, not text — the sliding knob + tinting track IS the value. ConsoleSwitch(on = row.toggled, focused = focused) } else { - Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) + Text("‹ ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) // The value slides in the direction it was stepped and its width animates, so // cycling a choice reads as motion through a list rather than a text swap. AnimatedContent( @@ -421,7 +422,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick overflow = TextOverflow.Ellipsis, ) } - Text(" ›", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) + Text(" ›", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) } } // The focused row carries its own one-line description — no dedicated (space-eating) @@ -434,7 +435,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick Text( row.detail, style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.6f), + color = ink.fg(0.6f), maxLines = 2, modifier = Modifier.padding(top = 6.dp), ) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index 34fd3a9b..525ee127 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -90,6 +90,7 @@ fun LibraryScreen( onBack: () -> Unit, navActive: Boolean = true, ) { + val ink = LocalGamepadInk.current BackHandler(onBack = onBack) val context = LocalContext.current val scope = rememberCoroutineScope() @@ -177,8 +178,8 @@ fun LibraryScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), ) { - CircularProgressIndicator(color = Color.White) - Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge) + CircularProgressIndicator(color = ink.fg) + Text("Launching…", color = ink.fg, style = MaterialTheme.typography.bodyLarge) } } } @@ -202,17 +203,19 @@ fun LibraryScreen( @Composable private fun LoadingState() { + val ink = LocalGamepadInk.current Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) { - CircularProgressIndicator(color = Color.White) - Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge) + CircularProgressIndicator(color = ink.fg) + Text("Loading library…", color = ink.fg(0.7f), style = MaterialTheme.typography.bodyLarge) } } @Composable private fun MessageState(text: String) { + val ink = LocalGamepadInk.current Text( text, - color = Color.White.copy(alpha = 0.75f), + color = ink.fg(0.75f), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 24.dp), @@ -226,6 +229,7 @@ private fun Coverflow( navActive: Boolean, onLaunch: (GameEntry) -> Unit, ) { + val ink = LocalGamepadInk.current BoxWithConstraints(Modifier.fillMaxSize()) { // Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen. val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp) @@ -321,7 +325,7 @@ private fun Coverflow( current?.title ?: " ", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -330,7 +334,7 @@ private fun Coverflow( if (current.isLauncher) "${current.storeLabel.uppercase()} \u00B7 LAUNCHER" else current.storeLabel.uppercase(), style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.5f), + color = ink.fg(0.5f), letterSpacing = 2.sp, ) } @@ -342,6 +346,7 @@ private fun Coverflow( /** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */ @Composable private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current val candidates = game.art.posterCandidates var idx by remember(game.id) { mutableStateOf(0) } val shape = RoundedCornerShape(16.dp) @@ -349,7 +354,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo modifier = modifier .clip(shape) .background(Color(0xFF241F3D)) - .border(1.dp, Color.White.copy(alpha = 0.12f), shape), + .border(1.dp, ink.fg(0.12f), shape), contentAlignment = Alignment.Center, ) { if (idx < candidates.size) { @@ -368,7 +373,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo if (game.isLauncher) game.storeLabel else game.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - color = Color.White.copy(alpha = 0.75f), + color = ink.fg(0.75f), textAlign = TextAlign.Center, modifier = Modifier.padding(12.dp), ) @@ -378,7 +383,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo Text( game.storeLabel, style = MaterialTheme.typography.labelSmall, - color = Color.White, + color = ink.fg, modifier = Modifier .clip(RoundedCornerShape(50)) .background( diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt index 40b40b98..df662de4 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt @@ -5,92 +5,118 @@ 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. +// (`pf-console-ui::library`) and Swift (`GamepadPalette.swift`) ports reproduce — the same ids in +// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look +// 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) + private fun luma(c: Triple) = + 0.2126 * c.first + 0.7152 * c.second + 0.0722 * c.third + + /** Hue angle in degrees, or null for something too grey to have one. */ + private fun hue(c: Triple): Double? { + val (r, g, b) = c + val max = maxOf(r, g, b) + val min = minOf(r, g, b) + val d = max - min + if (d < 0.04) return null + val h = when (max) { + r -> 60.0 * (((g - b) / d) % 6.0) + g -> 60.0 * ((b - r) / d + 2.0) + else -> 60.0 * ((r - g) / d + 4.0) + } + return (h + 360.0) % 360.0 } - /** The ids and their order are the cross-client contract (strip order, and the L1/R1 cycle). */ + /** Ids, order and the light/dark split are the cross-client contract. */ @Test fun tableMatchesTheOtherClients() { assertEquals( - listOf("violet", "tide", "forest", "ember", "rose", "graphite"), + listOf( + "violet", "nebula", "abyss", "ember", "moss", "graphite", + "holo", "sunset", "bloom", "dawn", "mint", "opal", + ), GamepadPalette.ALL.map { it.id }, ) - assertEquals( - listOf("Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"), - GamepadPalette.ALL.map { it.name }, - ) + // Dark fields lead, pale ones follow, so stepping the row walks one direction. + val firstLight = GamepadPalette.ALL.indexOfFirst { it.light } + assertEquals(6, firstLight) + assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light }) + // An unknown name is a newer client's palette, not an error. + assertEquals("violet", GamepadPalette.named("chartreuse").id) + assertEquals("violet", GamepadPalette.named("").id) + // The brand default keeps the shipped field rather than a generated ramp. + assertTrue(GamepadPalette.named("violet").stops.isEmpty()) } /** - * 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. + * A palette must read as SEVERAL hues, not one hue at several brightnesses — that was exactly + * the complaint about the hue-rotation model this replaced. */ @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) + fun everyPaletteIsMultiTone() { + for (p in GamepadPalette.ALL) { + val stops = p.stops.ifEmpty { continue } + val hues = stops.mapNotNull { hue(it) } + assertTrue("${p.id}: too few coloured stops", hues.size >= 3) + var spread = 0.0 + for (a in hues) { + for (b in hues) { + val d = Math.abs(a - b) % 360.0 + spread = maxOf(spread, minOf(d, 360.0 - d)) } } + // Graphite and Opal are deliberately near-neutral; the rest must travel. + val floor = if (p.id == "graphite" || p.id == "opal") 20.0 else 45.0 + assertTrue("${p.id} spans only $spread° of hue", spread >= floor) } } + /** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */ + @Test + fun palettesAreHonestAboutLightness() { + for (p in GamepadPalette.ALL) { + if (p.light) { + assertTrue("${p.id}'s ground is dark", luma(p.ground) > 0.6) + assertTrue("${p.id}'s accent is too pale", luma(p.accent) < 0.45) + } else { + assertTrue("${p.id}'s ground is light", luma(p.ground) < 0.2) + assertTrue("${p.id}'s accent is too dark", luma(p.accent) > 0.25) + } + } + } + + /** The ramp is the shared sampling rule the Rust and Swift ports reproduce. */ + @Test + fun rampInterpolatesBetweenStops() { + val stops = listOf( + Triple(0.0, 0.0, 0.0), Triple(1.0, 0.0, 0.0), Triple(1.0, 1.0, 1.0), + ) + assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.0)) + assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 1.0)) + assertEquals(Triple(1.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.5)) + assertEquals(0.5, GamepadPalette.ramp(stops, 0.25).first, 1e-9) + // Out of range clamps rather than throwing. + assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, -3.0)) + assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 9.0)) + assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(emptyList(), 0.5)) + } + + /** The ink a palette calls for: white on a dark field, near-black on a pale one. */ + @Test + fun inkFollowsTheField() { + val dark = GamepadInk.of(GamepadPalette.named("violet")) + assertTrue(!dark.isLight) + assertEquals(1f, dark.fg.red, 1e-6f) + assertEquals(1f, dark.shadeScale, 1e-6f) + + val light = GamepadInk.of(GamepadPalette.named("holo")) + assertTrue(light.isLight) + assertTrue("pale fields need dark ink", light.fg.red < 0.3f) + // A pale field's scrims must pull far less, or they bleach the gradient. + assertTrue(light.shadeScale < 0.5f) + } + /** * 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. diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 70dd0e57..8af7d299 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -109,6 +109,11 @@ class ScreenshotTest { @Test fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() } + /** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */ + @Test + fun consoleSettingsLight() = + shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") } + @Test fun trust() = shootScreen("trust") { HostsScene() diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 43562f37..de69a24e 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -31,7 +31,12 @@ import io.unom.punktfunk.BrandDark import io.unom.punktfunk.ConnectModal import io.unom.punktfunk.ConnectPhase import io.unom.punktfunk.ConnectTakeover +import androidx.compose.runtime.CompositionLocalProvider +import io.unom.punktfunk.GamepadInk +import io.unom.punktfunk.GamepadPalette import io.unom.punktfunk.GamepadSettingsScreen +import io.unom.punktfunk.LocalGamepadInk +import io.unom.punktfunk.LocalGamepadPalette import io.unom.punktfunk.Settings import io.unom.punktfunk.TouchMode import io.unom.punktfunk.SettingsCategory @@ -415,5 +420,17 @@ internal fun ConnectConsoleScene() = * a layout regression would eat first. */ @Composable -internal fun ConsoleSettingsScene() = - GamepadSettingsScreen(initial = SHOT_SETTINGS, onChange = {}, onBack = {}) +internal fun ConsoleSettingsScene(paletteId: String = "violet") { + // The scene calls the screen directly, so it has to publish the palette locals `App` would + // normally provide — without them a light palette would render with the default DARK ink and + // the shot would silently prove nothing. + val palette = GamepadPalette.named(paletteId) + CompositionLocalProvider( + LocalGamepadPalette provides palette, + LocalGamepadInk provides GamepadInk.of(palette), + ) { + GamepadSettingsScreen( + initial = SHOT_SETTINGS.copy(uiPalette = paletteId), onChange = {}, onBack = {}, + ) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 75bf05b6..2b16327b 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -536,6 +536,9 @@ struct ContentView: View { waker: waker, gamepadUI: gamepadUIActive, onCancelConnect: { model.disconnect() }) + // The takeover mounts OUTSIDE the gamepad screens (it covers the whole home), so + // it publishes the palette's ink itself rather than inheriting it. + .gamepadPaletteInk() } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift b/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift index b8246e98..c3fe8b3e 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift @@ -47,12 +47,16 @@ struct ConnectOverlay: View { return nil } + @Environment(\.gamepadInk) private var ink + var body: some View { if let phase { ZStack { if gamepadUI { - // Console: an opaque, living aurora over everything. - Color.black.ignoresSafeArea() + // Console: an opaque, living aurora over everything, in the chosen palette. + // The takeover's own text rides `ink`, so a pale palette flips it here too — + // without that this is the one console screen that stays white-on-white. + ink.isLight ? Color.white.ignoresSafeArea() : Color.black.ignoresSafeArea() GamepadScreenBackground().ignoresSafeArea() Color.clear.contentShape(Rectangle()).onTapGesture {} content(phase).padding(40).frame(maxWidth: 460) @@ -70,7 +74,8 @@ struct ConnectOverlay: View { .padding(40) } } - .environment(\.colorScheme, .dark) + // The console takeover follows the palette; the default UI's modal stays dark. + .environment(\.colorScheme, gamepadUI && ink.isLight ? .light : .dark) .transition(.opacity) #if os(iOS) || os(macOS) .background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index 30a820c5..f5af6b36 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -12,6 +12,7 @@ import SwiftUI #if os(iOS) || os(macOS) || os(tvOS) struct GamepadAddHostView: View { + @Environment(\.gamepadInk) private var ink @Environment(\.dismiss) private var dismiss let onAdd: (StoredHost) -> Void @@ -47,12 +48,12 @@ struct GamepadAddHostView: View { VStack(spacing: 4) { Text("Add Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) if !compact { Text("Hosts on this network appear automatically — add one by address " + "for everything else.") .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(ink.fg(0.55)) .multilineTextAlignment(.center) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72) } @@ -73,6 +74,9 @@ struct GamepadAddHostView: View { } // No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen. .background { GamepadFormBackground() } + // Publish the palette's ink to this screen (text, glass, accent, scrims) — a + // pale palette flips all of them, and no leaf should have to read the setting. + .gamepadPaletteInk() // A port can't exceed 5 digits — cap while typing so the row can't grow absurd. .onChange(of: port) { _, value in if value.count > 5 { port = String(value.prefix(5)) } @@ -143,7 +147,7 @@ struct GamepadAddHostView: View { Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) .glassBackground(Circle(), interactive: true) .contentShape(Circle()) @@ -180,22 +184,22 @@ struct GamepadAddHostView: View { if row.isAction { Label("Add Host", systemImage: "plus.circle.fill") .font(.geist(m.labelFont, .semibold, relativeTo: .body)) - .foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35)) + .foregroundStyle(canAdd ? ink.accent : ink.fg(0.35)) .frame(maxWidth: .infinity) } else { Text(row.label) .font(.geist(m.labelFont, .semibold, relativeTo: .body)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) Spacer(minLength: 12) Text(row.value.isEmpty ? row.placeholder : row.value) .font(.geistFixed(m.valueFont, .medium)) - .foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white) + .foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg) .lineLimit(1) .truncationMode(.head) // keep the end of a long address visible while typing if editing == row.id { // The live-edit caret: this row is what the keyboard tray is typing into. Rectangle() - .fill(Color.brand) + .fill(ink.accent) .frame(width: 2, height: m.labelFont + 2) } } @@ -206,12 +210,12 @@ struct GamepadAddHostView: View { // takes the brand wash, and the edited row keeps its brand caret border. .consoleGlass( RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous), - tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil, + tint: (focused || editing == row.id) ? ink.accent(0.30) : nil, interactive: focused) .overlay { RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) .strokeBorder( - editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06), + editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06), lineWidth: 1) } .scaleEffect(focused ? 1.0 : 0.98) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index 80c5390e..f0fb8fc4 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -89,6 +89,7 @@ struct GamepadHint: Identifiable { /// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over /// the backdrop instead of dissolving into it. struct GamepadHintBar: View { + @Environment(\.gamepadInk) private var ink let hints: [GamepadHint] // 10-foot legend on tvOS, in-hand sizes elsewhere. @@ -108,17 +109,17 @@ struct GamepadHintBar: View { HStack(spacing: 7) { Image(systemName: hint.glyph) .font(.system(size: Self.glyphFont)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) Text(hint.text) } .fixedSize() // keep glyph + label together; never truncate a hint mid-word } } .font(.geist(Self.textFont, .semibold, relativeTo: .subheadline)) - .foregroundStyle(.white.opacity(0.85)) + .foregroundStyle(ink.fg(0.85)) .padding(Self.pad) .consoleGlass(Capsule()) - .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) + .overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1)) } } @@ -145,6 +146,7 @@ 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 { + @Environment(\.gamepadInk) private var ink /// Quiet the field for a form screen (see the type comment). var calm = false @@ -171,36 +173,41 @@ struct GamepadScreenBackground: View { /// 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 + // Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark + // field's strength bleaches the chroma straight out of the gradient, so a pale palette + // gets under half — the same `u_scrim.a` the console shader carries. + let scrim: Color = palette.light ? ink.fg : .black + let strength = palette.light ? 0.45 : 1.0 + return ZStack { + Self.color(palette.ground) 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… + // Calm = col·0.6 + ground·0.4: over the ground, `.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)) + // …and a plusLighter wash of the palette's own ground IS the add. Chosen so the + // ground lands exactly where it was and the bright pools come down to meet it. + Self.color(palette.ground) .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. + // Cinematic vignette: the edges settle toward the scrim so the cards sit in the + // pooled light. Soft (extends past the frame) so the corners deepen rather than + // crush. 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 them just eats the list. EllipticalGradient( - colors: [.clear, .black.opacity(calm ? 0.21 : 0.42)], + colors: [.clear, scrim.opacity((calm ? 0.21 : 0.42) * strength)], 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 - // blur), so it stays a gradient, just a light one now. + // works on the field itself (it's the backdrop's bottom layer — nothing behind it to + // blur), so it stays a gradient, just a light one. LinearGradient( stops: [ - .init(color: .black.opacity(0.38), location: 0), - .init(color: .black.opacity(0.06), location: 0.32), - .init(color: .black.opacity(0.08), location: 0.68), - .init(color: .black.opacity(0.40), location: 1), + .init(color: scrim.opacity(0.38 * strength), location: 0), + .init(color: scrim.opacity(0.06 * strength), location: 0.32), + .init(color: scrim.opacity(0.08 * strength), location: 0.68), + .init(color: scrim.opacity(0.40 * strength), location: 1), ], startPoint: .top, endPoint: .bottom) } @@ -211,7 +218,7 @@ struct GamepadScreenBackground: View { MeshGradient( width: 4, height: 4, points: Self.meshPoints(at: t), - colors: Self.meshColors(palette), + colors: palette.meshColors.map(Self.color), smoothsColors: true) } else { LegacyBlobField(t: t, palette: palette) @@ -224,28 +231,6 @@ struct GamepadScreenBackground: View { 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. A palette rotates - /// the whole grid; `violet` is the identity, so this array IS what the default draws. - private static let baseMeshRGB: [SIMD3] = [ - cornerRGB, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), cornerRGB, - SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64), - SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), - cornerRGB, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), cornerRGB, - ] - - /// `baseMeshRGB` under a palette. Recomputed per frame rather than cached — sixteen `tint` - /// calls at 30 Hz costs nothing next to rasterising the mesh, and the obvious cache would be - /// mutable global state on a type SwiftUI is free to evaluate off the main actor. - private static func meshColors(_ palette: GamepadPalette) -> [Color] { - baseMeshRGB.map { color(palette.tint($0)) } - } - /// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh /// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black /// behind it), while only the four interior points wander on slow, out-of-phase sinusoids @@ -280,9 +265,9 @@ private struct LegacyBlobField: View { 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. + /// (rad/s — periods of 30–90 s), and a radius that slowly breathes. The COLOUR comes from the + /// palette's ramp at draw time (see `blobColors`), so an older OS honours the setting too. private struct Blob { - let rgb: SIMD3 let center: CGPoint let drift: CGSize let speed: (x: Double, y: Double) @@ -293,20 +278,16 @@ private struct LegacyBlobField: View { } private static let blobs: [Blob] = [ - 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), + Blob(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(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), + Blob(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(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), + Blob(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(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), + Blob(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), ] @@ -316,19 +297,21 @@ private struct LegacyBlobField: View { let side = max(geo.size.width, geo.size.height) ZStack { ForEach(Self.blobs.indices, id: \.self) { i in - blobView(Self.blobs[i], in: geo.size, side: side) + blobView(Self.blobs[i], tone: palette.blobColors[i], in: geo.size, side: side) } } .drawingGroup() } } - private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View { + private func blobView( + _ blob: Blob, tone: SIMD3, in size: CGSize, side: CGFloat + ) -> some View { let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x)) 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)) + let color = GamepadScreenBackground.color(tone) return Circle() .fill(RadialGradient( colors: [color, color.opacity(0)], @@ -336,7 +319,9 @@ private struct LegacyBlobField: View { .frame(width: r, height: r) .position(x: x * size.width, y: y * size.height) .opacity(blob.opacity) - .blendMode(.plusLighter) + // Additive only works over a DARK ground; over a pale one every blob saturates to + // white and the field turns grey. Pale palettes tint instead. + .blendMode(palette.light ? .normal : .plusLighter) } } @@ -346,15 +331,17 @@ private struct LegacyBlobField: View { /// the tray's text sits on a softly blurred backdrop that dissolves into the rows. struct GamepadTrayScrim: View { let edge: VerticalEdge + @Environment(\.gamepadInk) private var ink var body: some View { let fromEdge: UnitPoint = edge == .top ? .top : .bottom let toContent: UnitPoint = edge == .top ? .bottom : .top Rectangle() .fill(.ultraThinMaterial) - // These trays always sit on the dark console UI; force dark so the material frosts dark - // (white text stays legible) regardless of the system appearance. - .environment(\.colorScheme, .dark) + // Force the frost to match the PALETTE, not the system appearance: the tray exists + // to keep the pinned title legible, so it has to frost dark under white ink and + // light under dark ink. + .environment(\.colorScheme, ink.isLight ? .light : .dark) // Fade the whole blur out toward the content so it dissolves rather than ending on a line. .mask { LinearGradient( @@ -404,6 +391,7 @@ struct ConsoleBareButtonStyle: ButtonStyle { /// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders /// when the pad or its battery state changes. struct ControllerStatusChip: View { + @Environment(\.gamepadInk) private var ink let controller: GamepadManager.DiscoveredController // Legible from the couch on tvOS, quiet in hand elsewhere. @@ -428,15 +416,15 @@ struct ControllerStatusChip: View { Image(systemName: batterySymbol(level)) .font(.system(size: Self.font)) .foregroundStyle(level <= 0.2 && !controller.isCharging - ? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7))) + ? AnyShapeStyle(.red) : AnyShapeStyle(ink.fg(0.7))) } } .font(.geist(Self.font, .medium, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(ink.fg(0.7)) .padding(.horizontal, Self.hPad) .padding(.vertical, Self.vPad) - .background(Capsule().fill(.white.opacity(0.08))) - .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) + .background(Capsule().fill(ink.fg(0.08))) + .overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1)) } private func batterySymbol(_ level: Float) -> String { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 1282fdb8..030ce2dd 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -64,6 +64,7 @@ private struct HomeTile: Identifiable { } struct GamepadHomeView: View { + @Environment(\.gamepadInk) private var ink @ObservedObject var store: HostStore @ObservedObject var model: SessionModel @ObservedObject var discovery: HostDiscovery @@ -115,6 +116,9 @@ struct GamepadHomeView: View { .padding(.top, compact ? 4 : 8) } .background { GamepadScreenBackground() } + // Publish the palette's ink to this screen (text, glass, accent, scrims) — a + // pale palette flips all of them, and no leaf should have to read the setting. + .gamepadPaletteInk() .onAppear { discovery.start() } .onDisappear { discovery.stop() } // Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show @@ -186,7 +190,7 @@ struct GamepadHomeView: View { statusChip(hidden: true) Text("Select a Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.75) .frame(maxWidth: .infinity) @@ -355,6 +359,7 @@ struct GamepadHomeView: View { /// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by /// the caller's `.scrollTransition` so it always tracks the real scroll position. private struct GamepadHostTile: View { + @Environment(\.gamepadInk) private var ink let tile: HomeTile let size: CGSize @@ -394,7 +399,7 @@ private struct GamepadHostTile: View { if tile.isPaired { Image(systemName: "lock.fill") .font(.system(size: Self.statusFont, weight: .semibold)) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(ink.fg(0.5)) } if tile.isOnline { Circle() @@ -407,7 +412,7 @@ private struct GamepadHostTile: View { Spacer(minLength: 0) Text(tile.title) .font(.geist(Self.titleFont, .bold, relativeTo: .title2)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.7) if let profile = tile.profile { @@ -417,7 +422,7 @@ private struct GamepadHostTile: View { } Text(tile.subtitle) .font(.geist(Self.subtitleFont, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(ink.fg(0.55)) .lineLimit(1) .padding(.top, 2) } @@ -427,12 +432,12 @@ private struct GamepadHostTile: View { // Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself. .consoleGlass( RoundedRectangle(cornerRadius: Self.corner, style: .continuous), - tint: tile.filled ? Color.brand.opacity(0.20) : nil) + tint: tile.filled ? ink.accent(0.20) : nil) .overlay { RoundedRectangle(cornerRadius: Self.corner, style: .continuous) .strokeBorder( LinearGradient( - colors: [.white.opacity(0.22), .white.opacity(0.04)], + colors: [ink.fg(0.22), ink.fg(0.04)], startPoint: .top, endPoint: .bottom), style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5])) } @@ -444,15 +449,15 @@ private struct GamepadHostTile: View { return ZStack { shape.fill(tile.filled ? AnyShapeStyle(LinearGradient( - colors: [Color.brand, Color.brand.opacity(0.68)], + colors: [ink.accent, ink.accent(0.68)], startPoint: .top, endPoint: .bottom)) - : AnyShapeStyle(Color.brand.opacity(0.16))) + : AnyShapeStyle(ink.accent(0.16))) if tile.isConnecting { - ProgressView().tint(.white) + ProgressView().tint(ink.fg) } else if let icon = tile.icon { Image(systemName: icon) .font(.system(size: Self.iconFont, weight: .semibold)) - .foregroundStyle(Color.brand) + .foregroundStyle(ink.accent) } else if let mark = osIconImage(for: tile.osChain) { // The OS mark stands in for the initial (template asset — tints like the text it // replaces), and carries the label, since nothing else on the tile names the OS. @@ -460,18 +465,18 @@ private struct GamepadHostTile: View { .resizable() .scaledToFit() .frame(width: Self.monogramFont, height: Self.monogramFont) - .foregroundStyle(tile.filled ? .white : Color.brand) + .foregroundStyle(tile.filled ? ink.fg : ink.accent) .accessibilityLabel(tile.osChain ?? "") } else { Text(monogram(tile.title)) .font(.geistFixed(Self.monogramFont, .bold)) - .foregroundStyle(tile.filled ? .white : Color.brand) + .foregroundStyle(tile.filled ? ink.fg : ink.accent) } } .frame(width: Self.badgeSide, height: Self.badgeSide) .overlay { if !tile.filled { - shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1) + shape.strokeBorder(ink.accent(0.5), lineWidth: 1) } } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift new file mode 100644 index 00000000..fbb74b34 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift @@ -0,0 +1,91 @@ +// The ink the gamepad UI draws with under the chosen background palette. +// +// The console screens were white-on-dark throughout with the brand violet hardcoded as the +// accent. Both had to become palette-derived at once: a pale field needs dark text or it is +// unreadable, and a violet focus wash on a copper field is exactly the clash this exists to fix. +// +// Handed down the view tree as an environment value rather than passed to each screen, so a +// leaf (a row, a hint pill, a card) can ask for the right colour without every caller in between +// knowing about palettes. `pf-console-ui` does the same thing with a thread-local `Ink`. + +import PunktfunkShared +import SwiftUI + +#if os(iOS) || os(macOS) || os(tvOS) + +struct GamepadInk: Equatable, Sendable { + /// Primary text/glyph colour. + let fg: Color + /// Focus wash, selected tab pill, switch track, caret — the palette's own accent. + let accent: Color + /// What reads ON the accent (a filled pill's label). + let onAccent: Color + /// The base fill every glass surface starts from. + let glass: Color + /// What a wash laid UNDER text tends toward: black on a dark field, white on a pale one. + let shade: Color + /// How hard those washes go. A pale field needs far less — mixing toward white at the dark + /// field's strength bleaches the chroma straight out of the gradient. + let shadeScale: Double + /// True when the field is pale, for the few places that need to branch rather than blend + /// (a material's `colorScheme`, a shadow's presence). + let isLight: Bool + + /// The foreground at `alpha`. + func fg(_ alpha: Double) -> Color { fg.opacity(alpha) } + /// The accent at `alpha`. + func accent(_ alpha: Double) -> Color { accent.opacity(alpha) } + /// A wash under text: `alpha` is the dark-field strength, scaled for a pale one. + func shade(_ alpha: Double) -> Color { shade.opacity(alpha * shadeScale) } + + static func of(_ p: GamepadPalette) -> GamepadInk { + let accent = Color(red: p.accent.x, green: p.accent.y, blue: p.accent.z) + let accentLuma = 0.2126 * p.accent.x + 0.7152 * p.accent.y + 0.0722 * p.accent.z + // Chosen by luminance, not by `light`: an accent is picked for contrast against the + // GLASS, not against the field. + let onAccent: Color = accentLuma > 0.55 ? .black : .white + guard p.light else { + return GamepadInk( + fg: .white, accent: accent, onAccent: onAccent, + glass: Color(red: 0.086, green: 0.086, blue: 0.125), + shade: .black, shadeScale: 1, isLight: false) + } + return GamepadInk( + // Tinted toward the palette's own ground so it doesn't read as a foreign grey. + fg: Color(red: p.ground.x * 0.16, green: p.ground.y * 0.14, blue: p.ground.z * 0.20), + accent: accent, onAccent: onAccent, + glass: .white, + shade: .white, shadeScale: 0.45, isLight: true) + } + + /// The shipped dark look — what a preview or a test composition gets. + static let dark = GamepadInk.of(GamepadPalette.named("violet")) +} + +private struct GamepadInkKey: EnvironmentKey { + static let defaultValue = GamepadInk.dark +} + +extension EnvironmentValues { + /// The ink of the palette currently drawing. Set once, high up (see `GamepadInkModifier`). + var gamepadInk: GamepadInk { + get { self[GamepadInkKey.self] } + set { self[GamepadInkKey.self] = newValue } + } +} + +extension View { + /// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the + /// gamepad screens' common root so no individual view has to read the setting. + func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) } +} + +private struct GamepadInkModifier: ViewModifier { + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" + + func body(content: Content) -> some View { + content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID))) + } +} + +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift index 05882914..88965c6c 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift @@ -14,6 +14,7 @@ import SwiftUI #if os(iOS) || os(macOS) struct GamepadKeyboard: View { + @Environment(\.gamepadInk) private var ink @Binding var text: String /// Restricts typed characters (e.g. digits for a port field); backspace always works. var allowed: CharacterSet? @@ -79,7 +80,7 @@ struct GamepadKeyboard: View { } .overlay { RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(.white.opacity(0.12), lineWidth: 1) + .strokeBorder(ink.fg(0.12), lineWidth: 1) } .sensoryFeedback(.selection, trigger: cursor) .sensoryFeedback(.impact(weight: .light), trigger: pressTick) @@ -110,11 +111,11 @@ struct GamepadKeyboard: View { .font(.geist(15, .semibold, relativeTo: .callout)) } } - .foregroundStyle(focused ? Color.black : .white) + .foregroundStyle(focused ? Color.black : ink.fg) .frame(maxWidth: .infinity, minHeight: compact ? 34 : 42) .background { RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(focused ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.white.opacity(0.08))) + .fill(focused ? AnyShapeStyle(ink.accent) : AnyShapeStyle(ink.fg(0.08))) } .animation(.smooth(duration: 0.12), value: focused) .contentShape(Rectangle()) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index e4d3593f..5f73b0ef 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -19,6 +19,7 @@ import SwiftUI import GameController struct LibraryCoverflowView: View { + @Environment(\.gamepadInk) private var ink let games: [GameEntry] let imageSession: URLSession? var onLaunch: ((String) -> Void)? @@ -46,6 +47,9 @@ struct LibraryCoverflowView: View { .padding(.vertical, compact ? 6 : 10) } .background { GamepadScreenBackground() } + // Publish the palette's ink to this screen (text, glass, accent, scrims) — a + // pale palette flips all of them, and no leaf should have to read the setting. + .gamepadPaletteInk() } @ViewBuilder private func content(for size: CGSize) -> some View { @@ -97,7 +101,7 @@ struct LibraryCoverflowView: View { } .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(.white.opacity(0.12), lineWidth: 1) + .strokeBorder(ink.fg(0.12), lineWidth: 1) } .shadow(color: .black.opacity(0.5), radius: 16, y: 12) .scrollTransition { content, phase in @@ -131,7 +135,7 @@ struct LibraryCoverflowView: View { return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES") .font(.geist(11, .semibold, relativeTo: .caption2)) .tracking(1.4) - .foregroundStyle(.white.opacity(0.45)) + .foregroundStyle(ink.fg(0.45)) } /// The centered title + store tag — empty (not hidden) so the layout doesn't jump. @@ -140,18 +144,19 @@ struct LibraryCoverflowView: View { VStack(spacing: 6) { Text(game?.title ?? " ") .font(.geist(compact ? 22 : 25, .bold, relativeTo: .title)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.75) .multilineTextAlignment(.center) if let game { + // main's richer store label, in the palette's ink. Text( game.isLauncher ? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased() ) .font(.geist(11, .semibold, relativeTo: .caption2)) .tracking(1.2) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(ink.fg(0.5)) } } .frame(maxWidth: .infinity) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 355c72d7..c23452cb 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -45,6 +45,7 @@ enum GpSettingsTab: String, CaseIterable, Hashable { } struct GamepadSettingsView: View { + @Environment(\.gamepadInk) private var ink @Environment(\.dismiss) private var dismiss /// The saved-host store — the pin picker writes `setPinned` through it and the profile rows /// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen @@ -136,7 +137,7 @@ struct GamepadSettingsView: View { VStack(spacing: compact ? 4 : 8) { Text(title) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .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 @@ -151,7 +152,7 @@ struct GamepadSettingsView: View { VStack(alignment: .leading, spacing: 8) { Text(focusedDetail) .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(ink.fg(0.55)) .lineLimit(2, reservesSpace: true) .animation(.smooth(duration: 0.2), value: focusID) GamepadHintBar(hints: hints) @@ -168,6 +169,9 @@ struct GamepadSettingsView: View { // 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() } + // Publish the palette's ink to this screen (text, glass, accent, scrims) — a + // pale palette flips all of them, and no leaf should have to read the setting. + .gamepadPaletteInk() .onAppear { gamepads.refresh() gamepads.startDiscovery() @@ -220,7 +224,7 @@ struct GamepadSettingsView: View { let selected = t == tab return Text(t.rawValue) .font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote)) - .foregroundStyle(selected ? .white : .white.opacity(0.55)) + .foregroundStyle(selected ? ink.fg : ink.fg(0.55)) .padding(.horizontal, 13) .padding(.vertical, 7) .background { @@ -228,7 +232,7 @@ struct GamepadSettingsView: View { // in and out — the highlight travels the way the press did. if selected { Capsule() - .fill(Color.brand.opacity(0.85)) + .fill(ink.accent(0.85)) .matchedGeometryEffect(id: "tab", in: tabHighlight) } } @@ -276,7 +280,7 @@ struct GamepadSettingsView: View { Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) .glassBackground(Circle(), interactive: true) .contentShape(Circle()) @@ -347,18 +351,18 @@ struct GamepadSettingsView: View { HStack(spacing: 14) { Image(systemName: row.icon) .font(.system(size: m.iconFont)) - .foregroundStyle(focused ? Color.brand : .white.opacity(0.55)) + .foregroundStyle(focused ? ink.accent : ink.fg(0.55)) .frame(width: m.iconWidth) Text(row.label) .font(.geist(m.labelFont, .semibold, relativeTo: .body)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .lineLimit(1) Spacer(minLength: 12) HStack(spacing: 9) { Image(systemName: "chevron.left") .font(.system(size: m.chevronFont, weight: .semibold)) .foregroundStyle( - .white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0)) + ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0)) // Keyed by the value so a change slides the new option in instead of // hard-swapping the string — a QUIET horizontal slip following the user's // motion (a right-step enters from the right), crossfading over ~14 pt. @@ -369,7 +373,7 @@ struct GamepadSettingsView: View { ZStack { Text(row.value) .font(.geist(m.valueFont, .medium, relativeTo: .callout)) - .foregroundStyle(focused ? .white : .white.opacity(0.6)) + .foregroundStyle(focused ? ink.fg : ink.fg(0.6)) .lineLimit(1) .id(row.value) .transition(.asymmetric( @@ -380,7 +384,7 @@ struct GamepadSettingsView: View { Image(systemName: "chevron.right") .font(.system(size: m.chevronFont, weight: .semibold)) .foregroundStyle( - .white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0)) + ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0)) } } // Contents only — the glass and border below stay at full strength, so a dimmed row @@ -391,11 +395,11 @@ struct GamepadSettingsView: View { // Every row is Liquid Glass; the focused one takes a brand wash and reacts to press. .consoleGlass( RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous), - tint: focused ? Color.brand.opacity(0.30) : nil, + tint: focused ? ink.accent(0.30) : nil, interactive: focused) .overlay { RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) - .strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1) + .strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1) } .scaleEffect(focused ? 1.0 : 0.98) .animation(.smooth(duration: 0.18), value: focused) diff --git a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift index bfa111c7..6f429e44 100644 --- a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift +++ b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift @@ -80,6 +80,12 @@ private struct ConsoleGlass: ViewModifier { let shape: S var tint: Color? var interactive = false + /// The console surface follows the background palette: a PALE field needs the material to + /// frost light and the glass to read as white, or the dark ink on top of it disappears. + /// Defaults to the dark ink, so every non-gamepad caller is unchanged. + @Environment(\.gamepadInk) private var ink + + private var scheme: ColorScheme { ink.isLight ? .light : .dark } func body(content: Content) -> some View { #if os(tvOS) @@ -89,16 +95,16 @@ private struct ConsoleGlass: ViewModifier { // the 10-foot platform). The tint rides an overlay so the focused row keeps its wash. content.background { shape.fill(.ultraThinMaterial) - .environment(\.colorScheme, .dark) + .environment(\.colorScheme, scheme) .overlay { if let tint { shape.fill(tint) } } } #else if #available(iOS 26, macOS 26, *) { - content.glassEffect(glass, in: shape) + content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme) } else { - content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) } + content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, scheme) } } #endif } diff --git a/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift b/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift index c5c1b976..09b6d702 100644 --- a/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift +++ b/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift @@ -1,14 +1,18 @@ -// The gamepad UI's background colour families. +// The gamepad UI's background colour families, and the ink each one calls for. // -// 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. +// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The +// 4×4 mesh samples that ramp diagonally with a per-cell offset (`cellRamp`), so neighbouring +// cells land on different parts of it and the colours pool and swirl the way a real gradient +// poster does; the mesh's existing control-point drift then moves those pools around. An earlier +// version rotated ONE field's hue per palette, which is why every non-default palette read flat. // -// 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. +// A palette also owns the UI sitting on it: `accent` is the focus wash / selected pill / switch +// colour, and `light` flips the ink so a pale field gets dark text instead of white. +// +// The table, `ramp` and `cellRamp` are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the +// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so one `ui_palette` value is +// one look on every client. Keep the three copies in step: a palette added here without the +// others is a value the other clients 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. @@ -21,20 +25,121 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable { 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 colour ramp, dark end first. Empty = use `violetMesh` verbatim (the brand default, + /// kept bit-identical to what every install already sees). + public let stops: [SIMD3] + /// The field's ground — what the corners settle onto and what the calm mix lifts toward. + public let ground: SIMD3 + /// The UI accent: focus wash, selected tab pill, switch track, caret. + public let accent: SIMD3 + /// A pale field: the UI flips to dark ink and the legibility scrims go white. + public let light: Bool - /// The six shipped palettes, in cycling order: the brand violet, then cool → warm, then the - /// neutral. + /// Where each of the 16 mesh cells samples the ramp. The base is the diagonal + /// `0.5·(x + y)` — top-left is the ramp's dark end, bottom-right its bright one — and the + /// per-cell nudges break the banding a pure diagonal would give, so hues pool instead of + /// striping. + static let cellRamp: [Double] = [ + 0.10, -0.06, 0.04, -0.12, + -0.08, 0.14, -0.10, 0.06, + 0.06, -0.12, 0.16, -0.04, + -0.10, 0.08, -0.06, 0.12, + ] + + /// The brand default's 16 mesh colours, row-major 4×4: dark-violet corners sink the frame, + /// the edges carry mid-tone violets, and the interior holds the bright brand family. + public static let violetMesh: [SIMD3] = { + let corner = SIMD3(0.075, 0.060, 0.160) + return [ + corner, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), corner, + 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), + corner, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), corner, + ] + }() + + /// The brand default's blob ramp — the four colours the pre-18/15 legacy field used, kept so + /// `violet` is unchanged on older OSes too. + static let violetBlobs: [SIMD3] = [ + SIMD3(0.53, 0.47, 0.96), SIMD3(0.24, 0.20, 0.72), SIMD3(0.62, 0.30, 0.80), + SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96), + ] + + /// The twelve shipped palettes: the brand default, five more dark fields, then six pale + /// ones. Cycling order runs dark → light, so stepping the row walks the whole range one way. 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), + // --- dark fields (white ink) --- + GamepadPalette( + id: "violet", name: "Violet", stops: [], + ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false), + GamepadPalette( + // Deep indigo climbing through violet into a hot magenta. + id: "nebula", name: "Nebula", + stops: [SIMD3(0.07, 0.05, 0.20), SIMD3(0.26, 0.14, 0.54), SIMD3(0.52, 0.20, 0.72), + SIMD3(0.82, 0.26, 0.62), SIMD3(0.98, 0.46, 0.68)], + ground: SIMD3(0.055, 0.040, 0.135), accent: SIMD3(0.95, 0.42, 0.72), light: false), + GamepadPalette( + // Ink-blue water: teal → cerulean → a violet undertow. + id: "abyss", name: "Abyss", + stops: [SIMD3(0.02, 0.10, 0.17), SIMD3(0.04, 0.28, 0.42), SIMD3(0.07, 0.46, 0.63), + SIMD3(0.16, 0.38, 0.78), SIMD3(0.26, 0.22, 0.58)], + ground: SIMD3(0.018, 0.070, 0.130), accent: SIMD3(0.26, 0.76, 0.92), light: false), + GamepadPalette( + // Banked coals: plum embers → crimson → burnt orange → gold. + id: "ember", name: "Ember", + stops: [SIMD3(0.16, 0.03, 0.10), SIMD3(0.45, 0.06, 0.12), SIMD3(0.72, 0.18, 0.06), + SIMD3(0.90, 0.42, 0.08), SIMD3(0.95, 0.68, 0.18)], + ground: SIMD3(0.090, 0.035, 0.040), accent: SIMD3(0.98, 0.62, 0.26), light: false), + GamepadPalette( + // Forest floor into moss and a lime break. + id: "moss", name: "Moss", + stops: [SIMD3(0.03, 0.11, 0.09), SIMD3(0.06, 0.27, 0.20), SIMD3(0.09, 0.45, 0.31), + SIMD3(0.28, 0.61, 0.28), SIMD3(0.58, 0.77, 0.31)], + ground: SIMD3(0.025, 0.085, 0.070), accent: SIMD3(0.48, 0.86, 0.46), light: false), + GamepadPalette( + // Neutral, but never flat: barely-there saturation that still travels from a cool + // charcoal to a warm stone. + id: "graphite", name: "Graphite", + stops: [SIMD3(0.06, 0.07, 0.11), SIMD3(0.15, 0.18, 0.25), SIMD3(0.30, 0.31, 0.35), + SIMD3(0.45, 0.42, 0.38), SIMD3(0.60, 0.56, 0.49)], + ground: SIMD3(0.055, 0.055, 0.070), accent: SIMD3(0.78, 0.80, 0.86), light: false), + // --- pale fields (dark ink) --- + GamepadPalette( + // The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom. + id: "holo", name: "Holo", + stops: [SIMD3(0.99, 0.72, 0.90), SIMD3(0.80, 0.60, 0.98), SIMD3(0.58, 0.62, 0.99), + SIMD3(0.55, 0.86, 0.98), SIMD3(0.94, 0.98, 1.00)], + ground: SIMD3(0.96, 0.92, 0.99), accent: SIMD3(0.42, 0.28, 0.86), light: true), + GamepadPalette( + // The poster sunset: periwinkle → magenta → scarlet → tangerine → gold. + id: "sunset", name: "Sunset", + stops: [SIMD3(0.55, 0.45, 0.92), SIMD3(0.86, 0.31, 0.66), SIMD3(0.97, 0.26, 0.34), + SIMD3(0.99, 0.51, 0.18), SIMD3(1.00, 0.80, 0.22)], + ground: SIMD3(0.98, 0.74, 0.34), accent: SIMD3(0.64, 0.13, 0.44), light: true), + GamepadPalette( + // Peach into blush and lilac — the softest of the set. + id: "bloom", name: "Bloom", + stops: [SIMD3(1.00, 0.86, 0.72), SIMD3(0.99, 0.73, 0.79), SIMD3(0.95, 0.65, 0.89), + SIMD3(0.82, 0.68, 0.96), SIMD3(0.73, 0.79, 0.99)], + ground: SIMD3(0.99, 0.90, 0.89), accent: SIMD3(0.72, 0.24, 0.55), light: true), + GamepadPalette( + // First light: pale gold → coral → lilac. + id: "dawn", name: "Dawn", + stops: [SIMD3(1.00, 0.92, 0.70), SIMD3(1.00, 0.80, 0.62), SIMD3(0.99, 0.66, 0.62), + SIMD3(0.90, 0.62, 0.78), SIMD3(0.77, 0.69, 0.95)], + ground: SIMD3(1.00, 0.93, 0.82), accent: SIMD3(0.82, 0.33, 0.28), light: true), + GamepadPalette( + // Sea glass: mint → aqua → a pale sky. + id: "mint", name: "Mint", + stops: [SIMD3(0.82, 0.98, 0.90), SIMD3(0.62, 0.94, 0.88), SIMD3(0.55, 0.88, 0.95), + SIMD3(0.63, 0.82, 0.99), SIMD3(0.82, 0.87, 1.00)], + ground: SIMD3(0.90, 0.98, 0.96), accent: SIMD3(0.04, 0.42, 0.40), light: true), + GamepadPalette( + // Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn. + id: "opal", name: "Opal", + stops: [SIMD3(0.98, 0.92, 0.96), SIMD3(0.87, 0.93, 0.99), SIMD3(0.91, 0.99, 0.95), + SIMD3(0.99, 0.96, 0.88), SIMD3(0.94, 0.90, 0.99)], + ground: SIMD3(0.97, 0.96, 0.99), accent: SIMD3(0.36, 0.32, 0.44), light: true), ] /// The palette stored under `id`, falling back to the brand default — an unknown name is a @@ -43,37 +148,30 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable { all.first { $0.id == id } ?? all[0] } - /// `true` for the identity transform, so the default path can skip the per-colour work. - public var isIdentity: Bool { hueDegrees == 0 && saturation == 1 } - - /// Apply this palette to one RGB triple. - public func tint(_ c: SIMD3) -> SIMD3 { - guard !isIdentity else { return c } - return GamepadPalette.tint(c, hueDegrees: hueDegrees, saturation: saturation) + /// Sample an ordered colour ramp at `t` ∈ [0, 1] (linear between neighbouring stops). + public static func ramp(_ stops: [SIMD3], _ t: Double) -> SIMD3 { + guard let first = stops.first else { return SIMD3(0, 0, 0) } + guard stops.count > 1 else { return first } + let x = min(max(t, 0), 1) * Double(stops.count - 1) + let i = min(Int(x.rounded(.down)), stops.count - 2) + let f = x - Double(i) + return stops[i] + (stops[i + 1] - stops[i]) * f } - /// Rotate `c` about the grey axis by `hueDegrees` (Rodrigues — the same rotation the field's - /// own ±8° warm/cool sway uses, in the same orientation) and scale its saturation about - /// luminance. Clamped, because a large rotation can push a channel out of gamut. - /// - /// Deliberately computed here rather than left to SwiftUI's `.hueRotation`: that modifier's - /// exact behaviour is the framework's, and the Rust and Kotlin clients have no equivalent — - /// doing the arithmetic on the COLOURS keeps the three implementations identical. - public static func tint( - _ c: SIMD3, hueDegrees: Double, saturation: Double - ) -> SIMD3 { - let a = hueDegrees * .pi / 180 - let cs = cos(a) - let sn = sin(a) - let invSqrt3 = 1 / 3.0.squareRoot() - let grey = (c.x + c.y + c.z) / 3 * (1 - cs) - // The `sn` term is cross(k, c) with k = (1,1,1)/√3. - let rot = SIMD3( - c.x * cs + (c.z - c.y) * invSqrt3 * sn + grey, - c.y * cs + (c.x - c.z) * invSqrt3 * sn + grey, - c.z * cs + (c.y - c.x) * invSqrt3 * sn + grey) - let luma = 0.2126 * rot.x + 0.7152 * rot.y + 0.0722 * rot.z - func mix(_ v: Double) -> Double { min(max(luma + (v - luma) * saturation, 0), 1) } - return SIMD3(mix(rot.x), mix(rot.y), mix(rot.z)) + /// The 16 mesh colours for this palette: the ramp sampled per cell, or `violetMesh` verbatim + /// for the brand default. + public var meshColors: [SIMD3] { + guard !stops.isEmpty else { return Self.violetMesh } + return (0..<16).map { i in + let (x, y) = (Double(i % 4) / 3.0, Double(i / 4) / 3.0) + return Self.ramp(stops, 0.5 * (x + y) + Self.cellRamp[i]) + } + } + + /// Four drifting blob colours for the pre-18/15 legacy field. Spread across the ramp so it + /// still shows several hues at once. + public var blobColors: [SIMD3] { + let s = stops.isEmpty ? Self.violetBlobs : stops + return (0..<4).map { Self.ramp(s, 0.15 + 0.25 * Double($0)) } } } diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift index 8b2ab4f7..2a5f7b28 100644 --- a/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift @@ -1,81 +1,110 @@ // 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. +// (`pf-console-ui::library`) and Kotlin (`GamepadPalette.kt`) ports reproduce — the same ids in +// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look +// 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) + private func luma(_ c: SIMD3) -> Double { + 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z + } - /// 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 + /// Hue angle in degrees, or nil for something too grey to have one. + private func hue(_ c: SIMD3) -> Double? { + let maxV = max(c.x, c.y, c.z) + let minV = min(c.x, c.y, c.z) + let d = maxV - minV + guard d >= 0.04 else { return nil } + let h: Double + if maxV == c.x { + h = 60 * (((c.y - c.z) / d).truncatingRemainder(dividingBy: 6)) + } else if maxV == c.y { + h = 60 * ((c.z - c.x) / d + 2) + } else { + h = 60 * ((c.x - c.y) / d + 4) + } + return (h + 360).truncatingRemainder(dividingBy: 360) + } + + /// The brand default must still be the SHIPPED field, colour for colour. Every install + /// already sees it, and a palette table that quietly restyled the default 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) + XCTAssertTrue(violet.stops.isEmpty, "the default is the explicit grid") + XCTAssertEqual(violet.meshColors, GamepadPalette.violetMesh) // 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). + /// Ids, order and the light/dark split are the cross-client contract. func testTableMatchesTheOtherClients() { XCTAssertEqual( GamepadPalette.all.map(\.id), - ["violet", "tide", "forest", "ember", "rose", "graphite"]) - XCTAssertEqual( - GamepadPalette.all.map(\.name), - ["Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"]) + ["violet", "nebula", "abyss", "ember", "moss", "graphite", + "holo", "sunset", "bloom", "dawn", "mint", "opal"]) + // Dark fields lead, pale ones follow, so stepping the row walks one direction. + let firstLight = GamepadPalette.all.firstIndex { $0.light } + XCTAssertEqual(firstLight, 6) + XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light)) } - /// A rotation moves the hue while roughly holding luminance, and the saturation scale - /// collapses toward grey — the same four checks the Rust test makes. - func testTintRotatesHueAndScalesSaturation() { - XCTAssertTrue(violetPool.z > violetPool.x && violetPool.z > violetPool.y, "blue-dominant") - - // +105° (Ember) turns the blue-dominant pool red-dominant… - let ember = GamepadPalette.named("ember").tint(violetPool) - XCTAssertGreaterThan(ember.x, ember.z, "\(ember) should be warm") - // …−130° (Forest) turns it green-dominant… - let forest = GamepadPalette.named("forest").tint(violetPool) - XCTAssertTrue(forest.y > forest.x && forest.y > forest.z, "\(forest)") - // …and −70° (Tide) lands on a cyan whose green and blue both beat red. - let tide = GamepadPalette.named("tide").tint(violetPool) - XCTAssertTrue(tide.y > tide.x && tide.z > tide.x, "\(tide)") - - // Graphite's saturation scale leaves the channels nearly equal… - let grey = GamepadPalette.named("graphite").tint(violetPool) - let spread = max(grey.x, grey.y, grey.z) - min(grey.x, grey.y, grey.z) - XCTAssertLessThan(spread, 0.08, "\(grey)") - // …at about the source's luminance (it desaturates, it doesn't dim). - let luma = 0.2126 * violetPool.x + 0.7152 * violetPool.y + 0.0722 * violetPool.z - XCTAssertEqual(grey.y, luma, accuracy: 0.05) - } - - /// Every palette stays in gamut on every colour the field is built from — an out-of-range - /// channel would clamp differently on each platform's rasteriser. - func testEveryPaletteStaysInGamut() { - let field: [SIMD3] = [ - SIMD3(0.075, 0.060, 0.160), SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), - SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), - SIMD3(0.16, 0.26, 0.64), SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), - SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), SIMD3(0.22, 0.18, 0.54), - SIMD3(0.24, 0.20, 0.58), - ] - for palette in GamepadPalette.all { - for c in field { - let t = palette.tint(c) - for v in [t.x, t.y, t.z] { - XCTAssertTrue((0...1).contains(v), "\(palette.id) \(c) → \(t)") + /// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was + /// exactly the complaint about the hue-rotation model this replaced. + func testEveryPaletteIsMultiTone() { + for p in GamepadPalette.all { + let hues = p.meshColors.compactMap(hue) + XCTAssertGreaterThanOrEqual(hues.count, 8, "\(p.id): too few coloured cells") + var spread = 0.0 + for a in hues { + for b in hues { + let d = abs(a - b).truncatingRemainder(dividingBy: 360) + spread = max(spread, min(d, 360 - d)) } } + // Graphite and Opal are deliberately near-neutral; the rest must travel. + let floor = (p.id == "graphite" || p.id == "opal") ? 20.0 : 45.0 + XCTAssertGreaterThanOrEqual(spread, floor, "\(p.id) spans only \(spread)° of hue") + } + } + + /// Every colour stays in gamut, and a pale palette really is pale — its ink flips, so a + /// mislabelled one would put dark text on a dark field. + func testPalettesAreInGamutAndHonestAboutLightness() { + for p in GamepadPalette.all { + for c in p.meshColors + p.blobColors { + for v in [c.x, c.y, c.z] { + XCTAssertTrue((0...1).contains(v), "\(p.id) \(c)") + } + } + let mean = p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count) + if p.light { + XCTAssertGreaterThan(mean, 0.5, "\(p.id) is flagged light") + XCTAssertGreaterThan(luma(p.ground), 0.6, "\(p.id)'s ground is dark") + XCTAssertLessThan(luma(p.accent), 0.45, "\(p.id)'s accent is too pale") + } else { + XCTAssertLessThan(mean, 0.45, "\(p.id) is flagged dark") + XCTAssertLessThan(luma(p.ground), 0.2, "\(p.id)'s ground is light") + XCTAssertGreaterThan(luma(p.accent), 0.25, "\(p.id)'s accent is too dark") + } } } + + /// The ramp is the shared sampling rule the Rust and Kotlin ports reproduce. + func testRampInterpolatesBetweenStops() { + let stops = [SIMD3(0.0, 0.0, 0.0), SIMD3(1.0, 0.0, 0.0), SIMD3(1.0, 1.0, 1.0)] + XCTAssertEqual(GamepadPalette.ramp(stops, 0), SIMD3(0.0, 0.0, 0.0)) + XCTAssertEqual(GamepadPalette.ramp(stops, 1), SIMD3(1.0, 1.0, 1.0)) + XCTAssertEqual(GamepadPalette.ramp(stops, 0.5), SIMD3(1.0, 0.0, 0.0)) + XCTAssertEqual(GamepadPalette.ramp(stops, 0.25).x, 0.5, accuracy: 1e-9) + // Out of range clamps rather than trapping. + XCTAssertEqual(GamepadPalette.ramp(stops, -3), SIMD3(0.0, 0.0, 0.0)) + XCTAssertEqual(GamepadPalette.ramp(stops, 9), SIMD3(1.0, 1.0, 1.0)) + XCTAssertEqual(GamepadPalette.ramp([], 0.5), SIMD3(0.0, 0.0, 0.0)) + } } diff --git a/crates/pf-console-ui/src/glyphs.rs b/crates/pf-console-ui/src/glyphs.rs index 72422b44..fa224355 100644 --- a/crates/pf-console-ui/src/glyphs.rs +++ b/crates/pf-console-ui/src/glyphs.rs @@ -5,9 +5,9 @@ //! no pad at all the legend swaps to keyboard keycaps — the console stays fully //! drivable either way. -use crate::theme::{white, Fonts, W}; +use crate::theme::{fg, Fonts, W}; use punktfunk_core::config::GamepadPref; -use skia_safe::{Canvas, Color4f, Paint, Path, Point, RRect, Rect}; +use skia_safe::{Canvas, Paint, Path, Point, RRect, Rect}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum GlyphStyle { @@ -94,13 +94,13 @@ pub(crate) fn hint_bar( let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32); canvas.draw_rrect( RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32), - &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.30), None), + &Paint::new(crate::theme::shade(0.30), None), ); canvas.draw_rrect( RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32), - &Paint::new(white(0.06), None), + &Paint::new(fg(0.06), None), ); - let mut sp = Paint::new(white(0.12), None); + let mut sp = Paint::new(fg(0.12), None); sp.set_style(skia_safe::PaintStyle::Stroke); sp.set_stroke_width(1.0); sp.set_anti_alias(true); @@ -122,7 +122,7 @@ pub(crate) fn hint_bar( cy + LABEL_SIZE * k * 0.36, W::SemiBold, LABEL_SIZE * k, - white(0.85), + fg(0.85), ); pen += lw + gap_hint; } @@ -199,8 +199,8 @@ fn draw_glyph( Resolved::Badge(face) => { let r = BADGE_D * k / 2.0; let center = Point::new((x + r) as f32, cy as f32); - canvas.draw_circle(center, r as f32, &Paint::new(white(0.10), None)); - let mut ring = Paint::new(white(0.32), None); + canvas.draw_circle(center, r as f32, &Paint::new(fg(0.10), None)); + let mut ring = Paint::new(fg(0.32), None); ring.set_style(skia_safe::PaintStyle::Stroke); ring.set_stroke_width((1.2 * k) as f32); ring.set_anti_alias(true); @@ -223,7 +223,7 @@ fn draw_glyph( cy + size * 0.36, W::SemiBold, size, - white(0.92), + fg(0.92), ); } } @@ -235,7 +235,7 @@ fn draw_glyph( let rect = Rect::from_xywh(pen as f32, (cy - h / 2.0) as f32, w as f32, h as f32); canvas.draw_rrect( RRect::new_rect_xy(rect, (4.0 * k) as f32, (4.0 * k) as f32), - &Paint::new(white(0.10), None), + &Paint::new(fg(0.10), None), ); let size = 10.0 * k; let tw = fonts.measure(label, W::SemiBold, size) as f64; @@ -246,7 +246,7 @@ fn draw_glyph( cy + size * 0.36, W::SemiBold, size, - white(0.92), + fg(0.92), ); pen += w + 3.0 * k; } @@ -257,7 +257,7 @@ fn draw_glyph( let (cx, cyf) = ((x + r) as f32, cy as f32); let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32); let gap = (2.6 * k) as f32; - let paint = Paint::new(white(0.85), None); + let paint = Paint::new(fg(0.85), None); let mut left = Path::new(); left.move_to((cx - gap, cyf - th)); left.line_to((cx - gap - tw, cyf)); @@ -277,9 +277,9 @@ fn draw_glyph( let rect = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32); canvas.draw_rrect( RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32), - &Paint::new(white(0.10), None), + &Paint::new(fg(0.10), None), ); - let mut ring = Paint::new(white(0.28), None); + let mut ring = Paint::new(fg(0.28), None); ring.set_style(skia_safe::PaintStyle::Stroke); ring.set_stroke_width(1.0); ring.set_anti_alias(true); @@ -296,7 +296,7 @@ fn draw_glyph( cy + size * 0.36, W::SemiBold, size, - white(0.92), + fg(0.92), ); } } @@ -305,7 +305,7 @@ fn draw_glyph( /// The PlayStation face shapes, stroked inside the badge: Confirm=✕, Back=○, X-position /// =□, Y-position=△ (the DualSense's physical layout). fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32) { - let mut p = Paint::new(white(0.92), None); + let mut p = Paint::new(fg(0.92), None); p.set_style(skia_safe::PaintStyle::Stroke); p.set_stroke_width(stroke); p.set_stroke_cap(skia_safe::PaintCap::Round); diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index 0e7f6cf1..53158e13 100644 --- a/crates/pf-console-ui/src/library.rs +++ b/crates/pf-console-ui/src/library.rs @@ -205,62 +205,158 @@ pub const MESH_INTERIOR: [(f64, f64, f64, f64, f64, f64); 4] = [ // --- 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. +/// One background colour family for the gamepad UI's living backdrop. +/// +/// A palette is a short ordered ramp of [`Palette::stops`] — several DISTINCT hues, not one hue +/// at several brightnesses. The 4×4 mesh samples that ramp diagonally with a per-cell offset +/// ([`CELL_RAMP`]), so neighbouring cells land on different parts of it and the colours pool and +/// swirl the way a real gradient poster does; the interior points' existing domain warp then +/// drifts those pools around. An earlier version rotated ONE field's hue per palette, which is +/// why every non-default palette read as flat and monotone. +/// +/// A palette also owns the UI it sits under: [`Palette::accent`] is the focus wash / selected +/// pill / switch colour, and [`Palette::light`] flips the ink (see [`crate::theme::Ink`]) so a +/// pale field gets dark text instead of white. The Apple and Android clients carry the same +/// table under the same ids, so one `ui_palette` value is one look everywhere. 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 colour ramp, dark end first. `None` = use [`MESH_COLORS`] verbatim (the brand + /// default, kept bit-identical to what every install already sees). + pub stops: Option<&'static [(f64, f64, f64)]>, + /// The field's ground — what the corners settle onto and what the calm mix lifts toward. + pub ground: (f64, f64, f64), + /// The UI accent: focus wash, selected tab pill, switch track, caret. + pub accent: (f64, f64, f64), + /// A pale field: the UI flips to dark ink and the legibility scrims go white. + pub light: bool, } -/// 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] = [ +/// Where each of the 16 mesh cells samples the ramp. The base is the diagonal +/// `0.5·(x + y)` — top-left is the ramp's dark end, bottom-right its bright one, like both +/// reference gradients — and the per-cell nudges break the banding that a pure diagonal would +/// give, so hues pool instead of striping. +#[rustfmt::skip] +const CELL_RAMP: [f64; 16] = [ + 0.10, -0.06, 0.04, -0.12, + -0.08, 0.14, -0.10, 0.06, + 0.06, -0.12, 0.16, -0.04, + -0.10, 0.08, -0.06, 0.12, +]; + +/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones. +/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction. +/// 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. +#[rustfmt::skip] +pub const PALETTES: [Palette; 12] = [ + // --- dark fields (white ink) --- Palette { - id: "violet", - name: "Violet", - hue_deg: 0.0, - sat: 1.0, + id: "violet", name: "Violet", stops: None, + ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false, }, Palette { - id: "tide", - name: "Tide", - hue_deg: -70.0, - sat: 1.0, + // Deep indigo climbing through violet into a hot magenta. + id: "nebula", name: "Nebula", + stops: Some(&[ + (0.07, 0.05, 0.20), (0.26, 0.14, 0.54), (0.52, 0.20, 0.72), + (0.82, 0.26, 0.62), (0.98, 0.46, 0.68), + ]), + ground: (0.055, 0.040, 0.135), accent: (0.95, 0.42, 0.72), light: false, }, Palette { - id: "forest", - name: "Forest", - hue_deg: -130.0, - sat: 0.9, + // Ink-blue water: teal → cerulean → a violet undertow. + id: "abyss", name: "Abyss", + stops: Some(&[ + (0.02, 0.10, 0.17), (0.04, 0.28, 0.42), (0.07, 0.46, 0.63), + (0.16, 0.38, 0.78), (0.26, 0.22, 0.58), + ]), + ground: (0.018, 0.070, 0.130), accent: (0.26, 0.76, 0.92), light: false, }, Palette { - id: "ember", - name: "Ember", - hue_deg: 105.0, - sat: 1.0, + // Banked coals: plum embers → crimson → burnt orange → gold. + id: "ember", name: "Ember", + stops: Some(&[ + (0.16, 0.03, 0.10), (0.45, 0.06, 0.12), (0.72, 0.18, 0.06), + (0.90, 0.42, 0.08), (0.95, 0.68, 0.18), + ]), + ground: (0.090, 0.035, 0.040), accent: (0.98, 0.62, 0.26), light: false, }, Palette { - id: "rose", - name: "Rose", - hue_deg: 60.0, - sat: 0.95, + // Forest floor into moss and a lime break. + id: "moss", name: "Moss", + stops: Some(&[ + (0.03, 0.11, 0.09), (0.06, 0.27, 0.20), (0.09, 0.45, 0.31), + (0.28, 0.61, 0.28), (0.58, 0.77, 0.31), + ]), + ground: (0.025, 0.085, 0.070), accent: (0.48, 0.86, 0.46), light: false, }, Palette { - id: "graphite", - name: "Graphite", - hue_deg: 0.0, - sat: 0.12, + // Neutral, but never flat: barely-there saturation that still travels from a cool + // charcoal to a warm stone, so even the restrained option has somewhere to go. + id: "graphite", name: "Graphite", + stops: Some(&[ + (0.06, 0.07, 0.11), (0.15, 0.18, 0.25), (0.30, 0.31, 0.35), + (0.45, 0.42, 0.38), (0.60, 0.56, 0.49), + ]), + ground: (0.055, 0.055, 0.070), accent: (0.78, 0.80, 0.86), light: false, + }, + // --- pale fields (dark ink) --- + Palette { + // The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom. + id: "holo", name: "Holo", + stops: Some(&[ + (0.99, 0.72, 0.90), (0.80, 0.60, 0.98), (0.58, 0.62, 0.99), + (0.55, 0.86, 0.98), (0.94, 0.98, 1.00), + ]), + ground: (0.96, 0.92, 0.99), accent: (0.42, 0.28, 0.86), light: true, + }, + Palette { + // The poster sunset: periwinkle → magenta → scarlet → tangerine → gold. + id: "sunset", name: "Sunset", + stops: Some(&[ + (0.55, 0.45, 0.92), (0.86, 0.31, 0.66), (0.97, 0.26, 0.34), + (0.99, 0.51, 0.18), (1.00, 0.80, 0.22), + ]), + ground: (0.98, 0.74, 0.34), accent: (0.64, 0.13, 0.44), light: true, + }, + Palette { + // Peach into blush and lilac — the softest of the set. + id: "bloom", name: "Bloom", + stops: Some(&[ + (1.00, 0.86, 0.72), (0.99, 0.73, 0.79), (0.95, 0.65, 0.89), + (0.82, 0.68, 0.96), (0.73, 0.79, 0.99), + ]), + ground: (0.99, 0.90, 0.89), accent: (0.72, 0.24, 0.55), light: true, + }, + Palette { + // First light: pale gold → coral → lilac. + id: "dawn", name: "Dawn", + stops: Some(&[ + (1.00, 0.92, 0.70), (1.00, 0.80, 0.62), (0.99, 0.66, 0.62), + (0.90, 0.62, 0.78), (0.77, 0.69, 0.95), + ]), + ground: (1.00, 0.93, 0.82), accent: (0.82, 0.33, 0.28), light: true, + }, + Palette { + // Sea glass: mint → aqua → a pale sky. + id: "mint", name: "Mint", + stops: Some(&[ + (0.82, 0.98, 0.90), (0.62, 0.94, 0.88), (0.55, 0.88, 0.95), + (0.63, 0.82, 0.99), (0.82, 0.87, 1.00), + ]), + ground: (0.90, 0.98, 0.96), accent: (0.04, 0.42, 0.40), light: true, + }, + Palette { + // Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn. + id: "opal", name: "Opal", + stops: Some(&[ + (0.98, 0.92, 0.96), (0.87, 0.93, 0.99), (0.91, 0.99, 0.95), + (0.99, 0.96, 0.88), (0.94, 0.90, 0.99), + ]), + ground: (0.97, 0.96, 0.99), accent: (0.36, 0.32, 0.44), light: true, }, ]; @@ -270,35 +366,58 @@ 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)) +/// Sample an ordered colour ramp at `t` ∈ [0, 1] (linear between neighbouring stops). Ported +/// verbatim to Swift and Kotlin — keep the three copies in step or a palette drifts between +/// clients. +pub fn ramp(stops: &[(f64, f64, f64)], t: f64) -> (f64, f64, f64) { + match stops.len() { + 0 => (0.0, 0.0, 0.0), + 1 => stops[0], + n => { + let x = t.clamp(0.0, 1.0) * (n - 1) as f64; + let i = (x.floor() as usize).min(n - 2); + let f = x - i as f64; + let (a, b) = (stops[i], stops[i + 1]); + ( + a.0 + (b.0 - a.0) * f, + a.1 + (b.1 - a.1) * f, + a.2 + (b.2 - a.2) * f, + ) + } + } } impl Palette { - /// [`MESH_COLORS`] under this palette's transform. + /// The 16 mesh colours for this palette: the ramp sampled per cell (see [`CELL_RAMP`]), or + /// [`MESH_COLORS`] verbatim for the brand default. pub fn mesh_colors(&self) -> [(f64, f64, f64); 16] { - core::array::from_fn(|i| tint(MESH_COLORS[i], self.hue_deg, self.sat)) + let Some(stops) = self.stops else { + return MESH_COLORS; + }; + core::array::from_fn(|i| { + let (x, y) = ((i % 4) as f64 / 3.0, (i / 4) as f64 / 3.0); + ramp(stops, 0.5 * (x + y) + CELL_RAMP[i]) + }) + } + + /// Four drifting blob colours, for the clients that approximate the mesh with a blob field + /// (Android). Spread across the ramp so the field still shows several hues at once. + pub fn blob_colors(&self) -> [(f64, f64, f64); 4] { + let stops = self.stops.unwrap_or(&VIOLET_BLOBS); + core::array::from_fn(|i| ramp(stops, 0.15 + 0.25 * i as f64)) } } +/// The brand default's blob ramp — the four colours the pre-palette Android/legacy-Apple field +/// used, kept so `violet` is unchanged there too. +const VIOLET_BLOBS: [(f64, f64, f64); 5] = [ + (0.53, 0.47, 0.96), + (0.24, 0.20, 0.72), + (0.62, 0.30, 0.80), + (0.22, 0.38, 0.86), + (0.53, 0.47, 0.96), +]; + /// 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 @@ -337,6 +456,11 @@ pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> String { // 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\ + // rgb = what the vignette and scrims tend toward (black under a dark palette, white\n\ + // under a pale one — darkening a pastel field would strand the dark text on it), and\n\ + // a = how hard. A pale field needs far less: mixing toward white at the dark field's\n\ + // strength bleaches the chroma straight out of the gradient.\n\ + uniform float4 u_scrim;\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\ @@ -380,15 +504,16 @@ pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> String { \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) * mix(0.42, 0.21, calm);\n\ - \x20 col *= 1.0 - vig;\n\ + \x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0)\n\ + \x20 * mix(0.42, 0.21, calm) * u_scrim.a;\n\ + \x20 col = mix(col, u_scrim.rgb, vig);\n\ \n\ \x20 // Vertical legibility scrim: black 0.38/0.06/0.08/0.40 at 0/0.32/0.68/1.\n\ \x20 float v = xy.y / u_res.y;\n\ \x20 float s = v < 0.32 ? mix(0.38, 0.06, v / 0.32)\n\ \x20 : v < 0.68 ? mix(0.06, 0.08, (v - 0.32) / 0.36)\n\ \x20 : mix(0.08, 0.40, (v - 0.68) / 0.32);\n\ - \x20 col *= 1.0 - s;\n\ + \x20 col = mix(col, u_scrim.rgb, s * u_scrim.a);\n\ \n\ \x20 return half4(half3(col), 1.0);\n\ }}\n", @@ -661,49 +786,132 @@ mod tests { 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. + /// The brand default must still be the SHIPPED field, colour for colour. Every install + /// already sees it, and a palette table that quietly restyled the default 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:?}"); - } + assert!( + PALETTES[0].stops.is_none(), + "the default is the explicit grid" + ); + assert_eq!(palette("violet").mesh_colors(), MESH_COLORS); // 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. + /// Hue angle in degrees, or `None` for something too grey to have one. + fn hue(c: (f64, f64, f64)) -> Option { + let (r, g, b) = c; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let d = max - min; + if d < 0.04 { + return None; + } + let h = if max == r { + 60.0 * (((g - b) / d) % 6.0) + } else if max == g { + 60.0 * ((b - r) / d + 2.0) + } else { + 60.0 * ((r - g) / d + 4.0) + }; + Some((h + 360.0) % 360.0) + } + + /// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was + /// exactly the complaint about the hue-rotation model this replaced. Measured as the + /// widest gap between any two of the 16 mesh colours' hue angles. #[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. + fn every_palette_is_multi_tone() { for p in &PALETTES { - for c in p.mesh_colors() { + let hues: Vec = p.mesh_colors().iter().filter_map(|c| hue(*c)).collect(); + assert!(hues.len() >= 8, "{}: too few coloured cells", p.id); + let spread = hues + .iter() + .flat_map(|a| { + hues.iter().map(move |b| { + let d = (a - b).abs() % 360.0; + d.min(360.0 - d) + }) + }) + .fold(0.0f64, f64::max); + // Graphite and Opal are deliberately near-neutral; everything else must carry a + // real hue journey. + let floor = if matches!(p.id, "graphite" | "opal") { + 20.0 + } else { + 45.0 + }; + assert!(spread >= floor, "{} spans only {spread:.0}° of hue", p.id); + } + } + + /// Ids, order and the light/dark split are the cross-client contract — the Apple and + /// Android tables must match this exactly. + #[test] + fn table_matches_the_other_clients() { + let ids: Vec<&str> = PALETTES.iter().map(|p| p.id).collect(); + assert_eq!( + ids, + [ + "violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset", + "bloom", "dawn", "mint", "opal", + ] + ); + // Dark fields lead, pale ones follow, so stepping the row walks one direction. + let first_light = PALETTES + .iter() + .position(|p| p.light) + .expect("some are light"); + assert!(PALETTES[first_light..].iter().all(|p| p.light)); + assert_eq!(first_light, 6); + } + + /// Every colour a palette produces stays in gamut, and a pale palette really is pale — + /// its ink flips, so a mislabelled one would put dark text on a dark field. + #[test] + fn palettes_are_in_gamut_and_honest_about_lightness() { + let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2; + for p in &PALETTES { + for c in p.mesh_colors().iter().chain(p.blob_colors().iter()) { for v in [c.0, c.1, c.2] { assert!((0.0..=1.0).contains(&v), "{} {c:?}", p.id); } } + let mean = p.mesh_colors().iter().map(|c| luma(*c)).sum::() / 16.0; + if p.light { + assert!(mean > 0.5, "{} is flagged light but means {mean:.2}", p.id); + assert!(luma(p.ground) > 0.6, "{}'s ground is dark", p.id); + } else { + assert!(mean < 0.45, "{} is flagged dark but means {mean:.2}", p.id); + assert!(luma(p.ground) < 0.2, "{}'s ground is light", p.id); + } + // The accent tints glass of the OPPOSITE polarity to the field, so it has to be + // legible there: dark accents on white frost, bright ones on dark glass. + let a = luma(p.accent); + if p.light { + assert!(a < 0.45, "{}'s accent is too pale for white glass", p.id); + } else { + assert!(a > 0.25, "{}'s accent is too dark for dark glass", p.id); + } } } + + /// The ramp is the shared sampling rule the Swift and Kotlin ports reproduce. + #[test] + fn ramp_interpolates_between_stops() { + let stops = [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 1.0)]; + assert_eq!(ramp(&stops, 0.0), (0.0, 0.0, 0.0)); + assert_eq!(ramp(&stops, 1.0), (1.0, 1.0, 1.0)); + assert_eq!(ramp(&stops, 0.5), (1.0, 0.0, 0.0)); + let q = ramp(&stops, 0.25); + assert!((q.0 - 0.5).abs() < 1e-9 && q.1 == 0.0); + // Out of range clamps rather than panicking. + assert_eq!(ramp(&stops, -3.0), (0.0, 0.0, 0.0)); + assert_eq!(ramp(&stops, 9.0), (1.0, 1.0, 1.0)); + assert_eq!(ramp(&[], 0.5), (0.0, 0.0, 0.0)); + } } diff --git a/crates/pf-console-ui/src/screens/add_host.rs b/crates/pf-console-ui/src/screens/add_host.rs index c60347e5..71f815a2 100644 --- a/crates/pf-console-ui/src/screens/add_host.rs +++ b/crates/pf-console-ui/src/screens/add_host.rs @@ -7,7 +7,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::model::ConsoleCmd; use crate::screens::{Ctx, Outbox}; -use crate::theme::{Fonts, DIM, W}; +use crate::theme::{fg, Fonts, W}; use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use skia_safe::{Canvas, Rect}; @@ -214,7 +214,7 @@ impl AddHostScreen { "Hosts on this network appear automatically — add one by address for everything else.", W::Regular, 13.0 * k, - DIM, + fg(0.55), cx, f64::from(rect.top) + 2.0 * k, f64::from(rect.width()) * 0.72, diff --git a/crates/pf-console-ui/src/screens/home.rs b/crates/pf-console-ui/src/screens/home.rs index 482e6213..7165af33 100644 --- a/crates/pf-console-ui/src/screens/home.rs +++ b/crates/pf-console-ui/src/screens/home.rs @@ -10,7 +10,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::library::{step_cursor, StepResult, BUMP_C, BUMP_K, BUMP_PX, SPRING_C, SPRING_K}; use crate::model::{ConsoleCmd, HostRow}; use crate::screens::{ConnectIntent, Ctx, Outbox, Screen}; -use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, DIM, ONLINE_GREEN, W, WHITE}; +use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Color4f, MaskFilter, Paint, Path, Point, RRect, Rect}; @@ -251,7 +251,7 @@ impl HomeScreen { "Hosts on this network appear automatically — add one by address for everything else.", W::Regular, 13.0 * k, - DIM, + fg(0.55), f64::from(rect.left) + w / 2.0, cy + tile_h / 2.0 + 24.0 * k, w * 0.7, @@ -265,7 +265,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 canvas, rect, TILE_CORNER as f32, - h.saved.then(|| brand(0.20)), + h.saved.then(|| accent(0.20)), if h.saved { PanelStroke::Gradient } else { @@ -327,7 +327,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 sub_base, W::Regular, 13.0 * k, - white(0.55), + fg(0.55), max_w, ); let x = l + addr_w + 8.0 * k; @@ -352,7 +352,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 sub_base, W::Regular, 13.0 * k, - white(0.55), + fg(0.55), max_w, ); } @@ -364,22 +364,22 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 sub_base - 22.0 * k, W::Bold, 23.0 * k, - WHITE, + fg(1.0), max_w, ); } -/// A profile's `#RRGGBB` accent as a color, defaulting to the brand tint. Parsed +/// A profile's `#RRGGBB` accent as a color, defaulting to the PALETTE's accent. Parsed /// leniently — a malformed accent (hand-edited catalog) falls back rather than erroring. -fn accent_color(accent: Option<&str>) -> skia_safe::Color4f { - let Some(hex) = accent +fn accent_color(hex: Option<&str>) -> skia_safe::Color4f { + let Some(hex) = hex .and_then(|a| a.strip_prefix('#')) .filter(|h| h.len() == 6) else { - return BRAND; + return accent(1.0); }; let Ok(v) = u32::from_str_radix(hex, 16) else { - return BRAND; + return accent(1.0); }; skia_safe::Color4f::new( ((v >> 16) & 0xff) as f32 / 255.0, @@ -404,9 +404,9 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { let badge = Rect::from_xywh(l as f32, t as f32, (52.0 * k) as f32, (52.0 * k) as f32); canvas.draw_rrect( RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32), - &Paint::new(brand(0.16), None), + &Paint::new(accent(0.16), None), ); - let mut ring = Paint::new(brand(0.5), None); + let mut ring = Paint::new(accent(0.5), None); ring.set_style(skia_safe::PaintStyle::Stroke); ring.set_stroke_width(1.0); ring.set_anti_alias(true); @@ -415,7 +415,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { &ring, ); let (bcx, bcy) = (l + 26.0 * k, t + 26.0 * k); - let mut p = Paint::new(BRAND, None); + let mut p = Paint::new(accent(1.0), None); p.set_style(skia_safe::PaintStyle::Stroke); p.set_stroke_width((3.0 * k) as f32); p.set_stroke_cap(skia_safe::PaintCap::Round); @@ -441,7 +441,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { sub_base, W::Regular, 13.0 * k, - white(0.55), + fg(0.55), max_w, ); fonts.draw_clipped( @@ -451,7 +451,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { sub_base - 22.0 * k, W::Bold, 23.0 * k, - WHITE, + fg(1.0), max_w, ); } @@ -467,8 +467,8 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6 Point::new(badge.left, badge.bottom), ), skia_safe::gradient_shader::GradientShaderColors::Colors(&[ - BRAND.to_color(), - brand(0.68).to_color(), + accent(1.0).to_color(), + accent(0.68).to_color(), ]), None, skia_safe::TileMode::Clamp, @@ -477,8 +477,8 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6 )); canvas.draw_rrect(rr, &p); } else { - canvas.draw_rrect(rr, &Paint::new(brand(0.16), None)); - let mut ring = Paint::new(brand(0.5), None); + canvas.draw_rrect(rr, &Paint::new(accent(0.16), None)); + let mut ring = Paint::new(accent(0.5), None); ring.set_style(skia_safe::PaintStyle::Stroke); ring.set_stroke_width(1.0); ring.set_anti_alias(true); @@ -499,13 +499,13 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6 y + 26.0 * k + size * 0.36, W::Bold, size, - if filled { WHITE } else { BRAND }, + if filled { fg(1.0) } else { accent(1.0) }, ); } /// A small padlock: filled body + stroked shackle (the paired-identity mark). fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) { - let ink = white(0.5); + let ink = fg(0.5); let body_w = 11.0 * k; let body_h = 8.0 * k; let body_top = y + 5.0 * k; diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index 558abd7b..3f62a2a3 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -12,7 +12,7 @@ use crate::library::{ }; use crate::model::{ConsoleCmd, HostRow}; use crate::screens::{ConnectIntent, Ctx, Outbox}; -use crate::theme::{brand, white, Fonts, DIM, W, WHITE}; +use crate::theme::{accent, fg, Fonts, W}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Color4f, Data, Image, Paint, Point, RRect, Rect, M44}; use std::collections::HashMap; @@ -237,7 +237,7 @@ impl LibraryScreen { "Loading library…", W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, cy_all + 16.0 * k, w * 0.8, @@ -249,7 +249,7 @@ impl LibraryScreen { "No games found", W::Bold, 22.0 * k, - WHITE, + fg(1.0), cx, cy_all - 20.0 * k, w * 0.8, @@ -259,7 +259,7 @@ impl LibraryScreen { "Install Steam titles or add custom entries in the host's web console.", W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, cy_all + 12.0 * k, w * 0.8, @@ -271,7 +271,7 @@ impl LibraryScreen { &title, W::Bold, 22.0 * k, - WHITE, + fg(1.0), cx, cy_all - 32.0 * k, w * 0.8, @@ -281,7 +281,7 @@ impl LibraryScreen { &body, W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, cy_all + 4.0 * k, (600.0 * k).min(w * 0.85), @@ -315,7 +315,7 @@ impl LibraryScreen { heading, W::SemiBold, 12.0 * k, - white(0.5), + fg(0.5), f64::from(rect.left) + w / 2.0, cy - card_h / 2.0 - 22.0 * k, w * 0.5, @@ -382,9 +382,9 @@ impl LibraryScreen { }; canvas.draw_rect(crect, &Paint::new(face, None)); let (glyph, size, ink) = if game.launcher { - (store_label(&game.store).to_string(), 22.0 * k, white(0.85)) + (store_label(&game.store).to_string(), 22.0 * k, fg(0.85)) } else { - (initials(&game.title), 38.0 * k, white(0.45)) + (initials(&game.title), 38.0 * k, fg(0.45)) }; let font = fonts.font(W::Bold, size); let tw = font.measure_str(&glyph, None).0; @@ -409,9 +409,9 @@ impl LibraryScreen { // Brand-filled for a launcher, smoked glass for a game — the one cue that // survives being three cards deep in the recede. let pill = if game.launcher { - brand(0.85) + accent(0.85) } else { - Color4f::new(0.0, 0.0, 0.0, 0.55) + crate::theme::shade(0.55) }; canvas.draw_rrect( RRect::new_rect_xy( @@ -428,7 +428,7 @@ impl LibraryScreen { py + 14.0 * k, W::SemiBold, size, - WHITE, + fg(1.0), ); } // The brightness recede: an opaque-black veil, never whole-card alpha. @@ -452,7 +452,7 @@ impl LibraryScreen { &g.title, W::Bold, 27.0 * k, - WHITE, + fg(1.0), cx, f64::from(rect.bottom) - 64.0 * k, w * 0.8, @@ -467,7 +467,7 @@ impl LibraryScreen { &sub, W::Regular, 12.0 * k, - white(0.5), + fg(0.5), cx, f64::from(rect.bottom) - 30.0 * k, w * 0.5, diff --git a/crates/pf-console-ui/src/screens/pair.rs b/crates/pf-console-ui/src/screens/pair.rs index 20a87703..45244d69 100644 --- a/crates/pf-console-ui/src/screens/pair.rs +++ b/crates/pf-console-ui/src/screens/pair.rs @@ -7,7 +7,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::model::{ConsoleCmd, HostRow, PairPhase}; use crate::screens::{ConnectIntent, Ctx, Outbox}; -use crate::theme::{Fonts, DIM, ERROR, W}; +use crate::theme::{fg, Fonts, ERROR, W}; use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use skia_safe::{Canvas, Rect}; @@ -297,7 +297,7 @@ impl PairScreen { intro, W::Regular, 13.0 * k, - DIM, + fg(0.55), cx, f64::from(rect.top) + 2.0 * k, f64::from(rect.width()) * 0.72, @@ -336,7 +336,7 @@ impl PairScreen { "Pairing… confirm the PIN on the host", W::Regular, 13.0 * k, - DIM, + fg(0.55), cx + 10.0 * k, status_y, f64::from(rect.width()) * 0.6, diff --git a/crates/pf-console-ui/src/screens/pin_hosts.rs b/crates/pf-console-ui/src/screens/pin_hosts.rs index ad7dab4e..924b643b 100644 --- a/crates/pf-console-ui/src/screens/pin_hosts.rs +++ b/crates/pf-console-ui/src/screens/pin_hosts.rs @@ -8,7 +8,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::model::ConsoleCmd; use crate::screens::{Ctx, Outbox}; -use crate::theme::{Fonts, DIM, W}; +use crate::theme::{fg, Fonts, W}; use crate::widgets::{ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use skia_safe::{Canvas, Rect}; @@ -115,7 +115,7 @@ impl PinHostsScreen { "No saved hosts yet — pair with a host first, then pin this profile to it.", W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, f64::from(rect.top) + f64::from(rect.height()) / 2.0, f64::from(rect.width()) * 0.7, @@ -157,7 +157,7 @@ impl PinHostsScreen { "A pinned profile appears as its own card on the host — one press connects with it.", W::Regular, 13.0 * k, - DIM, + fg(0.55), cx, f64::from(rect.bottom) - detail_h + 6.0 * k, f64::from(rect.width()) * 0.8, diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 4fe1d83b..550d29d7 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -11,7 +11,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::screens::{Ctx, Outbox, Screen}; -use crate::theme::{Fonts, DIM, W}; +use crate::theme::{fg, Fonts, W}; 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}; @@ -405,7 +405,7 @@ impl SettingsScreen { detail, W::Regular, 13.0 * k, - DIM, + fg(0.55), f64::from(rect.left) + f64::from(rect.width()) / 2.0, f64::from(rect.bottom) - detail_h + 6.0 * k, f64::from(rect.width()) * 0.8, diff --git a/crates/pf-console-ui/src/shell.rs b/crates/pf-console-ui/src/shell.rs index f92cc23c..ab146c41 100644 --- a/crates/pf-console-ui/src/shell.rs +++ b/crates/pf-console-ui/src/shell.rs @@ -86,10 +86,16 @@ pub(crate) struct Shell { /// 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 + /// The palette's ground × 0.4 — the calm lift, precomputed with `mesh`. Chosen so + /// `col*0.6 + lift` leaves the ground 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], + /// The backdrop's scrim under this palette: rgb = what the vignette and scrims tend + /// toward (black on a dark field, white on a pale one), a = how hard. Kept with the ink. + mesh_scrim: [f32; 4], + /// The text/accent/glass the palette calls for, published to the whole crate once per + /// frame (see [`crate::theme::set_ink`]). + ink: crate::theme::Ink, /// 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, @@ -110,7 +116,7 @@ impl Shell { ) -> Result { anyhow::ensure!(!stack.is_empty(), "the console needs a root screen"); let settings = trust::Settings::load(); - let (mesh, mesh_lift) = build_mesh(&settings.ui_palette)?; + let (mesh, mesh_lift, mesh_scrim, ink) = build_mesh(&settings.ui_palette)?; let bg_mix = match stack.last().expect("non-empty").background() { Bg::Aurora => 0.0, Bg::Form => 1.0, @@ -135,6 +141,8 @@ impl Shell { toast: None, mesh, mesh_lift, + mesh_scrim, + ink, bg_mix, glyphs: GlyphStyle::Keyboard, chip: None, @@ -206,9 +214,11 @@ impl Shell { // because someone picked a colour. if self.settings.ui_palette != self.mesh_palette { match build_mesh(&self.settings.ui_palette) { - Ok((mesh, lift)) => { + Ok((mesh, lift, scrim, ink)) => { self.mesh = mesh; self.mesh_lift = lift; + self.mesh_scrim = scrim; + self.ink = ink; self.mesh_palette = self.settings.ui_palette.clone(); } Err(e) => { @@ -468,8 +478,9 @@ impl Shell { /// 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] = [ + // Laid out to match the SkSL block: u_res (float2), u_tc (float2), u_lift (float4), + // u_scrim (float4). + let uniforms: [f32; 12] = [ w as f32, h as f32, t as f32, @@ -478,11 +489,15 @@ impl Shell { self.mesh_lift[1], self.mesh_lift[2], 0.0, + self.mesh_scrim[0], + self.mesh_scrim[1], + self.mesh_scrim[2], + self.mesh_scrim[3], ]; - // 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 + // SAFETY: `uniforms` is a local `[f32; 12]` — exactly 48 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::(), 32) }; + let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::(), 48) }; match self.mesh.make_shader(Data::new_copy(bytes), &[], None) { Some(shader) => { let mut paint = Paint::default(); @@ -496,28 +511,30 @@ impl Shell { } } -/// Compile the mesh shader for a palette, returning it with its precomputed calm lift. +/// Compile the mesh shader for a palette and resolve everything else that palette decides: +/// the calm lift, the scrim direction, and the ink the whole UI draws with. /// `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])> { +type MeshLook = (RuntimeEffect, [f32; 3], [f32; 4], crate::theme::Ink); + +fn build_mesh(palette_id: &str) -> Result { 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() == 48, + "mesh uniform block is {} bytes, expected 48 (u_res, u_tc, u_lift, u_scrim)", effect.uniform_size() ); - let corner = colors[0]; + let ink = crate::theme::Ink::of(p); + let g = p.ground; Ok(( effect, - [ - (corner.0 * 0.4) as f32, - (corner.1 * 0.4) as f32, - (corner.2 * 0.4) as f32, - ], + [(g.0 * 0.4) as f32, (g.1 * 0.4) as f32, (g.2 * 0.4) as f32], + [ink.scrim.r, ink.scrim.g, ink.scrim.b, ink.scrim.a], + ink, )) } diff --git a/crates/pf-console-ui/src/shell/overlays.rs b/crates/pf-console-ui/src/shell/overlays.rs index 24601855..4a042d8c 100644 --- a/crates/pf-console-ui/src/shell/overlays.rs +++ b/crates/pf-console-ui/src/shell/overlays.rs @@ -2,8 +2,8 @@ use crate::anim::{approach, ease_out_cubic}; use crate::glyphs::{hint_bar, Hint, HintKey}; -use crate::theme::{white, Fonts, PanelStroke, DIM, W, WHITE}; -use skia_safe::{gradient_shader, Canvas, Color4f, Paint, Point, Rect, TileMode}; +use crate::theme::{fg, Fonts, PanelStroke, W}; +use skia_safe::{gradient_shader, Canvas, Paint, Point, Rect, TileMode}; use super::{Shell, BOTTOM_BAND}; @@ -118,7 +118,7 @@ impl Shell { let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32); canvas.draw_rrect( skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32), - &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.6), None), + &Paint::new(crate::theme::shade(0.6), None), ); crate::theme::panel( canvas, @@ -135,7 +135,7 @@ impl Shell { by + bh / 2.0 + size * 0.36, W::Medium, size, - white(0.92), + fg(0.92), ); canvas.restore(); } @@ -167,14 +167,15 @@ impl Shell { // 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, 0.0); - // A soft pool of shade under the centre seats the white text against a bright aurora. + // A soft pool of shade under the centre seats the text against a bright field — + // dark on a dark palette, light on a pale one, so it always separates. let mut vignette = Paint::default(); vignette.set_shader(gradient_shader::radial( Point::new(cx as f32, (h / 2.0) as f32), (w.max(h) * 0.42) as f32, gradient_shader::GradientShaderColors::Colors(&[ - Color4f::new(0.0, 0.0, 0.0, 0.5).to_color(), - Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(), + crate::theme::shade(0.5).to_color(), + crate::theme::shade(0.0).to_color(), ]), None, TileMode::Clamp, @@ -193,7 +194,7 @@ impl Shell { title, W::SemiBold, 23.0 * k, - WHITE, + fg(1.0), cx, title_y, w * 0.82, @@ -204,7 +205,7 @@ impl Shell { body, W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, title_y + 32.0 * k, w * 0.66, diff --git a/crates/pf-console-ui/src/shell/render.rs b/crates/pf-console-ui/src/shell/render.rs index c9c99eec..a5728d0a 100644 --- a/crates/pf-console-ui/src/shell/render.rs +++ b/crates/pf-console-ui/src/shell/render.rs @@ -5,7 +5,7 @@ use crate::glyphs::{hint_bar, GlyphStyle}; use crate::library::LibraryShared; use crate::model::HostRow; use crate::screens::{Bg, Ctx, Screen}; -use crate::theme::{white, Fonts, PanelStroke, W, WHITE}; +use crate::theme::{fg, Fonts, PanelStroke, W}; use pf_client_core::gamepad::PadInfo; use pf_client_core::trust; use skia_safe::{Canvas, Rect}; @@ -31,6 +31,10 @@ impl Shell { .replace(now) .map_or(1.0 / 60.0, |t| (now - t).as_secs_f64().clamp(0.0, 0.05)); self.sync(); + // Publish the palette's ink before ANYTHING draws — every widget, glyph and panel in + // the crate reads it (see `theme::set_ink`), so a frame that skipped this would paint + // the previous palette's text over the new palette's field. + crate::theme::set_ink(self.ink); self.pads = pads.to_vec(); self.glyphs = GlyphStyle::from_pref(pad_pref); self.chip = Some(pad.map_or_else( @@ -168,7 +172,7 @@ impl Shell { 18.0 * k + 16.0 * k, W::Medium, size, - white(0.7), + fg(0.7), ); } @@ -224,7 +228,7 @@ impl LayerEnv<'_> { &screen.title(&ctx), W::Bold, 30.0 * self.k, - WHITE, + fg(1.0), self.w / 2.0, 18.0 * self.k, self.w * 0.7, diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index da58255f..0c0af972 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -249,25 +249,34 @@ 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… + // The Interface tab (5 shoulder presses along) leads with the Background row, so these + // frames show the strip mid-list AND the palette picker. Palettes are set directly rather + // than by counting Confirm presses, so reordering the table can't silently shoot the wrong + // one. Each is a whole LOOK, not just a backdrop: accent, ink and scrim move together, so + // the pale ones must be eyeballed with dark text on them. 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 id in ["violet", "ember", "abyss", "holo", "sunset", "mint"] { + s.settings.ui_palette = id.to_string(); + dump(&mut s, 40, 8, &format!("03-settings-{id}"), true); } + // Back to the first tab so the later scenes look like they always did. for _ in 0..5 { s.handle_menu(MenuEvent::JumpBack); } + // …and the LAUNCHER at full contrast under a few of them — the backdrop's loudest form, + // and the one the palettes are really chosen by. + s.handle_menu(MenuEvent::Back); + dump(&mut s, 20, 8, "_settle", true); + for id in ["nebula", "sunset", "holo"] { + s.settings.ui_palette = id.to_string(); + dump(&mut s, 40, 8, &format!("01-home-{id}"), true); + } + s.settings.ui_palette = "violet".to_string(); + dump(&mut s, 20, 8, "_settle2", true); + s.handle_menu(MenuEvent::Tertiary); // back into Settings for the scenes below + dump(&mut s, 20, 8, "_settle3", true); // Add Host with the keyboard tray up (keyboard glyph style: no pad). s.handle_menu(MenuEvent::Back); diff --git a/crates/pf-console-ui/src/theme.rs b/crates/pf-console-ui/src/theme.rs index fb59b0e9..ac58f7db 100644 --- a/crates/pf-console-ui/src/theme.rs +++ b/crates/pf-console-ui/src/theme.rs @@ -14,28 +14,117 @@ use skia_safe::{ Point, RRect, Rect, TileMode, Typeface, }; -// --- Palette ----------------------------------------------------------------------------- +// --- Ink ---------------------------------------------------------------------------------- -/// The punktfunk brand violet — the DARK-appearance value (#8678F5); the console UI is -/// always dark. (Light surfaces use #6656F2; nothing here is light.) -pub(crate) const BRAND: Color4f = Color4f::new(0.525, 0.471, 0.961, 1.0); -pub(crate) const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0); -pub(crate) const DIM: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.55); -pub(crate) const FAINT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.35); -/// The error/status red (the GTK client's #ff938a). +/// The error/status red (the GTK client's #ff938a). Fixed: a warning must not change meaning +/// with the wallpaper. pub(crate) const ERROR: Color4f = Color4f::new(1.0, 0.576, 0.541, 1.0); pub(crate) const ONLINE_GREEN: Color4f = Color4f::new(0.20, 0.84, 0.29, 1.0); -pub(crate) fn white(alpha: f32) -> Color4f { - Color4f::new(1.0, 1.0, 1.0, alpha) +/// Everything about the console's look that follows the chosen background palette: which way +/// the text runs, what the glass is made of, and the accent that marks focus. +/// +/// The console UI was white-on-dark throughout, with the brand violet hardcoded as the accent. +/// Both had to become palette-derived at once: a pale field needs dark text or it is +/// unreadable, and a violet focus wash on a copper field is the clash this exists to fix. +#[derive(Clone, Copy)] +pub(crate) struct Ink { + /// Primary text/glyph colour, opaque. + fg: Color4f, + /// Focus wash, selected pill, caret — the palette's own accent. + accent: Color4f, + /// The base fill every glass panel starts from. + glass: Color4f, + /// What the vignette and legibility scrims tend toward — black under a dark field, white + /// under a pale one (darkening a pastel field would strand the dark text on it) — with the + /// alpha carrying HOW HARD. A pale field needs far less: mixing toward white at the dark + /// field's strength bleaches the chroma straight out of the gradient. + pub(crate) scrim: Color4f, } -pub(crate) fn brand(alpha: f32) -> Color4f { - Color4f::new(BRAND.r, BRAND.g, BRAND.b, alpha) +/// The shipped dark look — also what a test or a preview gets before any palette is applied. +const DARK_INK: Ink = Ink { + fg: Color4f::new(1.0, 1.0, 1.0, 1.0), + // The punktfunk brand violet, DARK-appearance value (#8678F5). + accent: Color4f::new(0.525, 0.471, 0.961, 1.0), + glass: Color4f::new(0.086, 0.086, 0.125, 0.62), + scrim: Color4f::new(0.0, 0.0, 0.0, 1.0), +}; + +impl Ink { + /// The ink a palette calls for. On a pale field the text goes near-black (tinted toward the + /// palette's own ground so it doesn't read as a foreign grey) and the glass turns to white + /// frost, which is what keeps a row legible over a bright gradient. + pub(crate) fn of(p: &crate::library::Palette) -> Ink { + let accent = Color4f::new(p.accent.0 as f32, p.accent.1 as f32, p.accent.2 as f32, 1.0); + if !p.light { + return Ink { accent, ..DARK_INK }; + } + let g = p.ground; + Ink { + fg: Color4f::new( + (g.0 * 0.16) as f32, + (g.1 * 0.14) as f32, + (g.2 * 0.20) as f32, + 1.0, + ), + accent, + // More body than the dark glass carries: white frost over a bright gradient has + // far less to separate it from its backdrop than dark glass over a dark one. + glass: Color4f::new(1.0, 1.0, 1.0, 0.66), + scrim: Color4f::new(1.0, 1.0, 1.0, 0.45), + } + } } -/// The dark-glass base fill every panel starts from. -const GLASS_BASE: Color4f = Color4f::new(0.086, 0.086, 0.125, 0.62); +thread_local! { + /// The ink the CURRENT frame draws with. A thread-local rather than a parameter because + /// every widget, glyph and panel in the crate reads it and the console renders on exactly + /// one thread — threading an `Ink` through ~90 call sites would be all cost and no safety. + /// [`crate::shell::Shell::render`] sets it once per frame, before anything draws. + static INK: std::cell::Cell = const { std::cell::Cell::new(DARK_INK) }; +} + +pub(crate) fn set_ink(ink: Ink) { + INK.with(|i| i.set(ink)); +} + +pub(crate) fn ink() -> Ink { + INK.with(std::cell::Cell::get) +} + +/// The foreground at `alpha` — white on a dark palette, near-black on a pale one. +pub(crate) fn fg(alpha: f32) -> Color4f { + let c = ink().fg; + Color4f::new(c.r, c.g, c.b, alpha) +} + +/// The palette's accent at `alpha`. +pub(crate) fn accent(alpha: f32) -> Color4f { + let c = ink().accent; + Color4f::new(c.r, c.g, c.b, alpha) +} + +/// A wash laid UNDER text to seat it against the field — black on a dark palette, white on a +/// pale one. `alpha` is the dark-field strength; a pale field needs less (see [`Ink::scrim`]), +/// so it is scaled the same way the backdrop's own scrims are. +pub(crate) fn shade(alpha: f32) -> Color4f { + let s = ink().scrim; + Color4f::new(s.r, s.g, s.b, alpha * s.a) +} + +/// Ink that reads ON the accent (a filled key, a selected pill): whichever of black or white +/// the accent has more room for. Chosen by luminance rather than by `light`, because an accent +/// is picked for contrast against the GLASS, not against the field. +pub(crate) fn on_accent() -> Color4f { + let a = ink().accent; + let luma = 0.2126 * a.r + 0.7152 * a.g + 0.0722 * a.b; + if luma > 0.55 { + Color4f::new(0.0, 0.0, 0.0, 1.0) + } else { + Color4f::new(1.0, 1.0, 1.0, 1.0) + } +} // --- Panels (the Liquid Glass stand-in) -------------------------------------------------- @@ -61,7 +150,7 @@ pub(crate) fn panel( k: f32, ) { let rr = RRect::new_rect_xy(rect, corner * k, corner * k); - canvas.draw_rrect(rr, &Paint::new(GLASS_BASE, None)); + canvas.draw_rrect(rr, &Paint::new(ink().glass, None)); if let Some(tint) = tint { canvas.draw_rrect(rr, &Paint::new(tint, None)); } @@ -71,10 +160,10 @@ pub(crate) fn panel( sp.set_anti_alias(true); match stroke { PanelStroke::Plain(alpha) => { - sp.set_color4f(white(alpha), None); + sp.set_color4f(fg(alpha), None); } PanelStroke::Brand(alpha) => { - sp.set_color4f(brand(alpha), None); + sp.set_color4f(accent(alpha), None); } PanelStroke::Gradient | PanelStroke::GradientDashed => { sp.set_shader(gradient_shader::linear( @@ -83,8 +172,8 @@ pub(crate) fn panel( Point::new(rect.left, rect.bottom), ), gradient_shader::GradientShaderColors::Colors(&[ - white(0.22).to_color(), - white(0.04).to_color(), + fg(0.22).to_color(), + fg(0.04).to_color(), ]), None, TileMode::Clamp, @@ -122,7 +211,7 @@ pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alph /// 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) { let start = (t * 300.0) % 360.0; - let mut paint = Paint::new(white(0.85), None); + let mut paint = Paint::new(fg(0.85), None); paint.set_style(skia_safe::PaintStyle::Stroke); paint.set_stroke_width((r / 5.0) as f32); paint.set_stroke_cap(skia_safe::PaintCap::Round); diff --git a/crates/pf-console-ui/src/widgets.rs b/crates/pf-console-ui/src/widgets.rs index b5f9fa0b..916888cd 100644 --- a/crates/pf-console-ui/src/widgets.rs +++ b/crates/pf-console-ui/src/widgets.rs @@ -6,7 +6,7 @@ use crate::anim::{approach, Spring, TRAY_C, TRAY_K}; use crate::library::{BUMP_C, BUMP_K}; -use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, DIM, FAINT, W, WHITE}; +use crate::theme::{accent, fg, Fonts, PanelStroke, W}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Paint, Path, RRect, Rect}; @@ -209,7 +209,7 @@ impl MenuList { W::SemiBold, 12.0 * k, 1.4 * k, - white(0.45), + fg(0.45), ); } // Focus scale eases 0.98 → 1.0 about the row center. @@ -226,9 +226,9 @@ impl MenuList { PanelStroke::Plain(0.06 + 0.22 * f as f32) }; let tint = if row.caret { - Some(brand(0.30)) + Some(accent(0.30)) } else if f > 0.01 { - Some(brand(0.30 * f as f32)) + Some(accent(0.30 * f as f32)) } else { None }; @@ -237,7 +237,7 @@ impl MenuList { let baseline = cy + 16.0 * k * 0.36; if row.value.is_none() { // Action row: centered label, brand when actionable. - let color = if row.enabled { BRAND } else { FAINT }; + let color = if row.enabled { accent(1.0) } else { fg(0.35) }; let tw = fonts.measure(&row.label, W::SemiBold, 16.0 * k) as f64; fonts.draw( canvas, @@ -256,15 +256,15 @@ impl MenuList { baseline, W::SemiBold, 16.0 * k, - if row.enabled { WHITE } else { DIM }, + if row.enabled { fg(1.0) } else { fg(0.55) }, ); let value = row.value.as_deref().unwrap_or_default(); let vcolor = if row.value_dim { - FAINT + fg(0.35) } else if f > 0.5 { - WHITE + fg(1.0) } else { - white(0.6 + 0.4 * f as f32) + fg(0.6 + 0.4 * f as f32) }; let chevron_w = if row.adjustable { 18.0 * k } else { 0.0 }; let caret_w = if row.caret { 8.0 * k } else { 0.0 }; @@ -282,7 +282,7 @@ impl MenuList { (2.0 * k) as f32, (18.0 * k) as f32, ), - &Paint::new(BRAND, None), + &Paint::new(accent(1.0), None), ); } if row.adjustable && f > 0.01 { @@ -362,7 +362,7 @@ impl TabStrip { 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)), + Some(accent(0.85)), PanelStroke::Plain(0.22), k as f32, ); @@ -382,7 +382,7 @@ impl TabStrip { baseline, W::SemiBold, size, - white(0.5 + 0.5 * covered), + fg(0.5 + 0.5 * covered), ); x += widths[i] + gap; } @@ -407,7 +407,7 @@ fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> Stri fn chevron(canvas: &Canvas, x: f64, cy: f64, r: f64, left: bool, alpha: f32) { let dir = if left { -1.0 } else { 1.0 }; - let mut p = Paint::new(white(alpha), None); + let mut p = Paint::new(fg(alpha), None); p.set_style(skia_safe::PaintStyle::Stroke); p.set_stroke_width((1.8 * r / 4.0) as f32); p.set_stroke_cap(skia_safe::PaintCap::Round); @@ -594,7 +594,7 @@ impl Keyboard { let focused = r == self.row && c == self.col; let kr = Rect::from_xywh(x as f32, y as f32, key_w as f32, key_h as f32); let fill = if focused { - let mut b = BRAND; + let mut b = accent(1.0); if self.key_flash > 0.02 { // A just-typed key flashes brighter, then eases back. let f = self.key_flash as f32; @@ -607,16 +607,18 @@ impl Keyboard { } b } else { - white(0.08) + fg(0.08) }; canvas.draw_rrect( RRect::new_rect_xy(kr, (9.0 * k) as f32, (9.0 * k) as f32), &Paint::new(fill, None), ); + // The focused key is filled with the accent, so its letter needs ink that + // reads on THAT, not on the field. let ink = if focused { - skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0) + crate::theme::on_accent() } else { - WHITE + fg(1.0) }; let (cx, cy) = (x + key_w / 2.0, y + key_h / 2.0); match key {