diff --git a/clients/linux/README.md b/clients/linux/README.md index f01fcec..24368c5 100644 --- a/clients/linux/README.md +++ b/clients/linux/README.md @@ -26,6 +26,10 @@ Built in Rust, it links the shared **`punktfunk-core`** directly (no C ABI) and shows its games (Steam + custom) as a poster grid; click one to launch it in the session. Fetched from the host's management API over mTLS — paired devices are authorized by their certificate, no extra host setup. +- **Gamepad library launcher** (`--browse host`) — a console-style, controller-driven coverflow of + a paired host's library (drifting aurora backdrop, center-focus posters, button hints): A plays + the focused title, B quits, L1/R1 jump. Built for the Steam Deck plugin's "Open library" launch; + session end returns to the launcher. Arrow keys/Enter/Esc drive it too (no pad needed). ## Get it @@ -49,24 +53,28 @@ and SDL3 (with hidapi) development packages. ```sh # from the repo root cargo run -p punktfunk-client-linux # launch the app -cargo run -p punktfunk-client-linux -- --discover # list hosts on the LAN, then exit cargo run -p punktfunk-client-linux -- --connect HOST[:PORT] # skip the host list and connect +cargo run -p punktfunk-client-linux -- --browse HOST # the gamepad library launcher ``` The binary is named **`punktfunk-client`**. Handy flags: `--connect host[:port]` (start a session -immediately — for scripting and the Steam Deck launcher), `--discover [secs]`, and +immediately — for scripting and the Steam Deck launcher) with optional `--launch ` (ask the +host to launch that library title, id from `--library`), `--browse host[:port]` (the gamepad +library launcher; `--mgmt ` overrides the management port it fetches from), `--pair --connect host[:port]` (run the pairing ceremony headlessly), and `--library host[:mgmt_port]` (print a host's game library headlessly). Force a decoder with -`PUNKTFUNK_DECODER=software|vaapi`. +`PUNKTFUNK_DECODER=software|vaapi`; `PUNKTFUNK_FAKE_LIBRARY=` feeds the launcher +canned entries for UI work with no host. ## Layout ``` src/ main.rs · app.rs entry point, GTK application, primary menu, CSS - cli.rs CLI paths (--connect, headless --pair, screenshot scenes) + cli.rs CLI paths (--connect/--launch, --browse, headless --pair, screenshot scenes) ui_hosts.rs host card grids (saved + discovered) · add-host dialog · banner ui_library.rs game-library poster grid (per-host, launches titles) + ui_gamepad_library.rs the --browse gamepad launcher (aurora · coverflow · hint bar) ui_trust.rs TOFU / PIN-pairing / request-access dialogs ui_settings.rs resolution · refresh · decoder · bitrate · compositor · mic ui_stream.rs the stream window (GtkGraphicsOffload present) + input capture diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index c28e3a7..313b15d 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -34,6 +34,28 @@ const CSS: &str = " xdg_toplevel fullscreen state, so GTK keeps the floating-CSD styling — libadwaita's rounded corners + shadow margin stay visible over the stream. Flatten them outright. */ window.pf-chromeless { border-radius: 0; box-shadow: none; } +/* The gamepad library launcher (`--browse`, ui_gamepad_library) — always-dark console + chrome over the aurora, independent of the desktop theme. */ +.pf-gl-page { background: black; color: white; } +.pf-gl-host { font-size: 1.15em; font-weight: bold; color: rgba(255, 255, 255, 0.9); } +.pf-gl-chip { font-size: 0.8em; color: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 999px; padding: 4px 12px; } +/* Solid face, not glass: coverflow side cards OVERLAP — a translucent card would bleed + the stack through the one on top. */ +.pf-gl-poster { border-radius: 16px; background: rgb(30, 30, 37); + border: 1px solid rgba(255, 255, 255, 0.07); } +.pf-gl-dim { background: black; border-radius: 16px; } +.pf-gl-detail-title { font-size: 1.7em; font-weight: bold; color: white; } +.pf-gl-detail-store { font-size: 0.75em; font-weight: 600; letter-spacing: 2px; + color: rgba(255, 255, 255, 0.5); } +.pf-gl-glyph { font-size: 0.85em; font-weight: bold; color: white; + background: rgba(255, 255, 255, 0.14); + border-radius: 999px; min-width: 26px; min-height: 26px; padding: 2px 8px; } +.pf-gl-hint { color: rgba(255, 255, 255, 0.85); } +.pf-gl-status { font-size: 0.85em; color: #ff938a; } +.pf-gl-error-title { font-size: 1.4em; font-weight: bold; color: white; } "; pub struct App { @@ -55,6 +77,9 @@ pub struct App { /// The hosts page handle (banner + per-card connecting spinner), set right after the /// page is built — `None` only during construction. pub hosts: RefCell>>, + /// The gamepad library launcher — `Some` only under `--browse`, where it replaces the + /// hosts page as the root (and session end returns here instead of quitting). + pub browse: RefCell>>, } impl App { @@ -66,11 +91,17 @@ impl App { self.hosts.borrow().clone() } - /// Surface a connect failure on the hosts page banner (toast fallback pre-build). + pub fn browse_ui(&self) -> Option> { + self.browse.borrow().clone() + } + + /// Surface a connect failure: the launcher in browse mode, else the hosts page banner + /// (toast fallback pre-build). pub fn connect_error(&self, msg: &str) { - match self.hosts_ui() { - Some(h) => h.show_error(msg), - None => self.toast(msg), + match (self.browse_ui(), self.hosts_ui()) { + (Some(l), _) => l.show_error(msg), + (_, Some(h)) => h.show_error(msg), + _ => self.toast(msg), } } } @@ -112,6 +143,14 @@ fn build_ui(gtk_app: &adw::Application) { } }; load_css(); + // Screenshot scenes must capture settled frames: kill every GTK/libadwaita animation + // (nav-push slides especially — a headless session may starve the frame clock and + // leave a transition frozen mid-flight in the capture). + if crate::cli::shot_scene().is_some() { + if let Some(s) = gtk::Settings::default() { + s.set_gtk_enable_animations(false); + } + } let nav = adw::NavigationView::new(); let toasts = adw::ToastOverlay::new(); @@ -141,8 +180,11 @@ fn build_ui(gtk_app: &adw::Application) { gamepad: crate::gamepad::GamepadService::start(), busy: std::cell::Cell::new(false), fullscreen, + // (`--browse` makes cli_connect_request None — browse mode returns to the + // launcher on session end instead of quitting.) quit_on_session_end: fullscreen && crate::cli::cli_connect_request().is_some(), hosts: RefCell::new(None), + browse: RefCell::new(None), }); // Re-apply the persisted forwarded-controller pin (stable key; the service matches it @@ -155,6 +197,18 @@ fn build_ui(gtk_app: &adw::Application) { } } + // Browse mode (`--browse host`): the app IS the gamepad library launcher — it becomes + // the ONE root page. No hosts page (whose construction starts the mDNS browse), no + // header-menu actions; `Settings::library_enabled` is deliberately ignored (the flag + // gates the desktop menu item — asking to browse IS the opt-in here). + if let Some((req, paired, mgmt_port)) = crate::cli::cli_browse_request() { + let launcher = crate::ui_gamepad_library::open(app.clone(), req, paired, mgmt_port); + nav.add(&launcher.page); + *app.browse.borrow_mut() = Some(launcher); + window.present(); + return; + } + let hosts_ui = Rc::new(crate::ui_hosts::new( app.settings.clone(), HostsCallbacks { diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index 028aa98..a8b4e0c 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -84,7 +84,14 @@ pub fn headless_pair(pin: &str) -> glib::ExitCode { /// already pinned at this address connects silently on its stored pin; an unknown host is /// routed to the PIN ceremony (never a silent TOFU connect — `fp_hex`/`pair_optional` are /// unset, so `initiate_connect`'s manual arm mandates pairing). +/// +/// `--launch ` asks the host to launch that library title (store-qualified id from +/// `--library`, e.g. `steam:570` — the Decky wrapper's `PF_LAUNCH`); the raw id doubles +/// as the stream title (best-effort — no extra fetch just for a prettier label). pub fn cli_connect_request() -> Option { + if arg_value("--browse").is_some() { + return None; // browse mode owns the session lifecycle (precedence over --connect) + } let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?; let (addr, port) = parse_host_port(&target); Some(ConnectRequest { @@ -93,10 +100,43 @@ pub fn cli_connect_request() -> Option { port: port?, fp_hex: None, pair_optional: false, - launch: None, + launch: arg_value("--launch").map(|id| (id.clone(), id)), }) } +/// `--browse host[:port]` — open the gamepad library launcher for that host instead of +/// connecting (the Decky wrapper's `PF_BROWSE`; native port, default 9777). The host must +/// already be paired: the stored pin is what lets the launcher fetch the library and +/// connect silently — no dialog can run under gamescope, so an unpaired target renders +/// the launcher's pair-first scene. Returns the request (name + stored fingerprint from +/// the known-hosts store), whether it's paired, and the mgmt port (`--mgmt `, the +/// wrapper's `PF_MGMT`; default 47990 — browse mode runs no mDNS to learn it). +pub fn cli_browse_request() -> Option<(ConnectRequest, bool, u16)> { + let target = arg_value("--browse")?; + let (addr, port) = parse_host_port(&target); + let port = port.unwrap_or(9777); + let known = crate::trust::KnownHosts::load(); + let k = known + .hosts + .iter() + .find(|h| h.addr == addr && h.port == port); + let mgmt = arg_value("--mgmt") + .and_then(|p| p.parse().ok()) + .unwrap_or(crate::library::DEFAULT_MGMT_PORT); + Some(( + ConnectRequest { + name: k.map_or_else(|| addr.clone(), |k| k.name.clone()), + addr, + port, + fp_hex: k.map(|k| k.fp_hex.clone()), + pair_optional: false, + launch: None, + }, + k.is_some_and(|k| k.paired), + mgmt, + )) +} + /// `--library host[:mgmt_port]` — fetch and print the host's game library over the real /// mTLS + pinned-fingerprint client, no GTK window (scripting, and the live-API proof /// that the library HTTP path works against a real host). The pin comes from `--fp HEX` @@ -219,26 +259,17 @@ pub fn run_shot(app: Rc, scene: &str) { // no-art placeholders (monogram tiles), and one solid-color texture standing in // for a loaded poster (the real poster path, minus the network). "library" | "08-library" => { - let game = |id: &str, store: &str, title: &str| crate::library::GameEntry { - id: id.to_string(), - store: store.to_string(), - title: title.to_string(), - art: crate::library::Artwork::default(), - }; - let games = vec![ - game("steam:570", "steam", "Dota 2"), - game("steam:1091500", "steam", "Cyberpunk 2077"), - game("custom:emu-1", "custom", "RetroArch"), - game("heroic:fortnite", "heroic", "Fortnite"), - game("gog:witcher3", "gog", "The Witcher 3"), - game("lutris:osu", "lutris", "osu!"), - ]; - let art = vec![( - "steam:570".to_string(), - solid_texture(300, 450, 0x35, 0x84, 0xe4), - )]; + let (games, art) = mock_library(); crate::ui_library::open_mock(app.clone(), mock_req(), games, art); } + // The gamepad launcher (`--browse`) with the same injected entries — cursor sits + // at 1 so both recede directions show; aurora + easing render frozen (shot mode). + "gamepad-library" | "09-gamepad-library" => { + let (games, art) = mock_library(); + let ui = crate::ui_gamepad_library::open_mock(app.clone(), mock_req(), games, art); + app.nav.push(&ui.page); + *app.browse.borrow_mut() = Some(ui); + } other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"), } @@ -268,6 +299,33 @@ pub fn run_shot(app: Rc, scene: &str) { }); } +/// The mock game set shared by the `library` and `gamepad-library` scenes: mixed stores +/// exercising the badge set, plus one solid-colour poster texture. +fn mock_library() -> ( + Vec, + Vec<(String, gtk::gdk::Texture)>, +) { + let game = |id: &str, store: &str, title: &str| crate::library::GameEntry { + id: id.to_string(), + store: store.to_string(), + title: title.to_string(), + art: crate::library::Artwork::default(), + }; + let games = vec![ + game("steam:570", "steam", "Dota 2"), + game("steam:1091500", "steam", "Cyberpunk 2077"), + game("custom:emu-1", "custom", "RetroArch"), + game("heroic:fortnite", "heroic", "Fortnite"), + game("gog:witcher3", "gog", "The Witcher 3"), + game("lutris:osu", "lutris", "osu!"), + ]; + let art = vec![( + "steam:570".to_string(), + solid_texture(300, 450, 0x35, 0x84, 0xe4), + )]; + (games, art) +} + /// A WxH single-colour RGBA texture — the `library` scene's stand-in for a fetched poster. fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture { let px = [r, g, b, 0xff].repeat((w * h) as usize); diff --git a/clients/linux/src/gamepad.rs b/clients/linux/src/gamepad.rs index 090a470..46d5250 100644 --- a/clients/linux/src/gamepad.rs +++ b/clients/linux/src/gamepad.rs @@ -18,6 +18,17 @@ //! idle host-list window would kill the Deck's system input. The pad list for Settings is //! built from SDL's ID-based metadata getters, which need no open. //! +//! **Menu mode is the one idle exception.** The gamepad library launcher (`--browse`) +//! flips [`GamepadService::set_menu_mode`] on for its lifetime: the worker then holds the +//! active pad open and translates its buttons/stick into [`MenuEvent`]s (polled off the +//! open handle each loop — Apple `GamepadMenuInput` parity: edge-triggered buttons, +//! snapshot-on-entry so a button still held from a previous screen or stream can't ghost- +//! fire, stick/dpad direction with initial-delay auto-repeat). The Valve HIDAPI drivers +//! stay OFF — a plain SDL open of the virtual X360 / evdev pad doesn't touch lizard mode — +//! and an attached session always supersedes menu translation (the stream path is +//! untouched); detach re-snapshots so the escape chord that ended the session fires +//! nothing in the menu. +//! //! This thread is also the single consumer of the rumble and HID-output pull planes. use punktfunk_core::client::NativeClient; @@ -50,6 +61,169 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir /// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press). const DISCONNECT_HOLD: Duration = Duration::from_millis(1500); +/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple +/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift). +const MENU_DEADZONE: u16 = 16384; +/// A held direction starts auto-repeating after this initial delay… +const MENU_REPEAT_DELAY: Duration = Duration::from_millis(380); +/// …and then repeats at this cadence until released or changed. +const MENU_REPEAT_INTERVAL: Duration = Duration::from_millis(160); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MenuDir { + Up, + Down, + Left, + Right, +} + +/// One controller action for the launcher UI, translated from the open pad while menu +/// mode is on and no session is attached. Buttons are edge-triggered; `Move` debounces +/// the stick/dpad and auto-repeats ([`MENU_REPEAT_DELAY`]/[`MENU_REPEAT_INTERVAL`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MenuEvent { + Move(MenuDir), + /// A — activate the focused item. + Confirm, + /// B — back / quit. + Back, + /// Y (Apple "secondary"; unused by the launcher today, kept for parity). + Secondary, + /// X (Apple "tertiary"; unused). + Tertiary, + /// L1 — jump back 5. + JumpBack, + /// R1 — jump forward 5. + JumpForward, +} + +/// Menu haptic pulses — short rumble ticks on the menu pad (never during a stream). +#[derive(Clone, Copy, Debug)] +pub enum MenuPulse { + Move, + Confirm, + Boundary, +} + +/// Raw pad state sampled once per worker iteration for menu translation. +#[derive(Clone, Copy, Default)] +struct MenuSample { + /// a, b, x, y, l1, r1 — the order [`MenuNav::poll`] maps to events. + buttons: [bool; 6], + /// Left stick, SDL convention (+y = down). + lx: i16, + ly: i16, + /// up, down, left, right. + dpad: [bool; 4], +} + +/// The pure menu-input state machine (no SDL types — unit-tested below). Port of the +/// Swift client's `GamepadMenuInput`: the poll after a [`reset`](Self::reset) adopts the +/// currently-held buttons and direction WITHOUT firing, so a press that crossed a screen +/// handoff (the B that closed a stream, a held A on mode entry) must be released before +/// it can act; buttons fire on the rising edge only. +struct MenuNav { + /// Adopt the next sample silently (set on mode entry / stream detach / pad change). + snapshot_pending: bool, + /// Previous button states, [`MenuSample::buttons`] order. + was: [bool; 6], + dir: Option, + /// When `dir` engaged — start of the initial-repeat delay. + dir_since: Instant, + last_repeat: Instant, +} + +impl MenuNav { + fn new() -> MenuNav { + MenuNav { + snapshot_pending: true, + was: [false; 6], + dir: None, + dir_since: Instant::now(), + last_repeat: Instant::now(), + } + } + + /// Arm the snapshot: the next poll adopts held state without firing. + fn reset(&mut self) { + self.snapshot_pending = true; + self.dir = None; + } + + /// Direction from the left stick (dominant axis wins past the deadzone), falling back + /// to the discrete dpad. SDL sticks are +y = down. + fn resolve_dir(s: &MenuSample) -> Option { + let (ax, ay) = (s.lx.unsigned_abs(), s.ly.unsigned_abs()); + if ax > MENU_DEADZONE || ay > MENU_DEADZONE { + return Some(if ax >= ay { + if s.lx > 0 { + MenuDir::Right + } else { + MenuDir::Left + } + } else if s.ly > 0 { + MenuDir::Down + } else { + MenuDir::Up + }); + } + let [up, down, left, right] = s.dpad; + if left { + Some(MenuDir::Left) + } else if right { + Some(MenuDir::Right) + } else if up { + Some(MenuDir::Up) + } else if down { + Some(MenuDir::Down) + } else { + None + } + } + + fn poll(&mut self, s: &MenuSample, now: Instant, out: &mut Vec) { + let dir = Self::resolve_dir(s); + if self.snapshot_pending { + self.snapshot_pending = false; + self.was = s.buttons; + self.dir = dir; + self.dir_since = now; + self.last_repeat = now; + return; + } + // buttons order a, b, x, y, l1, r1 → the matching event per index. + const EVENTS: [MenuEvent; 6] = [ + MenuEvent::Confirm, + MenuEvent::Back, + MenuEvent::Tertiary, + MenuEvent::Secondary, + MenuEvent::JumpBack, + MenuEvent::JumpForward, + ]; + for (i, ev) in EVENTS.iter().enumerate() { + if s.buttons[i] && !self.was[i] { + out.push(*ev); + } + self.was[i] = s.buttons[i]; + } + if dir != self.dir { + self.dir = dir; + self.dir_since = now; + self.last_repeat = now; + if let Some(d) = dir { + out.push(MenuEvent::Move(d)); + } + } else if let Some(d) = dir { + if now.duration_since(self.dir_since) >= MENU_REPEAT_DELAY + && now.duration_since(self.last_repeat) >= MENU_REPEAT_INTERVAL + { + self.last_repeat = now; + out.push(MenuEvent::Move(d)); + } + } + } +} + #[derive(Clone, Debug)] pub struct PadInfo { pub name: String, @@ -133,6 +307,8 @@ enum Ctl { Attach(Arc), Detach, Pin(Option), + MenuMode(bool), + MenuRumble(MenuPulse), } #[derive(Clone)] @@ -146,6 +322,9 @@ pub struct GamepadService { /// Fires once when the [`ESCAPE_CHORD`] is held past [`DISCONNECT_HOLD`]; the stream page /// consumes it to end the session (the controller equivalent of Ctrl+Alt+Shift+D). disconnect_rx: async_channel::Receiver<()>, + /// Menu-navigation events while menu mode is on and no session is attached; the + /// launcher page consumes them. + menu_rx: async_channel::Receiver, } impl GamepadService { @@ -155,11 +334,12 @@ impl GamepadService { let (ctl, ctl_rx) = std::sync::mpsc::channel(); let (escape_tx, escape_rx) = async_channel::unbounded(); let (disconnect_tx, disconnect_rx) = async_channel::unbounded(); + let (menu_tx, menu_rx) = async_channel::unbounded(); let (p, a) = (pads.clone(), active.clone()); if let Err(e) = std::thread::Builder::new() .name("punktfunk-gamepad".into()) .spawn(move || { - if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx) { + if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx, &menu_tx) { tracing::warn!(error = %e, "gamepad service ended — pads disabled"); } }) @@ -172,6 +352,7 @@ impl GamepadService { ctl, escape_rx, disconnect_rx, + menu_rx, } } @@ -187,6 +368,25 @@ impl GamepadService { self.disconnect_rx.clone() } + /// Menu-navigation events ([`MenuEvent`]) — flowing only while menu mode is on and no + /// session is attached. A fresh clone per call; the launcher spawns a future on it. + pub fn menu_events(&self) -> async_channel::Receiver { + self.menu_rx.clone() + } + + /// Turn menu mode on/off: while on (and no session attached) the worker holds the + /// active pad open and translates it into [`MenuEvent`]s. The launcher flips this on + /// once for its lifetime — an attached session supersedes translation automatically. + pub fn set_menu_mode(&self, on: bool) { + let _ = self.ctl.send(Ctl::MenuMode(on)); + } + + /// Play a short menu haptic tick on the menu pad (no-op while a session is attached + /// or no pad is open; best-effort on pads without rumble). + pub fn menu_rumble(&self, pulse: MenuPulse) { + let _ = self.ctl.send(Ctl::MenuRumble(pulse)); + } + pub fn pads(&self) -> Vec { self.pads.lock().unwrap().clone() } @@ -363,6 +563,11 @@ struct Worker<'a> { chord_since: Option, /// The disconnect signal already fired for the current hold — latched so it fires once. disconnect_fired: bool, + /// Menu mode ([`GamepadService::set_menu_mode`]): hold the active pad open while idle + /// and translate it into [`MenuEvent`]s. An attached session pauses translation. + menu_mode: bool, + menu_nav: MenuNav, + menu_tx: async_channel::Sender, } impl Worker<'_> { @@ -421,12 +626,12 @@ impl Worker<'_> { }) } - /// Hold exactly the right device: the active pad while a session is attached, nothing - /// otherwise. The single place that decides to open (= grab) hardware; dropping the - /// old handle closes it (`SDL_CloseGamepad`) — on a Deck the firmware watchdog then - /// restores lizard mode. + /// Hold exactly the right device: the active pad while a session is attached or menu + /// mode owns navigation, nothing otherwise. The single place that decides to open + /// (= grab) hardware; dropping the old handle closes it (`SDL_CloseGamepad`) — on a + /// Deck the firmware watchdog then restores lizard mode. fn sync_open(&mut self) { - let want = if self.attached.is_some() { + let want = if self.attached.is_some() || self.menu_mode { self.active_id() } else { None @@ -439,7 +644,15 @@ impl Worker<'_> { match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) { Ok(pad) => { self.open = Some((id, pad)); - self.set_sensors(true); + // Sensors stream only for an attached session (USB/BT bandwidth); the + // menu needs buttons + stick only. + if self.attached.is_some() { + self.set_sensors(true); + } else { + // The menu pad changed under us (hot-plug while the launcher is + // open): adopt the new pad's held state instead of firing it. + self.menu_nav.reset(); + } } Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"), } @@ -645,14 +858,42 @@ impl Worker<'_> { Ok(Ctl::Detach) => { self.flush_held(); self.attached = None; - self.sync_open(); // closes the held device + self.sync_open(); // closes the held device (menu mode keeps it) set_valve_hidapi(false); + if self.menu_mode { + // Back to the launcher: adopt whatever is still physically held + // (the escape chord that ended the session, a lingering B) so it + // can't ghost-fire menu actions. + self.menu_nav.reset(); + } } Ok(Ctl::Pin(key)) => { let before = self.active_id(); self.pinned = key; self.refresh_active(before); } + Ok(Ctl::MenuMode(on)) => { + self.menu_mode = on; + if on { + self.menu_nav.reset(); + } + self.sync_open(); + } + Ok(Ctl::MenuRumble(pulse)) => { + if self.attached.is_none() { + if let Some((_, pad)) = self.open.as_mut() { + let (low, high, ms) = match pulse { + // Light high-freq detent — won't jackhammer at repeat rate. + MenuPulse::Move => (0, 0x3000, 25), + // Fuller both-motor thunk. + MenuPulse::Confirm => (0x5000, 0x5000, 60), + // Dull low-freq wall. + MenuPulse::Boundary => (0x6000, 0, 60), + }; + let _ = pad.set_rumble(low, high, ms); + } + } + } Err(std::sync::mpsc::TryRecvError::Empty) => return true, Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone } @@ -784,6 +1025,42 @@ impl Worker<'_> { } } + /// Sample the open pad and translate it into [`MenuEvent`]s — only while menu mode is + /// on and no session is attached (attach supersedes; SDL events merely wake the loop, + /// so a press is translated the iteration it arrives). + fn menu_poll(&mut self) { + if !self.menu_mode || self.attached.is_some() { + return; + } + let Some((_, pad)) = self.open.as_ref() else { + return; + }; + use sdl3::gamepad::{Axis, Button}; + let s = MenuSample { + buttons: [ + pad.button(Button::South), + pad.button(Button::East), + pad.button(Button::West), + pad.button(Button::North), + pad.button(Button::LeftShoulder), + pad.button(Button::RightShoulder), + ], + lx: pad.axis(Axis::LeftX), + ly: pad.axis(Axis::LeftY), + dpad: [ + pad.button(Button::DPadUp), + pad.button(Button::DPadDown), + pad.button(Button::DPadLeft), + pad.button(Button::DPadRight), + ], + }; + let mut out = Vec::new(); + self.menu_nav.poll(&s, Instant::now(), &mut out); + for e in out { + let _ = self.menu_tx.try_send(e); + } + } + /// Drain and render the feedback planes — rumble plus HID output (lightbar / /// player LEDs / adaptive triggers) — on the active pad; this thread is their single /// consumer. The host re-sends rumble state periodically, so a generous duration with @@ -847,6 +1124,7 @@ fn run( ctl: &Receiver, escape_tx: &async_channel::Sender<()>, disconnect_tx: &async_channel::Sender<()>, + menu_tx: &async_channel::Sender, ) -> Result<(), String> { // Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its // own thread. @@ -877,6 +1155,9 @@ fn run( chord_armed: false, chord_since: None, disconnect_fired: false, + menu_mode: false, + menu_nav: MenuNav::new(), + menu_tx: menu_tx.clone(), }; loop { @@ -891,8 +1172,13 @@ fn run( // rumble/HID feedback, and the escape-chord hold check all run once per wakeup, // so their worst case is one timeout (~10 ms attached, imperceptible for // haptics; DISCONNECT_HOLD is 1500 ms, so 10 ms hold-check granularity is far - // inside tolerance). Idle (no session) wakes lazily at 30 ms for hotplug + ctl. - let timeout = Duration::from_millis(if w.attached.is_some() { 10 } else { 30 }); + // inside tolerance; menu mode needs the same cadence for its repeat timing). + // Idle (no session, no menu) wakes lazily at 30 ms for hotplug + ctl. + let timeout = Duration::from_millis(if w.attached.is_some() || w.menu_mode { + 10 + } else { + 30 + }); if let Some(event) = pump.wait_event_timeout(timeout) { w.handle_event(event); // Drain whatever else queued while we were waiting or handling. @@ -905,6 +1191,169 @@ fn run( // new button events; the chord itself is only detected while a session is attached). w.maybe_fire_disconnect(); + w.menu_poll(); w.render_feedback(); } } + +#[cfg(test)] +mod menu_nav_tests { + use super::*; + + fn sample() -> MenuSample { + MenuSample::default() + } + + fn events(nav: &mut MenuNav, s: &MenuSample, at: Instant) -> Vec { + let mut out = Vec::new(); + nav.poll(s, at, &mut out); + out + } + + #[test] + fn snapshot_adopts_held_state_without_firing() { + let mut nav = MenuNav::new(); + let t = Instant::now(); + let mut held = sample(); + held.buttons[0] = true; // A held on entry + held.lx = 30000; // stick already deflected right + assert!(events(&mut nav, &held, t).is_empty(), "snapshot poll fired"); + // Still held: nothing (no rising edge, direction unchanged since snapshot). + assert!(events(&mut nav, &held, t + Duration::from_millis(10)).is_empty()); + // Release, then press again → now it fires. + assert!(events(&mut nav, &sample(), t + Duration::from_millis(20)).is_empty()); + assert_eq!( + events(&mut nav, &held, t + Duration::from_millis(30)), + vec![MenuEvent::Confirm, MenuEvent::Move(MenuDir::Right)] + ); + } + + #[test] + fn buttons_fire_on_rising_edge_only() { + let mut nav = MenuNav::new(); + let t = Instant::now(); + events(&mut nav, &sample(), t); // consume the snapshot + let mut s = sample(); + s.buttons[1] = true; // B down + assert_eq!( + events(&mut nav, &s, t + Duration::from_millis(10)), + vec![MenuEvent::Back] + ); + for i in 2..20 { + assert!( + events(&mut nav, &s, t + Duration::from_millis(10 * i)).is_empty(), + "held button re-fired" + ); + } + } + + #[test] + fn reset_rearms_the_snapshot() { + let mut nav = MenuNav::new(); + let t = Instant::now(); + events(&mut nav, &sample(), t); + nav.reset(); + let mut s = sample(); + s.buttons[1] = true; + assert!( + events(&mut nav, &s, t + Duration::from_millis(10)).is_empty(), + "post-reset poll fired a held button" + ); + } + + #[test] + fn direction_repeats_after_delay_at_interval() { + let mut nav = MenuNav::new(); + let t = Instant::now(); + events(&mut nav, &sample(), t); + let mut s = sample(); + s.dpad[3] = true; // dpad right + // Engage: fires immediately. + assert_eq!( + events(&mut nav, &s, t + Duration::from_millis(10)), + vec![MenuEvent::Move(MenuDir::Right)] + ); + // Inside the initial delay: silent. + assert!(events(&mut nav, &s, t + Duration::from_millis(300)).is_empty()); + // Past the delay: repeats… + assert_eq!( + events(&mut nav, &s, t + Duration::from_millis(400)), + vec![MenuEvent::Move(MenuDir::Right)] + ); + // …but not faster than the interval… + assert!(events(&mut nav, &s, t + Duration::from_millis(500)).is_empty()); + // …and again once it elapses. + assert_eq!( + events(&mut nav, &s, t + Duration::from_millis(570)), + vec![MenuEvent::Move(MenuDir::Right)] + ); + // Release cancels; re-engage fires immediately again. + assert!(events(&mut nav, &sample(), t + Duration::from_millis(580)).is_empty()); + assert_eq!( + events(&mut nav, &s, t + Duration::from_millis(590)), + vec![MenuEvent::Move(MenuDir::Right)] + ); + } + + #[test] + fn direction_change_fires_immediately() { + let mut nav = MenuNav::new(); + let t = Instant::now(); + events(&mut nav, &sample(), t); + let mut right = sample(); + right.lx = 30000; + let mut left = sample(); + left.lx = -30000; + assert_eq!( + events(&mut nav, &right, t + Duration::from_millis(10)), + vec![MenuEvent::Move(MenuDir::Right)] + ); + assert_eq!( + events(&mut nav, &left, t + Duration::from_millis(20)), + vec![MenuEvent::Move(MenuDir::Left)] + ); + } + + #[test] + fn direction_resolution() { + // Below the deadzone: nothing. + let mut s = sample(); + s.lx = MENU_DEADZONE as i16; + assert_eq!(MenuNav::resolve_dir(&s), None); + // Dominant axis wins; SDL +y = down. + s.lx = 20000; + s.ly = 25000; + assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Down)); + s.ly = -25000; + assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Up)); + s.lx = 26000; + assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Right)); + s.lx = -26000; + assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Left)); + // Dpad fallback… + let mut d = sample(); + d.dpad[1] = true; + assert_eq!(MenuNav::resolve_dir(&d), Some(MenuDir::Down)); + // …but the stick overrides it. + d.lx = 30000; + assert_eq!(MenuNav::resolve_dir(&d), Some(MenuDir::Right)); + } + + #[test] + fn shoulder_and_face_button_mapping() { + let mut nav = MenuNav::new(); + let t = Instant::now(); + events(&mut nav, &sample(), t); + let mut s = sample(); + s.buttons = [false, false, true, true, true, true]; // x, y, l1, r1 + assert_eq!( + events(&mut nav, &s, t + Duration::from_millis(10)), + vec![ + MenuEvent::Tertiary, + MenuEvent::Secondary, + MenuEvent::JumpBack, + MenuEvent::JumpForward, + ] + ); + } +} diff --git a/clients/linux/src/launch.rs b/clients/linux/src/launch.rs index 58511e1..cb4d8cf 100644 --- a/clients/linux/src/launch.rs +++ b/clients/linux/src/launch.rs @@ -299,18 +299,24 @@ impl SessionUi { } // A pinned connect rejected on trust grounds means the host's cert no // longer matches the stored pin (rotated cert or impostor) — route to - // the PIN ceremony to re-establish trust rather than dead-ending. - if trust_rejected && !self.tofu { + // the PIN ceremony to re-establish trust rather than dead-ending. Browse + // mode can't: gamescope never maps dialogs, so it renders the advice instead + // (re-pairing is the plugin's job there). + if trust_rejected && !self.tofu && self.app.browse_ui().is_none() { self.app .toast("Host fingerprint changed — re-pair with a PIN to continue"); crate::ui_trust::pin_dialog(self.app.clone(), self.req.clone()); + } else if trust_rejected && !self.tofu { + self.app + .connect_error("Host identity changed — re-pair from the Punktfunk plugin."); } else { - // Errors land on the hosts page banner, not a transient toast. + // Errors land on the hosts page banner / launcher strip, not a transient toast. self.app.connect_error(&format!("Couldn't connect — {msg}")); } } - /// `Ended`: detach gamepads, pop back to the hosts page, and surface the reason. + /// `Ended`: detach gamepads, pop back to the launcher (browse mode) or the hosts + /// page, and surface the reason. fn on_ended(&mut self, err: Option) { self.close_waiting(); self.app.gamepad.detach(); @@ -324,6 +330,18 @@ impl SessionUi { self.app.window.close(); return; } + // Browse mode: back to the launcher to pick the next game — B there quits to + // Gaming Mode. (The gamepad worker re-opened the pad and armed the held-state + // snapshot on the detach above, so the chord that ended the session fires nothing.) + if let Some(l) = self.app.browse_ui() { + self.app.nav.pop_to_tag("launcher"); + l.on_session_ended(); + if let Some(e) = err { + self.app.connect_error(&e); + } + self.app.busy.set(false); + return; + } self.app.nav.pop_to_tag("hosts"); if let Some(h) = self.app.hosts_ui() { h.set_connecting(None); diff --git a/clients/linux/src/library.rs b/clients/linux/src/library.rs index f9025d5..ac40f00 100644 --- a/clients/linux/src/library.rs +++ b/clients/linux/src/library.rs @@ -6,8 +6,9 @@ //! verified by its pinned SHA-256 fingerprint (`KnownHost::fp_hex`), not a CA chain. use serde::Deserialize; +use std::collections::VecDeque; use std::io::Read; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; /// The management API's default port — matches `mgmt::DEFAULT_PORT` on the host. A @@ -181,6 +182,57 @@ pub fn fetch_art(pinned: &ureq::Agent, base: &str, url: &str) -> Result, Ok(bytes) } +/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a +/// big library into a connection burst. +const ART_WORKERS: usize = 3; + +/// Fetch poster bytes for `jobs` (entry id → candidate URLs, walked in order until one +/// loads) on a small worker pool; results stream on the returned channel as they land. +/// Dropping the receiver (the consuming page popped) winds the workers down. Shared by +/// the touch grid and the gamepad launcher — the consumer does its own texture decode on +/// the main loop. +pub fn spawn_art_fetch( + base: String, + identity: (String, String), + pin: Option<[u8; 32]>, + jobs: VecDeque<(String, Vec)>, +) -> async_channel::Receiver<(String, Vec)> { + let queue = Arc::new(Mutex::new(jobs)); + let (tx, rx) = async_channel::unbounded::<(String, Vec)>(); + for _ in 0..ART_WORKERS { + let queue = queue.clone(); + let tx = tx.clone(); + let base = base.clone(); + let identity = identity.clone(); + std::thread::Builder::new() + .name("punktfunk-lib-art".into()) + .spawn(move || { + let Ok(agent) = agent(&identity, pin) else { + return; + }; + loop { + let job = queue.lock().unwrap().pop_front(); + let Some((id, candidates)) = job else { break }; + for url in &candidates { + match fetch_art(&agent, &base, url) { + Ok(bytes) => { + // Receiver gone (page popped) — stop fetching. + if tx.send_blocking((id, bytes)).is_err() { + return; + } + break; + } + // 404 on a guessed CDN path is routine — try the next kind. + Err(e) => tracing::debug!(%id, url, error = %e, "poster miss"), + } + } + } + }) + .expect("spawn art thread"); + } + rx +} + fn classify(e: ureq::Error) -> LibraryError { match e { ureq::Error::Status(401 | 403, _) => LibraryError::NotPaired, diff --git a/clients/linux/src/main.rs b/clients/linux/src/main.rs index 798a625..578f408 100644 --- a/clients/linux/src/main.rs +++ b/clients/linux/src/main.rs @@ -26,6 +26,8 @@ mod session; #[cfg(target_os = "linux")] mod trust; #[cfg(target_os = "linux")] +mod ui_gamepad_library; +#[cfg(target_os = "linux")] mod ui_hosts; #[cfg(target_os = "linux")] mod ui_library; diff --git a/clients/linux/src/ui_gamepad_library.rs b/clients/linux/src/ui_gamepad_library.rs new file mode 100644 index 0000000..79a5539 --- /dev/null +++ b/clients/linux/src/ui_gamepad_library.rs @@ -0,0 +1,1199 @@ +//! The gamepad library launcher (`--browse` — the Apple client's console UI ported): +//! a chrome-less, controller-driven coverflow of the host's game library over a drifting +//! "aurora" backdrop. A launches the focused title (the id rides the Hello), B quits back +//! to Gaming Mode, L1/R1 jump. Scope is deliberately library-only — host selection and +//! settings stay in the touch UI; the Decky plugin owns pairing (`--pair`). +//! +//! Input is the gamepad service's menu mode (`gamepad::MenuEvent` over an async channel, +//! same pattern as the stream page's escape events) plus a keyboard fallback +//! (arrows/Enter/Esc), so the launcher is fully drivable with no pad — that's also how CI +//! and the GPU-less dev VM exercise it. Zero popovers/dialogs anywhere: gamescope never +//! maps them (see `ui_settings::gamescope_session`) — every state renders in-page. + +use crate::app::App; +use crate::gamepad::{MenuDir, MenuEvent, MenuPulse}; +use crate::library::{self, GameEntry}; +use crate::trust; +use crate::ui_hosts::ConnectRequest; +use adw::prelude::*; +use gtk::{cairo, gdk, glib, graphene, gsk}; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; + +/// Poster geometry: 2:3 covers, sized so the focused poster + detail panel + hint bar fit +/// a Deck's 1280×800 with air. +const POSTER_W: i32 = 220; +const POSTER_H: i32 = 330; +/// Center of the focused card to the center of its first neighbor — the "breathing room" +/// gap around the focus. +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. +/// Overlap needs paint order: [`restack`] keeps cards closer to the cursor on top +/// (`gtk::Fixed` paints in child order). +const SIDE_SPACING: f64 = 104.0; +/// Cards farther than this from the eased position aren't laid out at all. +const VISIBLE_RANGE: f64 = 5.5; +/// Neighbors recede to this scale… (Apple coverflow parity). +const RECEDE_SCALE: f64 = 0.24; +/// …and swing this many degrees about their own vertical axis under perspective — the +/// actual coverflow tilt (Apple's ±38°), side cards facing the corridor (their inner edge +/// recedes behind the focus). Driven continuously by distance-from-center, so a card +/// flips through flat exactly as it crosses the focus point mid-animation. +const ROTATE_DEG: f64 = 38.0; +/// Perspective depth for the tilt, px — smaller is more dramatic (CSS `perspective()` +/// semantics; per-card vanishing point like Apple's rotation3DEffect). +const PERSPECTIVE: f32 = 800.0; +/// Side cards stay fully opaque — they OVERLAP, and any whole-card translucency bleeds +/// the stack through the card on top; the darkening veil (opaque black, inside the card) +/// carries the entire recede cue. +const RECEDE_DIM: f64 = 0.30; +/// Boundary recoil: a refused move deflects the strip this many px against the push. +const BUMP_PX: f64 = 16.0; +/// L1/R1 jump distance (Apple parity). +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 instead of +// a lerp. Damping = 2·ζ·√k; dt is clamped far inside the integrator's stability bound. +/// Cursor chase: ζ ≈ 0.85 — settles in ~0.3 s with a whisker of overshoot. +const SPRING_K: f64 = 200.0; +const SPRING_C: f64 = 24.0; +/// Boundary recoil: stiffer and more underdamped (ζ ≈ 0.55) — one visible wobble. +const BUMP_K: f64 = 600.0; +const BUMP_C: f64 = 27.0; + +/// Everything the launcher re-renders from. Kept alive by `App::browse` for the app's +/// lifetime (browse mode never pops this page — streams push on top and return). +struct State { + app: Rc, + /// The browse-target host; cards clone it and add `launch`. `fp_hex` is the stored + /// pin (browse requires a paired host — enforced before any fetch). + req: ConnectRequest, + paired: bool, + mgmt_port: u16, + root: gtk::Overlay, + stack: gtk::Stack, + // Carousel: the integer cursor is the navigation authority (Apple pattern); the + // eased float position chases it on a frame-clock tick. + fixed: gtk::Fixed, + /// The viewport the strip is centered on. `gtk::Fixed` measures its TRANSFORMED + /// children, so far-out cards would otherwise inflate the whole page's minimum width + /// past the window (observed: the top bar allocated wider than the screen, chip + /// off-glass). A ScrolledWindow with External policy exists to swallow exactly that; + /// it's `can_target(false)` so touch/wheel can never actually scroll the strip. + scroller: gtk::ScrolledWindow, + cards: RefCell>, + cursor: Cell, + anim_pos: Cell, + anim_vel: Cell, + bump: Cell, + bump_vel: Cell, + anim_active: Cell, + last_tick: Cell, + animations: bool, + detail_title: gtk::Label, + detail_store: gtk::Label, + /// Transient error strip on the carousel scene (connect failures land here — the + /// library is still loaded, so no scene swap). + status: gtk::Label, + error_title: gtk::Label, + error_body: gtk::Label, + hints: gtk::Box, + chip: gtk::Label, + games: RefCell>, + /// Poster cache (entry id → texture) — survives re-renders without refetching. + art: RefCell>, + /// The Picture each entry currently renders into, so async art lands on the right card. + pics: RefCell>, + /// The error scene's A action retries the fetch (fetch errors only — not unpaired). + can_retry: Cell, + /// A connect is in flight — hint bar shows the spinner, menu input is parked. + connecting: Cell, + /// Screenshot mode: render injected entries only, never touch network or gamepads. + mock: Cell, +} + +struct Card { + root: gtk::Overlay, + dim: gtk::Box, +} + +/// The launcher page handle, held in `App::browse`. +pub struct LauncherUi { + pub page: adw::NavigationPage, + state: Rc, +} + +impl LauncherUi { + /// A session that started from here ended: restore the hint bar, re-grab keyboard + /// focus (menu mode never turned off — the gamepad worker re-snapshotted on detach). + pub fn on_session_ended(&self) { + self.state.connecting.set(false); + show_scene( + &self.state, + current_scene(&self.state).as_deref().unwrap_or(""), + ); + update_chip(&self.state); + self.state.root.grab_focus(); + } + + /// Surface a connect/session error. With the library on screen the carousel stays put + /// and the message lands on the transient status strip; otherwise the error scene. + pub fn show_error(&self, msg: &str) { + let state = &self.state; + state.connecting.set(false); + if current_scene(state).as_deref() == Some("carousel") { + show_transient_error(state, msg); + show_scene(state, "carousel"); + } else { + state.error_title.set_text("Couldn't connect"); + state.error_body.set_text(msg); + state.can_retry.set(false); + show_scene(state, "error"); + } + } +} + +/// Open the launcher for a saved host and start the fetch. `paired` gates everything: an +/// unpaired target renders the pair-first scene instead (pairing is the plugin's job — no +/// ceremony can run under gamescope). +pub fn open(app: Rc, req: ConnectRequest, paired: bool, mgmt_port: u16) -> Rc { + let ui = build(app.clone(), req, paired, mgmt_port); + app.gamepad.set_menu_mode(true); + attach_menu_input(&ui.state); + // The fake-library dev hook exists precisely for host-less/pairing-less UI work. + if paired || std::env::var_os("PUNKTFUNK_FAKE_LIBRARY").is_some() { + load(&ui.state); + } else { + ui.state.error_title.set_text("Not paired with this host"); + ui.state + .error_body + .set_text("Pair from the Punktfunk plugin first."); + ui.state.can_retry.set(false); + show_scene(&ui.state, "error"); + } + ui +} + +/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by +/// entry id) with no host, no network, and no gamepad service — the CI `gamepad-library` +/// scene. The cursor starts at 1 so both recede directions are visible. +pub fn open_mock( + app: Rc, + req: ConnectRequest, + games: Vec, + art: Vec<(String, gdk::Texture)>, +) -> Rc { + let ui = build(app, req, true, library::DEFAULT_MGMT_PORT); + ui.state.mock.set(true); + ui.state.art.borrow_mut().extend(art); + if games.is_empty() { + show_scene(&ui.state, "empty"); + } else { + ui.state.cursor.set(1.min(games.len() as i32 - 1)); + *ui.state.games.borrow_mut() = games; + render(&ui.state); + show_scene(&ui.state, "carousel"); + } + ui +} + +fn build(app: Rc, req: ConnectRequest, paired: bool, mgmt_port: u16) -> Rc { + // Scene: loading. + let loading = gtk::Box::new(gtk::Orientation::Vertical, 12); + loading.set_valign(gtk::Align::Center); + let spinner = gtk::Spinner::new(); + spinner.set_size_request(32, 32); + spinner.start(); + spinner.set_halign(gtk::Align::Center); + loading.append(&spinner); + let loading_label = gtk::Label::new(Some("Loading library…")); + loading_label.add_css_class("pf-gl-hint"); + loading.append(&loading_label); + + // Scene: error (fetch failures, the unpaired gate, off-carousel connect errors). + let error_title = gtk::Label::new(None); + error_title.add_css_class("pf-gl-error-title"); + let error_body = gtk::Label::new(None); + error_body.add_css_class("pf-gl-hint"); + error_body.set_wrap(true); + error_body.set_max_width_chars(60); + error_body.set_justify(gtk::Justification::Center); + let error = gtk::Box::new(gtk::Orientation::Vertical, 10); + error.set_valign(gtk::Align::Center); + error.append(&error_title); + error.append(&error_body); + + // Scene: empty. + let empty_title = gtk::Label::new(Some("No games found")); + empty_title.add_css_class("pf-gl-error-title"); + let empty_body = gtk::Label::new(Some( + "Install Steam titles or add custom entries in the host's web console.", + )); + empty_body.add_css_class("pf-gl-hint"); + let empty = gtk::Box::new(gtk::Orientation::Vertical, 10); + empty.set_valign(gtk::Align::Center); + empty.append(&empty_title); + empty.append(&empty_body); + + // Scene: the carousel + detail panel. + let fixed = gtk::Fixed::new(); + fixed.set_vexpand(true); + fixed.set_hexpand(true); + let scroller = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::External) + .vscrollbar_policy(gtk::PolicyType::External) + .child(&fixed) + .hexpand(true) + .vexpand(true) + .build(); + scroller.set_can_target(false); + let detail_title = gtk::Label::new(Some(" ")); + detail_title.add_css_class("pf-gl-detail-title"); + detail_title.set_ellipsize(gtk::pango::EllipsizeMode::End); + let detail_store = gtk::Label::new(Some(" ")); + detail_store.add_css_class("pf-gl-detail-store"); + let status = gtk::Label::new(None); + status.add_css_class("pf-gl-status"); + status.set_wrap(true); + status.set_visible(false); + let detail = gtk::Box::new(gtk::Orientation::Vertical, 4); + detail.set_halign(gtk::Align::Center); + detail.set_margin_bottom(12); + detail.append(&detail_title); + detail.append(&detail_store); + detail.append(&status); + let carousel_scene = gtk::Box::new(gtk::Orientation::Vertical, 0); + carousel_scene.append(&scroller); + carousel_scene.append(&detail); + + let stack = gtk::Stack::new(); + stack.set_vexpand(true); + stack.add_named(&loading, Some("loading")); + stack.add_named(&error, Some("error")); + stack.add_named(&empty, Some("empty")); + stack.add_named(&carousel_scene, Some("carousel")); + + // Chrome: host label + controller chip on top, button hints at the bottom. + let host_label = gtk::Label::new(Some(&req.name)); + host_label.add_css_class("pf-gl-host"); + host_label.set_hexpand(true); + host_label.set_halign(gtk::Align::Start); + let chip = gtk::Label::new(None); + chip.add_css_class("pf-gl-chip"); + let top = gtk::Box::new(gtk::Orientation::Horizontal, 12); + top.set_margin_top(18); + top.set_margin_start(24); + top.set_margin_end(24); + top.append(&host_label); + top.append(&chip); + + let hints = gtk::Box::new(gtk::Orientation::Horizontal, 18); + hints.set_margin_bottom(16); + hints.set_margin_start(24); + hints.set_halign(gtk::Align::Start); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.append(&top); + content.append(&stack); + content.append(&hints); + + let root = gtk::Overlay::new(); + root.add_css_class("pf-gl-page"); + root.set_child(Some(&build_aurora())); + root.add_overlay(&content); + root.set_focusable(true); + + let page = adw::NavigationPage::builder() + .title(req.name.clone()) + .tag("launcher") + .child(&root) + .build(); + + let state = Rc::new(State { + app, + req, + paired, + mgmt_port, + root: root.clone(), + stack, + fixed, + scroller, + cards: RefCell::new(Vec::new()), + cursor: Cell::new(0), + anim_pos: Cell::new(0.0), + anim_vel: Cell::new(0.0), + bump: Cell::new(0.0), + bump_vel: Cell::new(0.0), + anim_active: Cell::new(false), + last_tick: Cell::new(0), + animations: animations_enabled(), + detail_title, + detail_store, + status, + error_title, + error_body, + hints, + chip, + games: RefCell::new(Vec::new()), + art: RefCell::new(HashMap::new()), + pics: RefCell::new(HashMap::new()), + can_retry: Cell::new(false), + connecting: Cell::new(false), + mock: Cell::new(false), + }); + + // Keyboard fallback — same event vocabulary through the same handler, so the launcher + // is fully drivable padless (dev VM, CI). + let key = gtk::EventControllerKey::new(); + key.set_propagation_phase(gtk::PropagationPhase::Capture); + { + let weak = Rc::downgrade(&state); + key.connect_key_pressed(move |_, keyval, _, _| { + let Some(state) = weak.upgrade() else { + return glib::Propagation::Proceed; + }; + use gtk::gdk::Key; + let ev = match keyval { + Key::Left => MenuEvent::Move(MenuDir::Left), + Key::Right => MenuEvent::Move(MenuDir::Right), + Key::Up => MenuEvent::Move(MenuDir::Up), + Key::Down => MenuEvent::Move(MenuDir::Down), + Key::Return | Key::KP_Enter | Key::space => MenuEvent::Confirm, + Key::Escape | Key::BackSpace => MenuEvent::Back, + Key::Page_Up => MenuEvent::JumpBack, + Key::Page_Down => MenuEvent::JumpForward, + _ => return glib::Propagation::Proceed, + }; + handle_menu_event(&state, ev); + glib::Propagation::Stop + }); + } + root.add_controller(key); + { + let root = root.clone(); + root.clone().connect_map(move |_| { + root.grab_focus(); + }); + } + + // The aurora area resizes with the page — piggyback the carousel relayout on it + // (gtk::Fixed has no resize signal of its own). + if let Some(aurora) = root.child().and_downcast::() { + let weak = Rc::downgrade(&state); + aurora.connect_resize(move |_, _, _| { + if let Some(state) = weak.upgrade() { + kick_anim(&state); + } + }); + } + + update_chip(&state); + // The chip tracks pad hot-plug lazily — nothing else needs a poll. + { + let weak = Rc::downgrade(&state); + glib::timeout_add_seconds_local(2, move || match weak.upgrade() { + Some(state) => { + update_chip(&state); + glib::ControlFlow::Continue + } + None => glib::ControlFlow::Break, + }); + } + + Rc::new(LauncherUi { page, state }) +} + +/// Menu events from the gamepad worker → the same handler the keyboard feeds. +fn attach_menu_input(state: &Rc) { + let rx = state.app.gamepad.menu_events(); + let weak = Rc::downgrade(state); + glib::spawn_future_local(async move { + while let Ok(ev) = rx.recv().await { + let Some(state) = weak.upgrade() else { break }; + handle_menu_event(&state, ev); + } + }); +} + +fn current_scene(state: &State) -> Option { + state.stack.visible_child_name() +} + +/// Route one menu action by scene. Parked while a connect is in flight or a stream page +/// owns the app (`busy` — the worker also stops translating once attached; this covers +/// the connect window before attach). +fn handle_menu_event(state: &Rc, ev: MenuEvent) { + if state.app.busy.get() || state.connecting.get() { + return; + } + match current_scene(state).as_deref() { + Some("carousel") => match ev { + MenuEvent::Move(MenuDir::Left) => step(state, -1, false), + MenuEvent::Move(MenuDir::Right) => step(state, 1, false), + MenuEvent::JumpBack => step(state, -JUMP, true), + MenuEvent::JumpForward => step(state, JUMP, true), + MenuEvent::Confirm => launch_selected(state), + MenuEvent::Back => quit(state), + // Single row: up/down are neither moves nor boundaries (Apple parity). + MenuEvent::Move(_) | MenuEvent::Secondary | MenuEvent::Tertiary => {} + }, + Some("error") => match ev { + MenuEvent::Confirm if state.can_retry.get() => load(state), + MenuEvent::Back => quit(state), + _ => {} + }, + Some("empty" | "loading") if ev == MenuEvent::Back => quit(state), + _ => {} + } +} + +/// One semi-implicit-Euler step of a damped spring toward `target`: acceleration from +/// displacement and damping, velocity integrated before position — the standard stable +/// discretization. +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 (unit-tested +/// for convergence and long-frame stability). A stalled frame (dt clamped to 50 ms) would +/// put the stiff bump spring at ω·dt ≈ 1.2 — inside the integrator's formal stability +/// bound but distorted enough to ring for ages; substeps keep ω·dt ≈ 0.2, so the motion +/// feels identical at any frame rate. +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 (unit-tested): `clamp` lands jumps on the ends, +/// a plain step refuses to leave them. +#[derive(Debug, PartialEq, Eq)] +enum StepResult { + Moved(i32), + Boundary, +} + +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) + } +} + +fn step(state: &Rc, delta: i32, clamp: bool) { + let len = state.games.borrow().len(); + match step_cursor(state.cursor.get(), len, delta, clamp) { + StepResult::Moved(to) => { + state.cursor.set(to); + state.app.gamepad.menu_rumble(MenuPulse::Move); + update_detail(state); + restack(state); + kick_anim(state); + } + StepResult::Boundary => { + // Recoil against the push: advancing shifts cards left, so a refused + // right-push deflects left (and vice versa); the bump spring wobbles it back. + state.bump.set(-BUMP_PX * f64::from(delta.signum())); + state.bump_vel.set(0.0); + state.app.gamepad.menu_rumble(MenuPulse::Boundary); + kick_anim(state); + } + } +} + +/// A on the focused poster: request the session with the library id riding the Hello. +/// Direct `launch::start_session` — trust is already established (browse requires the +/// stored pin) and every other `initiate_connect` branch opens a dialog gamescope can't map. +fn launch_selected(state: &Rc) { + if state.mock.get() { + return; + } + let (id, title) = { + let games = state.games.borrow(); + let Some(g) = games.get(state.cursor.get() as usize) else { + return; + }; + (g.id.clone(), g.title.clone()) + }; + state.app.gamepad.menu_rumble(MenuPulse::Confirm); + state.status.set_visible(false); + state.connecting.set(true); + show_scene(state, "carousel"); + let mut req = state.req.clone(); + req.launch = Some((id, title)); + let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); + crate::launch::start_session(state.app.clone(), req, pin); +} + +/// B at the launcher root: the app IS the "game" — closing it returns the Deck to +/// Gaming Mode (same mechanism as `quit_on_session_end`). +fn quit(state: &Rc) { + state.app.window.close(); +} + +/// Fetch the library off the main thread and route the result into a scene (the +/// `ui_library::load` pattern). `PUNKTFUNK_FAKE_LIBRARY=` short-circuits with +/// entries from disk — the GPU-less/pairing-less dev path. +fn load(state: &Rc) { + if state.mock.get() { + return; + } + if let Ok(path) = std::env::var("PUNKTFUNK_FAKE_LIBRARY") { + load_fake(state, &path); + return; + } + if !state.paired { + return; + } + show_scene(state, "loading"); + let addr = state.req.addr.clone(); + let port = state.mgmt_port; + let identity = state.app.identity.clone(); + let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); + let (tx, rx) = async_channel::bounded(1); + std::thread::Builder::new() + .name("punktfunk-library".into()) + .spawn(move || { + let _ = tx.send_blocking(library::fetch_games(&addr, port, &identity, pin)); + }) + .expect("spawn library thread"); + let weak = Rc::downgrade(state); + glib::spawn_future_local(async move { + let Ok(result) = rx.recv().await else { return }; + let Some(state) = weak.upgrade() else { return }; + match result { + Ok(games) if games.is_empty() => show_scene(&state, "empty"), + Ok(games) => { + state.cursor.set(0); + *state.games.borrow_mut() = games; + render(&state); + show_scene(&state, "carousel"); + load_art(&state); + } + Err(e) => { + state.error_title.set_text("Couldn't load the library"); + state.error_body.set_text(&e.to_string()); + state.can_retry.set(true); + show_scene(&state, "error"); + } + } + }); +} + +/// Dev hook: entries from a JSON file; portrait paths starting with `/` load from disk. +fn load_fake(state: &Rc, path: &str) { + let games: Vec = std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + if games.is_empty() { + show_scene(state, "empty"); + return; + } + { + let mut art = state.art.borrow_mut(); + for g in &games { + if let Some(p) = g.art.portrait.as_deref().filter(|p| p.starts_with('/')) { + if let Ok(bytes) = std::fs::read(p) { + if let Ok(tex) = gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes)) { + art.insert(g.id.clone(), tex); + } + } + } + } + } + state.cursor.set(0); + *state.games.borrow_mut() = games; + render(state); + show_scene(state, "carousel"); +} + +/// (Re)build the poster cards from the current games snapshot and lay them out. +fn render(state: &Rc) { + while let Some(child) = state.fixed.first_child() { + state.fixed.remove(&child); + } + state.pics.borrow_mut().clear(); + let games = state.games.borrow(); + let mut cards = Vec::with_capacity(games.len()); + for game in games.iter() { + let card = build_card(state, game); + state.fixed.put(&card.root, 0.0, 0.0); + cards.push(card); + } + drop(games); + *state.cards.borrow_mut() = cards; + update_detail(state); + // Snap the sprung position onto the cursor — a fresh render has no old position to + // animate from. + state.anim_pos.set(f64::from(state.cursor.get())); + state.anim_vel.set(0.0); + restack(state); + kick_anim(state); +} + +/// One coverflow card: monogram placeholder → async poster → store badge, plus the black +/// `dim` veil whose opacity implements the brightness recede. +fn build_card(state: &Rc, game: &GameEntry) -> Card { + let monogram = gtk::Label::new(Some(&crate::ui_library::initials(&game.title))); + monogram.add_css_class("pf-poster-monogram"); + monogram.set_halign(gtk::Align::Center); + monogram.set_valign(gtk::Align::Center); + let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0); + placeholder.append(&monogram); + monogram.set_vexpand(true); + + let pic = gtk::Picture::new(); + pic.set_content_fit(gtk::ContentFit::Cover); + if let Some(tex) = state.art.borrow().get(&game.id) { + pic.set_paintable(Some(tex)); + } + state.pics.borrow_mut().insert(game.id.clone(), pic.clone()); + + let badge = gtk::Label::new(Some(crate::ui_library::store_label(&game.store))); + badge.add_css_class("pf-pill"); + badge.add_css_class("pf-store-badge"); + badge.set_halign(gtk::Align::Start); + badge.set_valign(gtk::Align::Start); + badge.set_margin_start(8); + badge.set_margin_top(8); + + let dim = gtk::Box::new(gtk::Orientation::Vertical, 0); + dim.add_css_class("pf-gl-dim"); + dim.set_opacity(0.0); + dim.set_can_target(false); + + let root = gtk::Overlay::new(); + root.set_child(Some(&placeholder)); + root.add_overlay(&pic); + root.add_overlay(&badge); + root.add_overlay(&dim); + root.add_css_class("pf-gl-poster"); + root.set_overflow(gtk::Overflow::Hidden); + root.set_size_request(POSTER_W, POSTER_H); + Card { root, dim } +} + +/// Fetch poster art for every uncached entry (shared worker pool) and texture the cards +/// as results land. +fn load_art(state: &Rc) { + let base = library::base_url(&state.req.addr, state.mgmt_port); + let jobs: VecDeque<(String, Vec)> = { + let cache = state.art.borrow(); + state + .games + .borrow() + .iter() + .filter(|g| !cache.contains_key(&g.id)) + .map(|g| (g.id.clone(), g.art.poster_candidates(&base))) + .filter(|(_, candidates)| !candidates.is_empty()) + .collect() + }; + if jobs.is_empty() { + return; + } + let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); + let rx = library::spawn_art_fetch(base, state.app.identity.clone(), pin, jobs); + let weak = Rc::downgrade(state); + glib::spawn_future_local(async move { + while let Ok((id, bytes)) = rx.recv().await { + let Some(state) = weak.upgrade() else { break }; + match gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes)) { + Ok(tex) => { + if let Some(pic) = state.pics.borrow().get(&id) { + pic.set_paintable(Some(&tex)); + } + state.art.borrow_mut().insert(id, tex); + } + Err(e) => tracing::debug!(%id, error = %e, "undecodable poster"), + } + } + }); +} + +/// The focused title + store tag under the strip — updated synchronously on every move +/// (the scroll chases; Apple parity). Single spaces keep the layout from jumping when +/// there's nothing to show. +fn update_detail(state: &State) { + let games = state.games.borrow(); + match games.get(state.cursor.get() as usize) { + Some(g) => { + state.detail_title.set_text(&g.title); + state + .detail_store + .set_text(&crate::ui_library::store_label(&g.store).to_uppercase()); + } + None => { + state.detail_title.set_text(" "); + state.detail_store.set_text(" "); + } + } +} + +fn update_chip(state: &State) { + match state.app.gamepad.active() { + Some(p) => state.chip.set_text(&p.name), + None => state.chip.set_text("No controller — keyboard works too"), + } +} + +/// Swap the visible scene and set its hint bar. Hints double as the connect affordance. +fn show_scene(state: &Rc, scene: &str) { + if !scene.is_empty() { + state.stack.set_visible_child_name(scene); + } + let hints = &state.hints; + while let Some(c) = hints.first_child() { + hints.remove(&c); + } + if state.connecting.get() { + let spinner = gtk::Spinner::new(); + spinner.start(); + hints.append(&spinner); + let label = gtk::Label::new(Some("Connecting…")); + label.add_css_class("pf-gl-hint"); + hints.append(&label); + return; + } + let items: &[(&str, &str)] = match current_scene(state).as_deref() { + Some("carousel") => &[("A", "Play"), ("B", "Quit"), ("L1 · R1", "Jump")], + Some("error") if state.can_retry.get() => &[("A", "Retry"), ("B", "Quit")], + _ => &[("B", "Quit")], + }; + for (glyph, text) in items { + let g = gtk::Label::new(Some(glyph)); + g.add_css_class("pf-gl-glyph"); + let t = gtk::Label::new(Some(text)); + t.add_css_class("pf-gl-hint"); + let pair = gtk::Box::new(gtk::Orientation::Horizontal, 8); + pair.append(&g); + pair.append(&t); + hints.append(&pair); + } +} + +/// A connect failure with the carousel on screen: an inline strip, cleared after a few +/// seconds (never a dialog). +fn show_transient_error(state: &Rc, msg: &str) { + state.status.set_text(msg); + state.status.set_visible(true); + let weak = Rc::downgrade(state); + glib::timeout_add_seconds_local_once(6, move || { + if let Some(state) = weak.upgrade() { + state.status.set_visible(false); + } + }); +} + +// ---- Carousel layout + animation ------------------------------------------------------ + +/// Start (or fast-path) the layout animation on a frame-clock tick: two damped springs — +/// the strip position chasing the cursor and the boundary bump returning to rest — and +/// the tick uninstalls itself once both settle, so the launcher idles at zero layout +/// work. Retargeting mid-flight just moves the spring's goal; velocity carries over, so +/// held-repeat scrolling glides instead of restarting a curve every step. Always +/// tick-driven (even with animations off): the first kick usually lands before +/// `gtk::Fixed` has an allocation (built → rendered → THEN pushed/mapped), so the tick +/// waits for a real size instead of laying out into 0×0 and leaving every card stacked +/// at the origin. +fn kick_anim(state: &Rc) { + if state.anim_active.replace(true) { + return; // tick already running — it picks the new target up + } + state.last_tick.set(0); + let weak = Rc::downgrade(state); + state.fixed.add_tick_callback(move |_, clock| { + let Some(state) = weak.upgrade() else { + return glib::ControlFlow::Break; + }; + if state.scroller.width() <= 0 || state.scroller.height() <= 0 { + return glib::ControlFlow::Continue; // not allocated yet — wait, don't settle + } + if !state.animations { + state.anim_pos.set(f64::from(state.cursor.get())); + state.anim_vel.set(0.0); + state.bump.set(0.0); + state.bump_vel.set(0.0); + relayout(&state); + state.anim_active.set(false); + return glib::ControlFlow::Break; + } + let now = clock.frame_time(); + let last = state.last_tick.replace(now); + // Clamped well inside the semi-implicit integrator's stability bound (ω·dt < 2; + // the stiffer spring has ω ≈ 24.5 → dt must stay < 80 ms). + let dt = if last == 0 { + 1.0 / 60.0 + } else { + ((now - last) as f64 / 1e6).clamp(0.0, 0.05) + }; + let target = f64::from(state.cursor.get()); + let (mut pos, mut vel) = spring_advance( + state.anim_pos.get(), + state.anim_vel.get(), + target, + SPRING_K, + SPRING_C, + dt, + ); + if (target - pos).abs() < 0.001 && vel.abs() < 0.01 { + pos = target; + vel = 0.0; + } + state.anim_pos.set(pos); + state.anim_vel.set(vel); + let (mut bump, mut bvel) = spring_advance( + state.bump.get(), + state.bump_vel.get(), + 0.0, + BUMP_K, + BUMP_C, + dt, + ); + if bump.abs() < 0.3 && bvel.abs() < 4.0 { + bump = 0.0; + bvel = 0.0; + } + state.bump.set(bump); + state.bump_vel.set(bvel); + relayout(&state); + if pos == target && bump == 0.0 { + state.anim_active.set(false); + glib::ControlFlow::Break + } else { + glib::ControlFlow::Continue + } + }); +} + +/// Re-stack the strip's paint order so cards CLOSER to the (integer) cursor draw on top — +/// the dense side stacks overlap and `gtk::Fixed` paints in child order. Runs on cursor +/// changes only (not per frame); re-inserting an existing child just repositions it in +/// the widget list, layout properties (position/transform) are untouched. +fn restack(state: &State) { + let cards = state.cards.borrow(); + if cards.is_empty() { + return; + } + let cur = state.cursor.get(); + let mut order: Vec = (0..cards.len()).collect(); + // Farthest first (painted first = bottom); stable, so the equidistant left/right + // neighbors keep a deterministic order (they never overlap each other anyway). + order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - cur).abs())); + let mut prev: Option = None; + for &i in &order { + let w: gtk::Widget = cards[i].root.clone().upcast(); + w.insert_after(&state.fixed, prev.as_ref()); + prev = Some(w); + } +} + +/// Place every card from the eased position: center-focused scale/opacity recede, the +/// whole strip offset by the decaying boundary bump. Off-strip cards are hidden. +/// Centering is on the VIEWPORT (the scroller), not the Fixed — the Fixed's own +/// allocation grows with its transformed children (see `State::scroller`). +fn relayout(state: &State) { + let w = f64::from(state.scroller.width()); + let h = f64::from(state.scroller.height()); + if w <= 0.0 || h <= 0.0 { + return; + } + let pos = state.anim_pos.get(); + let bump = state.bump.get(); + for (i, card) in state.cards.borrow().iter().enumerate() { + let d = i as f64 - pos; + let a = d.abs(); + if a > VISIBLE_RANGE { + card.root.set_visible(false); + continue; + } + card.root.set_visible(true); + let prox = a.min(1.0); + let scale = 1.0 - prox * RECEDE_SCALE; + // Coverflow tilt: side cards face the corridor (inner edge receding behind the + // focus), flipping through flat exactly at the focus point. Rotation is about the + // card's own vertical center axis, so the transform moves the origin to the card + // center first and draws centered last. + let angle = -d.clamp(-1.0, 1.0) * ROTATE_DEG; + // Piecewise strip layout: a full FOCUS_GAP around the focused card, then the + // dense side stacks (the classic coverflow shelf). + let offset = if a <= 1.0 { + d * FOCUS_GAP + } else { + d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) + }; + let cx = w / 2.0 + offset + bump; + let cy = h / 2.0; + card.dim.set_opacity(prox * RECEDE_DIM); + let transform = gsk::Transform::new() + .translate(&graphene::Point::new(cx as f32, cy as f32)) + .perspective(PERSPECTIVE) + .rotate_3d(angle as f32, &graphene::Vec3::y_axis()) + .scale(scale as f32, scale as f32) + .translate(&graphene::Point::new( + -(POSTER_W as f32) / 2.0, + -(POSTER_H as f32) / 2.0, + )); + state + .fixed + .set_child_transform(&card.root, Some(&transform)); + } +} + +// ---- Aurora backdrop ------------------------------------------------------------------- + +/// Low-res render target for the blob field — radial gradients in software are cheap at +/// 256×160 (< 1 ms) and the bilinear upscale is exactly what a blurry gradient field wants. +const AURORA_W: i32 = 256; +const AURORA_H: i32 = 160; + +/// One drifting color blob (the Swift `GamepadScreenBackground` table, verbatim): base +/// position + drift ellipse in unit coordinates, angular speeds in rad/s (30–90 s +/// periods), a breathing radius (fraction of the larger dimension), and the layer opacity. +struct Blob { + rgb: (f64, f64, f64), + center: (f64, f64), + drift: (f64, f64), + speed: (f64, f64), + phase: (f64, f64), + radius: f64, + breathe: (f64, f64), + 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. +const BLOBS: [Blob; 4] = [ + Blob { + rgb: (0.53, 0.47, 0.96), // brand violet + 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), // deep indigo + 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), // plum + 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), // cool blue + 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, + }, +]; + +/// Animations are wanted unless the desktop disabled them or a screenshot scene needs a +/// deterministic frame (then the field renders once, frozen at t = 0 — reduce-motion parity). +fn animations_enabled() -> bool { + crate::cli::shot_scene().is_none() + && gtk::Settings::default().is_none_or(|s| s.is_gtk_enable_animations()) +} + +/// The full-bleed aurora: a DrawingArea re-rendered at ~30 Hz off the frame clock (the +/// Swift TimelineView cadence — drift is centimeters per minute, display rate would be +/// wasted heat on a couch device). +fn build_aurora() -> gtk::DrawingArea { + let area = gtk::DrawingArea::new(); + area.set_hexpand(true); + area.set_vexpand(true); + let t = Rc::new(Cell::new(0.0f64)); + let cache = RefCell::new(None::); + { + let t = t.clone(); + area.set_draw_func(move |_, cr, w, h| draw_aurora(cr, w, h, t.get(), &cache)); + } + if animations_enabled() { + let start = Cell::new(0i64); + let last = Cell::new(0i64); + area.add_tick_callback(move |area, clock| { + let now = clock.frame_time(); + if start.get() == 0 { + start.set(now); + } + if now - last.get() >= 33_000 { + last.set(now); + t.set((now - start.get()) as f64 / 1e6); + area.queue_draw(); + } + glib::ControlFlow::Continue + }); + } + area +} + +/// Black → additive blob field → legibility scrim, composed at low res and blitted +/// scaled. The scrim keeps the title (top) and detail/hints (bottom) on near-black +/// whatever the blobs are doing behind them. +fn draw_aurora( + cr: &cairo::Context, + w: i32, + h: i32, + t: f64, + cache: &RefCell>, +) { + let mut cached = cache.borrow_mut(); + if cached.is_none() { + *cached = cairo::ImageSurface::create(cairo::Format::ARgb32, AURORA_W, AURORA_H).ok(); + } + let Some(surf) = cached.as_ref() else { + let _ = cr.save(); + cr.set_source_rgb(0.0, 0.0, 0.0); + let _ = cr.paint(); + let _ = cr.restore(); + return; + }; + { + let Ok(c) = cairo::Context::new(surf) else { + return; + }; + let (fw, fh) = (f64::from(AURORA_W), f64::from(AURORA_H)); + let side = fw.max(fh); + c.set_source_rgb(0.0, 0.0, 0.0); + let _ = c.paint(); + c.set_operator(cairo::Operator::Add); + for b in &BLOBS { + let x = (b.center.0 + b.drift.0 * (t * b.speed.0 + b.phase.0).sin()) * fw; + let y = (b.center.1 + b.drift.1 * (t * b.speed.1 + b.phase.1).cos()) * fh; + let r = side * b.radius * (1.0 + b.breathe.0 * (t * b.breathe.1 + b.phase.0).sin()); + let g = cairo::RadialGradient::new(x, y, 0.0, x, y, r / 2.0); + g.add_color_stop_rgba(0.0, b.rgb.0, b.rgb.1, b.rgb.2, b.opacity); + g.add_color_stop_rgba(1.0, b.rgb.0, b.rgb.1, b.rgb.2, 0.0); + let _ = c.set_source(&g); + let _ = c.paint(); + } + c.set_operator(cairo::Operator::Over); + let scrim = cairo::LinearGradient::new(0.0, 0.0, 0.0, fh); + scrim.add_color_stop_rgba(0.0, 0.0, 0.0, 0.0, 0.55); + scrim.add_color_stop_rgba(0.35, 0.0, 0.0, 0.0, 0.15); + scrim.add_color_stop_rgba(0.65, 0.0, 0.0, 0.0, 0.20); + scrim.add_color_stop_rgba(1.0, 0.0, 0.0, 0.0, 0.60); + let _ = c.set_source(&scrim); + let _ = c.paint(); + } + let _ = cr.save(); + cr.scale( + f64::from(w) / f64::from(AURORA_W), + f64::from(h) / f64::from(AURORA_H), + ); + let _ = cr.set_source_surface(surf, 0.0, 0.0); + let _ = cr.paint(); + let _ = cr.restore(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn step_refuses_the_ends() { + assert_eq!(step_cursor(0, 6, -1, false), StepResult::Boundary); + assert_eq!(step_cursor(5, 6, 1, false), StepResult::Boundary); + assert_eq!(step_cursor(0, 6, 1, false), StepResult::Moved(1)); + assert_eq!(step_cursor(5, 6, -1, false), StepResult::Moved(4)); + } + + #[test] + fn jump_clamps_onto_the_ends() { + assert_eq!(step_cursor(1, 6, -5, true), StepResult::Moved(0)); + assert_eq!(step_cursor(4, 6, 5, true), StepResult::Moved(5)); + // Already at the end: a clamped jump is a boundary, not a no-op move. + assert_eq!(step_cursor(0, 6, -5, true), StepResult::Boundary); + assert_eq!(step_cursor(5, 6, 5, true), StepResult::Boundary); + } + + #[test] + fn empty_list_is_all_boundary() { + assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary); + assert_eq!(step_cursor(0, 0, -5, true), StepResult::Boundary); + } + + /// Drive a spring from rest at 0 toward 1 and report (settle step, peak position). + fn run_spring(k: f64, c: f64, dt: f64, max_steps: usize) -> (Option, f64) { + let (mut pos, mut vel) = (0.0f64, 0.0f64); + let mut peak = 0.0f64; + for i in 0..max_steps { + (pos, vel) = spring_advance(pos, vel, 1.0, k, c, dt); + peak = peak.max(pos); + if (1.0 - pos).abs() < 0.001 && vel.abs() < 0.01 { + return (Some(i), peak); + } + } + (None, peak) + } + + #[test] + fn cursor_spring_settles_fast_without_visible_overshoot() { + let (settled, peak) = run_spring(SPRING_K, SPRING_C, 1.0 / 60.0, 600); + let steps = settled.expect("spring never settled"); + // ~0.3 s at 60 Hz; ζ = 0.85's theoretical overshoot (~0.6 %) is under the settle + // epsilon, so only bound it — the springy character comes from velocity carry. + assert!(steps < 45, "settled in {steps} frames (> 0.75 s)"); + assert!(peak < 1.05, "overshoot too big: {peak}"); + } + + #[test] + fn springs_converge_at_the_clamped_max_frame() { + // dt is clamped to 50 ms in the tick; spring_advance's substeps must keep both + // parameter sets convergent and bounded there (a raw 50 ms step would leave the + // stiff bump spring ringing at ω·dt ≈ 1.2). + for (k, c) in [(SPRING_K, SPRING_C), (BUMP_K, BUMP_C)] { + let (settled, peak) = run_spring(k, c, 0.05, 600); + assert!(settled.is_some(), "k={k}: no convergence at dt=0.05"); + assert!(peak < 1.2, "k={k}: unstable at dt=0.05 (peak {peak})"); + } + } + + #[test] + fn bump_spring_returns_to_rest_from_a_deflection() { + // The boundary recoil starts displaced (±16 px) at zero velocity and must die out. + let (mut pos, mut vel) = (-BUMP_PX, 0.0f64); + for _ in 0..600 { + (pos, vel) = spring_advance(pos, vel, 0.0, BUMP_K, BUMP_C, 1.0 / 60.0); + if pos.abs() < 0.3 && vel.abs() < 4.0 { + return; + } + } + panic!("bump never settled (pos {pos}, vel {vel})"); + } +} diff --git a/clients/linux/src/ui_library.rs b/clients/linux/src/ui_library.rs index 59d4cff..6d5adc9 100644 --- a/clients/linux/src/ui_library.rs +++ b/clients/linux/src/ui_library.rs @@ -14,11 +14,6 @@ use gtk::{gdk, glib}; use std::cell::{Cell, RefCell}; use std::collections::{HashMap, VecDeque}; use std::rc::Rc; -use std::sync::{Arc, Mutex}; - -/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a -/// big library into a connection burst. -const ART_WORKERS: usize = 3; /// Everything the page re-renders from. Kept alive by the widget closures (reload/retry/ /// card activation); dropped when the page is popped, which also winds down any in-flight @@ -295,39 +290,7 @@ fn load_art(state: &Rc, games: &[GameEntry]) { } let identity = state.app.identity.clone(); let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); - let queue = Arc::new(Mutex::new(jobs)); - let (tx, rx) = async_channel::unbounded::<(String, Vec)>(); - for _ in 0..ART_WORKERS { - let queue = queue.clone(); - let tx = tx.clone(); - let base = base.clone(); - let identity = identity.clone(); - std::thread::Builder::new() - .name("punktfunk-lib-art".into()) - .spawn(move || { - let Ok(agent) = library::agent(&identity, pin) else { - return; - }; - loop { - let job = queue.lock().unwrap().pop_front(); - let Some((id, candidates)) = job else { break }; - for url in &candidates { - match library::fetch_art(&agent, &base, url) { - Ok(bytes) => { - // Receiver gone (page popped) — stop fetching. - if tx.send_blocking((id, bytes)).is_err() { - return; - } - break; - } - // 404 on a guessed CDN path is routine — try the next kind. - Err(e) => tracing::debug!(%id, url, error = %e, "poster miss"), - } - } - } - }) - .expect("spawn art thread"); - } + let rx = library::spawn_art_fetch(base, identity, pin, jobs); let weak = Rc::downgrade(state); glib::spawn_future_local(async move { while let Ok((id, bytes)) = rx.recv().await { @@ -349,7 +312,8 @@ fn load_art(state: &Rc, games: &[GameEntry]) { /// The store badge text — `store` comes from the entry (today `steam`/`custom`; future /// stores per the host's provider list), with the id prefix as a fallback spelling. -fn store_label(store: &str) -> &'static str { +/// Shared with the gamepad launcher's posters. +pub fn store_label(store: &str) -> &'static str { match store { "steam" => "Steam", "custom" => "Custom", @@ -363,7 +327,8 @@ fn store_label(store: &str) -> &'static str { } /// Monogram for the placeholder tile: the first letters of the first two words. -fn initials(title: &str) -> String { +/// Shared with the gamepad launcher's posters. +pub fn initials(title: &str) -> String { title .split_whitespace() .take(2) diff --git a/clients/linux/tools/screenshots.sh b/clients/linux/tools/screenshots.sh index 6979e75..8ca6e24 100755 --- a/clients/linux/tools/screenshots.sh +++ b/clients/linux/tools/screenshots.sh @@ -27,7 +27,7 @@ GEOMETRY="${GEOMETRY:-1380x860x24}" SETTLE="${SETTLE:-1.2}" SHOT_DISPLAY="${SHOT_DISPLAY:-:99}" -if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair addhost shortcuts library); fi +if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair addhost shortcuts library gamepad-library); fi [ -x "$BIN" ] || { echo "client binary not found: $BIN (build it first: cargo build --release -p punktfunk-client-linux)" >&2