Files
punktfunk/crates/pf-console-ui/src/screens/library.rs
T
enricobuehler 180ac3aa61
apple / swift (push) Successful in 1m6s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
decky / build-publish (push) Successful in 25s
flatpak / build-publish (push) Successful in 4m31s
feat(console): full gamepad shell — hosts, PIN pairing, settings, OSK, screen transitions
The Skia console UI grows from the single library coverflow into a complete
couch shell (Apple gamepad-UI parity), so a Deck/gamescope session is
self-sufficient end to end:

- Home: host carousel (saved + discovered + Add Host tile) with presence
  pips, paired locks, wake & connect; A/Y/X/B per the Apple launcher.
- In-console SPAKE2 PIN pairing (previously needed the Decky plugin or a
  desktop), add-host with a controller keyboard, couch settings over the
  shared Settings store, wake-on-LAN overlay with retry, cancelable dials.
- Shell chrome: screen stack with push/pop entrance/exit choreography
  (slide + fade + recede; aurora↔form backdrop crossfade), per-pad button
  glyphs (PS shapes vs ABXY, keycaps with no pad), menu haptics, toasts,
  embedded Geist typography, in-stream start banner.
- Steam Deck: our OSK never draws — fields start SDL text input (the new
  Overlay::text_input_active hook) and Steam's keyboard types; a hint chip
  points at STEAM + X. Other Linux gets the full gamepad keyboard tray.
- punktfunk-session: bare --browse opens Home; --browse host opens that
  host's library with Home behind it (Decky launches keep working, B now
  pops instead of quitting). Service threads run discovery, 10 s probes,
  pairing, wake loops, fetches, and known-hosts persistence.
- Presenter contract: Launch actions carry the host, CancelConnect never
  engages a won race, pad kind/list ride FrameCtx, menu events flow while
  dialing so B can cancel.

Every screen renders to PNG on CPU raster for review
(PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui -- --ignored dump).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:46:49 +02:00

408 lines
15 KiB
Rust

