feat(console): full gamepad shell — hosts, PIN pairing, settings, OSK, screen transitions
apple / swift (push) Successful in 1m6s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
decky / build-publish (push) Successful in 25s
flatpak / build-publish (push) Successful in 4m31s
apple / swift (push) Successful in 1m6s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
decky / build-publish (push) Successful in 25s
flatpak / build-publish (push) Successful in 4m31s
The Skia console UI grows from the single library coverflow into a complete couch shell (Apple gamepad-UI parity), so a Deck/gamescope session is self-sufficient end to end: - Home: host carousel (saved + discovered + Add Host tile) with presence pips, paired locks, wake & connect; A/Y/X/B per the Apple launcher. - In-console SPAKE2 PIN pairing (previously needed the Decky plugin or a desktop), add-host with a controller keyboard, couch settings over the shared Settings store, wake-on-LAN overlay with retry, cancelable dials. - Shell chrome: screen stack with push/pop entrance/exit choreography (slide + fade + recede; aurora↔form backdrop crossfade), per-pad button glyphs (PS shapes vs ABXY, keycaps with no pad), menu haptics, toasts, embedded Geist typography, in-stream start banner. - Steam Deck: our OSK never draws — fields start SDL text input (the new Overlay::text_input_active hook) and Steam's keyboard types; a hint chip points at STEAM + X. Other Linux gets the full gamepad keyboard tray. - punktfunk-session: bare --browse opens Home; --browse host opens that host's library with Home behind it (Decky launches keep working, B now pops instead of quitting). Service threads run discovery, 10 s probes, pairing, wake loops, fetches, and known-hosts persistence. - Presenter contract: Launch actions carry the host, CancelConnect never engages a won race, pad kind/list ride FrameCtx, menu events flow while dialing so B can cancel. Every screen renders to PNG on CPU raster for review (PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui -- --ignored dump). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
//! The controller "Add Host" screen — three field rows (name / address / port) plus the
|
||||
//! Add action. A on a field opens the on-screen keyboard in a bottom tray (on a Steam
|
||||
//! Deck it opens SDL text input instead — Steam's own keyboard types, ours never
|
||||
//! draws), B peels one layer: keyboard first, then the screen. Field edits are live;
|
||||
//! hardware keyboards type straight into the focused field through SDL text input.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::ConsoleCmd;
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Field {
|
||||
Name,
|
||||
Address,
|
||||
Port,
|
||||
}
|
||||
|
||||
const FIELDS: [Field; 3] = [Field::Name, Field::Address, Field::Port];
|
||||
|
||||
pub(crate) struct AddHostScreen {
|
||||
list: MenuList,
|
||||
keyboard: Keyboard,
|
||||
name: String,
|
||||
address: String,
|
||||
port: String,
|
||||
editing: Option<Field>,
|
||||
}
|
||||
|
||||
impl AddHostScreen {
|
||||
pub(crate) fn new() -> AddHostScreen {
|
||||
AddHostScreen {
|
||||
list: MenuList::new(),
|
||||
keyboard: Keyboard::new(),
|
||||
name: String::new(),
|
||||
address: String::new(),
|
||||
port: "9777".into(),
|
||||
editing: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn editing(&self) -> bool {
|
||||
self.editing.is_some()
|
||||
}
|
||||
|
||||
fn can_add(&self) -> bool {
|
||||
!self.address.trim().is_empty() && self.port.parse::<u16>().is_ok_and(|p| p > 0)
|
||||
}
|
||||
|
||||
fn field_mut(&mut self, f: Field) -> &mut String {
|
||||
match f {
|
||||
Field::Name => &mut self.name,
|
||||
Field::Address => &mut self.address,
|
||||
Field::Port => &mut self.port,
|
||||
}
|
||||
}
|
||||
|
||||
fn charset(f: Field) -> Charset {
|
||||
match f {
|
||||
Field::Name => Charset::Free,
|
||||
Field::Address => Charset::Hostname,
|
||||
Field::Port => Charset::Digits,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one typed character to the editing field (charset + the port's 5-digit
|
||||
/// cap). Returns false when refused — the boundary thud.
|
||||
fn type_char(&mut self, ch: char) -> bool {
|
||||
let Some(f) = self.editing else { return false };
|
||||
if !permits(Self::charset(f), ch) {
|
||||
return false;
|
||||
}
|
||||
if f == Field::Port && self.field_mut(f).chars().count() >= 5 {
|
||||
return false;
|
||||
}
|
||||
self.field_mut(f).push(ch);
|
||||
true
|
||||
}
|
||||
|
||||
fn backspace(&mut self) -> bool {
|
||||
let Some(f) = self.editing else { return false };
|
||||
self.field_mut(f).pop().is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn text_input(&mut self, text: &str) {
|
||||
for ch in text.chars() {
|
||||
self.type_char(ch);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn edit_key(&mut self, sc: sdl3::keyboard::Scancode) -> bool {
|
||||
use sdl3::keyboard::Scancode as S;
|
||||
if self.editing.is_none() {
|
||||
return false;
|
||||
}
|
||||
match sc {
|
||||
S::Backspace => {
|
||||
self.backspace();
|
||||
true
|
||||
}
|
||||
S::Return | S::KpEnter | S::Escape => {
|
||||
self.editing = None;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if let Some(_field) = self.editing {
|
||||
if ctx.deck {
|
||||
// Steam's keyboard owns typing; the pad only closes the field.
|
||||
return match ev {
|
||||
MenuEvent::Back | MenuEvent::Confirm => {
|
||||
self.editing = None;
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let (msg, pulse) = self.keyboard.menu(ev);
|
||||
return match msg {
|
||||
KeyMsg::Type(c) => {
|
||||
if self.type_char(c) {
|
||||
Some(MenuPulse::Move)
|
||||
} else {
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
KeyMsg::Backspace => {
|
||||
if self.backspace() {
|
||||
Some(MenuPulse::Move)
|
||||
} else {
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
KeyMsg::Done => {
|
||||
self.editing = None;
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
KeyMsg::None => pulse,
|
||||
};
|
||||
}
|
||||
|
||||
if ev == MenuEvent::Back {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, FIELDS.len() + 1);
|
||||
match msg {
|
||||
ListMsg::Activate => {
|
||||
if self.list.cursor < FIELDS.len() {
|
||||
self.editing = Some(FIELDS[self.list.cursor]);
|
||||
} else if self.can_add() {
|
||||
fx.cmds.push(ConsoleCmd::SaveHost {
|
||||
name: self.name.trim().to_string(),
|
||||
addr: self.address.trim().to_string(),
|
||||
port: self.port.parse().unwrap_or(9777),
|
||||
});
|
||||
fx.toast = Some(format!("Added {}", self.address.trim()));
|
||||
fx.pop();
|
||||
} else {
|
||||
// Not addable yet — jump to what's missing instead of a dead press.
|
||||
self.list.cursor = 1; // the address row
|
||||
self.editing = Some(Field::Address);
|
||||
}
|
||||
pulse
|
||||
}
|
||||
_ => pulse,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
if self.editing.is_some() {
|
||||
if ctx.deck {
|
||||
return vec![
|
||||
Hint::new(HintKey::Key("STEAM + X"), "Keyboard"),
|
||||
Hint::new(HintKey::Confirm, "Done"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
return vec![
|
||||
Hint::new(HintKey::Confirm, "Type"),
|
||||
Hint::new(HintKey::Tertiary, "Delete"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
vec![
|
||||
Hint::new(HintKey::Confirm, "Select"),
|
||||
Hint::new(HintKey::Back, "Cancel"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
f64::from(rect.top) + 2.0 * k,
|
||||
f64::from(rect.width()) * 0.72,
|
||||
);
|
||||
|
||||
// While the keyboard tray is up (never on Deck) the rows squeeze above it.
|
||||
let seat = self.keyboard.seat(self.editing.is_some() && !ctx.deck, dt);
|
||||
let tray_h = if seat > 0.0 {
|
||||
(Keyboard::tray_height() + 12.0) * k * seat
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top + (34.0 * k) as f32,
|
||||
rect.right,
|
||||
rect.bottom - tray_h as f32,
|
||||
);
|
||||
let rows = self.rows();
|
||||
self.list.render(
|
||||
canvas,
|
||||
list_rect,
|
||||
&rows,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
self.editing.is_none(),
|
||||
);
|
||||
if seat > 0.0 {
|
||||
self.keyboard.render(
|
||||
canvas,
|
||||
fonts,
|
||||
f64::from(rect.width()),
|
||||
f64::from(rect.bottom),
|
||||
seat,
|
||||
k,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(&self) -> Vec<RowSpec> {
|
||||
let field_row = |label: &str, value: &str, placeholder: &str, f: Field| {
|
||||
let mut row = RowSpec::field(label, value.to_string(), placeholder);
|
||||
row.caret = self.editing == Some(f);
|
||||
row
|
||||
};
|
||||
vec![
|
||||
field_row(
|
||||
"Name",
|
||||
&self.name,
|
||||
"Optional — e.g. Living Room",
|
||||
Field::Name,
|
||||
),
|
||||
field_row("Address", &self.address, "IP or hostname", Field::Address),
|
||||
field_row("Port", &self.port, "9777", Field::Port),
|
||||
RowSpec::action("Add Host", self.can_add()),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::screens::Nav;
|
||||
use pf_client_core::trust::Settings;
|
||||
|
||||
fn ctx<'a>(
|
||||
settings: &'a mut Settings,
|
||||
pads: &'a [pf_client_core::gamepad::PadInfo],
|
||||
library: &'a crate::library::LibraryShared,
|
||||
deck: bool,
|
||||
) -> Ctx<'a> {
|
||||
Ctx {
|
||||
hosts: &[],
|
||||
library,
|
||||
settings,
|
||||
pads,
|
||||
deck,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_to_end_add_flow() {
|
||||
let mut settings = Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut c = ctx(&mut settings, &[], &library, false);
|
||||
let mut s = AddHostScreen::new();
|
||||
let mut fx = Outbox::default();
|
||||
|
||||
// A on "Add Host" while the address is empty jumps into the address field.
|
||||
s.list.cursor = 3;
|
||||
s.menu(MenuEvent::Confirm, &mut c, &mut fx);
|
||||
assert_eq!(s.editing, Some(Field::Address));
|
||||
assert_eq!(s.list.cursor, 1);
|
||||
|
||||
// Hardware keys / Steam keyboard commit text; spaces are refused for addresses.
|
||||
s.text_input("deck tower.local");
|
||||
assert_eq!(s.address, "decktower.local");
|
||||
s.edit_key(sdl3::keyboard::Scancode::Backspace);
|
||||
assert_eq!(s.address, "decktower.loca");
|
||||
s.text_input("l");
|
||||
s.edit_key(sdl3::keyboard::Scancode::Return);
|
||||
assert!(s.editing.is_none());
|
||||
|
||||
// Now Add saves, toasts, and pops.
|
||||
s.list.cursor = 3;
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut c, &mut fx);
|
||||
assert!(matches!(
|
||||
fx.cmds.first(),
|
||||
Some(ConsoleCmd::SaveHost { addr, port: 9777, .. }) if addr == "decktower.local"
|
||||
));
|
||||
assert!(matches!(fx.nav, Some(Nav::Pop)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_caps_at_five_digits() {
|
||||
let mut s = AddHostScreen::new();
|
||||
s.editing = Some(Field::Port);
|
||||
s.port.clear();
|
||||
s.text_input("123456789");
|
||||
assert_eq!(s.port, "12345");
|
||||
assert!(!s.type_char('x'), "digits only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_mode_never_uses_the_grid() {
|
||||
let mut settings = Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut c = ctx(&mut settings, &[], &library, true);
|
||||
let mut s = AddHostScreen::new();
|
||||
let mut fx = Outbox::default();
|
||||
s.list.cursor = 1;
|
||||
s.menu(MenuEvent::Confirm, &mut c, &mut fx); // open the address field
|
||||
assert!(s.editing());
|
||||
// Moves don't drive a key grid on Deck; B closes the field.
|
||||
assert!(s
|
||||
.menu(
|
||||
MenuEvent::Move(pf_client_core::gamepad::MenuDir::Right),
|
||||
&mut c,
|
||||
&mut fx
|
||||
)
|
||||
.is_none());
|
||||
s.menu(MenuEvent::Back, &mut c, &mut fx);
|
||||
assert!(!s.editing());
|
||||
assert!(fx.nav.is_none(), "B closed the field, not the screen");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
//! The console home: a center-snapping carousel of host tiles (saved, discovered, and
|
||||
//! the trailing Add Host action) — the Swift `GamepadHomeView` re-homed onto Skia. A
|
||||
//! connects (or wakes, or routes to pairing), Y opens a paired host's library, X opens
|
||||
//! settings, B quits to Gaming Mode. The cursor is the authority; the sprung position
|
||||
//! chases it, and the focus pop (scale/brightness/fade) reads off the LIVE sprung
|
||||
//! distance so the look always matches the strip mid-motion.
|
||||
|
||||
use crate::anim::Spring;
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::library::{step_cursor, StepResult, BUMP_C, BUMP_K, BUMP_PX, SPRING_C, SPRING_K};
|
||||
use crate::model::{ConsoleCmd, HostRow};
|
||||
use crate::screens::{ConnectIntent, Ctx, Outbox, Screen};
|
||||
use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, DIM, ONLINE_GREEN, W, WHITE};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Color4f, MaskFilter, Paint, Path, Point, RRect, Rect};
|
||||
|
||||
const TILE_W: f64 = 340.0;
|
||||
const TILE_H: f64 = 224.0;
|
||||
const TILE_GAP: f64 = 30.0;
|
||||
const TILE_CORNER: f64 = 26.0;
|
||||
|
||||
/// The Add Host tile's synthetic key (host keys are fingerprints or `addr:port`,
|
||||
/// neither starts with `\0`).
|
||||
const ADD_KEY: &str = "\0add";
|
||||
|
||||
pub(crate) struct HomeScreen {
|
||||
cursor: i32,
|
||||
anim: Spring,
|
||||
bump: Spring,
|
||||
/// Last-seen tile keys — hosts churn under discovery; focus follows the KEY.
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
impl HomeScreen {
|
||||
pub(crate) fn new() -> HomeScreen {
|
||||
HomeScreen {
|
||||
cursor: 0,
|
||||
anim: Spring::rest(0.0),
|
||||
bump: Spring::rest(0.0),
|
||||
keys: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the cursor on "the same tile" across host-list churn.
|
||||
fn reconcile(&mut self, hosts: &[HostRow]) {
|
||||
let keys: Vec<String> = hosts
|
||||
.iter()
|
||||
.map(|h| h.key.clone())
|
||||
.chain(std::iter::once(ADD_KEY.to_string()))
|
||||
.collect();
|
||||
if keys != self.keys {
|
||||
let followed = self
|
||||
.keys
|
||||
.get(self.cursor as usize)
|
||||
.and_then(|old| keys.iter().position(|k| k == old));
|
||||
self.cursor = followed.unwrap_or(self.cursor as usize).min(keys.len() - 1) as i32;
|
||||
// A follow that moved the tile shifts the spring target; the chase animates
|
||||
// the strip to its new berth instead of snapping.
|
||||
self.keys = keys;
|
||||
}
|
||||
}
|
||||
|
||||
fn focused<'h>(&self, hosts: &'h [HostRow]) -> Option<&'h HostRow> {
|
||||
hosts.get(self.cursor as usize)
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
self.reconcile(ctx.hosts);
|
||||
let len = ctx.hosts.len() + 1;
|
||||
match ev {
|
||||
MenuEvent::Move(MenuDir::Left) => self.step(-1, len, false),
|
||||
MenuEvent::Move(MenuDir::Right) => self.step(1, len, false),
|
||||
MenuEvent::JumpBack => self.step(-5, len, true),
|
||||
MenuEvent::JumpForward => self.step(5, len, true),
|
||||
MenuEvent::Confirm => {
|
||||
match self.focused(ctx.hosts) {
|
||||
None => fx.push(Screen::AddHost(super::add_host::AddHostScreen::new())),
|
||||
Some(h) if !h.paired => fx.push(Screen::Pair(super::pair::PairScreen::new(
|
||||
h,
|
||||
ctx.device_name,
|
||||
))),
|
||||
Some(h) if !h.online && h.can_wake => {
|
||||
// Wake first; the wake overlay connects once it answers.
|
||||
fx.cmds.push(ConsoleCmd::Wake {
|
||||
key: h.key.clone(),
|
||||
then_connect: true,
|
||||
});
|
||||
}
|
||||
Some(h) => {
|
||||
// Dial-first even when the presence pips say offline — a
|
||||
// routed/VPN host is mDNS-blind and probe-shy but dials fine.
|
||||
fx.connect = Some(ConnectIntent {
|
||||
addr: h.addr.clone(),
|
||||
port: h.port,
|
||||
fp_hex: h.fp_hex.clone(),
|
||||
launch: None,
|
||||
title: h.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
MenuEvent::Secondary => match self.focused(ctx.hosts) {
|
||||
Some(h) if h.paired && h.saved => {
|
||||
fx.cmds.push(ConsoleCmd::FetchLibrary {
|
||||
addr: h.addr.clone(),
|
||||
mgmt: h.mgmt_port,
|
||||
fp_hex: h.fp_hex.clone(),
|
||||
});
|
||||
fx.push(Screen::Library(super::library::LibraryScreen::new(h)));
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
Some(_) => {
|
||||
fx.toast = Some("Pair with this host to browse its library".into());
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
MenuEvent::Tertiary => {
|
||||
fx.push(Screen::Settings(super::settings::SettingsScreen::new()));
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
MenuEvent::Back => {
|
||||
fx.pop(); // popping the root = quit (the shell's rule)
|
||||
None
|
||||
}
|
||||
MenuEvent::Move(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn step(&mut self, delta: i32, len: usize, clamp: bool) -> Option<MenuPulse> {
|
||||
match step_cursor(self.cursor, len, delta, clamp) {
|
||||
StepResult::Moved(to) => {
|
||||
self.cursor = to;
|
||||
Some(MenuPulse::Move)
|
||||
}
|
||||
StepResult::Boundary => {
|
||||
self.bump = Spring {
|
||||
pos: -BUMP_PX * f64::from(delta.signum()),
|
||||
vel: 0.0,
|
||||
};
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
let mut hints = Vec::new();
|
||||
match self.focused(ctx.hosts) {
|
||||
None => hints.push(Hint::new(HintKey::Confirm, "Add Host")),
|
||||
Some(h) if !h.paired => hints.push(Hint::new(HintKey::Confirm, "Pair…")),
|
||||
Some(h) if !h.online && h.can_wake => {
|
||||
hints.push(Hint::new(HintKey::Confirm, "Wake & Connect"))
|
||||
}
|
||||
Some(_) => hints.push(Hint::new(HintKey::Confirm, "Connect")),
|
||||
}
|
||||
if self.focused(ctx.hosts).is_some_and(|h| h.paired && h.saved) {
|
||||
hints.push(Hint::new(HintKey::Secondary, "Library"));
|
||||
}
|
||||
hints.push(Hint::new(HintKey::Tertiary, "Settings"));
|
||||
hints.push(Hint::new(HintKey::Back, "Quit"));
|
||||
hints
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
self.reconcile(ctx.hosts);
|
||||
self.anim
|
||||
.step(f64::from(self.cursor), SPRING_K, SPRING_C, dt);
|
||||
self.anim.settle(f64::from(self.cursor), 0.001, 0.01);
|
||||
self.bump.step(0.0, BUMP_K, BUMP_C, dt);
|
||||
self.bump.settle(0.0, 0.3, 4.0);
|
||||
|
||||
let w = f64::from(rect.width());
|
||||
let tile_w = (TILE_W * k).min(w * 0.84);
|
||||
let tile_h = (TILE_H * k)
|
||||
.min(f64::from(rect.height()) - 48.0 * k)
|
||||
.max(118.0 * k);
|
||||
let pitch = tile_w + TILE_GAP * k;
|
||||
let cx0 = f64::from(rect.left) + w / 2.0 + self.bump.pos * k;
|
||||
let cy = f64::from(rect.top) + f64::from(rect.height()) / 2.0;
|
||||
|
||||
let len = ctx.hosts.len() + 1;
|
||||
for i in 0..len {
|
||||
let d = i as f64 - self.anim.pos;
|
||||
if d.abs() > 2.6 {
|
||||
continue;
|
||||
}
|
||||
let f = 1.0 - d.abs().min(1.0); // 1 at focus → 0 one slot out
|
||||
let scale = 0.88 + 0.12 * f;
|
||||
let alpha = 0.78 + 0.22 * f;
|
||||
let cx = cx0 + d * pitch;
|
||||
let tile = Rect::from_xywh(
|
||||
(cx - tile_w / 2.0) as f32,
|
||||
(cy - tile_h / 2.0) as f32,
|
||||
tile_w as f32,
|
||||
tile_h as f32,
|
||||
);
|
||||
canvas.save();
|
||||
canvas.translate((cx as f32, cy as f32));
|
||||
canvas.scale((scale as f32, scale as f32));
|
||||
canvas.translate((-cx as f32, -cy as f32));
|
||||
canvas.save_layer_alpha_f(None, alpha as f32);
|
||||
if f > 0.4 {
|
||||
crate::theme::drop_shadow(
|
||||
canvas,
|
||||
tile,
|
||||
TILE_CORNER as f32,
|
||||
k as f32,
|
||||
0.45 * f as f32,
|
||||
);
|
||||
}
|
||||
match ctx.hosts.get(i) {
|
||||
Some(h) => draw_host_tile(canvas, fonts, h, tile, k, ctx.t),
|
||||
None => draw_add_tile(canvas, fonts, tile, k),
|
||||
}
|
||||
// The brightness recede: an opaque veil, matching the coverflow's rule.
|
||||
if f < 1.0 {
|
||||
let veil = (1.0 - f) as f32 * 0.24;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(tile, (TILE_CORNER * k) as f32, (TILE_CORNER * k) as f32),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, veil), None),
|
||||
);
|
||||
}
|
||||
canvas.restore(); // layer
|
||||
canvas.restore(); // transform
|
||||
}
|
||||
|
||||
if ctx.hosts.is_empty() {
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
DIM,
|
||||
f64::from(rect.left) + w / 2.0,
|
||||
cy + tile_h / 2.0 + 24.0 * k,
|
||||
w * 0.7,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f64, _t: f64) {
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
rect,
|
||||
TILE_CORNER as f32,
|
||||
h.saved.then(|| brand(0.20)),
|
||||
if h.saved {
|
||||
PanelStroke::Gradient
|
||||
} else {
|
||||
PanelStroke::GradientDashed
|
||||
},
|
||||
k as f32,
|
||||
);
|
||||
let pad = 20.0 * k;
|
||||
let (l, t) = (f64::from(rect.left) + pad, f64::from(rect.top) + pad);
|
||||
draw_monogram(canvas, fonts, &h.name, h.saved, l, t, k);
|
||||
|
||||
// Top-right status cluster: a lock for a paired identity, a glowing pip when live.
|
||||
let mut sx = f64::from(rect.right) - pad;
|
||||
if h.online {
|
||||
let r = 4.5 * k;
|
||||
let center = Point::new((sx - r) as f32, (t + 9.0 * k) as f32);
|
||||
let mut glow = Paint::new(
|
||||
Color4f::new(ONLINE_GREEN.r, ONLINE_GREEN.g, ONLINE_GREEN.b, 0.7),
|
||||
None,
|
||||
);
|
||||
glow.set_mask_filter(MaskFilter::blur(
|
||||
skia_safe::BlurStyle::Normal,
|
||||
(5.0 * k) as f32,
|
||||
None,
|
||||
));
|
||||
canvas.draw_circle(center, r as f32, &glow);
|
||||
canvas.draw_circle(center, r as f32, &Paint::new(ONLINE_GREEN, None));
|
||||
sx -= 2.0 * r + 9.0 * k;
|
||||
}
|
||||
if h.paired {
|
||||
draw_lock(canvas, sx - 9.0 * k, t + 4.0 * k, k);
|
||||
}
|
||||
|
||||
let max_w = f64::from(rect.width()) - 2.0 * pad;
|
||||
let sub_base = f64::from(rect.bottom) - pad;
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&format!("{}:{}", h.addr, h.port),
|
||||
l,
|
||||
sub_base,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
white(0.55),
|
||||
max_w,
|
||||
);
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&h.name,
|
||||
l,
|
||||
sub_base - 22.0 * k,
|
||||
W::Bold,
|
||||
23.0 * k,
|
||||
WHITE,
|
||||
max_w,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
rect,
|
||||
TILE_CORNER as f32,
|
||||
None,
|
||||
PanelStroke::GradientDashed,
|
||||
k as f32,
|
||||
);
|
||||
let pad = 20.0 * k;
|
||||
let (l, t) = (f64::from(rect.left) + pad, f64::from(rect.top) + pad);
|
||||
// The badge with a + instead of a monogram.
|
||||
let badge = Rect::from_xywh(l as f32, t as f32, (52.0 * k) as f32, (52.0 * k) as f32);
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32),
|
||||
&Paint::new(brand(0.16), None),
|
||||
);
|
||||
let mut ring = Paint::new(brand(0.5), None);
|
||||
ring.set_style(skia_safe::PaintStyle::Stroke);
|
||||
ring.set_stroke_width(1.0);
|
||||
ring.set_anti_alias(true);
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32),
|
||||
&ring,
|
||||
);
|
||||
let (bcx, bcy) = (l + 26.0 * k, t + 26.0 * k);
|
||||
let mut p = Paint::new(BRAND, None);
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width((3.0 * k) as f32);
|
||||
p.set_stroke_cap(skia_safe::PaintCap::Round);
|
||||
p.set_anti_alias(true);
|
||||
let r = 9.0 * k;
|
||||
canvas.draw_line(
|
||||
((bcx - r) as f32, bcy as f32),
|
||||
((bcx + r) as f32, bcy as f32),
|
||||
&p,
|
||||
);
|
||||
canvas.draw_line(
|
||||
(bcx as f32, (bcy - r) as f32),
|
||||
(bcx as f32, (bcy + r) as f32),
|
||||
&p,
|
||||
);
|
||||
|
||||
let max_w = f64::from(rect.width()) - 2.0 * pad;
|
||||
let sub_base = f64::from(rect.bottom) - pad;
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
"Register a host by address",
|
||||
l,
|
||||
sub_base,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
white(0.55),
|
||||
max_w,
|
||||
);
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
"Add Host",
|
||||
l,
|
||||
sub_base - 22.0 * k,
|
||||
W::Bold,
|
||||
23.0 * k,
|
||||
WHITE,
|
||||
max_w,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f64, y: f64, k: f64) {
|
||||
let badge = Rect::from_xywh(x as f32, y as f32, (52.0 * k) as f32, (52.0 * k) as f32);
|
||||
let rr = RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32);
|
||||
if filled {
|
||||
let mut p = Paint::default();
|
||||
p.set_shader(skia_safe::gradient_shader::linear(
|
||||
(
|
||||
Point::new(badge.left, badge.top),
|
||||
Point::new(badge.left, badge.bottom),
|
||||
),
|
||||
skia_safe::gradient_shader::GradientShaderColors::Colors(&[
|
||||
BRAND.to_color(),
|
||||
brand(0.68).to_color(),
|
||||
]),
|
||||
None,
|
||||
skia_safe::TileMode::Clamp,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
canvas.draw_rrect(rr, &p);
|
||||
} else {
|
||||
canvas.draw_rrect(rr, &Paint::new(brand(0.16), None));
|
||||
let mut ring = Paint::new(brand(0.5), None);
|
||||
ring.set_style(skia_safe::PaintStyle::Stroke);
|
||||
ring.set_stroke_width(1.0);
|
||||
ring.set_anti_alias(true);
|
||||
canvas.draw_rrect(rr, &ring);
|
||||
}
|
||||
let letter: String = name
|
||||
.trim()
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.to_uppercase().collect())
|
||||
.unwrap_or_else(|| "•".to_string());
|
||||
let size = 25.0 * k;
|
||||
let tw = fonts.measure(&letter, W::Bold, size) as f64;
|
||||
fonts.draw(
|
||||
canvas,
|
||||
&letter,
|
||||
x + 26.0 * k - tw / 2.0,
|
||||
y + 26.0 * k + size * 0.36,
|
||||
W::Bold,
|
||||
size,
|
||||
if filled { WHITE } else { BRAND },
|
||||
);
|
||||
}
|
||||
|
||||
/// A small padlock: filled body + stroked shackle (the paired-identity mark).
|
||||
fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) {
|
||||
let ink = white(0.5);
|
||||
let body_w = 11.0 * k;
|
||||
let body_h = 8.0 * k;
|
||||
let body_top = y + 5.0 * k;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(
|
||||
Rect::from_xywh(x as f32, body_top as f32, body_w as f32, body_h as f32),
|
||||
(2.0 * k) as f32,
|
||||
(2.0 * k) as f32,
|
||||
),
|
||||
&Paint::new(ink, None),
|
||||
);
|
||||
let mut p = Paint::new(ink, None);
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width((1.6 * k) as f32);
|
||||
p.set_anti_alias(true);
|
||||
let mut shackle = Path::new();
|
||||
let (cx, r) = (x + body_w / 2.0, 3.2 * k);
|
||||
shackle.move_to(((cx - r) as f32, body_top as f32));
|
||||
shackle.arc_to(
|
||||
Rect::from_xywh(
|
||||
(cx - r) as f32,
|
||||
(body_top - r) as f32,
|
||||
(2.0 * r) as f32,
|
||||
(2.0 * r) as f32,
|
||||
),
|
||||
180.0,
|
||||
180.0,
|
||||
false,
|
||||
);
|
||||
canvas.draw_path(&shackle, &p);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn host(key: &str, paired: bool, online: bool, can_wake: bool) -> HostRow {
|
||||
HostRow {
|
||||
key: key.into(),
|
||||
name: key.into(),
|
||||
addr: "10.0.0.9".into(),
|
||||
port: 9777,
|
||||
fp_hex: if paired { "ab".into() } else { String::new() },
|
||||
paired,
|
||||
saved: true,
|
||||
online,
|
||||
mgmt_port: 47990,
|
||||
can_wake,
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx_settings() -> pf_client_core::trust::Settings {
|
||||
pf_client_core::trust::Settings::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_follows_the_key_through_churn() {
|
||||
let mut s = HomeScreen::new();
|
||||
let a = host("a", true, true, false);
|
||||
let b = host("b", true, true, false);
|
||||
s.reconcile(&[a.clone(), b.clone()]);
|
||||
s.cursor = 1; // on "b"
|
||||
s.keys = vec!["a".into(), "b".into(), ADD_KEY.into()];
|
||||
// A new host lands in front — focus must stay on "b".
|
||||
let c = host("c", false, true, false);
|
||||
s.reconcile(&[c, a, b]);
|
||||
assert_eq!(s.cursor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_routes_by_host_state() {
|
||||
let mut settings = ctx_settings();
|
||||
let hosts = [
|
||||
host("paired-online", true, true, false),
|
||||
host("unpaired", false, true, false),
|
||||
host("asleep", true, false, true),
|
||||
];
|
||||
let pads: Vec<pf_client_core::gamepad::PadInfo> = Vec::new();
|
||||
|
||||
// Paired+online → connect intent.
|
||||
let mut s = HomeScreen::new();
|
||||
let mut fx = Outbox::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(fx.connect.is_some());
|
||||
|
||||
// Unpaired → the pair screen.
|
||||
let mut fx = Outbox::default();
|
||||
s.cursor = 1;
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(matches!(fx.nav, Some(crate::screens::Nav::Push(_))));
|
||||
assert!(fx.connect.is_none());
|
||||
|
||||
// Asleep + MAC on file → wake-then-connect.
|
||||
let mut fx = Outbox::default();
|
||||
s.cursor = 2;
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(matches!(
|
||||
fx.cmds.first(),
|
||||
Some(ConsoleCmd::Wake {
|
||||
then_connect: true,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_tile_is_always_last() {
|
||||
let mut settings = ctx_settings();
|
||||
let pads: Vec<pf_client_core::gamepad::PadInfo> = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = HomeScreen::new();
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.nav, Some(crate::screens::Nav::Push(b)) if matches!(*b, Screen::AddHost(_)))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//! The game-library screen: the coverflow carousel (spring-chased cursor, perspective
|
||||
//! card tilt, poster art streaming in) — the original console library, now one screen
|
||||
//! on the shell's stack. B pops back to the host list; A launches the focused title in
|
||||
//! the same window. The shell owns the aurora, chrome, and the connecting overlay.
|
||||
|
||||
use crate::anim::Spring;
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::library::{
|
||||
card_matrix, initials, step_cursor, store_label, LibraryGame, LibraryPhase, LibraryShared,
|
||||
StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, JUMP, PERSPECTIVE, POSTER_H, POSTER_W,
|
||||
RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K, VISIBLE_RANGE,
|
||||
};
|
||||
use crate::model::{ConsoleCmd, HostRow};
|
||||
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
||||
use crate::theme::{white, Fonts, DIM, W, WHITE};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Color4f, Data, Image, Paint, Point, RRect, Rect, M44};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub(crate) struct LibraryScreen {
|
||||
host_name: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
fp_hex: String,
|
||||
mgmt: u16,
|
||||
shared: Option<LibraryShared>,
|
||||
// Synced snapshot of the shared model (re-pulled when the generation bumps).
|
||||
generation: u64,
|
||||
phase: LibraryPhase,
|
||||
games: Vec<LibraryGame>,
|
||||
// Navigation: the integer cursor is the authority; the eased position chases it.
|
||||
cursor: i32,
|
||||
anim: Spring,
|
||||
bump: Spring,
|
||||
/// Decoded posters by game id (decode once; Skia uploads lazily on first draw).
|
||||
art: HashMap<String, Image>,
|
||||
}
|
||||
|
||||
impl LibraryScreen {
|
||||
pub(crate) fn new(host: &HostRow) -> LibraryScreen {
|
||||
LibraryScreen {
|
||||
host_name: host.name.clone(),
|
||||
addr: host.addr.clone(),
|
||||
port: host.port,
|
||||
fp_hex: host.fp_hex.clone(),
|
||||
mgmt: host.mgmt_port,
|
||||
shared: None, // adopted from Ctx on the first render (the shell owns it)
|
||||
generation: u64::MAX,
|
||||
phase: LibraryPhase::Loading,
|
||||
games: Vec::new(),
|
||||
cursor: 0,
|
||||
anim: Spring::rest(0.0),
|
||||
bump: Spring::rest(0.0),
|
||||
art: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn host_name(&self) -> &str {
|
||||
&self.host_name
|
||||
}
|
||||
|
||||
fn fetch_cmd(&self) -> ConsoleCmd {
|
||||
ConsoleCmd::FetchLibrary {
|
||||
addr: self.addr.clone(),
|
||||
mgmt: self.mgmt,
|
||||
fp_hex: self.fp_hex.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the shared model when it changed; decode newly arrived poster bytes.
|
||||
fn sync(&mut self, library: &LibraryShared) {
|
||||
if self.shared.is_none() {
|
||||
self.shared = Some(library.clone());
|
||||
}
|
||||
let Some(shared) = &self.shared else { return };
|
||||
if shared.generation() != self.generation {
|
||||
let (phase, games, generation) = shared.snapshot();
|
||||
let fresh = self.games.len() != games.len()
|
||||
|| self.games.iter().zip(&games).any(|(a, b)| a.id != b.id);
|
||||
self.phase = phase;
|
||||
self.games = games;
|
||||
self.generation = generation;
|
||||
if fresh {
|
||||
self.cursor = 0;
|
||||
self.anim = Spring::rest(0.0);
|
||||
self.bump = Spring::rest(0.0);
|
||||
self.art.clear();
|
||||
}
|
||||
self.cursor = self.cursor.clamp(0, (self.games.len() as i32 - 1).max(0));
|
||||
}
|
||||
for (id, bytes) in shared.drain_art() {
|
||||
match Image::from_encoded(Data::new_copy(&bytes)) {
|
||||
Some(img) => {
|
||||
self.art.insert(id, img);
|
||||
}
|
||||
None => tracing::debug!(%id, "undecodable poster"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
self.sync(ctx.library);
|
||||
match &self.phase {
|
||||
LibraryPhase::Ready => match ev {
|
||||
MenuEvent::Move(MenuDir::Left) => self.step(-1, false),
|
||||
MenuEvent::Move(MenuDir::Right) => self.step(1, false),
|
||||
MenuEvent::JumpBack => self.step(-JUMP, true),
|
||||
MenuEvent::JumpForward => self.step(JUMP, true),
|
||||
MenuEvent::Confirm => {
|
||||
let g = self.games.get(self.cursor as usize)?;
|
||||
fx.connect = Some(ConnectIntent {
|
||||
addr: self.addr.clone(),
|
||||
port: self.port,
|
||||
fp_hex: self.fp_hex.clone(),
|
||||
launch: Some(g.id.clone()),
|
||||
title: g.title.clone(),
|
||||
});
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
MenuEvent::Back => {
|
||||
fx.pop();
|
||||
None
|
||||
}
|
||||
MenuEvent::Move(_) | MenuEvent::Secondary | MenuEvent::Tertiary => None,
|
||||
},
|
||||
LibraryPhase::Error { can_retry, .. } => match ev {
|
||||
MenuEvent::Confirm if *can_retry => {
|
||||
self.phase = LibraryPhase::Loading; // local; the fetch re-syncs it
|
||||
fx.cmds.push(self.fetch_cmd());
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
MenuEvent::Back => {
|
||||
fx.pop();
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
LibraryPhase::Loading | LibraryPhase::Empty => {
|
||||
if ev == MenuEvent::Back {
|
||||
fx.pop();
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn step(&mut self, delta: i32, clamp: bool) -> Option<MenuPulse> {
|
||||
match step_cursor(self.cursor, self.games.len(), delta, clamp) {
|
||||
StepResult::Moved(to) => {
|
||||
self.cursor = to;
|
||||
Some(MenuPulse::Move)
|
||||
}
|
||||
StepResult::Boundary => {
|
||||
self.bump = Spring {
|
||||
pos: -BUMP_PX * f64::from(delta.signum()),
|
||||
vel: 0.0,
|
||||
};
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
match &self.phase {
|
||||
LibraryPhase::Ready => vec![
|
||||
Hint::new(HintKey::Confirm, "Play"),
|
||||
Hint::new(HintKey::Shoulders, "Jump"),
|
||||
Hint::new(HintKey::Back, "Back"),
|
||||
],
|
||||
LibraryPhase::Error {
|
||||
can_retry: true, ..
|
||||
} => vec![
|
||||
Hint::new(HintKey::Confirm, "Retry"),
|
||||
Hint::new(HintKey::Back, "Back"),
|
||||
],
|
||||
_ => vec![Hint::new(HintKey::Back, "Back")],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
self.sync(ctx.library);
|
||||
let (w, cy_all) = (
|
||||
f64::from(rect.width()),
|
||||
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
|
||||
);
|
||||
let cx = f64::from(rect.left) + w / 2.0;
|
||||
match self.phase.clone() {
|
||||
LibraryPhase::Ready => {
|
||||
self.anim
|
||||
.step(f64::from(self.cursor), SPRING_K, SPRING_C, dt);
|
||||
self.anim.settle(f64::from(self.cursor), 0.001, 0.01);
|
||||
self.bump.step(0.0, BUMP_K, BUMP_C, dt);
|
||||
self.bump.settle(0.0, 0.3, 4.0);
|
||||
self.draw_carousel(canvas, rect, k, fonts);
|
||||
}
|
||||
LibraryPhase::Loading => {
|
||||
crate::theme::spinner(canvas, cx, cy_all - 24.0 * k, 16.0 * k, ctx.t);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"Loading library…",
|
||||
W::Regular,
|
||||
14.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
cy_all + 16.0 * k,
|
||||
w * 0.8,
|
||||
);
|
||||
}
|
||||
LibraryPhase::Empty => {
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"No games found",
|
||||
W::Bold,
|
||||
22.0 * k,
|
||||
WHITE,
|
||||
cx,
|
||||
cy_all - 20.0 * k,
|
||||
w * 0.8,
|
||||
);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"Install Steam titles or add custom entries in the host's web console.",
|
||||
W::Regular,
|
||||
14.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
cy_all + 12.0 * k,
|
||||
w * 0.8,
|
||||
);
|
||||
}
|
||||
LibraryPhase::Error { title, body, .. } => {
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&title,
|
||||
W::Bold,
|
||||
22.0 * k,
|
||||
WHITE,
|
||||
cx,
|
||||
cy_all - 32.0 * k,
|
||||
w * 0.8,
|
||||
);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&body,
|
||||
W::Regular,
|
||||
14.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
cy_all + 4.0 * k,
|
||||
(600.0 * k).min(w * 0.85),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_carousel(&mut self, canvas: &Canvas, rect: Rect, k: f64, fonts: &Fonts) {
|
||||
let (card_w, card_h) = (POSTER_W * k, POSTER_H * k);
|
||||
let w = f64::from(rect.width());
|
||||
// The strip rides slightly above center; the detail block gets the band below.
|
||||
let cy = f64::from(rect.top) + f64::from(rect.height()) * 0.44;
|
||||
let pos = self.anim.pos;
|
||||
let bump = self.bump.pos * k;
|
||||
|
||||
// Paint order = draw order: farthest from the (integer) cursor first, so the
|
||||
// dense side stacks overlap toward the focus.
|
||||
let mut order: Vec<usize> = (0..self.games.len()).collect();
|
||||
order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - self.cursor).abs()));
|
||||
|
||||
for i in order {
|
||||
let d = i as f64 - pos;
|
||||
let a = d.abs();
|
||||
if a > VISIBLE_RANGE {
|
||||
continue;
|
||||
}
|
||||
let prox = a.min(1.0);
|
||||
let scale = 1.0 - prox * RECEDE_SCALE;
|
||||
let angle = -d.clamp(-1.0, 1.0) * ROTATE_DEG;
|
||||
let offset = if a <= 1.0 {
|
||||
d * FOCUS_GAP * k
|
||||
} else {
|
||||
d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) * k
|
||||
};
|
||||
let ccx = f64::from(rect.left) + w / 2.0 + offset + bump;
|
||||
let m = card_matrix(ccx, cy, angle, scale, card_w, card_h, PERSPECTIVE * k);
|
||||
|
||||
let game = &self.games[i];
|
||||
canvas.save();
|
||||
canvas.concat_44(&M44::row_major(&m));
|
||||
let crect = Rect::from_wh(card_w as f32, card_h as f32);
|
||||
let rr = RRect::new_rect_xy(crect, 16.0 * k as f32, 16.0 * k as f32);
|
||||
canvas.clip_rrect(rr, None, true);
|
||||
match self.art.get(&game.id) {
|
||||
Some(img) => {
|
||||
// Cover-fit: center-crop the source to the card's 2:3.
|
||||
let (iw, ih) = (img.width() as f32, img.height() as f32);
|
||||
let card_aspect = crect.width() / crect.height();
|
||||
let src = if iw / ih > card_aspect {
|
||||
let sw = ih * card_aspect;
|
||||
Rect::from_xywh((iw - sw) / 2.0, 0.0, sw, ih)
|
||||
} else {
|
||||
let sh = iw / card_aspect;
|
||||
Rect::from_xywh(0.0, (ih - sh) / 2.0, iw, sh)
|
||||
};
|
||||
canvas.draw_image_rect(
|
||||
img,
|
||||
Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)),
|
||||
crect,
|
||||
&Paint::default(),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
// Solid face, not glass: the side cards OVERLAP.
|
||||
canvas.draw_rect(
|
||||
crect,
|
||||
&Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None),
|
||||
);
|
||||
let mono = initials(&game.title);
|
||||
let font = fonts.font(W::Bold, 38.0 * k);
|
||||
let tw = font.measure_str(&mono, None).0;
|
||||
canvas.draw_str(
|
||||
&mono,
|
||||
Point::new(
|
||||
(card_w as f32 - tw) / 2.0,
|
||||
card_h as f32 / 2.0 + 13.0 * k as f32,
|
||||
),
|
||||
&font,
|
||||
&Paint::new(white(0.45), None),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Store badge, top-left.
|
||||
{
|
||||
let label = store_label(&game.store);
|
||||
let size = 11.0 * k;
|
||||
let tw = fonts.measure(label, W::SemiBold, size) as f64;
|
||||
let (px, py) = (8.0 * k, 8.0 * k);
|
||||
let (bw, bh) = (tw + 16.0 * k, 20.0 * k);
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(
|
||||
Rect::from_xywh(px as f32, py as f32, bw as f32, bh as f32),
|
||||
(bh / 2.0) as f32,
|
||||
(bh / 2.0) as f32,
|
||||
),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
|
||||
);
|
||||
fonts.draw(
|
||||
canvas,
|
||||
label,
|
||||
px + 8.0 * k,
|
||||
py + 14.0 * k,
|
||||
W::SemiBold,
|
||||
size,
|
||||
WHITE,
|
||||
);
|
||||
}
|
||||
// The brightness recede: an opaque-black veil, never whole-card alpha.
|
||||
if prox > 0.0 {
|
||||
canvas.draw_rect(
|
||||
crect,
|
||||
&Paint::new(
|
||||
Color4f::new(0.0, 0.0, 0.0, (prox * RECEDE_DIM) as f32),
|
||||
None,
|
||||
),
|
||||
);
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
// Detail block: focused title + store, in the band under the strip.
|
||||
if let Some(g) = self.games.get(self.cursor as usize) {
|
||||
let cx = f64::from(rect.left) + w / 2.0;
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&g.title,
|
||||
W::Bold,
|
||||
27.0 * k,
|
||||
WHITE,
|
||||
cx,
|
||||
f64::from(rect.bottom) - 64.0 * k,
|
||||
w * 0.8,
|
||||
);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&store_label(&g.store).to_uppercase(),
|
||||
W::Regular,
|
||||
12.0 * k,
|
||||
white(0.5),
|
||||
cx,
|
||||
f64::from(rect.bottom) - 30.0 * k,
|
||||
w * 0.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//! The in-console PIN pairing screen — the couch counterpart of the desktop PairSheet,
|
||||
//! and the piece that makes a Steam Deck self-sufficient (pairing used to need the
|
||||
//! Decky plugin or a desktop). The host shows a PIN in its web console; the user types
|
||||
//! it here (on-screen keyboard, or Steam's keyboard on Deck), the SPAKE2 ceremony runs
|
||||
//! on the binary's service thread, and success pins the host and pops back to Home.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::{ConsoleCmd, HostRow, PairPhase};
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{Fonts, DIM, ERROR, W};
|
||||
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Field {
|
||||
Pin,
|
||||
Device,
|
||||
}
|
||||
|
||||
pub(crate) struct PairScreen {
|
||||
host_name: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
list: MenuList,
|
||||
keyboard: Keyboard,
|
||||
pin: String,
|
||||
device: String,
|
||||
editing: Option<Field>,
|
||||
/// The ceremony is in flight (mirrors the model's `PairPhase::Busy` immediately so
|
||||
/// a second A can't double-submit before the service thread picks the command up).
|
||||
busy: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl PairScreen {
|
||||
pub(crate) fn new(host: &HostRow, device_name: &str) -> PairScreen {
|
||||
PairScreen {
|
||||
host_name: host.name.clone(),
|
||||
addr: host.addr.clone(),
|
||||
port: host.port,
|
||||
list: MenuList::new(),
|
||||
keyboard: Keyboard::new(),
|
||||
pin: String::new(),
|
||||
device: device_name.to_string(),
|
||||
editing: None,
|
||||
busy: false,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn host_name(&self) -> &str {
|
||||
&self.host_name
|
||||
}
|
||||
|
||||
/// The shell routes the model's pairing phase here (success pops shell-side).
|
||||
pub(crate) fn apply_phase(&mut self, phase: &PairPhase) {
|
||||
match phase {
|
||||
PairPhase::Busy => self.busy = true,
|
||||
PairPhase::Failed(msg) => {
|
||||
self.busy = false;
|
||||
self.error = Some(msg.clone());
|
||||
}
|
||||
PairPhase::Idle | PairPhase::Paired { .. } => self.busy = false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn editing(&self) -> bool {
|
||||
self.editing.is_some()
|
||||
}
|
||||
|
||||
fn can_pair(&self) -> bool {
|
||||
!self.pin.trim().is_empty() && !self.busy
|
||||
}
|
||||
|
||||
fn field_mut(&mut self, f: Field) -> &mut String {
|
||||
match f {
|
||||
Field::Pin => &mut self.pin,
|
||||
Field::Device => &mut self.device,
|
||||
}
|
||||
}
|
||||
|
||||
fn charset(f: Field) -> Charset {
|
||||
match f {
|
||||
Field::Pin => Charset::Digits,
|
||||
Field::Device => Charset::Free,
|
||||
}
|
||||
}
|
||||
|
||||
fn type_char(&mut self, ch: char) -> bool {
|
||||
let Some(f) = self.editing else { return false };
|
||||
if !permits(Self::charset(f), ch) {
|
||||
return false;
|
||||
}
|
||||
if f == Field::Pin && self.pin.chars().count() >= 8 {
|
||||
return false; // PINs are 4 digits today; leave headroom, refuse novels
|
||||
}
|
||||
self.field_mut(f).push(ch);
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn text_input(&mut self, text: &str) {
|
||||
for ch in text.chars() {
|
||||
self.type_char(ch);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn edit_key(&mut self, sc: sdl3::keyboard::Scancode) -> bool {
|
||||
use sdl3::keyboard::Scancode as S;
|
||||
if self.editing.is_none() {
|
||||
return false;
|
||||
}
|
||||
match sc {
|
||||
S::Backspace => {
|
||||
let f = self.editing.unwrap();
|
||||
self.field_mut(f).pop();
|
||||
true
|
||||
}
|
||||
S::Return | S::KpEnter | S::Escape => {
|
||||
self.editing = None;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if self.editing.is_some() {
|
||||
if ctx.deck {
|
||||
return match ev {
|
||||
MenuEvent::Back | MenuEvent::Confirm => {
|
||||
self.editing = None;
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let (msg, pulse) = self.keyboard.menu(ev);
|
||||
return match msg {
|
||||
KeyMsg::Type(c) => {
|
||||
if self.type_char(c) {
|
||||
Some(MenuPulse::Move)
|
||||
} else {
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
KeyMsg::Backspace => {
|
||||
let f = self.editing.unwrap();
|
||||
if self.field_mut(f).pop().is_some() {
|
||||
Some(MenuPulse::Move)
|
||||
} else {
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
KeyMsg::Done => {
|
||||
self.editing = None;
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
KeyMsg::None => pulse,
|
||||
};
|
||||
}
|
||||
|
||||
if ev == MenuEvent::Back {
|
||||
// Leaving mid-ceremony is fine — success still pins and toasts globally.
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, 3);
|
||||
match msg {
|
||||
ListMsg::Activate => {
|
||||
match self.list.cursor {
|
||||
0 => self.editing = Some(Field::Pin),
|
||||
1 => self.editing = Some(Field::Device),
|
||||
_ if self.can_pair() => {
|
||||
self.busy = true;
|
||||
self.error = None;
|
||||
fx.cmds.push(ConsoleCmd::Pair {
|
||||
addr: self.addr.clone(),
|
||||
port: self.port,
|
||||
pin: self.pin.trim().to_string(),
|
||||
device_name: if self.device.trim().is_empty() {
|
||||
ctx.device_name.to_string()
|
||||
} else {
|
||||
self.device.trim().to_string()
|
||||
},
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// No PIN yet — jump into the PIN field instead of a dead press.
|
||||
self.list.cursor = 0;
|
||||
self.editing = Some(Field::Pin);
|
||||
}
|
||||
}
|
||||
pulse
|
||||
}
|
||||
_ => pulse,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
if self.editing.is_some() {
|
||||
if ctx.deck {
|
||||
return vec![
|
||||
Hint::new(HintKey::Key("STEAM + X"), "Keyboard"),
|
||||
Hint::new(HintKey::Confirm, "Done"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
return vec![
|
||||
Hint::new(HintKey::Confirm, "Type"),
|
||||
Hint::new(HintKey::Tertiary, "Delete"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
vec![
|
||||
Hint::new(HintKey::Confirm, "Select"),
|
||||
Hint::new(HintKey::Back, "Cancel"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"Enter the PIN from the host's web console (Pairing page) or its log.",
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
f64::from(rect.top) + 2.0 * k,
|
||||
f64::from(rect.width()) * 0.72,
|
||||
);
|
||||
|
||||
let seat = self.keyboard.seat(self.editing.is_some() && !ctx.deck, dt);
|
||||
let tray_h = if seat > 0.0 {
|
||||
(Keyboard::tray_height() + 12.0) * k * seat
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// A status band under the rows: the ceremony spinner or the error line.
|
||||
let status_h = 34.0 * k;
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top + (34.0 * k) as f32,
|
||||
rect.right,
|
||||
rect.bottom - tray_h as f32 - status_h as f32,
|
||||
);
|
||||
let rows = self.rows();
|
||||
self.list.render(
|
||||
canvas,
|
||||
list_rect,
|
||||
&rows,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
self.editing.is_none() && !self.busy,
|
||||
);
|
||||
|
||||
let status_y = f64::from(rect.bottom) - tray_h - status_h + 6.0 * k;
|
||||
if self.busy {
|
||||
crate::theme::spinner(canvas, cx - 70.0 * k, status_y + 8.0 * k, 7.0 * k, ctx.t);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"Pairing… confirm the PIN on the host",
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
DIM,
|
||||
cx + 10.0 * k,
|
||||
status_y,
|
||||
f64::from(rect.width()) * 0.6,
|
||||
);
|
||||
} else if let Some(err) = &self.error {
|
||||
fonts.centered(
|
||||
canvas,
|
||||
err,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
ERROR,
|
||||
cx,
|
||||
status_y,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
}
|
||||
|
||||
if seat > 0.0 {
|
||||
self.keyboard.render(
|
||||
canvas,
|
||||
fonts,
|
||||
f64::from(rect.width()),
|
||||
f64::from(rect.bottom),
|
||||
seat,
|
||||
k,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(&self) -> Vec<RowSpec> {
|
||||
// The PIN renders spaced out (● would hide typos; a PIN is not a secret worth
|
||||
// masking — the host shows it on screen anyway).
|
||||
let pin_display: String = self
|
||||
.pin
|
||||
.chars()
|
||||
.flat_map(|c| [c, ' '])
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string();
|
||||
let mut pin = RowSpec::field("PIN", pin_display, "From the host");
|
||||
pin.caret = self.editing == Some(Field::Pin);
|
||||
let mut device = RowSpec::field(
|
||||
"Device name",
|
||||
self.device.clone(),
|
||||
"How the host lists this device",
|
||||
);
|
||||
device.caret = self.editing == Some(Field::Device);
|
||||
vec![
|
||||
pin,
|
||||
device,
|
||||
RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_pair()),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pf_client_core::trust::Settings;
|
||||
|
||||
fn host() -> HostRow {
|
||||
HostRow {
|
||||
key: "10.0.0.7:9777".into(),
|
||||
name: "Tower".into(),
|
||||
addr: "10.0.0.7".into(),
|
||||
port: 9777,
|
||||
fp_hex: String::new(),
|
||||
paired: false,
|
||||
saved: true,
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_submits_with_pin_and_device_fallback() {
|
||||
let mut settings = Settings::default();
|
||||
let pads = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "living-room-deck",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = PairScreen::new(&host(), "living-room-deck");
|
||||
s.device.clear(); // the user deleted the prefill
|
||||
s.editing = Some(Field::Pin);
|
||||
s.text_input("1234");
|
||||
s.edit_key(sdl3::keyboard::Scancode::Return);
|
||||
s.list.cursor = 2;
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(matches!(
|
||||
fx.cmds.first(),
|
||||
Some(ConsoleCmd::Pair { pin, device_name, .. })
|
||||
if pin == "1234" && device_name == "living-room-deck"
|
||||
));
|
||||
assert!(s.busy);
|
||||
// A second A while busy is a no-op (the action row is disabled).
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(fx.cmds.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_is_digits_only() {
|
||||
let mut s = PairScreen::new(&host(), "d");
|
||||
s.editing = Some(Field::Pin);
|
||||
s.text_input("12ab34");
|
||||
assert_eq!(s.pin, "1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_lands_in_the_error_line() {
|
||||
let mut s = PairScreen::new(&host(), "d");
|
||||
s.busy = true;
|
||||
s.apply_phase(&PairPhase::Failed("Wrong PIN".into()));
|
||||
assert!(!s.busy);
|
||||
assert_eq!(s.error.as_deref(), Some("Wrong PIN"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
//! The console settings screen — the couch-relevant subset of the Settings store,
|
||||
//! restyled as glass rows and fully controller-navigable (the Swift
|
||||
//! `GamepadSettingsView`, re-homed): up/down moves focus, left/right steps the focused
|
||||
//! value (clamped — the boundary thud tells the thumb it's the last option), A cycles
|
||||
//! forward wrapping, B closes. Every change persists immediately; the desktop shells
|
||||
//! read the same file, so values round-trip freely.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||
/// index when the pad list under the "Use controller" row churns.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum RowId {
|
||||
Resolution,
|
||||
Refresh,
|
||||
Bitrate,
|
||||
Compositor,
|
||||
Codec,
|
||||
Decoder,
|
||||
Hdr,
|
||||
Audio,
|
||||
Mic,
|
||||
Pad,
|
||||
PadType,
|
||||
Stats,
|
||||
}
|
||||
|
||||
const ROWS: [RowId; 12] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::Bitrate,
|
||||
RowId::Compositor,
|
||||
RowId::Codec,
|
||||
RowId::Decoder,
|
||||
RowId::Hdr,
|
||||
RowId::Audio,
|
||||
RowId::Mic,
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::Stats,
|
||||
];
|
||||
|
||||
const RESOLUTIONS: [(u32, u32); 6] = [
|
||||
(0, 0), // native
|
||||
(1280, 720),
|
||||
(1280, 800), // the Deck's panel
|
||||
(1920, 1080),
|
||||
(2560, 1440),
|
||||
(3840, 2160),
|
||||
];
|
||||
const REFRESH: [u32; 5] = [0, 30, 60, 90, 120];
|
||||
const BITRATES: [u32; 7] = [0, 5_000, 10_000, 20_000, 30_000, 50_000, 80_000];
|
||||
const COMPOSITORS: [(&str, &str); 5] = [
|
||||
("auto", "Automatic"),
|
||||
("kwin", "KWin"),
|
||||
("wlroots", "wlroots"),
|
||||
("mutter", "Mutter"),
|
||||
("gamescope", "gamescope"),
|
||||
];
|
||||
const CODECS: [(&str, &str); 4] = [
|
||||
("auto", "Automatic"),
|
||||
("hevc", "HEVC"),
|
||||
("h264", "H.264"),
|
||||
("av1", "AV1"),
|
||||
];
|
||||
const DECODERS: [(&str, &str); 4] = [
|
||||
("auto", "Automatic"),
|
||||
("vulkan", "Vulkan Video"),
|
||||
("vaapi", "VAAPI"),
|
||||
("software", "Software"),
|
||||
];
|
||||
const AUDIO: [(u8, &str); 3] = [(2, "Stereo"), (6, "5.1"), (8, "7.1")];
|
||||
const PAD_TYPES: [(&str, &str); 6] = [
|
||||
("auto", "Automatic"),
|
||||
("xbox360", "Xbox 360"),
|
||||
("xboxone", "Xbox One"),
|
||||
("dualsense", "DualSense"),
|
||||
("dualshock4", "DualShock 4"),
|
||||
("steamdeck", "Steam Deck"),
|
||||
];
|
||||
|
||||
pub(crate) struct SettingsScreen {
|
||||
list: MenuList,
|
||||
}
|
||||
|
||||
impl SettingsScreen {
|
||||
pub(crate) fn new() -> SettingsScreen {
|
||||
SettingsScreen {
|
||||
list: MenuList::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if ev == MenuEvent::Back {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, ROWS.len());
|
||||
match msg {
|
||||
ListMsg::Adjust(delta) => {
|
||||
let changed = adjust(ROWS[self.list.cursor], delta, false, ctx);
|
||||
if changed {
|
||||
ctx.settings.save();
|
||||
Some(MenuPulse::Move)
|
||||
} else {
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
ListMsg::Activate => {
|
||||
// A cycles forward WRAPPING, so every option is reachable one-handed.
|
||||
if adjust(ROWS[self.list.cursor], 1, true, ctx) {
|
||||
ctx.settings.save();
|
||||
}
|
||||
pulse
|
||||
}
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Confirm, "Change"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
// The focused row's explainer sits in a reserved band under the list.
|
||||
let detail_h = 34.0 * k;
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
let rows: Vec<RowSpec> = ROWS.iter().map(|id| row_spec(*id, ctx)).collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
let detail = detail(ROWS[self.list.cursor]);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
detail,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
DIM,
|
||||
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
|
||||
f64::from(rect.bottom) - detail_h + 6.0 * k,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
let s = &ctx.settings;
|
||||
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
||||
RowId::Resolution => (
|
||||
Some("Stream"),
|
||||
"Resolution",
|
||||
if s.width == 0 {
|
||||
"Native".into()
|
||||
} else {
|
||||
format!("{} × {}", s.width, s.height)
|
||||
},
|
||||
),
|
||||
RowId::Refresh => (
|
||||
None,
|
||||
"Refresh rate",
|
||||
if s.refresh_hz == 0 {
|
||||
"Native".into()
|
||||
} else {
|
||||
format!("{} Hz", s.refresh_hz)
|
||||
},
|
||||
),
|
||||
RowId::Bitrate => (
|
||||
None,
|
||||
"Bitrate",
|
||||
if s.bitrate_kbps == 0 {
|
||||
"Automatic".into()
|
||||
} else {
|
||||
format!("{} Mbps", s.bitrate_kbps / 1000)
|
||||
},
|
||||
),
|
||||
RowId::Compositor => (
|
||||
None,
|
||||
"Compositor",
|
||||
label_for(&COMPOSITORS, &s.compositor).into(),
|
||||
),
|
||||
RowId::Codec => (
|
||||
Some("Video"),
|
||||
"Video codec",
|
||||
label_for(&CODECS, &s.codec).into(),
|
||||
),
|
||||
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
|
||||
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
|
||||
RowId::Audio => (
|
||||
Some("Audio"),
|
||||
"Audio channels",
|
||||
AUDIO
|
||||
.iter()
|
||||
.find(|(v, _)| *v == s.audio_channels)
|
||||
.map_or("Stereo", |(_, l)| l)
|
||||
.into(),
|
||||
),
|
||||
RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()),
|
||||
RowId::Pad => (
|
||||
Some("Controller"),
|
||||
"Use controller",
|
||||
if s.forward_pad.is_empty() {
|
||||
"Automatic".into()
|
||||
} else {
|
||||
ctx.pads
|
||||
.iter()
|
||||
.find(|p| p.key == s.forward_pad)
|
||||
.map_or_else(|| "Saved (disconnected)".to_string(), |p| p.name.clone())
|
||||
},
|
||||
),
|
||||
RowId::PadType => (
|
||||
None,
|
||||
"Controller type",
|
||||
label_for(&PAD_TYPES, &s.gamepad).into(),
|
||||
),
|
||||
RowId::Stats => (
|
||||
Some("Interface"),
|
||||
"Statistics overlay",
|
||||
on_off(s.show_stats).into(),
|
||||
),
|
||||
};
|
||||
RowSpec {
|
||||
header,
|
||||
label: label.into(),
|
||||
value: Some(value),
|
||||
value_dim: false,
|
||||
caret: false,
|
||||
adjustable: true,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn detail(id: RowId) -> &'static str {
|
||||
match id {
|
||||
RowId::Resolution => {
|
||||
"The host creates a virtual display at exactly this size — no scaling."
|
||||
}
|
||||
RowId::Refresh => "Native follows the display this window is on.",
|
||||
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
|
||||
RowId::Compositor => {
|
||||
"Which compositor drives the virtual output — honored only if available on the host."
|
||||
}
|
||||
RowId::Codec => "A preference — the host falls back if it can't encode this one.",
|
||||
RowId::Decoder => "Automatic prefers Vulkan Video, then VAAPI, then software.",
|
||||
RowId::Hdr => {
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it."
|
||||
}
|
||||
RowId::Audio => "The speaker layout requested from the host.",
|
||||
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
|
||||
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
||||
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
||||
RowId::Stats => "Resolution, frame rate, throughput and latency while streaming.",
|
||||
}
|
||||
}
|
||||
|
||||
fn on_off(v: bool) -> &'static str {
|
||||
if v {
|
||||
"On"
|
||||
} else {
|
||||
"Off"
|
||||
}
|
||||
}
|
||||
|
||||
fn label_for<'a>(options: &'a [(&str, &'a str)], value: &str) -> &'a str {
|
||||
options
|
||||
.iter()
|
||||
.find(|(v, _)| *v == value)
|
||||
.map_or("—", |(_, l)| l)
|
||||
}
|
||||
|
||||
/// Step (`wrap=false`, clamped — false = boundary) or cycle (`wrap=true`) a row's
|
||||
/// value. Toggles read left = off, right = on; a no-op is a boundary.
|
||||
fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
let s = &mut *ctx.settings;
|
||||
match id {
|
||||
RowId::Resolution => {
|
||||
let cur = RESOLUTIONS
|
||||
.iter()
|
||||
.position(|(w, h)| (*w, *h) == (s.width, s.height));
|
||||
step_option(cur, RESOLUTIONS.len(), delta, wrap).map(|i| {
|
||||
(s.width, s.height) = RESOLUTIONS[i];
|
||||
})
|
||||
}
|
||||
RowId::Refresh => {
|
||||
let cur = REFRESH.iter().position(|r| *r == s.refresh_hz);
|
||||
step_option(cur, REFRESH.len(), delta, wrap).map(|i| s.refresh_hz = REFRESH[i])
|
||||
}
|
||||
RowId::Bitrate => {
|
||||
let cur = BITRATES.iter().position(|b| *b == s.bitrate_kbps);
|
||||
step_option(cur, BITRATES.len(), delta, wrap).map(|i| s.bitrate_kbps = BITRATES[i])
|
||||
}
|
||||
RowId::Compositor => step_str(&COMPOSITORS, &mut s.compositor, delta, wrap),
|
||||
RowId::Codec => step_str(&CODECS, &mut s.codec, delta, wrap),
|
||||
RowId::Decoder => step_str(&DECODERS, &mut s.decoder, delta, wrap),
|
||||
RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap),
|
||||
RowId::Audio => {
|
||||
let cur = AUDIO.iter().position(|(v, _)| *v == s.audio_channels);
|
||||
step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0)
|
||||
}
|
||||
RowId::Mic => toggle(&mut s.mic_enabled, delta, wrap),
|
||||
RowId::Pad => {
|
||||
// Automatic first, then every connected pad by stable key.
|
||||
let keys: Vec<String> = std::iter::once(String::new())
|
||||
.chain(ctx.pads.iter().map(|p| p.key.clone()))
|
||||
.collect();
|
||||
let cur = keys.iter().position(|c| *c == s.forward_pad);
|
||||
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
|
||||
}
|
||||
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
|
||||
RowId::Stats => toggle(&mut s.show_stats, delta, wrap),
|
||||
}
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// The shared stepping rule: clamp when adjusting, wrap when cycling; an unknown
|
||||
/// current value snaps to the first option on any step.
|
||||
fn step_option(current: Option<usize>, len: usize, delta: i32, wrap: bool) -> Option<usize> {
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
let Some(cur) = current else { return Some(0) };
|
||||
let target = cur as i32 + delta;
|
||||
if wrap {
|
||||
Some(target.rem_euclid(len as i32) as usize)
|
||||
} else if target < 0 || target >= len as i32 {
|
||||
None
|
||||
} else {
|
||||
Some(target as usize)
|
||||
}
|
||||
}
|
||||
|
||||
fn step_str(options: &[(&str, &str)], value: &mut String, delta: i32, wrap: bool) -> Option<()> {
|
||||
let cur = options.iter().position(|(v, _)| v == value);
|
||||
step_option(cur, options.len(), delta, wrap).map(|i| *value = options[i].0.to_string())
|
||||
}
|
||||
|
||||
fn toggle(value: &mut bool, delta: i32, wrap: bool) -> Option<()> {
|
||||
let target = if wrap { !*value } else { delta > 0 };
|
||||
if *value == target {
|
||||
None
|
||||
} else {
|
||||
*value = target;
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pf_client_core::trust::Settings;
|
||||
|
||||
fn ctx_parts() -> (Settings, Vec<pf_client_core::gamepad::PadInfo>) {
|
||||
(Settings::default(), Vec::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_clamps_and_activate_wraps() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
// Resolution starts at Native (index 0): left refuses, right steps.
|
||||
assert!(!adjust(RowId::Resolution, -1, false, &mut ctx));
|
||||
assert!(adjust(RowId::Resolution, 1, false, &mut ctx));
|
||||
assert_eq!((ctx.settings.width, ctx.settings.height), (1280, 720));
|
||||
// Cycle from the last option wraps to the first.
|
||||
(ctx.settings.width, ctx.settings.height) = (3840, 2160);
|
||||
assert!(adjust(RowId::Resolution, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.width, 0, "wrapped to Native");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggles_read_left_off_right_on() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.mic_enabled = false;
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(
|
||||
!adjust(RowId::Mic, -1, false, &mut ctx),
|
||||
"already off = thud"
|
||||
);
|
||||
assert!(adjust(RowId::Mic, 1, false, &mut ctx));
|
||||
assert!(ctx.settings.mic_enabled);
|
||||
assert!(adjust(RowId::Mic, 1, true, &mut ctx), "A always flips");
|
||||
assert!(!ctx.settings.mic_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_snaps_to_first() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.bitrate_kbps = 12_345; // set via a desktop shell's free-form field
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user