Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453b9850fa | ||
|
|
eb4b33ca49 | ||
|
|
8e02f3a8e1 | ||
|
|
4a9a1c3ed4 | ||
|
|
166e158afe | ||
|
|
fb707b4956 | ||
|
|
65996621d8 | ||
|
|
c48e60fbb7 | ||
|
|
70684e5079 | ||
|
|
3f8a70dd45 | ||
|
|
58ee74cb58 | ||
|
|
6ae2ea6708 | ||
|
|
dd20a17edb | ||
|
|
8ff2c2e1c6 | ||
|
|
883c317872 |
@@ -275,3 +275,31 @@ jobs:
|
||||
run: bun run build
|
||||
- name: Typecheck
|
||||
run: bun run lint
|
||||
|
||||
# web/bun.nix and sdk/bun.nix are GENERATED from their bun.lock (bun2nix) and committed; the Nix
|
||||
# build fetches node_modules from nothing else. They regenerate only on a local `bun install` that
|
||||
# runs lifecycle scripts — never under CI's `--ignore-scripts`, and never on a merge or rebase,
|
||||
# which happily carries a lockfile change past a bun.nix generated before it. That is not
|
||||
# theoretical: web/bun.nix sat stale on main for 553 commits (2026-07-27 → 2026-08-05) with
|
||||
# `nix build .#punktfunk-web` broken, and was repaired only by accident when an advisory bump
|
||||
# happened to rerun a real `bun install`.
|
||||
#
|
||||
# Deliberately UNFILTERED and in ci.yml rather than nix.yml: it needs no Nix, takes well under a
|
||||
# minute, and the whole point is that the drift arrives through commits that look unrelated to
|
||||
# Nix. The Nix-toolchain gates (flake eval + building the bun packages) live in nix.yml.
|
||||
bun-nix:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: oven/bun:1
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
|
||||
# actions/checkout needs all three (see the web job).
|
||||
- name: Install git + node + CA certs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# Regenerates each bun.nix from its committed bun.lock and diffs, and checks that the
|
||||
# bun2nix version pin agrees across flake.nix and both package.json files (bun.nix has no
|
||||
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
|
||||
- name: bun.nix drift gate
|
||||
run: sh scripts/ci/check-bun-nix.sh
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Nix packaging gate. Until this existed, NOTHING in CI ever evaluated flake.nix: the word "nix"
|
||||
# appeared in exactly one workflow file, and only in a comment about bun2nix breaking a Windows
|
||||
# step. Every Nix regression therefore reached main invisibly and was found by hand on a Nix box —
|
||||
# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job
|
||||
# in ci.yml for that story).
|
||||
#
|
||||
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
|
||||
# run for an hour on every push:
|
||||
#
|
||||
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
|
||||
# the NixOS module without building them. Catches the failures that actually happen to
|
||||
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
|
||||
# error, a package attribute dropped from packages.nix.
|
||||
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
|
||||
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
|
||||
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
|
||||
# the generated bun.nix really does materialise a working node_modules offline — it
|
||||
# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer
|
||||
# serves, or the codegen going quietly message-less (see packages.nix's inlang note).
|
||||
#
|
||||
# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here.
|
||||
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
|
||||
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
|
||||
#
|
||||
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
|
||||
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
|
||||
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
|
||||
# workflow parser is not a place to bet on anchor support. Keep them in step by hand.
|
||||
name: nix
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "packaging/nix/**"
|
||||
- "**/bun.lock"
|
||||
- "**/bun.nix"
|
||||
- "**/package.json"
|
||||
- "Cargo.lock"
|
||||
- "Cargo.toml"
|
||||
- "rust-toolchain.toml"
|
||||
- ".gitea/workflows/nix.yml"
|
||||
- "scripts/ci/check-bun-nix.sh"
|
||||
pull_request:
|
||||
paths:
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "packaging/nix/**"
|
||||
- "**/bun.lock"
|
||||
- "**/bun.nix"
|
||||
- "**/package.json"
|
||||
- "Cargo.lock"
|
||||
- "Cargo.toml"
|
||||
- "rust-toolchain.toml"
|
||||
- ".gitea/workflows/nix.yml"
|
||||
- "scripts/ci/check-bun-nix.sh"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-rust:
|
||||
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
flake:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
# Official Nix image (Docker Hub, like this fleet's other WAN images: oven/bun:1,
|
||||
# fedora:43, node:22-bookworm). It ships nix and little else.
|
||||
image: nixos/nix:latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# The image defaults to stable Nix with the experimental features off; the flake needs both.
|
||||
# Set at job level so every step — including the `nix profile install` below — sees it.
|
||||
NIX_CONFIG: "experimental-features = nix-command flakes"
|
||||
steps:
|
||||
# actions/checkout is a JS action and needs node; a plain `run:` step executes through the
|
||||
# container shell, so this must come BEFORE the checkout (same ordering, and the same
|
||||
# reason, as flatpak.yml's fedora job).
|
||||
- name: node + git for the JS actions
|
||||
run: nix profile install nixpkgs#nodejs nixpkgs#git
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Nix reads the flake through libgit2 and refuses a checkout owned by another uid
|
||||
# ("detected dubious ownership"), which is the normal case for a container job.
|
||||
- name: Trust the checkout
|
||||
run: git config --global --add safe.directory "$PWD"
|
||||
|
||||
# First-run diagnostics — cheap, and the difference between "the gate found a real problem"
|
||||
# and "the runner had no disk" is otherwise a guess.
|
||||
- name: Environment
|
||||
run: |
|
||||
nix --version
|
||||
df -h /nix /tmp || true
|
||||
|
||||
# Evaluates + instantiates every flake output without building any of it.
|
||||
- name: nix flake check (eval only)
|
||||
run: nix flake check --no-build --show-trace
|
||||
|
||||
# The bun packages, built for real. This is the leg that would have caught the stale
|
||||
# web/bun.nix end to end: the derivation's offline `bun install` runs against a store cache
|
||||
# built strictly from bun.nix, so a lockfile that cache does not cover fails here.
|
||||
- name: Build the bun packages
|
||||
run: nix build --print-build-logs .#punktfunk-web .#punktfunk-scripting
|
||||
|
||||
# Both launchers exec pkgs.bun from the store; confirm they were produced and are real entry
|
||||
# points rather than dangling wrappers.
|
||||
- name: Smoke the built launchers
|
||||
run: |
|
||||
set -eu
|
||||
web=$(nix path-info .#punktfunk-web)
|
||||
scripting=$(nix path-info .#punktfunk-scripting)
|
||||
test -x "$web/bin/punktfunk-web-server" || { echo "no punktfunk-web-server in $web" >&2; exit 1; }
|
||||
test -x "$scripting/bin/punktfunk-scripting" || { echo "no punktfunk-scripting in $scripting" >&2; exit 1; }
|
||||
# The console must be the bun bundle, not a node one — the same assertion packages.nix
|
||||
# makes at build time, re-checked on the installed output.
|
||||
grep -q 'Bun\.serve' "$web/share/punktfunk-web/.output/server/index.mjs" \
|
||||
|| { echo "installed console is not a bun bundle" >&2; exit 1; }
|
||||
echo "bun packages OK: $web $scripting"
|
||||
|
||||
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
|
||||
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
|
||||
- name: Build the Rust packages (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-rust == 'true' }}
|
||||
run: nix build --print-build-logs .#punktfunk-host .#punktfunk-client
|
||||
@@ -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)
|
||||
@@ -248,7 +252,22 @@ private fun Coverflow(
|
||||
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
|
||||
)
|
||||
|
||||
// Design D4: the launcher entries lead the strip (the client groups them at parse time).
|
||||
// A coverflow is one-dimensional, so instead of a second focus rail the heading names the
|
||||
// group the cursor is in and changes as it crosses the boundary. Only drawn when the
|
||||
// library actually has both groups — otherwise the screen is exactly what it was.
|
||||
val bothGroups = games.any { it.isLauncher } && games.any { !it.isLauncher }
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||
if (bothGroups) {
|
||||
Text(
|
||||
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
letterSpacing = 2.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(coverWidth),
|
||||
@@ -306,15 +325,16 @@ private fun Coverflow(
|
||||
current?.title ?: " ",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (current != null) {
|
||||
Text(
|
||||
if (current.isCustom) "CUSTOM" else "STEAM",
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -326,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)
|
||||
@@ -333,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) {
|
||||
@@ -346,24 +367,29 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||
)
|
||||
} else {
|
||||
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
|
||||
// would read as "a game whose cover failed to load".
|
||||
Text(
|
||||
game.title,
|
||||
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),
|
||||
)
|
||||
}
|
||||
// Store badge, top-start.
|
||||
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||
Text(
|
||||
if (game.isCustom) "Custom" else "Steam",
|
||||
game.storeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
color = ink.fg,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.background(
|
||||
if (game.isLauncher) MaterialTheme.colorScheme.primary
|
||||
else Color.Black.copy(alpha = 0.5f),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,51 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
|
||||
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
|
||||
}
|
||||
|
||||
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
|
||||
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
|
||||
/**
|
||||
* One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`).
|
||||
*
|
||||
* [role] is `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that
|
||||
* opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable
|
||||
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
|
||||
* game rather than break the decode (design D4).
|
||||
*/
|
||||
data class GameEntry(
|
||||
val id: String,
|
||||
val store: String,
|
||||
val title: String,
|
||||
val art: Artwork,
|
||||
val role: String? = null,
|
||||
) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
|
||||
/** Whether this entry opens a launcher rather than a game. */
|
||||
val isLauncher: Boolean get() = role == "launcher"
|
||||
|
||||
/**
|
||||
* Display name for the store badge — the same table the other clients use
|
||||
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
|
||||
* entry, which a Lutris or GOG title made a lie.
|
||||
*/
|
||||
val storeLabel: String get() = when (store) {
|
||||
"steam" -> "Steam"
|
||||
"custom" -> "Custom"
|
||||
"heroic" -> "Heroic"
|
||||
"lutris" -> "Lutris"
|
||||
"epic" -> "Epic"
|
||||
"gog" -> "GOG"
|
||||
"xbox" -> "Xbox"
|
||||
else -> "Game"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Design D4: launcher entries lead the shelf, keeping the host's title order within each group.
|
||||
* Applied once where the library is fetched, so no screen has to remember the rule — and a library
|
||||
* without launcher entries comes back untouched.
|
||||
*/
|
||||
fun List<GameEntry>.launchersFirst(): List<GameEntry> {
|
||||
val launchers = filter { it.isLauncher }
|
||||
return if (launchers.isEmpty()) this else launchers + filterNot { it.isLauncher }
|
||||
}
|
||||
|
||||
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
|
||||
@@ -108,10 +150,11 @@ object LibraryClient {
|
||||
header = resolveArt(str(art, "header"), base),
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
role = str(o, "role"),
|
||||
),
|
||||
)
|
||||
}
|
||||
return out
|
||||
return out.launchersFirst()
|
||||
}
|
||||
|
||||
/** A present, non-null, non-blank JSON string field, else null. */
|
||||
|
||||
@@ -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,18 +47,24 @@ 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 {
|
||||
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
|
||||
// safe-area inset, already out of this budget) — capped so it never dwarfs a large iPad and
|
||||
// clamped by width on a narrow screen.
|
||||
let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers
|
||||
let reserved: CGFloat = (compact ? 72 : 96) + (showsGroupHeading ? 26 : 0)
|
||||
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
|
||||
let coverWidth = coverHeight * 2 / 3
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 4)
|
||||
if showsGroupHeading {
|
||||
groupHeading.padding(.bottom, 6)
|
||||
}
|
||||
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
|
||||
detailPanel
|
||||
.padding(.top, 12)
|
||||
@@ -89,10 +96,12 @@ struct LibraryCoverflowView: View {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
.overlay(alignment: .topLeading) {
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||
}
|
||||
.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
|
||||
@@ -112,21 +121,42 @@ struct LibraryCoverflowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this library have both groups? Only then does the heading earn its row — a
|
||||
/// launcher-less library gets exactly the layout it had before design D4.
|
||||
private var showsGroupHeading: Bool {
|
||||
games.contains(where: \.isLauncher) && games.contains { !$0.isLauncher }
|
||||
}
|
||||
|
||||
/// Which group the cursor is in. A coverflow is one-dimensional, so instead of a second focus
|
||||
/// rail (a whole new up/down nav model for two or three tiles) the heading names the group and
|
||||
/// changes as the selection crosses the boundary — the launcher entries lead the strip.
|
||||
private var groupHeading: some View {
|
||||
let selected = games.first { $0.id == selection }
|
||||
return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(ink.fg(0.45))
|
||||
}
|
||||
|
||||
/// The centered title + store tag — empty (not hidden) so the layout doesn't jump.
|
||||
@ViewBuilder private var detailPanel: some View {
|
||||
let game = games.first { $0.id == selection }
|
||||
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 {
|
||||
Text(game.isCustom ? "CUSTOM" : "STEAM")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
// 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(ink.fg(0.5))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -139,7 +169,10 @@ struct LibraryCoverflowView: View {
|
||||
private var hints: [GamepadHint] {
|
||||
var hints: [GamepadHint] = []
|
||||
if onLaunch != nil {
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch"))
|
||||
// You *open* a launcher and *launch* a game — the hint follows the focused entry.
|
||||
let opens = games.first { $0.id == selection }?.isLauncher == true
|
||||
hints.append(
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
|
||||
}
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
|
||||
return hints
|
||||
|
||||
@@ -80,21 +80,47 @@ struct LibraryView: View {
|
||||
}
|
||||
|
||||
private var grid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(games) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
}
|
||||
// Design D4: launcher entries get their own section above the titles, never interleaved.
|
||||
// Both headers appear only when both groups exist, so a library without launcher entries
|
||||
// renders exactly as it did before.
|
||||
let launchers = games.filter(\.isLauncher)
|
||||
let titles = games.filter { !$0.isLauncher }
|
||||
let both = !launchers.isEmpty && !titles.isEmpty
|
||||
return ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !launchers.isEmpty {
|
||||
if both { sectionHeader("Launchers") }
|
||||
tiles(launchers)
|
||||
}
|
||||
if !titles.isEmpty {
|
||||
if both { sectionHeader("Games") }
|
||||
tiles(titles)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func tiles(_ entries: [GameEntry]) -> some View {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(entries) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeader(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.geist(12, .semibold, relativeTo: .caption))
|
||||
.tracking(1.1)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var columns: [GridItem] {
|
||||
#if os(tvOS)
|
||||
let minW: CGFloat = 220
|
||||
@@ -152,12 +178,15 @@ struct LibraryView: View {
|
||||
return
|
||||
}
|
||||
do {
|
||||
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
|
||||
// the gamepad coverflow both inherit the D4 ordering.
|
||||
games = try await LibraryClient.fetch(
|
||||
address: current.address,
|
||||
port: current.effectiveMgmtPort,
|
||||
certPEM: identity.certPEM,
|
||||
keyPEM: identity.keyPEM,
|
||||
hostFingerprint: current.pinnedSHA256)
|
||||
hostFingerprint: current.pinnedSHA256
|
||||
).launchersFirst
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = try LibraryImageLoader.session(
|
||||
address: current.address,
|
||||
@@ -185,7 +214,9 @@ private struct GameCard: View {
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
.overlay(alignment: .topLeading) {
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||
}
|
||||
Text(game.title)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.lineLimit(2)
|
||||
|
||||
@@ -12,14 +12,21 @@ import AppKit
|
||||
/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster —
|
||||
/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell.
|
||||
struct StoreBadge: View {
|
||||
let isCustom: Bool
|
||||
/// Which store surfaced the entry, already resolved to a display name (`GameEntry.storeLabel`).
|
||||
let label: String
|
||||
/// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size
|
||||
/// without reading the title.
|
||||
var isLauncher: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Text(isCustom ? "Custom" : "Steam")
|
||||
Text(label)
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.background(
|
||||
isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial),
|
||||
in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -38,12 +38,46 @@ public struct LaunchSpec: Codable, Hashable, Sendable {
|
||||
/// One title in the unified library. `id` is store-qualified: `steam:<appid>` / `custom:<id>`.
|
||||
public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: String
|
||||
public var store: String // "steam" | "custom"
|
||||
public var store: String // "steam" | "custom" | "lutris" | "heroic" | "epic" | "gog" | "xbox"
|
||||
public var title: String
|
||||
public var art: Artwork
|
||||
public var launch: LaunchSpec?
|
||||
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
|
||||
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. Deliberately a plain
|
||||
/// optional String: the host owns the vocabulary, and an unknown future value must never fail
|
||||
/// the whole library decode. Anything that isn't `"launcher"` is a game (design D4).
|
||||
public var role: String?
|
||||
|
||||
public var isCustom: Bool { store == "custom" }
|
||||
|
||||
/// Whether this entry opens a launcher rather than a game.
|
||||
public var isLauncher: Bool { role == "launcher" }
|
||||
|
||||
/// Display name for the store badge — the same table the Rust clients use
|
||||
/// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for
|
||||
/// every non-custom entry, which a Lutris or GOG title made a lie.
|
||||
public var storeLabel: String {
|
||||
switch store {
|
||||
case "steam": return "Steam"
|
||||
case "custom": return "Custom"
|
||||
case "heroic": return "Heroic"
|
||||
case "lutris": return "Lutris"
|
||||
case "epic": return "Epic"
|
||||
case "gog": return "GOG"
|
||||
case "xbox": return "Xbox"
|
||||
default: return "Game"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Array where Element == GameEntry {
|
||||
/// Design D4: launcher entries lead the shelf, and the host's title order survives within each
|
||||
/// group. Applied once where the library is fetched, so no individual view has to remember
|
||||
/// the rule — and a library without launcher entries comes back untouched.
|
||||
var launchersFirst: [GameEntry] {
|
||||
let launchers = filter(\.isLauncher)
|
||||
return launchers.isEmpty ? self : launchers + filter { !$0.isLauncher }
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,13 @@ const CSS: &str = "
|
||||
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
||||
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
|
||||
.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); }
|
||||
/* Launcher entries (design D4) open the launcher itself. They rarely have poster art, so an
|
||||
art-less one must not read as a game whose cover failed to load: accent face, the launcher
|
||||
named instead of a title monogram, and an accent badge. */
|
||||
.pf-poster.pf-launcher { background: alpha(@accent_color, 0.18); }
|
||||
.pf-poster-launcher-name { font-size: 1.15em; font-weight: bold; color: alpha(currentColor, 0.85); }
|
||||
.pf-store-badge.pf-launcher { color: white; background: @accent_color; }
|
||||
.pf-group-heading { font-size: 0.8em; font-weight: bold; color: alpha(currentColor, 0.55); }
|
||||
";
|
||||
|
||||
/// Everything the shell shares below the component tree.
|
||||
|
||||
@@ -204,10 +204,18 @@ pub fn headless_library(target: &str) -> glib::ExitCode {
|
||||
});
|
||||
match crate::library::fetch_games(&addr, port, &identity, pin) {
|
||||
Ok(games) => {
|
||||
// A fourth column, appended: `game` or `launcher` (design D4). Appended rather than
|
||||
// folded into an existing field so anything reading the first three columns is
|
||||
// untouched.
|
||||
for g in &games {
|
||||
println!("{}\t{}\t{}", g.id, g.store, g.title);
|
||||
let role = if g.is_launcher() { "launcher" } else { "game" };
|
||||
println!("{}\t{}\t{}\t{}", g.id, g.store, g.title, role);
|
||||
}
|
||||
let launchers = games.iter().filter(|g| g.is_launcher()).count();
|
||||
match launchers {
|
||||
0 => println!("{} game(s)", games.len()),
|
||||
n => println!("{} game(s), {} launcher(s)", games.len() - n, n),
|
||||
}
|
||||
println!("{} game(s)", games.len());
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -28,6 +28,12 @@ struct State {
|
||||
req: ConnectRequest,
|
||||
stack: gtk::Stack,
|
||||
flow: gtk::FlowBox,
|
||||
/// Launcher entries (design D4) get their own shelf above the games, so a handful of ways to
|
||||
/// open a launcher aren't buried in a 400-title grid. Hidden outright when there are none.
|
||||
launcher_flow: gtk::FlowBox,
|
||||
launchers_group: gtk::Box,
|
||||
/// The "Games" heading — only earns its space once a Launchers shelf is above it.
|
||||
games_heading: gtk::Label,
|
||||
error_page: adw::StatusPage,
|
||||
/// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching.
|
||||
art: RefCell<HashMap<String, gdk::Texture>>,
|
||||
@@ -94,11 +100,44 @@ fn build(
|
||||
flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
// The launcher shelf: same tile geometry as the games grid, its own FlowBox so the two
|
||||
// groups never interleave and each wraps on its own.
|
||||
let launcher_flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.activate_on_single_click(true)
|
||||
.homogeneous(true)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(6)
|
||||
.column_spacing(12)
|
||||
.row_spacing(18)
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
launcher_flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
let launchers_heading = gtk::Label::new(Some("Launchers"));
|
||||
launchers_heading.add_css_class("pf-group-heading");
|
||||
launchers_heading.set_halign(gtk::Align::Start);
|
||||
launchers_heading.set_margin_bottom(8);
|
||||
let launchers_group = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
launchers_group.append(&launchers_heading);
|
||||
launchers_group.append(&launcher_flow);
|
||||
launchers_group.set_margin_bottom(24);
|
||||
launchers_group.set_visible(false);
|
||||
|
||||
let games_heading = gtk::Label::new(Some("Games"));
|
||||
games_heading.add_css_class("pf-group-heading");
|
||||
games_heading.set_halign(gtk::Align::Start);
|
||||
games_heading.set_margin_bottom(8);
|
||||
games_heading.set_visible(false);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
content.set_margin_top(24);
|
||||
content.set_margin_bottom(24);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&launchers_group);
|
||||
content.append(&games_heading);
|
||||
content.append(&flow);
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(1100)
|
||||
@@ -166,6 +205,9 @@ fn build(
|
||||
req,
|
||||
stack,
|
||||
flow,
|
||||
launcher_flow,
|
||||
launchers_group,
|
||||
games_heading,
|
||||
error_page,
|
||||
art: RefCell::new(HashMap::new()),
|
||||
pics: RefCell::new(HashMap::new()),
|
||||
@@ -224,18 +266,41 @@ fn load(state: &Rc<State>) {
|
||||
/// immediately; the rest keep their monogram placeholder until `load_art` delivers.
|
||||
fn render(state: &Rc<State>, games: &[GameEntry]) {
|
||||
state.flow.remove_all();
|
||||
state.launcher_flow.remove_all();
|
||||
state.pics.borrow_mut().clear();
|
||||
for game in games {
|
||||
// Design D4: launchers never interleave with titles. The host already sorts by title, and
|
||||
// `partition` is stable, so each group keeps that order.
|
||||
let (launchers, titles): (Vec<&GameEntry>, Vec<&GameEntry>) =
|
||||
games.iter().partition(|g| g.is_launcher());
|
||||
for game in &launchers {
|
||||
state.launcher_flow.append(&game_card(state, game));
|
||||
}
|
||||
for game in &titles {
|
||||
state.flow.append(&game_card(state, game));
|
||||
}
|
||||
// A library with no launcher entries looks exactly as it did before this existed.
|
||||
state.launchers_group.set_visible(!launchers.is_empty());
|
||||
state
|
||||
.games_heading
|
||||
.set_visible(!launchers.is_empty() && !titles.is_empty());
|
||||
}
|
||||
|
||||
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
|
||||
/// monogram placeholder underneath the async art. Activation starts a session launching
|
||||
/// this title (silent on a pinned host — the normal trust gate applies).
|
||||
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
let monogram = gtk::Label::new(Some(&initials(&game.title)));
|
||||
monogram.add_css_class("pf-poster-monogram");
|
||||
// A launcher usually ships no poster. Naming the launcher on an accent face says "opens
|
||||
// Steam"; a title monogram on the neutral face would say "a game whose cover didn't load".
|
||||
let launcher = game.is_launcher();
|
||||
let monogram = if launcher {
|
||||
let l = gtk::Label::new(Some(store_label(&game.store)));
|
||||
l.add_css_class("pf-poster-launcher-name");
|
||||
l
|
||||
} else {
|
||||
let l = gtk::Label::new(Some(&initials(&game.title)));
|
||||
l.add_css_class("pf-poster-monogram");
|
||||
l
|
||||
};
|
||||
monogram.set_halign(gtk::Align::Center);
|
||||
monogram.set_valign(gtk::Align::Center);
|
||||
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
@@ -252,6 +317,9 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
let badge = gtk::Label::new(Some(store_label(&game.store)));
|
||||
badge.add_css_class("pf-pill");
|
||||
badge.add_css_class("pf-store-badge");
|
||||
if launcher {
|
||||
badge.add_css_class("pf-launcher");
|
||||
}
|
||||
badge.set_halign(gtk::Align::Start);
|
||||
badge.set_valign(gtk::Align::Start);
|
||||
badge.set_margin_start(6);
|
||||
@@ -262,6 +330,9 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
poster.add_overlay(&pic);
|
||||
poster.add_overlay(&badge);
|
||||
poster.add_css_class("pf-poster");
|
||||
if launcher {
|
||||
poster.add_css_class("pf-launcher");
|
||||
}
|
||||
poster.set_overflow(gtk::Overflow::Hidden);
|
||||
poster.set_size_request(150, 225);
|
||||
poster.set_halign(gtk::Align::Center);
|
||||
|
||||
@@ -803,6 +803,7 @@ fn spawn_fetch(
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -843,6 +844,7 @@ fn load_fake(shared: &LibraryShared, path: &str) {
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -560,7 +560,7 @@ fn wake_and_connect(
|
||||
None => {}
|
||||
}
|
||||
ticks += 1;
|
||||
if ticks % 5 == 0 {
|
||||
if ticks.is_multiple_of(5) {
|
||||
rescan.request();
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
|
||||
@@ -39,6 +39,10 @@ pub(crate) struct Game {
|
||||
pub(crate) id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) store: String,
|
||||
/// This entry opens the launcher itself (Steam Big Picture, Heroic) rather than a title —
|
||||
/// design D4. Reduced from the wire's `role` by `GameEntry::is_launcher`, so "anything that
|
||||
/// isn't `launcher` is a game" is decided in one place for every client.
|
||||
pub(crate) launcher: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
@@ -135,6 +139,7 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -215,6 +220,17 @@ fn initials(title: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A small group label above a tile grid ("Launchers" / "Games"). Only drawn when the page shows
|
||||
/// both groups — a single unlabelled grid is what every launcher-less library looked like before.
|
||||
fn group_heading(text: &str) -> Element {
|
||||
text_block(text)
|
||||
.font_size(12.0)
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.margin(edges(2.0, 8.0, 2.0, 2.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// One poster tile: the artwork (or a monogram placeholder while it loads) with the store
|
||||
/// badge overlaid top-left, the title below, tap-to-launch across the whole tile.
|
||||
fn poster_tile(
|
||||
@@ -228,13 +244,20 @@ fn poster_tile(
|
||||
.stretch(Stretch::UniformToFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
// A launcher rarely has poster art, and an art-less launcher drawn like an art-less game
|
||||
// reads as "a game whose cover failed to load". So it names its launcher instead of
|
||||
// showing a title monogram, and the frame below picks up the accent stroke.
|
||||
None => border(
|
||||
text_block(initials(&game.title))
|
||||
.font_size(28.0)
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
text_block(if game.launcher {
|
||||
store_label(&game.store).to_string()
|
||||
} else {
|
||||
initials(&game.title)
|
||||
})
|
||||
.font_size(if game.launcher { 18.0 } else { 28.0 })
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
)
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.height(poster_h)
|
||||
@@ -242,14 +265,27 @@ fn poster_tile(
|
||||
};
|
||||
let framed = border(grid(vec![
|
||||
poster,
|
||||
pill(store_label(&game.store), Pill::Neutral)
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.vertical_alignment(VerticalAlignment::Top)
|
||||
.margin(uniform(6.0))
|
||||
.into(),
|
||||
// `Pill::Info` rather than a solid accent fill — `style.rs` is explicit that
|
||||
// white-on-bright is unreadable here.
|
||||
pill(
|
||||
store_label(&game.store),
|
||||
if game.launcher {
|
||||
Pill::Info
|
||||
} else {
|
||||
Pill::Neutral
|
||||
},
|
||||
)
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.vertical_alignment(VerticalAlignment::Top)
|
||||
.margin(uniform(6.0))
|
||||
.into(),
|
||||
]))
|
||||
.corner_radius(8.0)
|
||||
.border_brush(ThemeRef::CardStroke)
|
||||
.border_brush(if game.launcher {
|
||||
ThemeRef::Accent
|
||||
} else {
|
||||
ThemeRef::CardStroke
|
||||
})
|
||||
.border_thickness(uniform(1.0));
|
||||
|
||||
border(
|
||||
@@ -332,22 +368,43 @@ pub(crate) fn library_page(props: &LibraryProps, cx: &mut RenderCx) -> Element {
|
||||
.into(),
|
||||
),
|
||||
LibraryPhase::Ready(games) => {
|
||||
let tiles: Vec<Element> = games
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
|
||||
let (target, id) = (target.clone(), g.id.clone());
|
||||
poster_tile(
|
||||
g,
|
||||
props.state.art.get(&g.id).map(String::as_str),
|
||||
poster_h,
|
||||
Box::new(move || {
|
||||
initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
body.push(tile_grid(tiles, cols, POSTER_GAP));
|
||||
let tile = |g: &Game| -> Element {
|
||||
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
|
||||
let (target, id) = (target.clone(), g.id.clone());
|
||||
poster_tile(
|
||||
g,
|
||||
props.state.art.get(&g.id).map(String::as_str),
|
||||
poster_h,
|
||||
Box::new(move || initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)),
|
||||
)
|
||||
};
|
||||
// Design D4: launcher entries get their own shelf above the titles, never
|
||||
// interleaved. `partition` is stable, so the host's title order survives in each
|
||||
// group. Headings appear only when both groups exist, so a library without launcher
|
||||
// entries renders exactly as it did before.
|
||||
let (launchers, titles): (Vec<&Game>, Vec<&Game>) =
|
||||
games.iter().partition(|g| g.launcher);
|
||||
let both = !launchers.is_empty() && !titles.is_empty();
|
||||
if !launchers.is_empty() {
|
||||
if both {
|
||||
body.push(group_heading("Launchers"));
|
||||
}
|
||||
body.push(tile_grid(
|
||||
launchers.iter().map(|g| tile(g)).collect(),
|
||||
cols,
|
||||
POSTER_GAP,
|
||||
));
|
||||
}
|
||||
if !titles.is_empty() {
|
||||
if both {
|
||||
body.push(group_heading("Games"));
|
||||
}
|
||||
body.push(tile_grid(
|
||||
titles.iter().map(|g| tile(g)).collect(),
|
||||
cols,
|
||||
POSTER_GAP,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -419,6 +544,11 @@ pub struct LibraryGame {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub store: String,
|
||||
/// This entry opens the launcher itself (Steam Big Picture, Heroic, Lutris) rather than a
|
||||
/// title — design D4. The host's `role` field, already reduced to a boolean by
|
||||
/// [`pf_client_core::library::GameEntry::is_launcher`] so the "anything that isn't
|
||||
/// `launcher` is a game" rule lives in exactly one place.
|
||||
pub launcher: bool,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
@@ -454,7 +584,15 @@ impl LibraryShared {
|
||||
}
|
||||
|
||||
/// Loaded games → the carousel (empty = the empty scene).
|
||||
///
|
||||
/// **Launcher entries are moved to the front, keeping the host's title order within each
|
||||
/// group.** Grouping here rather than in the renderer means the carousel's cursor arithmetic,
|
||||
/// the art pump and every future consumer of this model all inherit the invariant for free —
|
||||
/// a launcher tile is never buried in the middle of a 400-title shelf.
|
||||
pub fn set_games(&self, games: Vec<LibraryGame>) {
|
||||
let mut games = games;
|
||||
// `sort_by_key` is stable, so this is a partition that preserves the incoming order.
|
||||
games.sort_by_key(|g| !g.launcher);
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.phase = if games.is_empty() {
|
||||
LibraryPhase::Empty
|
||||
@@ -521,6 +659,52 @@ mod tests {
|
||||
assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary);
|
||||
}
|
||||
|
||||
/// Design D4: launcher entries lead the shelf, and the host's title order survives within
|
||||
/// each group. The renderer's `launcher_count()` reads the launcher group as the prefix
|
||||
/// `0..n`, so an interleaved list would silently mislabel the group heading.
|
||||
#[test]
|
||||
fn set_games_groups_launchers_first_and_keeps_title_order() {
|
||||
let g = |title: &str, launcher: bool| LibraryGame {
|
||||
id: format!("steam:{title}"),
|
||||
title: title.to_string(),
|
||||
store: "steam".into(),
|
||||
launcher,
|
||||
};
|
||||
let shared = LibraryShared::default();
|
||||
shared.set_games(vec![
|
||||
g("Celeste", false),
|
||||
g("Big Picture", true),
|
||||
g("Portal 2", false),
|
||||
g("Heroic", true),
|
||||
]);
|
||||
let (phase, games, _) = shared.snapshot();
|
||||
assert!(matches!(phase, LibraryPhase::Ready));
|
||||
let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect();
|
||||
assert_eq!(titles, ["Big Picture", "Heroic", "Celeste", "Portal 2"]);
|
||||
assert_eq!(games.iter().take_while(|g| g.launcher).count(), 2);
|
||||
}
|
||||
|
||||
/// A library with no launcher entries is untouched — the whole point of the grouping being
|
||||
/// invisible until a plugin actually publishes a launcher tile.
|
||||
#[test]
|
||||
fn set_games_leaves_a_launcher_less_library_alone() {
|
||||
let shared = LibraryShared::default();
|
||||
shared.set_games(
|
||||
["Celeste", "Portal 2", "Tunic"]
|
||||
.iter()
|
||||
.map(|t| LibraryGame {
|
||||
id: format!("steam:{t}"),
|
||||
title: (*t).to_string(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let (_, games, _) = shared.snapshot();
|
||||
let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect();
|
||||
assert_eq!(titles, ["Celeste", "Portal 2", "Tunic"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_clamps_onto_the_ends() {
|
||||
assert_eq!(step_cursor(1, 5, -JUMP, true), StepResult::Moved(0));
|
||||
@@ -602,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::{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;
|
||||
@@ -168,10 +168,31 @@ impl LibraryScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many launcher entries lead the shelf — [`LibraryShared::set_games`] groups them at the
|
||||
/// front, so the launcher group is always the prefix `0..launcher_count()`.
|
||||
fn launcher_count(&self) -> usize {
|
||||
self.games.iter().take_while(|g| g.launcher).count()
|
||||
}
|
||||
|
||||
/// Is the focused entry a launcher? (Drives the confirm hint: you *open* Steam, you *play* a
|
||||
/// game.)
|
||||
fn focused_is_launcher(&self) -> bool {
|
||||
self.games
|
||||
.get(self.cursor as usize)
|
||||
.is_some_and(|g| g.launcher)
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
match &self.phase {
|
||||
LibraryPhase::Ready => vec![
|
||||
Hint::new(HintKey::Confirm, "Play"),
|
||||
Hint::new(
|
||||
HintKey::Confirm,
|
||||
if self.focused_is_launcher() {
|
||||
"Open"
|
||||
} else {
|
||||
"Play"
|
||||
},
|
||||
),
|
||||
Hint::new(HintKey::Shoulders, "Jump"),
|
||||
Hint::new(HintKey::Back, "Back"),
|
||||
],
|
||||
@@ -216,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,
|
||||
@@ -228,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,
|
||||
@@ -238,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,
|
||||
@@ -250,7 +271,7 @@ impl LibraryScreen {
|
||||
&title,
|
||||
W::Bold,
|
||||
22.0 * k,
|
||||
WHITE,
|
||||
fg(1.0),
|
||||
cx,
|
||||
cy_all - 32.0 * k,
|
||||
w * 0.8,
|
||||
@@ -260,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),
|
||||
@@ -277,6 +298,30 @@ impl LibraryScreen {
|
||||
let pos = self.anim.pos;
|
||||
let bump = self.bump.pos * k;
|
||||
|
||||
// Group heading. The model groups launcher entries at the front (design D4), and a
|
||||
// coverflow is one-dimensional — so instead of a second focus rail (a new up/down nav
|
||||
// model, in three renderers, for two or three tiles) the heading names the group the
|
||||
// cursor is in and changes as it crosses the boundary. Drawn only when the shelf
|
||||
// actually has both groups, so a library without launchers looks exactly as before.
|
||||
let launchers = self.launcher_count();
|
||||
if launchers > 0 && launchers < self.games.len() {
|
||||
let heading = if (self.cursor as usize) < launchers {
|
||||
"LAUNCHERS"
|
||||
} else {
|
||||
"GAMES"
|
||||
};
|
||||
fonts.centered(
|
||||
canvas,
|
||||
heading,
|
||||
W::SemiBold,
|
||||
12.0 * k,
|
||||
fg(0.5),
|
||||
f64::from(rect.left) + w / 2.0,
|
||||
cy - card_h / 2.0 - 22.0 * k,
|
||||
w * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
// Paint order = draw order: farthest from the (integer) cursor first, so the
|
||||
// dense side stacks overlap toward the focus.
|
||||
let mut order: Vec<usize> = (0..self.games.len()).collect();
|
||||
@@ -326,21 +371,31 @@ impl LibraryScreen {
|
||||
}
|
||||
None => {
|
||||
// Solid face, not glass: the side cards OVERLAP.
|
||||
canvas.draw_rect(
|
||||
crect,
|
||||
&Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None),
|
||||
);
|
||||
let mono = initials(&game.title);
|
||||
let font = fonts.font(W::Bold, 38.0 * k);
|
||||
let tw = font.measure_str(&mono, None).0;
|
||||
//
|
||||
// A launcher tile usually has no poster, and an art-less launcher drawn like
|
||||
// an art-less game reads as "a game whose cover failed to load". So it gets
|
||||
// the brand-tinted face and names its launcher, instead of a title monogram.
|
||||
let face = if game.launcher {
|
||||
Color4f::new(0.153, 0.137, 0.267, 1.0)
|
||||
} else {
|
||||
Color4f::new(0.118, 0.118, 0.145, 1.0)
|
||||
};
|
||||
canvas.draw_rect(crect, &Paint::new(face, None));
|
||||
let (glyph, size, ink) = if game.launcher {
|
||||
(store_label(&game.store).to_string(), 22.0 * k, fg(0.85))
|
||||
} else {
|
||||
(initials(&game.title), 38.0 * k, fg(0.45))
|
||||
};
|
||||
let font = fonts.font(W::Bold, size);
|
||||
let tw = font.measure_str(&glyph, None).0;
|
||||
canvas.draw_str(
|
||||
&mono,
|
||||
&glyph,
|
||||
Point::new(
|
||||
(card_w as f32 - tw) / 2.0,
|
||||
card_h as f32 / 2.0 + 13.0 * k as f32,
|
||||
),
|
||||
&font,
|
||||
&Paint::new(white(0.45), None),
|
||||
&Paint::new(ink, None),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -351,13 +406,20 @@ impl LibraryScreen {
|
||||
let tw = fonts.measure(label, W::SemiBold, size) as f64;
|
||||
let (px, py) = (8.0 * k, 8.0 * k);
|
||||
let (bw, bh) = (tw + 16.0 * k, 20.0 * k);
|
||||
// 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 {
|
||||
accent(0.85)
|
||||
} else {
|
||||
crate::theme::shade(0.55)
|
||||
};
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(
|
||||
Rect::from_xywh(px as f32, py as f32, bw as f32, bh as f32),
|
||||
(bh / 2.0) as f32,
|
||||
(bh / 2.0) as f32,
|
||||
),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
|
||||
&Paint::new(pill, None),
|
||||
);
|
||||
fonts.draw(
|
||||
canvas,
|
||||
@@ -366,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.
|
||||
@@ -390,17 +452,22 @@ impl LibraryScreen {
|
||||
&g.title,
|
||||
W::Bold,
|
||||
27.0 * k,
|
||||
WHITE,
|
||||
fg(1.0),
|
||||
cx,
|
||||
f64::from(rect.bottom) - 64.0 * k,
|
||||
w * 0.8,
|
||||
);
|
||||
let sub = if g.launcher {
|
||||
format!("{} · LAUNCHER", store_label(&g.store).to_uppercase())
|
||||
} else {
|
||||
store_label(&g.store).to_uppercase()
|
||||
};
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&store_label(&g.store).to_uppercase(),
|
||||
&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);
|
||||
@@ -312,6 +321,7 @@ fn dump_console_screens() {
|
||||
id: format!("steam:{i}"),
|
||||
title: (*t).to_string(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -77,64 +77,21 @@ fn budget_for(bitrate_bps: u64, fps: u32) -> usize {
|
||||
((bitrate_bps / (8 * fps.max(1) as u64)) as usize).max(64 * 1024)
|
||||
}
|
||||
|
||||
/// Raise this process's WDDM GPU scheduling priority so the wavelet encode isn't starved by a
|
||||
/// GPU-bound game. PyroWave encodes on the GPU's compute/shader cores — the exact resource a game
|
||||
/// saturates — so under load `pyrowave_encoder_encode_gpu_synchronous` spikes from ~2 ms to
|
||||
/// 15-18 ms (measured, RTX 4090 at 95 % game load) and the stream fps collapses; NVENC is immune
|
||||
/// because it runs on the separate encoder ASIC. HIGH sits above a game's NORMAL/ABOVE_NORMAL so
|
||||
/// the WDDM scheduler services the encode's short compute bursts ahead of the game's rendering.
|
||||
/// (REALTIME is deliberately avoided: it needs a privilege and would preempt the desktop
|
||||
/// compositor too.) Best-effort + once-per-process: if the class isn't grantable we log and run at
|
||||
/// normal priority — no session-fatal path.
|
||||
fn raise_process_gpu_priority() {
|
||||
use std::sync::Once;
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(|| {
|
||||
use windows::Wdk::Graphics::Direct3D::{
|
||||
D3DKMTSetProcessSchedulingPriorityClass, D3DKMT_SCHEDULINGPRIORITYCLASS_ABOVE_NORMAL,
|
||||
D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH, D3DKMT_SCHEDULINGPRIORITYCLASS_REALTIME,
|
||||
};
|
||||
// `PUNKTFUNK_GPU_PRIORITY` = off | above-normal | high (default) | realtime. REALTIME can
|
||||
// force finer WDDM preemption than HIGH but needs a privilege and preempts the compositor,
|
||||
// so it stays opt-in; `off` skips the call entirely.
|
||||
let (class, label) = match std::env::var("PUNKTFUNK_GPU_PRIORITY")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref()
|
||||
{
|
||||
Some("off") => {
|
||||
tracing::info!("PyroWave: PUNKTFUNK_GPU_PRIORITY=off — leaving GPU scheduling priority at default");
|
||||
return;
|
||||
}
|
||||
Some("above-normal") | Some("above_normal") => {
|
||||
(D3DKMT_SCHEDULINGPRIORITYCLASS_ABOVE_NORMAL, "ABOVE_NORMAL")
|
||||
}
|
||||
Some("realtime") => (D3DKMT_SCHEDULINGPRIORITYCLASS_REALTIME, "REALTIME"),
|
||||
_ => (D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH, "HIGH"),
|
||||
};
|
||||
// SAFETY: `GetCurrentProcess` returns the current-process pseudo-handle; the D3DKMT call
|
||||
// only sets this process's GPU scheduling class — it creates/frees nothing.
|
||||
let status = unsafe {
|
||||
D3DKMTSetProcessSchedulingPriorityClass(GetCurrentProcess(), class)
|
||||
};
|
||||
if status.is_ok() {
|
||||
tracing::info!(
|
||||
priority = label,
|
||||
"PyroWave: raised process GPU scheduling priority (WDDM) so the wavelet encode is \
|
||||
serviced ahead of game rendering on the shared shader cores"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
?status,
|
||||
priority = label,
|
||||
"PyroWave: could not raise GPU scheduling priority (not grantable) — the encode \
|
||||
may be starved under heavy game GPU load"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
// GPU scheduling priority is deliberately NOT set here. `pf-frame`'s `dxgi::auto_priority_gate`
|
||||
// owns that policy for the whole process and runs once from `create_device` — the call the Windows
|
||||
// capture path always makes before any PyroWave texture exists.
|
||||
//
|
||||
// This module used to raise it itself, to HIGH, once per process. That was a SECOND owner of a
|
||||
// process-wide setting and it raced the real one: pf-frame's default `auto` mode starts at HIGH and
|
||||
// then UPGRADES to REALTIME once it has established that is safe (HAGS off, or HAGS on with VRAM
|
||||
// headroom, with a monitor that drops back when VRAM tightens — REALTIME + NVIDIA + HAGS +
|
||||
// near-full VRAM is a documented NVENC hang). Opening a PyroWave session after that upgrade stamped
|
||||
// HIGH back over REALTIME and the monitor, silently losing the ceiling-raise on exactly the
|
||||
// GPU-saturated workload PyroWave cares about most.
|
||||
//
|
||||
// The old `PUNKTFUNK_GPU_PRIORITY` knob went with it; `PUNKTFUNK_GPU_PRIORITY_CLASS`
|
||||
// (`off|normal|high|realtime|auto`, default `auto`) is the one that survives and it is strictly
|
||||
// more capable — the removed knob could not express the auto gate at all.
|
||||
|
||||
pub struct PyroWaveEncoder {
|
||||
// pyrowave owns the whole Vulkan device (create_device_by_compat) — no ash on this side.
|
||||
@@ -188,9 +145,6 @@ impl PyroWaveEncoder {
|
||||
chroma: crate::ChromaFormat,
|
||||
bit_depth: u8,
|
||||
) -> Result<Self> {
|
||||
// Prioritize the host's GPU work over a running game so the compute-shader encode gets
|
||||
// scheduled promptly instead of queuing behind a full frame of the game's rendering.
|
||||
raise_process_gpu_priority();
|
||||
let chroma444 = chroma.is_444();
|
||||
// A negotiated 10-bit session rides 16-bit UNORM planes carrying the P010-style
|
||||
// studio codes the capturer's HDR CSC writes (design/pyrowave-444-hdr.md §2.2) —
|
||||
|
||||
@@ -671,9 +671,10 @@ mod tests {
|
||||
} else {
|
||||
"/home/u/.cache/lutris/coverart/cover.jpg".to_string()
|
||||
};
|
||||
let url = file_url(std::path::Path::new(&path));
|
||||
let mut art = Artwork {
|
||||
portrait: Some(path.clone()),
|
||||
hero: Some(format!("file://{path}")),
|
||||
hero: Some(url),
|
||||
logo: Some("https://cdn/l.png".into()),
|
||||
header: None,
|
||||
};
|
||||
@@ -757,7 +758,7 @@ mod tests {
|
||||
// half that matters for the extracted scanners: they emit `file://` values, so if the
|
||||
// conversion happened after the confinement check the check would be inspecting a string
|
||||
// that is not the path being read.
|
||||
let as_url = format!("file://{}", cover.to_str().unwrap());
|
||||
let as_url = file_url(&cover);
|
||||
assert_eq!(
|
||||
local_art_bytes(&as_url)
|
||||
.expect("file:// reads the same cover")
|
||||
@@ -766,15 +767,15 @@ mod tests {
|
||||
);
|
||||
// …and a `file://` value is confined exactly like a bare one — no bypass by spelling.
|
||||
assert!(
|
||||
local_art_bytes(&format!("file://{}", elsewhere.to_str().unwrap())).is_none(),
|
||||
local_art_bytes(&file_url(&elsewhere)).is_none(),
|
||||
"file:// must not escape the art roots"
|
||||
);
|
||||
// Percent-encoded traversal is decoded BEFORE canonicalization, so it cannot hide from the
|
||||
// `..` check.
|
||||
assert!(
|
||||
local_art_bytes(&format!(
|
||||
"file://{}/%2e%2e/{}/cover.png",
|
||||
dir.to_str().unwrap(),
|
||||
"{}/%2e%2e/{}/cover.png",
|
||||
file_url(&dir),
|
||||
outside.file_name().unwrap().to_str().unwrap()
|
||||
))
|
||||
.is_none(),
|
||||
@@ -789,6 +790,21 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
/// Build a `file://` value the way the kit's `fileUrl` does, so these tests exercise the real
|
||||
/// plugin contract on both platforms. A POSIX path keeps the two-slash form
|
||||
/// (`file:///home/u/c.png` — empty authority, then the leading `/`); a Windows path becomes
|
||||
/// `file:///C:/covers/c.png`, i.e. three slashes and forward separators. Building it as
|
||||
/// `format!("file://{path}")` on Windows yields `file://C:\covers\c.png`, whose authority is
|
||||
/// `C:` — that is a UNC reference, not a local file, and the parser is right to refuse it.
|
||||
fn file_url(p: &std::path::Path) -> String {
|
||||
let posix = p.to_str().unwrap().replace('\\', "/");
|
||||
if posix.starts_with('/') {
|
||||
format!("file://{posix}")
|
||||
} else {
|
||||
format!("file:///{posix}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Write-time validation refuses what read-time would refuse, so an unservable path never even
|
||||
/// reaches `library.json`. URLs are none of its business.
|
||||
#[test]
|
||||
|
||||
@@ -371,7 +371,7 @@ pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
|
||||
/// copies of the very primitive the `/hooks` carve-out exists to withhold.
|
||||
///
|
||||
/// Returns the field name for the error message, so a plugin author sees exactly what was refused.
|
||||
/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`,
|
||||
/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`, `playnite`,
|
||||
/// `lutris_id`, `heroic`) are all
|
||||
/// host-resolved from a validated id and stay open to every lane — a provider plugin can still
|
||||
/// publish its whole catalogue, it just cannot hand the host a shell command to run.
|
||||
@@ -461,6 +461,21 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
|
||||
launch.value
|
||||
));
|
||||
}
|
||||
// The value is interpolated into a `playnite://` URI, so it is charset-checked here as
|
||||
// well as at launch time — same reasoning as the two kinds above.
|
||||
if launch.kind == "playnite" && !valid_playnite_id(&launch.value) {
|
||||
return Err(format!(
|
||||
"entries[{i}]: `launch.value` for kind `playnite` must be a Playnite game GUID"
|
||||
));
|
||||
}
|
||||
// `<Identity>!<AppId>`, both straight off `MicrosoftGame.config`. The host completes it
|
||||
// into an AUMID at launch (it can read the publisher hash; the runner cannot), so the
|
||||
// shape is checked here where the author can still act on the error.
|
||||
if launch.kind == "xbox" && !valid_aumid(&launch.value) {
|
||||
return Err(format!(
|
||||
"entries[{i}]: `launch.value` for kind `xbox` must be `<Identity>!<AppId>`"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(marker) = &e.detect.env_marker {
|
||||
if !valid_env_key(&marker.key) {
|
||||
@@ -743,6 +758,47 @@ mod tests {
|
||||
assert!(library_id_for(&r3[0]).starts_with("custom:"));
|
||||
}
|
||||
|
||||
/// A plugin's `launchers(cfg)` tile, end to end: `role: "launcher"` survives the reconcile onto
|
||||
/// the stored entry AND onto the `GameEntry` a client renders, keeps the deterministic claimed
|
||||
/// id, and stays out of the wire for ordinary games.
|
||||
///
|
||||
/// This is the path the lutris and heroic plugins publish through, and nothing exercised it
|
||||
/// before — every earlier test reconciled `GameRole::Game`, which is the serde default, so the
|
||||
/// field could have been dropped anywhere between the payload and the client without a failure.
|
||||
#[test]
|
||||
fn a_launcher_entry_survives_reconcile_onto_the_wire() {
|
||||
let mut launcher = input("launcher", "Lutris");
|
||||
launcher.role = GameRole::Launcher;
|
||||
launcher.launch = Some(LaunchSpec {
|
||||
kind: "launcher_ui".into(),
|
||||
value: "lutris".into(),
|
||||
});
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let out = reconcile_entries(
|
||||
&mut entries,
|
||||
"lutris",
|
||||
Some("lutris"),
|
||||
vec![launcher, input("42", "Some Game")],
|
||||
);
|
||||
|
||||
assert_eq!(library_id_for(&out[0]), "lutris:launcher");
|
||||
assert_eq!(out[0].role, GameRole::Launcher);
|
||||
assert_eq!(out[1].role, GameRole::Game, "the game is untouched");
|
||||
|
||||
// Onto the wire: the client sees `role`, and `is_game` keeps it off ordinary entries.
|
||||
let tile: GameEntry = out[0].clone().into();
|
||||
assert_eq!(tile.role, GameRole::Launcher);
|
||||
let v = serde_json::to_value(&tile).unwrap();
|
||||
assert_eq!(v["role"], "launcher");
|
||||
let game: GameEntry = out[1].clone().into();
|
||||
let vg = serde_json::to_value(&game).unwrap();
|
||||
assert!(
|
||||
vg.get("role").is_none(),
|
||||
"a game's role stays off the wire, so old clients are unaffected"
|
||||
);
|
||||
}
|
||||
|
||||
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
|
||||
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
|
||||
/// pre-metadata `library.json` / payload still parses (all-optional).
|
||||
|
||||
@@ -184,22 +184,59 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
|
||||
// shell:AppsFolder — which runs in the interactive user session (UWP activation fails as
|
||||
// SYSTEM/session-0; spawn_in_active_session uses the user token). Guard the charset (the value
|
||||
// is host-derived from MicrosoftGame.config + AppRepository, but belt-and-suspenders).
|
||||
"aumid" => {
|
||||
let valid = spec.value.split_once('!').is_some_and(|(pfn, app)| {
|
||||
let part = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
};
|
||||
part(pfn) && part(app)
|
||||
});
|
||||
valid.then(|| {
|
||||
(
|
||||
format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value),
|
||||
None,
|
||||
)
|
||||
})
|
||||
"aumid" => valid_aumid(&spec.value).then(|| {
|
||||
(
|
||||
format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value),
|
||||
None,
|
||||
)
|
||||
}),
|
||||
// Xbox / Game Pass from a library PLUGIN: `<Identity>!<AppId>`, both read straight out of
|
||||
// `MicrosoftGame.config`. The host completes it into the AUMID.
|
||||
//
|
||||
// This kind exists because of a measured privilege asymmetry (2026-08-06): resolving the
|
||||
// PackageFamilyName means enumerating `%ProgramData%\…\AppRepository\Packages`, which is
|
||||
// denied to `NT AUTHORITY\LocalService` — the principal the plugin runner runs as — and
|
||||
// allowed to the host, which runs as LocalSystem. So the plugin sends what it can read and
|
||||
// the host reads the authoritative publisher hash itself, at launch time.
|
||||
//
|
||||
// Resolving here rather than caching at install time also means a package update that
|
||||
// changes the hash cannot leave a stale, unlaunchable tile behind.
|
||||
"xbox" => {
|
||||
let (identity, app_id) = spec.value.split_once('!')?;
|
||||
if !aumid_part(identity) || !aumid_part(app_id) {
|
||||
return None;
|
||||
}
|
||||
let pfn = xbox_pfn(identity)?;
|
||||
Some((
|
||||
format!("explorer.exe \"shell:AppsFolder\\{pfn}!{app_id}\""),
|
||||
None,
|
||||
))
|
||||
}
|
||||
// Playnite: open the game through Playnite's own URI handler, which is what actually knows
|
||||
// how to start it (Playnite maps the id to whichever store owns the title). explorer.exe
|
||||
// resolves the registered protocol as the user — the same pattern as the `epic` kind — and
|
||||
// the id is GUID-validated, so the only variable part of the line is 36 hex-and-dash chars.
|
||||
//
|
||||
// This kind exists because the plugin used to publish `kind: "command"` (a `start ""` shell
|
||||
// line). The 2026-08-05 review made `command` operator-only, which refuses a plugin's whole
|
||||
// reconcile — so without a typed kind the Playnite plugin cannot publish anything at all.
|
||||
"playnite" => valid_playnite_id(&spec.value).then(|| {
|
||||
(
|
||||
format!("explorer.exe \"playnite://playnite/start/{}\"", spec.value),
|
||||
None,
|
||||
)
|
||||
}),
|
||||
// A launcher entry (D4) on Windows: today that is Playnite's Fullscreen app, spawned
|
||||
// directly (its `playnite://` handler opens the DESKTOP app, so no URI can do this). The
|
||||
// value is the literal "playnite" — nothing from the entry reaches the command line — and
|
||||
// the working directory is Playnite's own install dir, as a .NET app expects.
|
||||
"launcher_ui" => match spec.value.as_str() {
|
||||
"playnite" => playnite_fullscreen_exe().map(|exe| {
|
||||
let dir = exe.parent().map(std::path::Path::to_path_buf);
|
||||
(format!("\"{}\"", exe.display()), dir)
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
// Operator-typed custom command (host-owned, never client-set): run it through the shell in the
|
||||
// interactive session. `cmd.exe /c` is acceptable here precisely because the value is operator
|
||||
// input — the same trust as the operator typing it — not a client-influenced string.
|
||||
@@ -260,6 +297,38 @@ pub(crate) fn valid_steam_ui(value: &str) -> bool {
|
||||
matches!(value, "bigpicture" | "desktop")
|
||||
}
|
||||
|
||||
/// One half of an AUMID (a package family name or an app id): non-empty, and no character that
|
||||
/// could break out of the `shell:AppsFolder\…` argument. Both halves are host-derived, so this is
|
||||
/// belt-and-braces — but the `xbox` kind now takes an Identity straight off a plugin's wire, which
|
||||
/// makes it load-bearing rather than defensive.
|
||||
pub(crate) fn aumid_part(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
}
|
||||
|
||||
/// A full `<PFN>!<AppId>` AUMID.
|
||||
pub(crate) fn valid_aumid(value: &str) -> bool {
|
||||
value
|
||||
.split_once('!')
|
||||
.is_some_and(|(pfn, app)| aumid_part(pfn) && aumid_part(app))
|
||||
}
|
||||
|
||||
/// A Playnite game id: the GUID Playnite's own database uses, and the only client-influenced part
|
||||
/// of a `playnite` launch. Interpolated into a URI handed to explorer.exe, so the charset is
|
||||
/// validated first — 8-4-4-4-12 lowercase-or-uppercase hex with dashes, nothing else.
|
||||
pub(crate) fn valid_playnite_id(value: &str) -> bool {
|
||||
let groups = [8usize, 4, 4, 4, 12];
|
||||
let mut parts = value.split('-');
|
||||
for want in groups {
|
||||
match parts.next() {
|
||||
Some(p) if p.len() == want && p.bytes().all(|b| b.is_ascii_hexdigit()) => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
parts.next().is_none()
|
||||
}
|
||||
|
||||
/// The launcher UIs **this host** can open, as `launcher_ui` values (D4).
|
||||
///
|
||||
/// One kind for every launcher but Steam, rather than one kind each: they all have exactly a single
|
||||
@@ -280,17 +349,71 @@ fn launcher_ui_stores() -> &'static [&'static str] {
|
||||
{
|
||||
&["heroic", "lutris"]
|
||||
}
|
||||
// Windows launchers (Epic, GOG Galaxy, the Xbox app) are not wired yet — each needs its own
|
||||
// verified activation, and an unverified guess would ship a tile that does nothing.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
// Playnite's activation is verified (2026-08-06, on the .173 box); Epic, GOG Galaxy and the
|
||||
// Xbox app are still unwired — each needs its own verified activation, and an unverified guess
|
||||
// would ship a tile that does nothing.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
&["playnite"]
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
{
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this a `launcher_ui` value this host can resolve?
|
||||
///
|
||||
/// On Windows, Playnite is validated by *resolution* rather than by being on the list: a host
|
||||
/// without Playnite installed refuses the entry (a 400 the plugin author can act on) instead of
|
||||
/// publishing a tile that does nothing when a user clicks it.
|
||||
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
|
||||
launcher_ui_stores().contains(&value)
|
||||
if !launcher_ui_stores().contains(&value) {
|
||||
return false;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if value == "playnite" {
|
||||
return playnite_fullscreen_exe().is_some();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Windows: Playnite's **Fullscreen** app, if this host can find it.
|
||||
///
|
||||
/// Fullscreen rather than Desktop for two reasons: a launcher tile is opened from a couch over a
|
||||
/// stream, and — verified on 2026-08-06 — the registered `playnite://` protocol handler points at
|
||||
/// `Playnite.DesktopApp.exe`, so a URI cannot open fullscreen mode at all. The exe is launched
|
||||
/// directly, which is also why nothing here is interpolated from the entry: the whole value is the
|
||||
/// literal `"playnite"`.
|
||||
///
|
||||
/// Playnite installs per-user by default, so the install directory comes from its own uninstall
|
||||
/// entry (HKCU first, then HKLM for a machine-wide install), falling back to the default
|
||||
/// `%LOCALAPPDATA%\Playnite`. `None` when nothing resolves, which is what refuses the tile.
|
||||
#[cfg(windows)]
|
||||
fn playnite_fullscreen_exe() -> Option<std::path::PathBuf> {
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
use winreg::RegKey;
|
||||
const KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Playnite";
|
||||
const EXE: &str = "Playnite.FullscreenApp.exe";
|
||||
|
||||
let from_registry = [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE]
|
||||
.into_iter()
|
||||
.find_map(|root| {
|
||||
RegKey::predef(root)
|
||||
.open_subkey(KEY)
|
||||
.ok()?
|
||||
.get_value::<String, _>("InstallLocation")
|
||||
.ok()
|
||||
})
|
||||
.map(std::path::PathBuf::from);
|
||||
|
||||
from_registry
|
||||
.into_iter()
|
||||
.chain(
|
||||
std::env::var_os("LOCALAPPDATA").map(|l| std::path::PathBuf::from(l).join("Playnite")),
|
||||
)
|
||||
.map(|dir| dir.join(EXE))
|
||||
.find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
@@ -566,9 +689,23 @@ mod tests {
|
||||
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
|
||||
assert!(!valid_launcher_ui("gog"));
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// No Windows/macOS launcher UIs are wired yet, so every value is refused.
|
||||
// Playnite is accepted only when this host can actually FIND its Fullscreen app:
|
||||
// validation is resolution, so a box without Playnite refuses the entry rather than
|
||||
// publishing a tile that does nothing when clicked.
|
||||
assert_eq!(
|
||||
valid_launcher_ui("playnite"),
|
||||
playnite_fullscreen_exe().is_some()
|
||||
);
|
||||
// The Linux launchers, and the Windows ones whose activation is still unverified
|
||||
// (Epic, GOG Galaxy, the Xbox app), stay refused.
|
||||
assert!(!valid_launcher_ui("heroic"));
|
||||
assert!(!valid_launcher_ui("gog"));
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
{
|
||||
// No launcher UIs are wired on this OS, so every value is refused.
|
||||
assert!(!valid_launcher_ui("heroic"));
|
||||
assert!(!valid_launcher_ui("gog"));
|
||||
}
|
||||
@@ -576,6 +713,54 @@ mod tests {
|
||||
assert!(!valid_launcher_ui("lutris; rm -rf ~"));
|
||||
}
|
||||
|
||||
/// The `xbox` kind is what a library PLUGIN can publish: the runner's principal cannot read
|
||||
/// AppRepository (measured 2026-08-06), so it sends `<Identity>!<AppId>` and the host resolves
|
||||
/// the publisher hash. The charset guard is load-bearing here — unlike `aumid`, this value
|
||||
/// arrives over the wire.
|
||||
#[test]
|
||||
fn xbox_value_is_identity_bang_appid_and_charset_guarded() {
|
||||
assert!(valid_aumid("Microsoft.Foo!Game"));
|
||||
assert!(valid_aumid("A_b-c.d!App"));
|
||||
// Both halves must be present and non-empty.
|
||||
assert!(!valid_aumid("Microsoft.Foo"));
|
||||
assert!(!valid_aumid("!Game"));
|
||||
assert!(!valid_aumid("Microsoft.Foo!"));
|
||||
assert!(!valid_aumid(""));
|
||||
// Nothing that could break out of the `shell:AppsFolder\…` argument.
|
||||
assert!(!valid_aumid("Foo\"!Game"));
|
||||
assert!(!valid_aumid("Foo!Game\" & calc"));
|
||||
assert!(!valid_aumid("Foo\\..\\Bar!Game"));
|
||||
assert!(!valid_aumid("Foo Bar!Game"));
|
||||
}
|
||||
|
||||
/// Windows' launcher tile opens Playnite's FULLSCREEN app. Both negatives are the point: the
|
||||
/// desktop app is not what a couch tile should open, and the `playnite://` handler cannot be
|
||||
/// used because it is registered to the desktop app (verified on .173, 2026-08-06).
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn playnite_launcher_opens_the_fullscreen_app() {
|
||||
let ui = |v: &str| {
|
||||
windows_launch_for(&LaunchSpec {
|
||||
kind: "launcher_ui".into(),
|
||||
value: v.into(),
|
||||
})
|
||||
};
|
||||
// A launcher this host cannot open is refused, whatever the OS.
|
||||
assert!(ui("gog").is_none());
|
||||
assert!(ui("heroic").is_none());
|
||||
assert!(ui("").is_none());
|
||||
|
||||
// The rest only means anything on a box that actually has Playnite.
|
||||
let Some(exe) = playnite_fullscreen_exe() else {
|
||||
return;
|
||||
};
|
||||
let (cmd, dir) = ui("playnite").expect("resolvable when the exe was found");
|
||||
assert!(cmd.contains("Playnite.FullscreenApp.exe"), "{cmd}");
|
||||
assert!(!cmd.contains("DesktopApp"), "{cmd}");
|
||||
assert!(!cmd.contains("playnite://"), "{cmd}");
|
||||
assert_eq!(dir.as_deref(), exe.parent());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn launcher_ui_opens_the_launcher_itself() {
|
||||
|
||||
@@ -134,8 +134,14 @@ fn xbox_parse_config(text: &str, folder: Option<&str>) -> Option<(String, String
|
||||
/// Resolve a package's PackageFamilyName by finding its
|
||||
/// `AppRepository\Packages\<PackageFullName>` dir (machine-wide, SYSTEM-readable) and reducing the
|
||||
/// full name to `Name_PublisherHash`. This READS the authoritative PFN — never compute the hash.
|
||||
///
|
||||
/// **Readable by the host, NOT by the plugin runner.** Measured on 2026-08-06: that directory is
|
||||
/// `UnauthorizedAccessException` for `NT AUTHORITY\LocalService` (which the runner is), while the
|
||||
/// host service runs as LocalSystem and enumerates all 348 entries. That asymmetry is why the
|
||||
/// `xbox` launch kind exists — a library plugin sends the package Identity it CAN read out of
|
||||
/// `MicrosoftGame.config`, and this resolves the rest at launch time (see `launch.rs`).
|
||||
#[cfg(windows)]
|
||||
fn xbox_pfn(identity: &str) -> Option<String> {
|
||||
pub(crate) fn xbox_pfn(identity: &str) -> Option<String> {
|
||||
let pkgs = PathBuf::from(std::env::var_os("ProgramData")?)
|
||||
.join("Microsoft")
|
||||
.join("Windows")
|
||||
|
||||
@@ -31,7 +31,8 @@ fn check_entry_fields(
|
||||
&format!(
|
||||
"`{field}` is executed as the host user and may only be set with the \
|
||||
operator's admin token — a plugin may publish entries with any host-resolved \
|
||||
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, lutris_id, heroic) \
|
||||
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, xbox, lutris_id, \
|
||||
heroic, playnite) \
|
||||
instead"
|
||||
),
|
||||
));
|
||||
|
||||
+23
-8
@@ -234,18 +234,33 @@ The shell exports `PF_FFVK_VULKAN_INCLUDE` (Vulkan headers for pf-ffvk bindgen)
|
||||
already in the lockfile, via a generated-and-committed `bun.nix` (`web/bun.nix`, `sdk/bun.nix`).
|
||||
There is **no aggregate deps hash to bump** — the previous design put `bun install` in a
|
||||
fixed-output derivation whose single `outputHash` silently went stale on every lockfile change and
|
||||
broke the build. `bun.nix` regenerates itself: `bun2nix` is a devDependency of both packages and
|
||||
runs on every `bun install` (web's `postinstall`; the SDK's `prepare`, since sdk/ is the
|
||||
*published* `@punktfunk/host` package and a `postinstall` would then fire on consumers' installs).
|
||||
Regenerate by hand with `cd web && bunx bun2nix -o bun.nix` if a lockfile is ever edited directly.
|
||||
broke the build. `bun2nix` is a devDependency of both packages and regenerates `bun.nix` on every
|
||||
`bun install` (web's `postinstall`; the SDK's `prepare`, since sdk/ is the *published*
|
||||
`@punktfunk/host` package and a `postinstall` would then fire on consumers' installs).
|
||||
The `@unom` scope needs no special handling: `web/bun.lock` records those tarballs' full
|
||||
`https://git.unom.io/api/packages/unom/npm/…` URLs and the registry is read-public (the same
|
||||
anonymous pull CI's rpm/deb builds do).
|
||||
|
||||
> ⚠ **`bun.nix` has no schema stability across bun2nix versions.** The flake input is pinned
|
||||
> (`github:nix-community/bun2nix?ref=2.1.2`) and the npm devDependency is pinned to the *same*
|
||||
> exact version in `web/package.json` + `sdk/package.json`. Move both together, then rerun
|
||||
> `bun install` in `web/` and `sdk/` to regenerate.
|
||||
> ⚠⚠ **That devDependency hook is a convenience, NOT the guarantee — `bun.nix` still drifts.**
|
||||
> It fires only on a local `bun install` that runs lifecycle scripts. It does *not* fire under
|
||||
> `bun install --ignore-scripts`, which is what every bun install in CI uses; and it cannot fire
|
||||
> on a **merge or rebase**, where git carries someone else's `bun.lock` change past a `bun.nix`
|
||||
> generated before it and reports no conflict. That is how `web/bun.nix` shipped on main holding
|
||||
> `brace-expansion@5.0.7` while `web/bun.lock` said `5.0.8` — for **553 commits** (2026-07-27 →
|
||||
> 2026-08-05), with `nix build .#punktfunk-web` broken the whole time, until an unrelated
|
||||
> advisory bump happened to rerun a real `bun install` and closed it by accident.
|
||||
>
|
||||
> The enforcement point is **`scripts/ci/check-bun-nix.sh`** (the `bun-nix` job in `ci.yml`,
|
||||
> unfiltered so it sees the innocuous-looking commits drift arrives through). It regenerates each
|
||||
> `bun.nix` from its committed `bun.lock` and diffs. Fix any report with:
|
||||
>
|
||||
> scripts/ci/check-bun-nix.sh --fix
|
||||
>
|
||||
> Never regenerate with a bare `bunx bun2nix`: **`bun.nix` has no schema stability across bun2nix
|
||||
> versions**, and an unpinned `bunx` uses whatever is newest. The flake input
|
||||
> (`github:nix-community/bun2nix?ref=2.1.2`) and the npm devDependency in `web/package.json` +
|
||||
> `sdk/package.json` must name the *same exact version* — the script checks that too, and always
|
||||
> generates with the pinned one. Move all three together, then rerun it with `--fix`.
|
||||
|
||||
Everything past the deps fetch is offline (the console's codegen + vite build; the runner's
|
||||
`bun build --target=bun` bundle). Both launchers exec `pkgs.bun` from the store — unlike the
|
||||
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/bin/sh
|
||||
# Drift gate for the generated bun2nix lockfile expressions (web/bun.nix, sdk/bun.nix).
|
||||
#
|
||||
# `bun.nix` is a DERIVED file: bun2nix is a pure function of `bun.lock` (it reads the lockfile text
|
||||
# and emits one `fetchurl` per package, keyed by the lockfile's own integrity hashes — see
|
||||
# packaging/nix/README.md). Nothing but the lockfile goes in, so any disagreement between the two
|
||||
# committed files is drift, and it is always mechanically fixable.
|
||||
#
|
||||
# Why this exists: moving the bun packages to bun2nix (1db8f763) removed the *aggregate deps hash*
|
||||
# that used to go stale, but not the second, quieter way a derived file rots. `bun.nix` regenerates
|
||||
# only from a local `bun install` that runs lifecycle scripts (web's `postinstall`, the SDK's
|
||||
# `prepare`). It does NOT regenerate on:
|
||||
#
|
||||
# * `bun install --ignore-scripts` — which is what EVERY bun install in CI uses (ci.yml,
|
||||
# web-screenshots.yml, windows-host.yml, sdk-publish.yml), because web's `postinstall` shells
|
||||
# out to a `bun` on PATH that CI's portable bun isn't;
|
||||
# * a merge or rebase — git merges `bun.lock` and `bun.nix` as two unrelated files, so a branch
|
||||
# that generated `bun.nix` before picking up someone else's lockfile change silently commits
|
||||
# the pair out of step;
|
||||
# * a lockfile edited or re-resolved by hand.
|
||||
#
|
||||
# That second case is not hypothetical: it is how `web/bun.nix` shipped on main carrying
|
||||
# brace-expansion@5.0.7 (plus two nested entries the override had already collapsed) while
|
||||
# `web/bun.lock` said 5.0.8 — the `^5.0.8` override from ec9aa415 landed in the lockfile, the
|
||||
# bun2nix branch had generated `bun.nix` off the pre-override lock, and the merge kept both. The
|
||||
# Nix build fetches node_modules strictly from `bun.nix`, so the offline `bun install` inside the
|
||||
# derivation is then asked for a tarball the store cache does not contain and `punktfunk-web` fails
|
||||
# to build — with a "package not found" that names npm, not the lockfile that actually drifted.
|
||||
#
|
||||
# The gate also enforces the version pin the flake and README only *state*: `bun.nix` has no schema
|
||||
# stability across bun2nix releases, so the flake input ref and BOTH npm devDependencies must name
|
||||
# the same exact version. Nothing checked that before; a half-moved pin regenerates the file with a
|
||||
# generator the flake does not use.
|
||||
#
|
||||
# The list of packages to check is read out of packaging/nix/packages.nix (its `bunNix = src + …`
|
||||
# lines) rather than hardcoded here, so a third bun package is covered the day it is added — and an
|
||||
# empty list is a hard error, because a gate that checks nothing passes exactly like a clean tree.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/ci/check-bun-nix.sh # verify; non-zero on drift (CI)
|
||||
# scripts/ci/check-bun-nix.sh --fix # regenerate the committed files in place
|
||||
set -eu
|
||||
|
||||
FIX=0
|
||||
if [ $# -gt 0 ]; then
|
||||
case "$1" in
|
||||
--fix) FIX=1 ;;
|
||||
*) echo "usage: $0 [--fix]" >&2; exit 2 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)
|
||||
PACKAGES_NIX="$ROOT/packaging/nix/packages.nix"
|
||||
FLAKE="$ROOT/flake.nix"
|
||||
|
||||
command -v bun >/dev/null 2>&1 || {
|
||||
echo "check-bun-nix: bun is not on PATH (needed to run bun2nix and to read package.json)" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -f "$PACKAGES_NIX" ] || { echo "check-bun-nix: no $PACKAGES_NIX" >&2; exit 1; }
|
||||
[ -f "$FLAKE" ] || { echo "check-bun-nix: no $FLAKE" >&2; exit 1; }
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# --- the pinned bun2nix version -------------------------------------------------------------------
|
||||
# flake.nix: url = "github:nix-community/bun2nix?ref=2.1.2";
|
||||
PINNED=$(sed -n 's/.*github:nix-community\/bun2nix?ref=\([^"]*\)".*/\1/p' "$FLAKE" | head -1)
|
||||
[ -n "$PINNED" ] || {
|
||||
echo "check-bun-nix: could not read the bun2nix input ref out of $FLAKE." >&2
|
||||
echo "Expected a line like: url = \"github:nix-community/bun2nix?ref=<version>\";" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- which packages carry a generated bun.nix -----------------------------------------------------
|
||||
# packages.nix: bunDeps = bun2nix.fetchBunDeps { bunNix = src + "/web/bun.nix"; };
|
||||
sed -n 's/.*bunNix *= *src *+ *"\/\(.*\)\/bun\.nix".*/\1/p' "$PACKAGES_NIX" | sort -u > "$TMP/roots"
|
||||
if [ ! -s "$TMP/roots" ]; then
|
||||
echo "check-bun-nix: found no \`bunNix = src + \"/<dir>/bun.nix\"\` in $PACKAGES_NIX." >&2
|
||||
echo "Either the bun packages were removed (delete this gate) or the expression changed shape" >&2
|
||||
echo "and the gate silently stopped checking anything. Not passing vacuously." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fail=0
|
||||
checked=0
|
||||
|
||||
# --- version pin agreement ------------------------------------------------------------------------
|
||||
# `bun.nix` has no schema stability across bun2nix versions, so the generator the flake builds with
|
||||
# and the generator `bun install` runs must be the SAME exact version (packaging/nix/README.md).
|
||||
while read -r dir; do
|
||||
pkgjson="$ROOT/$dir/package.json"
|
||||
[ -f "$pkgjson" ] || { echo "check-bun-nix: no $pkgjson" >&2; fail=1; continue; }
|
||||
dev=$(bun -e "const d=require(process.argv[1]).devDependencies||{};console.log(d.bun2nix??'')" \
|
||||
"$pkgjson")
|
||||
if [ "$dev" != "$PINNED" ]; then
|
||||
echo "check-bun-nix: bun2nix version pin disagrees." >&2
|
||||
echo " flake.nix input ref : $PINNED" >&2
|
||||
echo " $dir/package.json devDependency : ${dev:-<absent>}" >&2
|
||||
echo "These must be the same exact version — bun.nix has no schema stability across" >&2
|
||||
echo "bun2nix releases. Move both together, then rerun this script with --fix." >&2
|
||||
fail=1
|
||||
fi
|
||||
done < "$TMP/roots"
|
||||
|
||||
# --- the generator ---------------------------------------------------------------------------------
|
||||
# Prefer an already-installed bun2nix at the pinned version (fast, offline — the dev case); otherwise
|
||||
# fetch exactly the pinned one, once, into $TMP. Never a floating `bunx bun2nix`: that would generate
|
||||
# with whatever is newest, and `bun.nix` has no schema stability across releases.
|
||||
BUN2NIX=""
|
||||
while read -r dir; do
|
||||
cand="$ROOT/$dir/node_modules/bun2nix/index.ts"
|
||||
[ -f "$cand" ] || continue
|
||||
have=$(bun -e "console.log(require(process.argv[1]).version??'')" \
|
||||
"$ROOT/$dir/node_modules/bun2nix/package.json" 2>/dev/null || echo '')
|
||||
if [ "$have" = "$PINNED" ]; then BUN2NIX="$cand"; break; fi
|
||||
done < "$TMP/roots"
|
||||
|
||||
if [ -z "$BUN2NIX" ]; then
|
||||
# Installed in its own scratch dir, so this never touches the repo's lockfiles or .npmrc.
|
||||
mkdir -p "$TMP/gen"
|
||||
if ! ( cd "$TMP/gen" && bun add --exact "bun2nix@$PINNED" ) > "$TMP/geninstall.log" 2>&1; then
|
||||
echo "check-bun-nix: could not install bun2nix@$PINNED" >&2
|
||||
cat "$TMP/geninstall.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
BUN2NIX="$TMP/gen/node_modules/bun2nix/index.ts"
|
||||
[ -f "$BUN2NIX" ] || { echo "check-bun-nix: bun2nix@$PINNED installed but $BUN2NIX is absent" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
run_bun2nix() { # <lockfile> <outfile>
|
||||
bun "$BUN2NIX" --lock-file "$1" --output-file "$2"
|
||||
}
|
||||
|
||||
# --- regenerate + compare ---------------------------------------------------------------------------
|
||||
while read -r dir; do
|
||||
lock="$ROOT/$dir/bun.lock"
|
||||
nix="$ROOT/$dir/bun.nix"
|
||||
[ -f "$lock" ] || { echo "check-bun-nix: no $lock (packages.nix expects $dir/bun.nix)" >&2; fail=1; continue; }
|
||||
|
||||
out="$TMP/$(echo "$dir" | tr '/' '_').bun.nix"
|
||||
run_bun2nix "$lock" "$out" >/dev/null
|
||||
|
||||
if [ "$FIX" -eq 1 ]; then
|
||||
if [ ! -f "$nix" ] || ! cmp -s "$nix" "$out"; then
|
||||
cp "$out" "$nix"
|
||||
echo "check-bun-nix: regenerated $dir/bun.nix from $dir/bun.lock"
|
||||
else
|
||||
echo "check-bun-nix: $dir/bun.nix already in sync"
|
||||
fi
|
||||
checked=$((checked + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ ! -f "$nix" ]; then
|
||||
echo "check-bun-nix: $dir/bun.nix is MISSING — packages.nix fetches node_modules from it." >&2
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
# Plain files, not `diff <(…) <(…)`: Gitea's runner executes a step's `run:` under `sh`, and
|
||||
# dash has no process substitution — it would reject the script at parse time and the gate
|
||||
# would never compare anything (exactly how the shader SPIR-V gate in ci.yml was lost).
|
||||
if cmp -s "$nix" "$out"; then
|
||||
echo "check-bun-nix: $dir/bun.nix matches $dir/bun.lock"
|
||||
else
|
||||
echo "check-bun-nix: $dir/bun.nix is STALE — it does not match $dir/bun.lock." >&2
|
||||
echo "The Nix build fetches node_modules only from bun.nix, so punktfunk's bun packages" >&2
|
||||
echo "would build against the wrong dependency set (or fail to fetch it at all)." >&2
|
||||
echo "Regenerate and commit it: scripts/ci/check-bun-nix.sh --fix" >&2
|
||||
echo "--- diff (committed -> regenerated from bun.lock) ---" >&2
|
||||
diff -u "$nix" "$out" >&2 || true
|
||||
fail=1
|
||||
fi
|
||||
checked=$((checked + 1))
|
||||
done < "$TMP/roots"
|
||||
|
||||
[ "$checked" -gt 0 ] || { echo "check-bun-nix: checked nothing — refusing to report success" >&2; exit 1; }
|
||||
if [ "$fail" -eq 0 ] && [ "$FIX" -eq 0 ]; then
|
||||
echo "check-bun-nix: $checked bun package(s) in sync, bun2nix pinned at $PINNED everywhere"
|
||||
fi
|
||||
exit "$fail"
|
||||
Reference in New Issue
Block a user