feat(session): console game library — Skia coverflow + SkSL aurora, --browse (phase 4b)

The run loop grows a browse mode: the console library idles between
streams in ONE window (no gamescope handoff), overlay actions launch
sessions via run_browse's callback, session end returns to the library.
The Overlay contract gains menu routing (MenuEvent → haptic pulse),
action draining, and session-phase edges.

pf-console-ui ports the GTK launcher wholesale: the coverflow's springs,
cursor arithmetic and recede/tilt constants move verbatim with their
tests (plus new projection tests — focused-card centering, the inner-
edge-recedes corridor); paint order is draw order (the gtk::Fixed
restack hack is gone); the aurora renders as an SkSL runtime shader at
full rate on every box (the 30 Hz CPU-upscale path and its frozen-on-
Deck fallback are deleted — the generated SkSL is compile-tested);
titles/scenes shape through textlayout (CJK-safe). Poster art streams
in as encoded bytes through the shared model and decodes renderer-side.

The session binary wires --browse host[:port] [--mgmt PORT]: KnownHosts
lookup (unpaired renders the pair-first scene), library + art fetch on
threads, PUNKTFUNK_FAKE_LIBRARY dev hook, and a session_params helper
shared with --connect. The minimal build refuses --browse cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 19:36:21 +02:00
parent 021a2261f6
commit be09f9f345
13 changed files with 1908 additions and 212 deletions
+6
View File
@@ -8,8 +8,14 @@
//! purpose, it proves the whole shared-device pipeline. The gamepad library moves in
//! next.
#[cfg(target_os = "linux")]
pub mod library;
#[cfg(target_os = "linux")]
mod library_ui;
#[cfg(target_os = "linux")]
mod skia_overlay;
#[cfg(target_os = "linux")]
pub use library::{LibraryGame, LibraryPhase, LibraryShared};
#[cfg(target_os = "linux")]
pub use skia_overlay::SkiaOverlay;
+473
View File
@@ -0,0 +1,473 @@
//! 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
}
// --- Aurora (the Swift `GamepadScreenBackground` blob table, verbatim) --------------------
/// One drifting color blob: base position + drift ellipse in unit coordinates, angular
/// speeds in rad/s, a breathing radius (fraction of the larger dimension), layer opacity.
pub struct Blob {
pub rgb: (f64, f64, f64),
pub center: (f64, f64),
pub drift: (f64, f64),
pub speed: (f64, f64),
pub phase: (f64, f64),
pub radius: f64,
pub breathe: (f64, f64),
pub opacity: f64,
}
/// The brand violet, a deeper indigo, a warmer plum, and a cool blue — related hues so
/// the field shifts within one temperature instead of strobing through the rainbow.
pub const BLOBS: [Blob; 4] = [
Blob {
rgb: (0.53, 0.47, 0.96),
center: (0.30, 0.24),
drift: (0.16, 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: (0.24, 0.20, 0.72),
center: (0.78, 0.66),
drift: (0.13, 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: (0.62, 0.30, 0.80),
center: (0.16, 0.82),
drift: (0.12, 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: (0.22, 0.38, 0.86),
center: (0.70, 0.12),
drift: (0.10, 0.08),
speed: (0.059, 0.104),
phase: (1.2, 5.0),
radius: 0.40,
breathe: (0.06, 0.055),
opacity: 0.38,
},
];
/// The aurora as SkSL: the blob table baked into the source (only time + resolution are
/// uniforms), each blob an additive radial falloff to `radius/2` — the Cairo gradient's
/// stops — then the legibility scrim's piecewise-linear vertical darkening. Runs on the
/// GPU at full rate; the 30 Hz CPU-upscale path (and its frozen-on-Deck fallback) is the
/// thing this whole port deletes.
pub fn aurora_sksl() -> String {
let mut src = String::from(
"uniform float2 u_res;\nuniform float u_t;\n\
half4 main(float2 xy) {\n\
float side = max(u_res.x, u_res.y);\n\
float3 acc = float3(0.0);\n\
float2 c; float r; float a;\n",
);
for b in &BLOBS {
src.push_str(&format!(
" c = float2(({cx} + {dx} * sin(u_t * {sx} + {px})) * u_res.x, \
({cy} + {dy} * cos(u_t * {sy} + {py})) * u_res.y);\n\
r = side * {radius} * (1.0 + {b0} * sin(u_t * {b1} + {px}));\n\
a = max(0.0, 1.0 - distance(xy, c) / (r * 0.5));\n\
acc += float3({r0}, {g0}, {b0c}) * (a * {op});\n",
cx = b.center.0,
cy = b.center.1,
dx = b.drift.0,
dy = b.drift.1,
sx = b.speed.0,
sy = b.speed.1,
px = b.phase.0,
py = b.phase.1,
radius = b.radius,
b0 = b.breathe.0,
b1 = b.breathe.1,
r0 = b.rgb.0,
g0 = b.rgb.1,
b0c = b.rgb.2,
op = b.opacity,
));
}
// Scrim stops (0, .55) (0.35, .15) (0.65, .20) (1, .60): title (top) and
// detail/hints (bottom) stay on near-black whatever the blobs do behind them.
src.push_str(
" float v = xy.y / u_res.y;\n\
float s = v < 0.35 ? mix(0.55, 0.15, v / 0.35)\n\
: v < 0.65 ? mix(0.15, 0.20, (v - 0.35) / 0.30)\n\
: mix(0.20, 0.60, (v - 0.65) / 0.35);\n\
return half4(half3(acc * (1.0 - s)), 1.0);\n\
}\n",
);
src
}
// --- The shared binary↔overlay model ------------------------------------------------------
#[derive(Clone, PartialEq)]
pub enum LibraryPhase {
Loading,
/// Browse target isn't paired — pairing is the plugin's job, render the advice.
PairFirst,
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,
}
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).
pub fn set_games(&self, games: Vec<LibraryGame>) {
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);
}
#[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 four blobs baked in).
#[test]
fn aurora_sksl_shape() {
let src = aurora_sksl();
assert_eq!(src.matches("acc +=").count(), 4);
assert_eq!(
src.matches('{').count(),
src.matches('}').count(),
);
}
}
+610
View File
@@ -0,0 +1,610 @@
//! The console library's Skia side: navigation state, scene selection, and rendering —
//! the GTK launcher (`ui_gamepad_library.rs`) re-homed onto the presenter surface. The
//! aurora runs as an SkSL shader at full rate (the 30 Hz CPU path is gone), the
//! coverflow is `concat_44` perspective with paint order = draw order (the restack hack
//! is gone), and every state renders in-scene (gamescope maps no dialogs).
use crate::library::{
aurora_sksl, card_matrix, initials, spring_advance, step_cursor, store_label,
LibraryGame, LibraryPhase, LibraryShared, StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP,
JUMP, PERSPECTIVE, POSTER_H, POSTER_W, RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING,
SPRING_C, SPRING_K, VISIBLE_RANGE,
};
use anyhow::{anyhow, Result};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use pf_presenter::overlay::OverlayAction;
use skia_safe::textlayout::{FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle};
use skia_safe::{
Canvas, Color4f, Data, Font, FontStyle, Image, Paint, Point, RRect, Rect, RuntimeEffect,
Typeface, M44,
};
use std::collections::{HashMap, VecDeque};
use std::time::Instant;
pub(crate) struct LibraryUi {
shared: LibraryShared,
host_label: String,
// Synced snapshot of the shared model (re-pulled when the generation bumps).
generation: u64,
phase: LibraryPhase,
games: Vec<LibraryGame>,
// Navigation: the integer cursor is the authority; the eased position chases it.
cursor: i32,
anim_pos: f64,
anim_vel: f64,
bump: f64,
bump_vel: f64,
last_frame: Option<Instant>,
t0: Instant,
/// Decoded posters by game id (decode once; Skia uploads lazily on first draw).
art: HashMap<String, Image>,
aurora: RuntimeEffect,
/// A launch is in flight — menu input parks, the hint bar says Connecting….
connecting: bool,
/// A session is on screen — the library doesn't render (stream chrome does).
pub(crate) in_stream: bool,
/// Transient error strip on the carousel (connect failures land here).
status: Option<String>,
actions: VecDeque<OverlayAction>,
}
impl LibraryUi {
pub(crate) fn new(shared: LibraryShared, host_label: String) -> Result<LibraryUi> {
let aurora = RuntimeEffect::make_for_shader(aurora_sksl(), None)
.map_err(|e| anyhow!("aurora SkSL: {e}"))?;
Ok(LibraryUi {
shared,
host_label,
generation: u64::MAX, // force the first sync
phase: LibraryPhase::Loading,
games: Vec::new(),
cursor: 0,
anim_pos: 0.0,
anim_vel: 0.0,
bump: 0.0,
bump_vel: 0.0,
last_frame: None,
t0: Instant::now(),
art: HashMap::new(),
aurora,
connecting: false,
in_stream: false,
status: None,
actions: VecDeque::new(),
})
}
/// Pull the shared model when it changed; decode any newly arrived poster bytes.
pub(crate) fn sync(&mut self) {
if self.shared.generation() != self.generation {
let (phase, games, generation) = self.shared.snapshot();
let fresh_games = self.games.len() != games.len()
|| self
.games
.iter()
.zip(&games)
.any(|(a, b)| a.id != b.id);
self.phase = phase;
self.games = games;
self.generation = generation;
if fresh_games {
// Fresh library: snap the sprung position onto the (reset) cursor.
self.cursor = 0;
self.anim_pos = 0.0;
self.anim_vel = 0.0;
self.bump = 0.0;
self.bump_vel = 0.0;
}
self.cursor = self.cursor.clamp(0, (self.games.len() as i32 - 1).max(0));
}
for (id, bytes) in self.shared.drain_art() {
match Image::from_encoded(Data::new_copy(&bytes)) {
Some(img) => {
self.art.insert(id, img);
}
None => tracing::debug!(%id, "undecodable poster"),
}
}
}
/// Menu-mode navigation (gamepad; the keyboard fallback funnels in here too).
pub(crate) fn menu(&mut self, ev: MenuEvent) -> Option<MenuPulse> {
if self.connecting {
return None; // a connect is in flight — input is parked
}
match &self.phase {
LibraryPhase::Ready => match ev {
MenuEvent::Move(MenuDir::Left) => self.step(-1, false),
MenuEvent::Move(MenuDir::Right) => self.step(1, false),
MenuEvent::JumpBack => self.step(-JUMP, true),
MenuEvent::JumpForward => self.step(JUMP, true),
MenuEvent::Confirm => {
let g = self.games.get(self.cursor as usize)?;
self.actions.push_back(OverlayAction::Launch {
id: g.id.clone(),
title: g.title.clone(),
});
self.status = None;
self.connecting = true;
Some(MenuPulse::Confirm)
}
MenuEvent::Back => {
self.actions.push_back(OverlayAction::Quit);
None
}
MenuEvent::Move(_) | MenuEvent::Secondary | MenuEvent::Tertiary => None,
},
LibraryPhase::Error { can_retry, .. } => match ev {
MenuEvent::Confirm if *can_retry => {
self.phase = LibraryPhase::Loading; // local; the fetch re-syncs it
self.actions.push_back(OverlayAction::Retry);
Some(MenuPulse::Confirm)
}
MenuEvent::Back => {
self.actions.push_back(OverlayAction::Quit);
None
}
_ => None,
},
LibraryPhase::Loading | LibraryPhase::Empty | LibraryPhase::PairFirst => {
if ev == MenuEvent::Back {
self.actions.push_back(OverlayAction::Quit);
}
None
}
}
}
/// Keyboard fallback (arrows/Enter/Esc/PageUp/PageDown) — the launcher is fully
/// drivable with no pad. Returns true when consumed.
pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, repeat: bool) -> bool {
use sdl3::keyboard::Scancode as S;
let ev = match sc {
S::Left => MenuEvent::Move(MenuDir::Left),
S::Right => MenuEvent::Move(MenuDir::Right),
S::Up => MenuEvent::Move(MenuDir::Up),
S::Down => MenuEvent::Move(MenuDir::Down),
S::Return | S::KpEnter | S::Space if !repeat => MenuEvent::Confirm,
S::Escape | S::Backspace if !repeat => MenuEvent::Back,
S::PageUp if !repeat => MenuEvent::JumpBack,
S::PageDown if !repeat => MenuEvent::JumpForward,
_ => return false,
};
self.menu(ev); // no pad to pulse
true
}
pub(crate) fn take_action(&mut self) -> Option<OverlayAction> {
self.actions.pop_front()
}
pub(crate) fn set_connecting(&mut self, on: bool) {
self.connecting = on;
if on {
self.status = None;
}
}
/// A launch that didn't stick (connect failed / session ended with a reason):
/// carousel-visible errors land on the transient strip, anything else becomes the
/// error scene (no retry — the library itself is fine).
pub(crate) fn session_error(&mut self, msg: &str) {
self.connecting = false;
self.in_stream = false;
if self.phase == LibraryPhase::Ready {
self.status = Some(format!("Couldn't connect — {msg}"));
} else {
self.phase = LibraryPhase::Error {
title: "Couldn't connect".into(),
body: msg.to_string(),
can_retry: false,
};
}
}
fn step(&mut self, delta: i32, clamp: bool) -> Option<MenuPulse> {
match step_cursor(self.cursor, self.games.len(), delta, clamp) {
StepResult::Moved(to) => {
self.cursor = to;
Some(MenuPulse::Move)
}
StepResult::Boundary => {
// Recoil against the push; the stiff bump spring wobbles it back.
self.bump = -BUMP_PX * f64::from(delta.signum());
self.bump_vel = 0.0;
Some(MenuPulse::Boundary)
}
}
}
/// Render the whole library scene. Always a full repaint — the aurora animates
/// every frame (that's the point of the GPU port).
pub(crate) fn render(&mut self, canvas: &Canvas, w: u32, h: u32, fonts: &Fonts) {
let (wf, hf) = (w as f64, h as f64);
// Uniform scale off the Deck's 800p design height; fonts and geometry follow.
let k = (hf / 800.0).clamp(0.75, 3.0);
// Springs (Ready only — other scenes have no strip).
let now = Instant::now();
let dt = self
.last_frame
.replace(now)
.map_or(1.0 / 60.0, |t| (now - t).as_secs_f64().clamp(0.0, 0.05));
if self.phase == LibraryPhase::Ready {
let target = f64::from(self.cursor);
(self.anim_pos, self.anim_vel) = spring_advance(
self.anim_pos,
self.anim_vel,
target,
SPRING_K,
SPRING_C,
dt,
);
if (target - self.anim_pos).abs() < 0.001 && self.anim_vel.abs() < 0.01 {
self.anim_pos = target;
self.anim_vel = 0.0;
}
(self.bump, self.bump_vel) =
spring_advance(self.bump, self.bump_vel, 0.0, BUMP_K, BUMP_C, dt);
if self.bump.abs() < 0.3 && self.bump_vel.abs() < 4.0 {
self.bump = 0.0;
self.bump_vel = 0.0;
}
}
self.draw_aurora(canvas, wf, hf);
match self.phase.clone() {
LibraryPhase::Ready => self.draw_carousel(canvas, wf, hf, k, fonts),
LibraryPhase::Loading => {
self.draw_spinner(canvas, wf / 2.0, hf / 2.0 - 24.0 * k, 16.0 * k);
fonts.centered(canvas, "Loading library…", 14.0 * k, DIM_TEXT, wf / 2.0, hf / 2.0 + 16.0 * k, wf * 0.8);
}
LibraryPhase::PairFirst => {
fonts.centered_bold(canvas, "Not paired with this host", 22.0 * k, WHITE, wf / 2.0, hf / 2.0 - 20.0 * k, wf * 0.8);
fonts.centered(canvas, "Pair from the Punktfunk plugin first.", 14.0 * k, DIM_TEXT, wf / 2.0, hf / 2.0 + 12.0 * k, wf * 0.8);
}
LibraryPhase::Empty => {
fonts.centered_bold(canvas, "No games found", 22.0 * k, WHITE, wf / 2.0, hf / 2.0 - 20.0 * k, wf * 0.8);
fonts.centered(canvas, "Install Steam titles or add custom entries in the host's web console.", 14.0 * k, DIM_TEXT, wf / 2.0, hf / 2.0 + 12.0 * k, wf * 0.8);
}
LibraryPhase::Error { title, body, .. } => {
fonts.centered_bold(canvas, &title, 22.0 * k, WHITE, wf / 2.0, hf / 2.0 - 32.0 * k, wf * 0.8);
fonts.centered(canvas, &body, 14.0 * k, DIM_TEXT, wf / 2.0, hf / 2.0 + 4.0 * k, (600.0 * k).min(wf * 0.85));
}
}
self.draw_chrome(canvas, wf, hf, k, fonts);
}
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64) {
let t = self.t0.elapsed().as_secs_f64();
// Uniform layout: float2 u_res, float u_t (declared order, no padding needed).
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
let data = Data::new_copy(bytemuck_bytes(&uniforms));
match self.aurora.make_shader(data, &[], None) {
Some(shader) => {
let mut paint = Paint::default();
paint.set_shader(shader);
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &paint);
}
None => {
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
}
}
}
fn draw_carousel(&mut self, canvas: &Canvas, w: f64, h: f64, k: f64, fonts: &Fonts) {
let (card_w, card_h) = (POSTER_W * k, POSTER_H * k);
// The strip's vertical center: the space between the top bar and the detail
// block (the GTK vexpand'ed scroller, approximated).
let cy = h * 0.44;
let pos = self.anim_pos;
let bump = self.bump * k;
// Paint order = draw order: farthest from the (integer) cursor first, so the
// dense side stacks overlap toward the focus. Stable → the equidistant
// neighbors keep a deterministic order.
let mut order: Vec<usize> = (0..self.games.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - self.cursor).abs()));
for i in order {
let d = i as f64 - pos;
let a = d.abs();
if a > VISIBLE_RANGE {
continue;
}
let prox = a.min(1.0);
let scale = 1.0 - prox * RECEDE_SCALE;
let angle = -d.clamp(-1.0, 1.0) * ROTATE_DEG;
// Piecewise strip: a full FOCUS_GAP around the focus, then the dense side
// stacks (the classic coverflow shelf).
let offset = if a <= 1.0 {
d * FOCUS_GAP * k
} else {
d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) * k
};
let cx = w / 2.0 + offset + bump;
let m = card_matrix(cx, cy, angle, scale, card_w, card_h, PERSPECTIVE * k);
let game = &self.games[i];
canvas.save();
canvas.concat_44(&M44::row_major(&m));
let rect = Rect::from_wh(card_w as f32, card_h as f32);
let rr = RRect::new_rect_xy(rect, 16.0 * k as f32, 16.0 * k as f32);
canvas.clip_rrect(rr, None, true);
match self.art.get(&game.id) {
Some(img) => {
// Cover-fit: center-crop the source to the card's 2:3.
let (iw, ih) = (img.width() as f32, img.height() as f32);
let card_aspect = rect.width() / rect.height();
let src = if iw / ih > card_aspect {
let sw = ih * card_aspect;
Rect::from_xywh((iw - sw) / 2.0, 0.0, sw, ih)
} else {
let sh = iw / card_aspect;
Rect::from_xywh(0.0, (ih - sh) / 2.0, iw, sh)
};
canvas.draw_image_rect(img, Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)), rect, &Paint::default());
}
None => {
// Solid face, not glass: the side cards OVERLAP (GTK CSS note).
canvas.draw_rect(rect, &Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None));
let mono = initials(&game.title);
let font = fonts.sans_bold(38.0 * k);
let tw = font.measure_str(&mono, None).0;
canvas.draw_str(
&mono,
Point::new((card_w as f32 - tw) / 2.0, card_h as f32 / 2.0 + 13.0 * k as f32),
&font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.45), None),
);
}
}
// Store badge, top-left.
{
let label = store_label(&game.store);
let font = fonts.sans_bold(11.0 * k);
let tw = font.measure_str(label, None).0;
let (px, py) = (8.0 * k as f32, 8.0 * k as f32);
let (bw, bh) = (tw + 16.0 * k as f32, 20.0 * k as f32);
canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(px, py, bw, bh), bh / 2.0, bh / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
);
canvas.draw_str(
label,
Point::new(px + 8.0 * k as f32, py + 14.0 * k as f32),
&font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 1.0), None),
);
}
// The brightness recede: an opaque-black veil, never whole-card alpha.
if prox > 0.0 {
canvas.draw_rect(
rect,
&Paint::new(Color4f::new(0.0, 0.0, 0.0, (prox * RECEDE_DIM) as f32), None),
);
}
canvas.restore();
}
// Detail block: focused title + store, centered between strip and hints.
if let Some(g) = self.games.get(self.cursor as usize) {
fonts.centered_bold(canvas, &g.title, 27.0 * k, WHITE, w / 2.0, h - 96.0 * k, w * 0.8);
fonts.centered(
canvas,
&store_label(&g.store).to_uppercase(),
12.0 * k,
Color4f::new(1.0, 1.0, 1.0, 0.5),
w / 2.0,
h - 66.0 * k,
w * 0.5,
);
}
if let Some(status) = &self.status {
fonts.centered(
canvas,
status,
13.0 * k,
Color4f::new(1.0, 0.576, 0.541, 1.0), // the GTK #ff938a
w / 2.0,
h - 44.0 * k,
w * 0.85,
);
}
}
/// Top bar (host + controller chip) and the bottom hint bar — per-scene affordances.
fn draw_chrome(&self, canvas: &Canvas, w: f64, h: f64, k: f64, fonts: &Fonts) {
let font = fonts.sans_bold(18.0 * k);
canvas.draw_str(
&self.host_label,
Point::new(24.0 * k as f32, 32.0 * k as f32),
&font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None),
);
if let Some(chip) = &fonts.chip_text {
let cf = fonts.sans(12.0 * k);
let tw = cf.measure_str(chip, None).0;
let (bh, pad) = (24.0 * k as f32, 12.0 * k as f32);
let bx = w as f32 - 24.0 * k as f32 - tw - 2.0 * pad;
canvas.draw_rrect(
RRect::new_rect_xy(
Rect::from_xywh(bx, 18.0 * k as f32, tw + 2.0 * pad, bh),
bh / 2.0,
bh / 2.0,
),
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.08), None),
);
canvas.draw_str(
chip,
Point::new(bx + pad, 18.0 * k as f32 + 16.0 * k as f32),
&cf,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.7), None),
);
}
let hint = if self.connecting {
"Connecting…".to_string()
} else {
match &self.phase {
LibraryPhase::Ready => "A Play B Quit L1 / R1 Jump".to_string(),
LibraryPhase::Error { can_retry: true, .. } => "A Retry B Quit".to_string(),
_ => "B Quit".to_string(),
}
};
fonts.left(
canvas,
&hint,
13.0 * k,
Color4f::new(1.0, 1.0, 1.0, 0.85),
24.0 * k,
h - 20.0 * k,
);
}
/// The loading spinner: a rotating arc off the aurora clock.
fn draw_spinner(&self, canvas: &Canvas, cx: f64, cy: f64, r: f64) {
let t = self.t0.elapsed().as_secs_f64();
let start = (t * 300.0) % 360.0;
let mut paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.85), None);
paint.set_style(skia_safe::PaintStyle::Stroke);
paint.set_stroke_width(3.0);
paint.set_anti_alias(true);
canvas.draw_arc(
Rect::from_xywh(
(cx - r) as f32,
(cy - r) as f32,
2.0 * r as f32,
2.0 * r as f32,
),
start as f32,
270.0,
false,
&paint,
);
}
}
const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0);
const DIM_TEXT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.7);
fn bytemuck_bytes(v: &[f32; 3]) -> &[u8] {
unsafe { std::slice::from_raw_parts(v.as_ptr().cast::<u8>(), 12) }
}
/// The text toolkit shared by every scene: typefaces + a paragraph-based centered-text
/// helper (shaping + font fallback — poster titles can be CJK; `draw_str` can't).
pub(crate) struct Fonts {
pub sans: Typeface,
pub collection: FontCollection,
/// The controller chip's current text (fed per frame by the overlay).
pub chip_text: Option<String>,
}
impl Fonts {
fn sans(&self, size: f64) -> Font {
Font::new(self.sans.clone(), size as f32)
}
fn sans_bold(&self, size: f64) -> Font {
let mut f = Font::new(self.sans.clone(), size as f32);
f.set_embolden(true);
f
}
fn paragraph(
&self,
text: &str,
size: f64,
color: Color4f,
bold: bool,
align: TextAlign,
max_w: f64,
) -> skia_safe::textlayout::Paragraph {
let mut style = ParagraphStyle::new();
style.set_text_align(align);
let mut ts = TextStyle::new();
ts.set_font_size(size as f32);
ts.set_color(color.to_color());
ts.set_font_style(if bold {
FontStyle::bold()
} else {
FontStyle::normal()
});
style.set_text_style(&ts);
let mut b = ParagraphBuilder::new(&style, self.collection.clone());
b.add_text(text);
let mut p = b.build();
p.layout(max_w as f32);
p
}
/// Centered paragraph with `y` as its top edge.
#[allow(clippy::too_many_arguments)]
fn centered(
&self,
canvas: &Canvas,
text: &str,
size: f64,
color: Color4f,
cx: f64,
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, size, color, false, TextAlign::Center, max_w);
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
}
#[allow(clippy::too_many_arguments)]
fn centered_bold(
&self,
canvas: &Canvas,
text: &str,
size: f64,
color: Color4f,
cx: f64,
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, size, color, true, TextAlign::Center, max_w);
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
}
fn left(&self, canvas: &Canvas, text: &str, size: f64, color: Color4f, x: f64, y: f64) {
canvas.draw_str(
text,
Point::new(x as f32, y as f32),
&self.sans(size),
&Paint::new(color, None),
);
}
}
pub(crate) fn build_fonts() -> Result<Fonts> {
let mgr = skia_safe::FontMgr::new();
let sans = mgr
.match_family_style("sans-serif", FontStyle::normal())
.ok_or_else(|| anyhow!("no sans-serif typeface via fontconfig"))?;
let mut collection = FontCollection::new();
collection.set_default_font_manager(mgr, None);
Ok(Fonts {
sans,
collection,
chip_text: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The generated aurora SkSL must actually compile (Skia's SkSL frontend runs on
/// the CPU — no GPU needed) — the shape test in `library` can't catch type errors.
#[test]
fn aurora_sksl_compiles() {
RuntimeEffect::make_for_shader(aurora_sksl(), None)
.unwrap_or_else(|e| panic!("aurora SkSL rejected:\n{e}"));
}
}
+118 -3
View File
@@ -3,10 +3,15 @@
//! still be sampling the previous image while we render the next), damage-driven
//! redraws (content/size change only — an unchanged OSD costs zero GPU work per frame).
use crate::library::LibraryShared;
use crate::library_ui::{build_fonts, Fonts, LibraryUi};
use anyhow::{anyhow, Context as _, Result};
use ash::vk as avk;
use ash::vk::Handle as _;
use pf_presenter::overlay::{FrameCtx, Overlay, OverlayFrame, SharedDevice};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use pf_presenter::overlay::{
FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase, SharedDevice,
};
use skia_safe::gpu::vk as skvk;
use skia_safe::gpu::{self, DirectContext, SurfaceOrigin};
use skia_safe::{Canvas, Color4f, Font, FontMgr, Paint, Point, RRect, Rect, Surface};
@@ -43,6 +48,9 @@ pub struct SkiaOverlay {
current: usize,
drawn: Drawn,
font: Option<Font>,
/// The console library (`--browse`) — `None` for a plain `--connect` session.
library: Option<LibraryUi>,
fonts: Option<Fonts>,
}
struct Gpu {
@@ -64,8 +72,23 @@ impl SkiaOverlay {
current: 0,
drawn: Drawn::default(),
font: None,
library: None,
fonts: None,
}
}
/// The `--browse` overlay: the console library between streams, stream chrome
/// during them. Returns the shared model the binary's fetch threads write into.
pub fn with_library(host_label: String) -> Result<(SkiaOverlay, LibraryShared)> {
let shared = LibraryShared::default();
let mut o = SkiaOverlay::new();
o.library = Some(LibraryUi::new(shared.clone(), host_label)?);
Ok((o, shared))
}
fn library_visible(&self) -> bool {
self.library.as_ref().is_some_and(|l| !l.in_stream)
}
}
impl Drop for SkiaOverlay {
@@ -125,6 +148,7 @@ impl Overlay for SkiaOverlay {
.match_family_style("monospace", skia_safe::FontStyle::normal())
.context("no monospace typeface via fontconfig")?;
self.font = Some(Font::new(typeface, 14.0));
self.fonts = Some(build_fonts()?);
self.gpu = Some(Gpu {
device: shared.device.clone(),
@@ -137,11 +161,102 @@ impl Overlay for SkiaOverlay {
Ok(())
}
fn handle_event(&mut self, _event: &sdl3::event::Event) -> bool {
false // the OSD/HUD consume nothing; the console library will
fn handle_event(&mut self, event: &sdl3::event::Event) -> bool {
// The library's keyboard fallback (arrows/Enter/Esc) — only while it's on
// screen, and never for chord-modified keys (those stay the run loop's).
if self.library_visible() {
if let sdl3::event::Event::KeyDown {
scancode: Some(sc),
keymod,
repeat,
..
} = event
{
use sdl3::keyboard::Mod;
if !keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD | Mod::LALTMOD | Mod::RALTMOD)
{
return self
.library
.as_mut()
.is_some_and(|l| l.key(*sc, *repeat));
}
}
}
false
}
fn handle_menu(&mut self, event: MenuEvent) -> Option<MenuPulse> {
if self.library_visible() {
self.library.as_mut().and_then(|l| l.menu(event))
} else {
None
}
}
fn take_action(&mut self) -> Option<OverlayAction> {
self.library.as_mut().and_then(|l| l.take_action())
}
fn session_phase(&mut self, phase: SessionPhase) {
let Some(lib) = &mut self.library else { return };
match phase {
SessionPhase::Connecting => lib.set_connecting(true),
SessionPhase::Streaming => {
lib.in_stream = true;
lib.set_connecting(false);
}
SessionPhase::Failed(msg) => lib.session_error(msg),
SessionPhase::Ended(None) => {
lib.in_stream = false;
lib.set_connecting(false);
}
SessionPhase::Ended(Some(reason)) => lib.session_error(reason),
}
}
fn frame(&mut self, ctx: &FrameCtx) -> Result<Option<OverlayFrame>> {
// The console library: full-screen, opaque, and always dirty (the aurora
// animates every frame — the GPU port's whole point).
if self.library_visible() {
let next = 1 - self.current;
self.ensure_slot(next, ctx.width, ctx.height)?;
let Self {
gpu,
slots,
library,
fonts,
..
} = self;
let gpu = gpu.as_mut().expect("init ran");
let slot = slots[next].as_mut().expect("just ensured");
let lib = library.as_mut().expect("library_visible");
let fonts = fonts.as_mut().expect("init ran");
fonts.chip_text = Some(
ctx.pad
.map_or("No controller — keyboard works too".to_string(), str::to_owned),
);
lib.sync();
lib.render(slot.surface.canvas(), ctx.width, ctx.height, fonts);
gpu.context.flush_surface_with_texture_state(
&mut slot.surface,
&gpu::FlushInfo::default(),
Some(&skvk::mutable_texture_states::new_vulkan(
skvk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
gpu.queue_family_index,
)),
);
gpu.context.submit(None);
self.current = next;
self.drawn = Drawn::default(); // stream chrome re-renders when it returns
let slot = self.slots[next].as_ref().expect("just rendered");
return Ok(Some(OverlayFrame {
image: slot.image,
view: slot.view,
width: slot.width,
height: slot.height,
}));
}
if ctx.stats.is_none() && ctx.hint.is_none() {
self.drawn = Drawn::default(); // forget content so re-show re-renders
return Ok(None);