//! The game-library screen: the coverflow carousel (spring-chased cursor, perspective
//! card tilt, poster art streaming in) — the original console library, now one screen
//! on the shell's stack. B pops back to the host list; A launches the focused title in
//! the same window. The shell owns the aurora, chrome, and the connecting overlay.
use crate::anim::Spring;
use crate::glyphs::{Hint, HintKey};
use crate::library::{
card_matrix, initials, 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 crate::model::{ConsoleCmd, HostRow};
use crate::screens::{ConnectIntent, Ctx, Outbox};
use crate::theme::{white, Fonts, DIM, W, WHITE};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Color4f, Data, Image, Paint, Point, RRect, Rect, M44};
use std::collections::HashMap;
pub(crate) struct LibraryScreen {
host_name: String,
addr: String,
port: u16,
fp_hex: String,
mgmt: u16,
shared: Option<LibraryShared>,
// 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: Spring,
bump: Spring,
/// Decoded posters by game id (decode once; Skia uploads lazily on first draw).
art: HashMap<String, Image>,
}
impl LibraryScreen {
pub(crate) fn new(host: &HostRow) -> LibraryScreen {
LibraryScreen {
host_name: host.name.clone(),
addr: host.addr.clone(),
port: host.port,
fp_hex: host.fp_hex.clone(),
mgmt: host.mgmt_port,
shared: None, // adopted from Ctx on the first render (the shell owns it)
generation: u64::MAX,
phase: LibraryPhase::Loading,
games: Vec::new(),
cursor: 0,
anim: Spring::rest(0.0),
bump: Spring::rest(0.0),
art: HashMap::new(),
}
}
pub(crate) fn host_name(&self) -> &str {
&self.host_name
}
fn fetch_cmd(&self) -> ConsoleCmd {
ConsoleCmd::FetchLibrary {
addr: self.addr.clone(),
mgmt: self.mgmt,
fp_hex: self.fp_hex.clone(),
}
}
/// Pull the shared model when it changed; decode newly arrived poster bytes.
fn sync(&mut self, library: &LibraryShared) {
if self.shared.is_none() {
self.shared = Some(library.clone());
}
let Some(shared) = &self.shared else { return };
if shared.generation() != self.generation {
let (phase, games, generation) = shared.snapshot();
let fresh = 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 {
self.cursor = 0;
self.anim = Spring::rest(0.0);
self.bump = Spring::rest(0.0);
self.art.clear();
}
self.cursor = self.cursor.clamp(0, (self.games.len() as i32 - 1).max(0));
}
for (id, bytes) in shared.drain_art() {
match Image::from_encoded(Data::new_copy(&bytes)) {
Some(img) => {
self.art.insert(id, img);
}
None => tracing::debug!(%id, "undecodable poster"),
}
}
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
self.sync(ctx.library);
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)?;
fx.connect = Some(ConnectIntent {
addr: self.addr.clone(),
port: self.port,
fp_hex: self.fp_hex.clone(),
launch: Some(g.id.clone()),
title: g.title.clone(),
});
Some(MenuPulse::Confirm)
}
MenuEvent::Back => {
fx.pop();
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
fx.cmds.push(self.fetch_cmd());
Some(MenuPulse::Confirm)
}
MenuEvent::Back => {
fx.pop();
None
}
_ => None,
},
LibraryPhase::Loading | LibraryPhase::Empty => {
if ev == MenuEvent::Back {
fx.pop();
}
None
}
}
}
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 => {
self.bump = Spring {
pos: -BUMP_PX * f64::from(delta.signum()),
vel: 0.0,
};
Some(MenuPulse::Boundary)
}
}
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
match &self.phase {
LibraryPhase::Ready => vec![
Hint::new(HintKey::Confirm, "Play"),
Hint::new(HintKey::Shoulders, "Jump"),
Hint::new(HintKey::Back, "Back"),
],
LibraryPhase::Error {
can_retry: true, ..
} => vec![
Hint::new(HintKey::Confirm, "Retry"),
Hint::new(HintKey::Back, "Back"),
],
_ => vec![Hint::new(HintKey::Back, "Back")],
}
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
self.sync(ctx.library);
let (w, cy_all) = (
f64::from(rect.width()),
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
);
let cx = f64::from(rect.left) + w / 2.0;
match self.phase.clone() {
LibraryPhase::Ready => {
self.anim
.step(f64::from(self.cursor), SPRING_K, SPRING_C, dt);
self.anim.settle(f64::from(self.cursor), 0.001, 0.01);
self.bump.step(0.0, BUMP_K, BUMP_C, dt);
self.bump.settle(0.0, 0.3, 4.0);
self.draw_carousel(canvas, rect, k, fonts);
}
LibraryPhase::Loading => {
crate::theme::spinner(canvas, cx, cy_all - 24.0 * k, 16.0 * k, ctx.t);
fonts.centered(
canvas,
"Loading library…",
W::Regular,
14.0 * k,
DIM,
cx,
cy_all + 16.0 * k,
w * 0.8,
);
}
LibraryPhase::Empty => {
fonts.centered(
canvas,
"No games found",
W::Bold,
22.0 * k,
WHITE,
cx,
cy_all - 20.0 * k,
w * 0.8,
);
fonts.centered(
canvas,
"Install Steam titles or add custom entries in the host's web console.",
W::Regular,
14.0 * k,
DIM,
cx,
cy_all + 12.0 * k,
w * 0.8,
);
}
LibraryPhase::Error { title, body, .. } => {
fonts.centered(
canvas,
&title,
W::Bold,
22.0 * k,
WHITE,
cx,
cy_all - 32.0 * k,
w * 0.8,
);
fonts.centered(
canvas,
&body,
W::Regular,
14.0 * k,
DIM,
cx,
cy_all + 4.0 * k,
(600.0 * k).min(w * 0.85),
);
}
}
}
fn draw_carousel(&mut self, canvas: &Canvas, rect: Rect, k: f64, fonts: &Fonts) {
let (card_w, card_h) = (POSTER_W * k, POSTER_H * k);
let w = f64::from(rect.width());
// The strip rides slightly above center; the detail block gets the band below.
let cy = f64::from(rect.top) + f64::from(rect.height()) * 0.44;
let pos = self.anim.pos;
let bump = self.bump.pos * k;
// Paint order = draw order: farthest from the (integer) cursor first, so the
// dense side stacks overlap toward the focus.
let mut order: Vec<usize> = (0..self.games.len()).collect();
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;
let offset = if a <= 1.0 {
d * FOCUS_GAP * k
} else {
d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) * k
};
let ccx = f64::from(rect.left) + w / 2.0 + offset + bump;
let m = card_matrix(ccx, cy, angle, scale, card_w, card_h, PERSPECTIVE * k);
let game = &self.games[i];
canvas.save();
canvas.concat_44(&M44::row_major(&m));
let crect = Rect::from_wh(card_w as f32, card_h as f32);
let rr = RRect::new_rect_xy(crect, 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 = crect.width() / crect.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)),
crect,
&Paint::default(),
);
}
None => {
// Solid face, not glass: the side cards OVERLAP.
canvas.draw_rect(
crect,
&Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None),
);
let mono = initials(&game.title);
let font = fonts.font(W::Bold, 38.0 * k);
let tw = font.measure_str(&mono, None).0;
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(white(0.45), None),
);
}
}
// Store badge, top-left.
{
let label = store_label(&game.store);
let size = 11.0 * k;
let tw = fonts.measure(label, W::SemiBold, size) as f64;
let (px, py) = (8.0 * k, 8.0 * k);
let (bw, bh) = (tw + 16.0 * k, 20.0 * k);
canvas.draw_rrect(
RRect::new_rect_xy(
Rect::from_xywh(px as f32, py as f32, bw as f32, bh as f32),
(bh / 2.0) as f32,
(bh / 2.0) as f32,
),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
);
fonts.draw(
canvas,
label,
px + 8.0 * k,
py + 14.0 * k,
W::SemiBold,
size,
WHITE,
);
}
// The brightness recede: an opaque-black veil, never whole-card alpha.
if prox > 0.0 {
canvas.draw_rect(
crect,
&Paint::new(
Color4f::new(0.0, 0.0, 0.0, (prox * RECEDE_DIM) as f32),
None,
),
);
}
canvas.restore();
}
// Detail block: focused title + store, in the band under the strip.
if let Some(g) = self.games.get(self.cursor as usize) {
let cx = f64::from(rect.left) + w / 2.0;
fonts.centered(
canvas,
&g.title,
W::Bold,
27.0 * k,
WHITE,
cx,
f64::from(rect.bottom) - 64.0 * k,
w * 0.8,
);
fonts.centered(
canvas,
&store_label(&g.store).to_uppercase(),
W::Regular,
12.0 * k,
white(0.5),
cx,
f64::from(rect.bottom) - 30.0 * k,
w * 0.5,
);
}
}
}