Files
punktfunk/crates/pf-console-ui/src/library.rs
T
enricobuehler 4a9a1c3ed4
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m59s
android / android (pull_request) Successful in 5m53s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m36s
ci / rust (pull_request) Successful in 9m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m41s
feat(clients/gamepad-ui): multi-tone palettes, and a UI that takes its colours from them
The first pass built each palette by rotating ONE colour field's hue, and it
showed: every option was a single tone at several brightnesses, which reads flat
next to any real gradient. A palette is now an ordered ramp of DISTINCT hues.
The 4×4 mesh samples that ramp along the diagonal with a fixed per-cell offset
table, so neighbouring cells land on different parts of it and the colours pool
and swirl instead of banding; the control points' existing drift then moves the
pools around. Violet keeps its explicit sixteen colours, so the default is
untouched.

Twelve of them now, dark first then pale: Violet, Nebula, Abyss, Ember, Moss,
Graphite, then Holo, Sunset, Bloom, Dawn, Mint, Opal. Holo and Sunset are
straight takes on the two reference gradients — foil and poster.

`every_palette_is_multi_tone` measures the hue spread across all sixteen cells
and fails under 45° (20° for Graphite and Opal, which are meant to be
restrained). It caught Ember at 35°, all reds and oranges — the very flatness
this rework exists to remove — and Graphite at 3° despite a comment claiming it
drifted cool to warm. Both were rebuilt until the numbers matched the prose.

The UI follows the palette now, rather than wearing brand violet over whatever
happens to be behind it. Each palette carries an accent and a light flag, and an
Ink derived from those (foreground, accent, on-accent, glass, scrim and its
strength) is published to the whole tree — a thread-local in the console, an
environment value on Apple, a CompositionLocal on Android. Pale palettes flip
the ink: dark text on white frost, with the materials, tray scrims and every
wash that sits under text following suit.

Three things only the renders could have told us:

  - Additive blending blows out over a pale ground. Android's blobs and Apple's
    legacy field composite with Plus/plusLighter, which over near-white
    saturates every blob to white — Holo rendered as a grey wash. Pale palettes
    blend normally.
  - A white scrim at the dark field's strength BLEACHES the gradient. Mixing
    toward black at 0.4 reads as depth; toward white at 0.4 destroys the chroma
    it is drawn over. The scrim now carries a per-palette strength.
  - White glass over a bright field has far less separating it from its backdrop
    than dark glass over a dark one, and needed more body.

Verified: console build + clippy -D warnings + 173 tests, Apple build + 200
tests + an iOS-triple typecheck, Android compile + 62 tests, and eyeball passes
on real renders of both the vivid and the pale ends (console CPU rasters; a new
Roborazzi light-palette scene, which is what exposed the blend-mode bug).
2026-08-06 15:55:26 +02:00

