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:
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user