57ae00a9c8
ci / rust (push) Failing after 41s
apple / swift (push) Successful in 1m8s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m20s
deb / build-publish (push) Successful in 2m55s
decky / build-publish (push) Successful in 27s
apple / screenshots (push) Successful in 5m46s
ci / bench (push) Successful in 5m5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
GTK Linux client: - hosts/library: clicking a card was dead — the handler was on FlowBoxChild::activate (never emitted on click); bridge child-activated → child.activate() on the FlowBox (ui_hosts, ui_library). - stream: the Ctrl+Alt+Shift+D/Q/S chords (and all key forwarding) were dropped because the key controller sat on the overlay, which loses focus to the header back button after nav.push+fullscreen — move it to the window and remove it on teardown. - video: a mid-session VAAPI decode error rebuilt a software decoder but never requested a keyframe, so under the infinite GOP the picture stayed gray/frozen forever. Request an IDR on any VAAPI error, keep the hardware decoder, and demote to software only after repeated failures. - stream: fix a per-session Capture↔overlay reference cycle that leaked the overlay subtree + the Arc<NativeClient> on every session end — hold the overlay weakly. - stream: accumulate the fractional wheel remainder so precision-scroll (Deck trackpad / hi-res wheels) sub-unit deltas aren't dropped. - gamepad library: keep the launcher smooth on the Deck — freeze the aurora and trim the visible card range (fewer 3D offscreen passes) on low-power. - gamepad: log full pad identity (vid:pid:name:type:virtual) on attach to diagnose an empty controller list on the Deck. - cli: --connect host:<badport> silently did nothing; default to 9777 + warn. - css: add the missing .pf-neutral pill rule; fix the clipped most-recent accent (inset outline instead of a corner-clipped box-shadow bar). Decky plugin: - surface the on-screen library browser: label the host-row Games button. - fix silent pin data-loss — the detached Games modal captured a frozen pins array, so pinning a second game clobbered the first; mirror pins in a ref and track the modal's pinned ids locally for a live label. - route pair-required hosts through the pairing modal from the fullscreen Stream button (parity with the QAM panel). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1215 lines
46 KiB
Rust
1215 lines
46 KiB
Rust
//! 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<App>,
|
||
/// 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<Vec<Card>>,
|
||
cursor: Cell<i32>,
|
||
anim_pos: Cell<f64>,
|
||
anim_vel: Cell<f64>,
|
||
bump: Cell<f64>,
|
||
bump_vel: Cell<f64>,
|
||
anim_active: Cell<bool>,
|
||
last_tick: Cell<i64>,
|
||
animations: bool,
|
||
/// Deck (or any low-power box): shrink the per-frame GPU work so navigation stays smooth
|
||
/// — fewer laid-out cards (fewer 3D offscreen passes) and a frozen aurora (no 30 Hz
|
||
/// full-screen CPU upscale + multi-MB texture upload contending for the iGPU's shared
|
||
/// bandwidth). The Deck iGPU otherwise drops to ~16 fps mid-navigation.
|
||
low_power: 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<Vec<GameEntry>>,
|
||
/// Poster cache (entry id → texture) — survives re-renders without refetching.
|
||
art: RefCell<HashMap<String, gdk::Texture>>,
|
||
/// The Picture each entry currently renders into, so async art lands on the right card.
|
||
pics: RefCell<HashMap<String, gtk::Picture>>,
|
||
/// The error scene's A action retries the fetch (fetch errors only — not unpaired).
|
||
can_retry: Cell<bool>,
|
||
/// A connect is in flight — hint bar shows the spinner, menu input is parked.
|
||
connecting: Cell<bool>,
|
||
/// Screenshot mode: render injected entries only, never touch network or gamepads.
|
||
mock: Cell<bool>,
|
||
}
|
||
|
||
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<State>,
|
||
}
|
||
|
||
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<App>, req: ConnectRequest, paired: bool, mgmt_port: u16) -> Rc<LauncherUi> {
|
||
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<App>,
|
||
req: ConnectRequest,
|
||
games: Vec<GameEntry>,
|
||
art: Vec<(String, gdk::Texture)>,
|
||
) -> Rc<LauncherUi> {
|
||
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<App>, req: ConnectRequest, paired: bool, mgmt_port: u16) -> Rc<LauncherUi> {
|
||
// 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 low_power = crate::gamepad::is_steam_deck();
|
||
let root = gtk::Overlay::new();
|
||
root.add_css_class("pf-gl-page");
|
||
// On the Deck the animated aurora's per-frame CPU upscale + texture upload starves the
|
||
// coverflow of iGPU bandwidth — freeze it (drift is centimeters/minute, unnoticeable).
|
||
root.set_child(Some(&build_aurora(low_power)));
|
||
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(),
|
||
low_power,
|
||
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::<gtk::DrawingArea>() {
|
||
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<State>) {
|
||
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<glib::GString> {
|
||
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<State>, 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<State>, 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<State>) {
|
||
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>) {
|
||
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=<file.json>` short-circuits with
|
||
/// entries from disk — the GPU-less/pairing-less dev path.
|
||
fn load(state: &Rc<State>) {
|
||
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<State>, path: &str) {
|
||
let games: Vec<GameEntry> = 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<State>) {
|
||
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<State>, 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<State>) {
|
||
let base = library::base_url(&state.req.addr, state.mgmt_port);
|
||
let jobs: VecDeque<(String, Vec<String>)> = {
|
||
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<State>, 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<State>, 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<State>) {
|
||
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<usize> = (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<gtk::Widget> = 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();
|
||
// Each laid-out side card is a non-affine (perspective + rotate_3d) transform, which GSK
|
||
// renders through its own offscreen pass — so the visible count is the per-frame GPU cost.
|
||
// Trim it hard on the Deck; desktop keeps the full deep shelf.
|
||
let range = if state.low_power { 3.0 } else { VISIBLE_RANGE };
|
||
for (i, card) in state.cards.borrow().iter().enumerate() {
|
||
let d = i as f64 - pos;
|
||
let a = d.abs();
|
||
if a > 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(low_power: bool) -> 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::<cairo::ImageSurface>);
|
||
{
|
||
let t = t.clone();
|
||
area.set_draw_func(move |_, cr, w, h| draw_aurora(cr, w, h, t.get(), &cache));
|
||
}
|
||
// Deck: render once, frozen — the 30 Hz tick's CPU upscale + texture upload is the
|
||
// bandwidth cost that starves the coverflow. Desktop keeps the live drift.
|
||
if animations_enabled() && !low_power {
|
||
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<Option<cairo::ImageSurface>>,
|
||
) {
|
||
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<usize>, 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})");
|
||
}
|
||
}
|