918 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The console library's model and math — everything about the coverflow that isn't
//! Skia: the shared binary↔overlay state (games, phase, incoming art bytes), the
//! spring-driven motion and cursor arithmetic (ported verbatim from the GTK launcher,
//! tests included), and the geometry constants. Rendering lives in `skia_overlay`.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
// --- Geometry (the GTK launcher's constants — Apple coverflow parity) --------------------
/// Poster geometry: 2:3 covers, sized so the focused poster + detail panel + hint bar
/// fit a Deck's 1280×800 with air. Scaled uniformly for other window sizes.
pub const POSTER_W: f64 = 220.0;
pub const POSTER_H: f64 = 330.0;
/// Center of the focused card to the center of its first neighbor.
pub const FOCUS_GAP: f64 = 230.0;
/// Center-to-center distance between successive SIDE cards — much tighter than their
/// projected width, so the side stacks overlap like the classic coverflow shelf.
pub const SIDE_SPACING: f64 = 104.0;
/// Cards farther than this from the eased position aren't drawn at all.
pub const VISIBLE_RANGE: f64 = 5.5;
/// Neighbors recede to this scale…
pub const RECEDE_SCALE: f64 = 0.24;
/// …and swing this many degrees about their own vertical axis under perspective, side
/// cards facing the corridor (their inner edge recedes behind the focus).
pub const ROTATE_DEG: f64 = 38.0;
/// Perspective depth for the tilt, px (CSS `perspective()` semantics).
pub const PERSPECTIVE: f64 = 800.0;
/// The darkening veil's max opacity (side cards stay opaque — they overlap).
pub const RECEDE_DIM: f64 = 0.30;
/// Boundary recoil: a refused move deflects the strip this many px against the push.
pub const BUMP_PX: f64 = 16.0;
/// L1/R1 jump distance.
pub const JUMP: i32 = 5;
// The motion is spring-driven (semi-implicit Euler), not eased — velocity carries across
// retargets, so holding a direction glides and a release settles like a detent.
/// Cursor chase: ζ ≈ 0.85 — settles in ~0.3 s with a whisker of overshoot.
pub const SPRING_K: f64 = 200.0;
pub const SPRING_C: f64 = 24.0;
/// Boundary recoil: stiffer and more underdamped (ζ ≈ 0.55) — one visible wobble.
pub const BUMP_K: f64 = 600.0;
pub const BUMP_C: f64 = 27.0;
/// One semi-implicit-Euler step of a damped spring toward `target`.
fn spring_step(pos: f64, vel: f64, target: f64, k: f64, c: f64, dt: f64) -> (f64, f64) {
let vel = vel + (k * (target - pos) - c * vel) * dt;
(pos + vel * dt, vel)
}
/// Advance a damped spring by a whole frame, integrating in ≤ 8 ms substeps — a stalled
/// frame stays far inside the integrator's stability bound, so the motion feels
/// identical at any frame rate.
pub fn spring_advance(
mut pos: f64,
mut vel: f64,
target: f64,
k: f64,
c: f64,
dt: f64,
) -> (f64, f64) {
let n = (dt / 0.008).ceil().max(1.0) as usize;
let h = dt / n as f64;
for _ in 0..n {
(pos, vel) = spring_step(pos, vel, target, k, c, h);
}
(pos, vel)
}
/// Pure cursor arithmetic for a move/jump: `clamp` lands jumps on the ends, a plain
/// step refuses to leave them.
#[derive(Debug, PartialEq, Eq)]
pub enum StepResult {
Moved(i32),
Boundary,
}
pub fn step_cursor(cursor: i32, len: usize, delta: i32, clamp: bool) -> StepResult {
if len == 0 {
return StepResult::Boundary;
}
let max = len as i32 - 1;
let target = if clamp {
(cursor + delta).clamp(0, max)
} else {
cursor + delta
};
if target == cursor || target < 0 || target > max {
StepResult::Boundary
} else {
StepResult::Moved(target)
}
}
// --- 4×4 matrix (row-major) — the coverflow card transform ------------------------------
/// `T(cx,cy) · P(depth) · Ry(angle) · S(s) · T(-w/2,-h/2)`: card-local (0..w, 0..h) →
/// screen, rotated about the card's own vertical center axis under perspective — the
/// GSK transform chain from the GTK launcher, as one row-major matrix for
/// `Canvas::concat_44`.
#[allow(clippy::too_many_arguments)]
pub fn card_matrix(
cx: f64,
cy: f64,
angle_deg: f64,
scale: f64,
w: f64,
h: f64,
depth: f64,
) -> [f32; 16] {
let t1 = translate(cx, cy);
let p = perspective(depth);
let r = rotate_y(angle_deg.to_radians());
let s = scale_xy(scale);
let t2 = translate(-w / 2.0, -h / 2.0);
let m = mat_mul(&mat_mul(&mat_mul(&mat_mul(&t1, &p), &r), &s), &t2);
core::array::from_fn(|i| m[i] as f32)
}
fn translate(x: f64, y: f64) -> [f64; 16] {
let mut m = identity();
m[3] = x;
m[7] = y;
m
}
fn perspective(d: f64) -> [f64; 16] {
let mut m = identity();
m[14] = -1.0 / d; // row 3, col 2 — w' = 1 z/d (CSS convention)
m
}
fn rotate_y(rad: f64) -> [f64; 16] {
let (s, c) = rad.sin_cos();
let mut m = identity();
m[0] = c;
m[2] = s;
m[8] = -s;
m[10] = c;
m
}
fn scale_xy(s: f64) -> [f64; 16] {
let mut m = identity();
m[0] = s;
m[5] = s;
m
}
fn identity() -> [f64; 16] {
let mut m = [0.0; 16];
m[0] = 1.0;
m[5] = 1.0;
m[10] = 1.0;
m[15] = 1.0;
m
}
fn mat_mul(a: &[f64; 16], b: &[f64; 16]) -> [f64; 16] {
let mut out = [0.0; 16];
for r in 0..4 {
for c in 0..4 {
out[r * 4 + c] = (0..4).map(|k| a[r * 4 + k] * b[k * 4 + c]).sum();
}
}
out
}
// --- Mesh-gradient background (the Swift `GamepadScreenBackground` MeshGradient, ported) --
/// The 16 mesh colours, row-major 4×4 (sRGB) — a verbatim port of the Swift client's
/// `meshColors`: dark-violet corners sink the frame, the edges carry mid-tone violets, and
/// the four interior points hold the bright brand family (warm pools left, cool right).
pub const MESH_COLORS: [(f64, f64, f64); 16] = [
(0.075, 0.060, 0.160),
(0.34, 0.27, 0.72),
(0.30, 0.26, 0.74),
(0.075, 0.060, 0.160),
(0.42, 0.20, 0.54),
(0.49, 0.39, 0.95),
(0.28, 0.31, 0.84),
(0.16, 0.26, 0.64),
(0.45, 0.23, 0.60),
(0.53, 0.31, 0.75),
(0.35, 0.35, 0.91),
(0.19, 0.28, 0.70),
(0.075, 0.060, 0.160),
(0.22, 0.18, 0.54),
(0.24, 0.20, 0.58),
(0.075, 0.060, 0.160),
];
/// The four interior control points that wander; the 12 boundary points stay pinned to the
/// frame (a drifting edge point would shrink the field and expose the black behind it). Each
/// row is `(base_ux, base_uy, amplitude, speed_x, speed_y, phase)` in unit UV / rad·s⁻¹ —
/// the exact `wob()` parameters from the Swift `meshPoints(at:)`. Their live displacement
/// `(amp·sin(t·sx+ph), amp·cos(t·sy+ph·1.3))` drives a domain warp, so the bright colour
/// pools follow the points as they breathe (periods ~90130 s, out of phase so it never loops).
pub const MESH_INTERIOR: [(f64, f64, f64, f64, f64, f64); 4] = [
(0.333, 0.333, 0.11, 0.049, 0.063, 0.4),
(0.667, 0.333, 0.10, 0.055, 0.052, 2.1),
(0.333, 0.667, 0.10, 0.058, 0.049, 3.6),
(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
];
// --- Background palettes -------------------------------------------------------------------
/// 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,
/// 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,
}
/// 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", stops: None,
ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false,
},
Palette {
// 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 {
// 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 {
// 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 {
// 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 {
// 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,
},
];
/// The palette stored under `id`, falling back to the brand default — an unknown name is a
/// palette a newer client shipped, not a reason to draw nothing.
pub fn palette(id: &str) -> &'static Palette {
PALETTES.iter().find(|p| p.id == id).unwrap_or(&PALETTES[0])
}
/// 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 {
/// 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] {
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
/// SwiftUI's `MeshGradient(smoothsColors: true)`. The four interior points drive a
/// bounded (weighted-average) domain warp so the bright pools drift; then the whole field
/// gets the ±8°/~5-min hue sway, an elliptical vignette, and the vertical legibility scrim,
/// all matching the Swift `composite(at:)`. Runs on the GPU at full rate.
///
/// `u_tc.y` is the CALM mix, 0 → 1: at 1 the same living field is flattened toward its own
/// corner colour (`u_lift`), which is how the form screens (settings, add-host, pair) stay
/// restful while still drifting — the motion never changes speed, only the contrast, so the
/// crossfade between a launcher screen and a form screen can't make the field jump.
pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> String {
// Colours as `float3(r, g, b)` literals, indices 0..15 (row-major 4×4).
let c = |i: usize| {
let (r, g, b) = colors[i];
format!("float3({r}, {g}, {b})")
};
// The four interior-point domain-warp accumulators. Displacement matches Swift `wob()`:
// x uses sin(t·sx+ph), y uses cos(t·sy+ph·1.3). SIG sets how far each point's pull
// reaches; the warp is the weight-normalised average displacement, so |warp| ≤ max|amp|.
let mut warp = String::new();
for (bx, by, amp, sx, sy, ph) in MESH_INTERIOR {
warp.push_str(&format!(
" q = uv - float2({bx}, {by});\n\
ww = exp(-dot(q, q) / (2.0 * 0.30 * 0.30));\n\
d = float2({amp} * sin(tt * {sx} + {ph}), \
{amp} * cos(tt * {sy} + {ph} * 1.3));\n\
wsum += d * ww; wtot += ww;\n",
));
}
format!(
"uniform float2 u_res;\n\
// x = seconds since the shell started, y = the calm mix (0 launcher, 1 form).\n\
uniform float2 u_tc;\n\
// rgb = the palette's corner colour scaled for the calm lift; a is unused (float4\n\
// so the uniform block stays 16-byte aligned under any packing rule).\n\
uniform float4 u_lift;\n\
// 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\
\x20 float u = 1.0 - t;\n\
\x20 return u*u*u*a + 3.0*u*u*t*b + 3.0*u*t*t*c + t*t*t*d;\n\
}}\n\
float3 bz3(float t, float3 a, float3 b, float3 c, float3 d) {{\n\
\x20 return float3(bz(t, a.r, b.r, c.r, d.r), bz(t, a.g, b.g, c.g, d.g), \
bz(t, a.b, b.b, c.b, d.b));\n\
}}\n\
// Hue rotation about the grey axis (Rodrigues) — the ±8° warm/cool sway.\n\
float3 hue(float3 col, float a) {{\n\
\x20 float3 k = float3(0.5773503);\n\
\x20 float cs = cos(a); float sn = sin(a);\n\
\x20 return col*cs + cross(k, col)*sn + k*dot(k, col)*(1.0 - cs);\n\
}}\n\
\n\
half4 main(float2 xy) {{\n\
\x20 float tt = u_tc.x; float calm = u_tc.y;\n\
\x20 float2 uv = xy / u_res;\n\
\x20 // Interior control points wander → bounded domain warp (pools follow them).\n\
\x20 float2 wsum = float2(0.0); float wtot = 0.0; float2 q; float ww; float2 d;\n\
{warp}\
\x20 uv = clamp(uv - wsum / (wtot + 1e-4), 0.0, 1.0);\n\
\n\
\x20 // Bicubic blend of the 16 mesh colours: cubic-Bézier in x per row, then in y.\n\
\x20 float3 r0 = bz3(uv.x, {c0}, {c1}, {c2}, {c3});\n\
\x20 float3 r1 = bz3(uv.x, {c4}, {c5}, {c6}, {c7});\n\
\x20 float3 r2 = bz3(uv.x, {c8}, {c9}, {c10}, {c11});\n\
\x20 float3 r3 = bz3(uv.x, {c12}, {c13}, {c14}, {c15});\n\
\x20 float3 col = bz3(uv.y, r0, r1, r2, r3);\n\
\n\
\x20 col = hue(col, sin(tt * 0.021) * 0.1396263);\n\
\n\
\x20 // Calm: flatten the field toward its own corner colour — the pools dim and the\n\
\x20 // corners lift, so a form screen keeps real colour under its glass rows while\n\
\x20 // losing the launcher's contrast. Motion is untouched (see the doc comment).\n\
\x20 col = mix(col, col * 0.60 + u_lift.rgb, calm);\n\
\n\
\x20 // Elliptical vignette: clear at r=0.25 → black·0.42 at r=1.15 (aspect-fit ellipse).\n\
\x20 // Halved under calm: a launcher's cards sit in the pooled centre, but a form\n\
\x20 // screen's rows run out toward the edges, where crushing to black just eats them.\n\
\x20 float2 e = (xy / u_res - 0.5) * 2.0;\n\
\x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0)\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 = mix(col, u_scrim.rgb, s * u_scrim.a);\n\
\n\
\x20 return half4(half3(col), 1.0);\n\
}}\n",
c0 = c(0), c1 = c(1), c2 = c(2), c3 = c(3),
c4 = c(4), c5 = c(5), c6 = c(6), c7 = c(7),
c8 = c(8), c9 = c(9), c10 = c(10), c11 = c(11),
c12 = c(12), c13 = c(13), c14 = c(14), c15 = c(15),
)
}
// --- The shared binary↔overlay model ------------------------------------------------------
#[derive(Clone, PartialEq)]
pub enum LibraryPhase {
Loading,
Error {
title: String,
body: String,
can_retry: bool,
},
Empty,
/// Games are loaded — the carousel.
Ready,
}
#[derive(Clone)]
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 {
phase: LibraryPhase,
games: Vec<LibraryGame>,
/// Fetched poster bytes the renderer hasn't decoded yet (id, encoded image).
art_in: VecDeque<(String, Vec<u8>)>,
/// Bumped on phase/games changes so the renderer re-syncs its snapshot.
generation: u64,
}
/// The binary's write handle / the overlay's read handle — fetch threads push into it,
/// the renderer drains per frame. Cheap locks, no rendering data inside.
#[derive(Clone)]
pub struct LibraryShared(Arc<Mutex<Shared>>);
impl Default for LibraryShared {
fn default() -> Self {
LibraryShared(Arc::new(Mutex::new(Shared {
phase: LibraryPhase::Loading,
games: Vec::new(),
art_in: VecDeque::new(),
generation: 0,
})))
}
}
impl LibraryShared {
pub fn set_phase(&self, phase: LibraryPhase) {
let mut s = self.0.lock().unwrap();
s.phase = phase;
s.generation += 1;
}
/// 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
} else {
LibraryPhase::Ready
};
s.games = games;
s.generation += 1;
}
pub fn push_art(&self, id: String, bytes: Vec<u8>) {
self.0.lock().unwrap().art_in.push_back((id, bytes));
}
/// Renderer side: the generation stamp (re-snapshot on change).
pub(crate) fn generation(&self) -> u64 {
self.0.lock().unwrap().generation
}
pub(crate) fn snapshot(&self) -> (LibraryPhase, Vec<LibraryGame>, u64) {
let s = self.0.lock().unwrap();
(s.phase.clone(), s.games.clone(), s.generation)
}
pub(crate) fn drain_art(&self) -> Vec<(String, Vec<u8>)> {
self.0.lock().unwrap().art_in.drain(..).collect()
}
}
/// Store id → display label (the GTK `ui_library` table).
pub fn store_label(store: &str) -> &'static str {
match store {
"steam" => "Steam",
"custom" => "Custom",
"heroic" => "Heroic",
"lutris" => "Lutris",
"epic" => "Epic",
"gog" => "GOG",
"xbox" => "Xbox",
_ => "Game",
}
}
/// Monogram for the placeholder tile: the first letters of the first two words.
pub fn initials(title: &str) -> String {
title
.split_whitespace()
.take(2)
.filter_map(|w| w.chars().next())
.flat_map(char::to_uppercase)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// The GTK launcher's cursor tests, ported with the math.
#[test]
fn step_refuses_the_ends() {
assert_eq!(step_cursor(0, 5, -1, false), StepResult::Boundary);
assert_eq!(step_cursor(4, 5, 1, false), StepResult::Boundary);
assert_eq!(step_cursor(2, 5, 1, false), StepResult::Moved(3));
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));
assert_eq!(step_cursor(3, 5, JUMP, true), StepResult::Moved(4));
assert_eq!(step_cursor(0, 5, -JUMP, true), StepResult::Boundary);
}
/// Springs converge onto the target and stay finite through a stalled frame.
#[test]
fn springs_converge() {
let (mut pos, mut vel) = (0.0, 0.0);
for _ in 0..120 {
(pos, vel) = spring_advance(pos, vel, 3.0, SPRING_K, SPRING_C, 1.0 / 60.0);
}
assert!((pos - 3.0).abs() < 0.01, "{pos}");
let (p, v) = spring_advance(0.0, 0.0, 1.0, BUMP_K, BUMP_C, 0.05);
assert!(
p.is_finite() && v.is_finite() && p > 0.0 && p < 2.0,
"{p}/{v}"
);
}
/// The focused card (angle 0, scale 1) maps its center to (cx, cy) exactly.
#[test]
fn card_matrix_centers_the_focused_card() {
let m = card_matrix(640.0, 400.0, 0.0, 1.0, POSTER_W, POSTER_H, PERSPECTIVE);
// Apply to the card-local center (w/2, h/2, 0, 1).
let (x, y) = (POSTER_W as f32 / 2.0, POSTER_H as f32 / 2.0);
let px = m[0] * x + m[1] * y + m[3];
let py = m[4] * x + m[5] * y + m[7];
let pw = m[12] * x + m[13] * y + m[15];
assert!((px / pw - 640.0).abs() < 0.01, "{}", px / pw);
assert!((py / pw - 400.0).abs() < 0.01, "{}", py / pw);
}
/// A right-side card's INNER (left) edge recedes: its projected x compresses toward
/// the center relative to the flat card — the coverflow corridor.
#[test]
fn side_card_inner_edge_recedes() {
let flat = card_matrix(900.0, 400.0, 0.0, 1.0, POSTER_W, POSTER_H, PERSPECTIVE);
let tilted = card_matrix(
900.0,
400.0,
-ROTATE_DEG,
1.0,
POSTER_W,
POSTER_H,
PERSPECTIVE,
);
let project = |m: &[f32; 16], x: f32, y: f32| {
let px = m[0] * x + m[1] * y + m[3];
let pw = m[12] * x + m[13] * y + m[15];
px / pw
};
// The inner edge is x=0 in card space. Perspective divide: receding (w < 1 side)
// pushes it AWAY from the vanishing center — the edge reads as farther.
let flat_left = project(&flat, 0.0, POSTER_H as f32 / 2.0);
let tilt_left = project(&tilted, 0.0, POSTER_H as f32 / 2.0);
let flat_right = project(&flat, POSTER_W as f32, POSTER_H as f32 / 2.0);
let tilt_right = project(&tilted, POSTER_W as f32, POSTER_H as f32 / 2.0);
// Tilt narrows the card's projected width (it turned away from the viewer).
assert!((tilt_right - tilt_left) < (flat_right - flat_left) * 0.95);
}
#[test]
fn initials_take_two_words() {
assert_eq!(initials("Dota 2"), "D2");
assert_eq!(initials("half-life"), "H");
}
/// The generated SkSL parses as far as syntax we control (sanity: balanced braces, all
/// 16 colours baked in, the five bicubic evals and four interior warp terms present).
#[test]
fn mesh_sksl_shape() {
let src = mesh_sksl(&MESH_COLORS);
assert!(src.matches("float3(").count() >= 16, "16 colours baked");
assert_eq!(src.matches("bz3(").count(), 6); // 1 definition + 5 call sites
assert_eq!(src.matches("wtot +=").count(), 4); // one per interior point
assert_eq!(src.matches('{').count(), src.matches('}').count());
}
/// The brand default must 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");
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");
}
/// 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 every_palette_is_multi_tone() {
for p in &PALETTES {
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));
}
}