Gamepad UI: multi-tone palettes, and a UI that takes its colours from them #74
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -79,6 +79,7 @@ fun GamepadAddHostScreen(
|
||||
suggestedMacs: List<String> = 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)
|
||||
|
||||
@@ -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<GamepadHint>, 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<GamepadHint>, 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<GamepadHint>, 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<GamepadHint>, 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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ fun GamepadDialog(
|
||||
actions: List<DialogAction>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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<Double, Double, Double>): Triple<Double, Double, Double> {
|
||||
val (r, g, b) = c
|
||||
val a = Math.toRadians(hueDegrees)
|
||||
val cs = cos(a)
|
||||
val sn = sin(a)
|
||||
val invSqrt3 = 1.0 / sqrt(3.0)
|
||||
val grey = (r + g + b) / 3.0 * (1.0 - cs)
|
||||
// The `sn` term is cross(k, c) with k = (1,1,1)/√3.
|
||||
val rr = r * cs + (b - g) * invSqrt3 * sn + grey
|
||||
val rg = g * cs + (r - b) * invSqrt3 * sn + grey
|
||||
val rb = b * cs + (g - r) * invSqrt3 * sn + grey
|
||||
val luma = 0.2126 * rr + 0.7152 * rg + 0.0722 * rb
|
||||
fun mix(v: Double) = (luma + (v - luma) * saturation).coerceIn(0.0, 1.0)
|
||||
return Triple(mix(rr), mix(rg), mix(rb))
|
||||
val stops: List<Triple<Double, Double, Double>>,
|
||||
/** The field's ground — what it settles onto and what the calm mix lifts toward. */
|
||||
val ground: Triple<Double, Double, Double>,
|
||||
/** The UI accent: focus wash, selected tab pill, switch track. */
|
||||
val accent: Triple<Double, Double, Double>,
|
||||
/** 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<Color> 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<Triple<Double, Double, Double>>,
|
||||
t: Double,
|
||||
): Triple<Double, Double, Double> {
|
||||
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<Double, Double, Double>): Color =
|
||||
Color(c.first.toFloat(), c.second.toFloat(), c.third.toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Double, Double, Double>) =
|
||||
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, Double, Double>): 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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Double>] = [
|
||||
cornerRGB, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), cornerRGB,
|
||||
SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64),
|
||||
SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70),
|
||||
cornerRGB, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), cornerRGB,
|
||||
]
|
||||
|
||||
/// `baseMeshRGB` under a palette. Recomputed per frame rather than cached — sixteen `tint`
|
||||
/// calls at 30 Hz costs nothing next to rasterising the mesh, and the obvious cache would be
|
||||
/// mutable global state on a type SwiftUI is free to evaluate off the main actor.
|
||||
private static func meshColors(_ palette: GamepadPalette) -> [Color] {
|
||||
baseMeshRGB.map { color(palette.tint($0)) }
|
||||
}
|
||||
|
||||
/// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
|
||||
/// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black
|
||||
/// 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<Double>
|
||||
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<Double>, 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -80,6 +80,12 @@ private struct ConsoleGlass<S: Shape>: 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<S: Shape>: 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
|
||||
}
|
||||
|
||||
@@ -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<Double>]
|
||||
/// The field's ground — what the corners settle onto and what the calm mix lifts toward.
|
||||
public let ground: SIMD3<Double>
|
||||
/// The UI accent: focus wash, selected tab pill, switch track, caret.
|
||||
public let accent: SIMD3<Double>
|
||||
/// 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<Double>] = {
|
||||
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<Double>] = [
|
||||
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<Double>) -> SIMD3<Double> {
|
||||
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<Double>], _ t: Double) -> SIMD3<Double> {
|
||||
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<Double>, hueDegrees: Double, saturation: Double
|
||||
) -> SIMD3<Double> {
|
||||
let a = hueDegrees * .pi / 180
|
||||
let cs = cos(a)
|
||||
let sn = sin(a)
|
||||
let invSqrt3 = 1 / 3.0.squareRoot()
|
||||
let grey = (c.x + c.y + c.z) / 3 * (1 - cs)
|
||||
// The `sn` term is cross(k, c) with k = (1,1,1)/√3.
|
||||
let rot = SIMD3(
|
||||
c.x * cs + (c.z - c.y) * invSqrt3 * sn + grey,
|
||||
c.y * cs + (c.x - c.z) * invSqrt3 * sn + grey,
|
||||
c.z * cs + (c.y - c.x) * invSqrt3 * sn + grey)
|
||||
let luma = 0.2126 * rot.x + 0.7152 * rot.y + 0.0722 * rot.z
|
||||
func mix(_ v: Double) -> Double { min(max(luma + (v - luma) * saturation, 0), 1) }
|
||||
return SIMD3(mix(rot.x), mix(rot.y), mix(rot.z))
|
||||
/// The 16 mesh colours for this palette: the ramp sampled per cell, or `violetMesh` verbatim
|
||||
/// for the brand default.
|
||||
public var meshColors: [SIMD3<Double>] {
|
||||
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<Double>] {
|
||||
let s = stops.isEmpty ? Self.violetBlobs : stops
|
||||
return (0..<4).map { Self.ramp(s, 0.15 + 0.25 * Double($0)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>) -> 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>) -> 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<Double>] = [
|
||||
SIMD3(0.075, 0.060, 0.160), SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74),
|
||||
SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84),
|
||||
SIMD3(0.16, 0.26, 0.64), SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75),
|
||||
SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), SIMD3(0.22, 0.18, 0.54),
|
||||
SIMD3(0.24, 0.20, 0.58),
|
||||
]
|
||||
for palette in GamepadPalette.all {
|
||||
for c in field {
|
||||
let t = palette.tint(c)
|
||||
for v in [t.x, t.y, t.z] {
|
||||
XCTAssertTrue((0...1).contains(v), "\(palette.id) \(c) → \(t)")
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<f64> {
|
||||
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<f64> = 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::<f64>() / 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Shell> {
|
||||
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::<u8>(), 32) };
|
||||
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 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<MeshLook> {
|
||||
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,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Ink> = 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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user