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

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:
2026-07-09 08:46:20 +02:00
parent c3fa6c1514
commit 180ac3aa61
31 changed files with 6406 additions and 1032 deletions
Generated
+1
View File
@@ -2794,6 +2794,7 @@ dependencies = [
"ash",
"pf-client-core",
"pf-presenter",
"punktfunk-core",
"sdl3",
"skia-safe",
"tracing",
+3 -1
View File
@@ -591,7 +591,9 @@ pub fn run() -> glib::ExitCode {
// Streams and the console library live in the session binary now — exec it,
// forwarding the relevant argv (the Decky wrapper keeps working through the shell
// until it's repointed).
if crate::cli::arg_value("--connect").is_some() || crate::cli::arg_value("--browse").is_some() {
// `--browse` may be bare now (the console home — hosts, pairing, settings), so the
// gate is the flag, not a value after it.
if crate::cli::arg_value("--connect").is_some() || crate::cli::arg_flag("--browse") {
return crate::cli::exec_session();
}
+1 -1
View File
@@ -34,7 +34,7 @@ pub fn arg_value(flag: &str) -> Option<String> {
}
/// True if argv contains `flag` (a valueless switch).
fn arg_flag(flag: &str) -> bool {
pub fn arg_flag(flag: &str) -> bool {
std::env::args().any(|a| a == flag)
}
-197
View File
@@ -1,197 +0,0 @@
//! `--browse host[:port]` — the console game library (phase 4b of the plan): the Skia
//! coverflow idles in the session window, A launches the focused title as a stream in
//! the SAME window (no gamescope window handoff — the whole point of one process), the
//! session's end returns to the library, B quits to Gaming Mode.
//!
//! The host must already be paired (the stored pin fetches the library and connects
//! silently; no ceremony can run under gamescope) — an unpaired target renders the
//! pair-first scene. `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds canned entries with no
//! host (portrait paths starting with `/` load from disk), the GPU-only dev path.
use crate::session_main::{
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, window_pos,
};
use pf_client_core::{library, trust};
use pf_console_ui::{LibraryGame, LibraryPhase, LibraryShared, SkiaOverlay};
use pf_presenter::overlay::OverlayAction;
use pf_presenter::ActionOutcome;
use std::collections::VecDeque;
pub fn run(target: &str) -> u8 {
let (addr, port) = parse_host_port(target);
let known = trust::KnownHosts::load();
let k = known
.hosts
.iter()
.find(|h| h.addr == addr && h.port == port);
let host_label = k.map_or_else(|| addr.clone(), |h| h.name.clone());
let paired = k.is_some_and(|h| h.paired);
let pin = k.and_then(|h| trust::parse_hex32(&h.fp_hex));
let mgmt = arg_value("--mgmt")
.and_then(|p| p.parse().ok())
.unwrap_or(library::DEFAULT_MGMT_PORT);
let identity = match trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
eprintln!("client identity: {e:#}");
return crate::session_main::EXIT_CONNECT_FAILED;
}
};
let settings = trust::Settings::load();
let (overlay, shared) = match SkiaOverlay::with_library(host_label.clone()) {
Ok(v) => v,
Err(e) => {
eprintln!("console UI: {e:#}");
return crate::session_main::EXIT_PRESENTER_FAILED;
}
};
// The library fetch — paired hosts only (the fake-library hook exists precisely for
// host-less/pairing-less UI work).
let fake = std::env::var_os("PUNKTFUNK_FAKE_LIBRARY").is_some();
if paired || fake {
spawn_fetch(shared.clone(), addr.clone(), mgmt, identity.clone(), pin);
} else {
shared.set_phase(LibraryPhase::PairFirst);
}
// `--json-status`: a shell parent is reading stdout (the WinUI shell hides itself on
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
let json_status = arg_flag("--json-status");
let opts = pf_presenter::SessionOpts {
window_title: format!("Punktfunk · {host_label}"),
fullscreen: fullscreen_mode(),
window_pos: window_pos(),
print_stats: settings.show_stats || arg_flag("--stats"),
json_status,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
trust::touch_last_used(&trust::hex(&fingerprint));
})),
overlay: Some(Box::new(overlay)),
};
let result =
pf_presenter::run_browse(opts, |action, gamepad, native, force_software, vulkan| {
match action {
OverlayAction::Launch { id, title } => {
// The carousel only renders for a paired host, so the pin exists; the
// guard keeps a logic slip from turning into a pinless connect.
let Some(pin) = pin else {
tracing::warn!("launch without a stored pin — refusing");
return ActionOutcome::Handled;
};
tracing::info!(%id, %title, "launching from the library");
ActionOutcome::Start(Box::new(session_params(
&settings,
addr.clone(),
port,
pin,
identity.clone(),
Some(id),
gamepad,
native,
force_software,
vulkan,
)))
}
OverlayAction::Retry => {
spawn_fetch(shared.clone(), addr.clone(), mgmt, identity.clone(), pin);
ActionOutcome::Handled
}
OverlayAction::Quit => ActionOutcome::Quit,
}
});
match result {
Ok(()) => 0,
Err(e) => {
// The shell contract's terminal line (a clean quit needs none — stdout EOF
// already routes the shell back to its host list silently).
if json_status {
crate::session_main::json_line("error", &format!("{e:#}"), Some(false));
}
eprintln!("browse: {e:#}");
crate::session_main::EXIT_PRESENTER_FAILED
}
}
}
/// Fetch the library off the main thread, then stream poster art into the shared model
/// as results land (the GTK launcher's `load` + `load_art`, minus the main-loop hops —
/// the renderer drains `push_art` per frame).
fn spawn_fetch(
shared: LibraryShared,
addr: String,
mgmt: u16,
identity: (String, String),
pin: Option<[u8; 32]>,
) {
shared.set_phase(LibraryPhase::Loading);
std::thread::Builder::new()
.name("punktfunk-library".into())
.spawn(move || {
if let Ok(path) = std::env::var("PUNKTFUNK_FAKE_LIBRARY") {
load_fake(&shared, &path);
return;
}
match library::fetch_games(&addr, mgmt, &identity, pin) {
Ok(games) => {
let base = library::base_url(&addr, mgmt);
let jobs: VecDeque<(String, Vec<String>)> = games
.iter()
.map(|g| (g.id.clone(), g.art.poster_candidates(&base)))
.filter(|(_, candidates)| !candidates.is_empty())
.collect();
shared.set_games(
games
.iter()
.map(|g| LibraryGame {
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
})
.collect(),
);
if !jobs.is_empty() {
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
while let Ok((id, bytes)) = rx.recv_blocking() {
shared.push_art(id, bytes);
}
}
}
Err(e) => shared.set_phase(LibraryPhase::Error {
title: "Couldn't load the library".into(),
body: e.to_string(),
can_retry: true,
}),
}
})
.ok();
}
/// Dev hook: entries from a JSON file; portrait paths starting with `/` load from disk.
fn load_fake(shared: &LibraryShared, path: &str) {
let games: Vec<library::GameEntry> = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
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) {
shared.push_art(g.id.clone(), bytes);
}
}
}
shared.set_games(
games
.iter()
.map(|g| LibraryGame {
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
})
.collect(),
);
}
+703
View File
@@ -0,0 +1,703 @@
//! `--browse [host[:port]]` — the console shell. Bare `--browse` opens the host list
//! (discovery, pairing, settings, wake — the whole couch flow); with a target it opens
//! straight into that host's library (the Decky per-host launch), B backing out to the
//! list. A launches in the SAME window (no gamescope window handoff — the whole point
//! of one process), the session's end returns to the console, B at the root quits to
//! Gaming Mode.
//!
//! This file is the console's SERVICE side: the shell (pf-console-ui) renders and
//! raises [`ConsoleCmd`]s; worker threads here run everything that blocks — mDNS
//! discovery, reachability probes, the SPAKE2 pairing ceremony, wake-on-LAN loops,
//! library fetches, known-hosts persistence — and write results into the shared
//! models. `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds canned entries with no host
//! (portrait paths starting with `/` load from disk), the GPU-only dev path.
use crate::session_main::{
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, window_pos,
};
use pf_client_core::gamepad::is_steam_deck;
use pf_client_core::{discovery, library, trust, wol};
use pf_console_ui::{
ConsoleCmd, ConsoleEntry, ConsoleHandles, ConsoleOptions, ConsoleShared, HostRow, LibraryGame,
LibraryPhase, LibraryShared, PairPhase, SkiaOverlay, WakeStatus,
};
use pf_presenter::overlay::OverlayAction;
use pf_presenter::ActionOutcome;
use std::collections::{HashMap, VecDeque};
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
pub fn run(target: Option<&str>) -> u8 {
let identity = match trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
eprintln!("client identity: {e:#}");
return crate::session_main::EXIT_CONNECT_FAILED;
}
};
// Resolve the entry point: a paired target opens straight into its library; an
// unpaired/unknown one lands on Home with the target seeded into the list (one A
// from pairing). The fake-library hook fabricates a paired host with no network.
let fake = std::env::var_os("PUNKTFUNK_FAKE_LIBRARY").is_some();
let known = trust::KnownHosts::load();
let mut seed: Option<HostRow> = None;
let (entry, window_label) = match target {
Some(target) => {
let (addr, port) = parse_host_port(target);
let k = known
.hosts
.iter()
.find(|h| h.addr == addr && h.port == port);
let row = HostRow {
key: k
.filter(|h| !h.fp_hex.is_empty())
.map_or_else(|| format!("{addr}:{port}"), |h| h.fp_hex.clone()),
name: k
.map(|h| host_display_name(&h.name, &h.addr))
.unwrap_or_else(|| addr.clone()),
addr: addr.clone(),
port,
fp_hex: k.map(|h| h.fp_hex.clone()).unwrap_or_default(),
paired: k.is_some_and(|h| h.paired) || fake,
saved: k.is_some(),
online: false,
mgmt_port: arg_value("--mgmt")
.and_then(|p| p.parse().ok())
.unwrap_or(library::DEFAULT_MGMT_PORT),
can_wake: false,
last_used: k.and_then(|h| h.last_used),
};
let label = row.name.clone();
if k.is_none() {
seed = Some(row.clone());
}
if row.paired {
(ConsoleEntry::Library(row), Some(label))
} else {
(ConsoleEntry::Home, Some(label))
}
}
None if fake => {
let row = fake_host_row();
(ConsoleEntry::Library(row), None)
}
None => (ConsoleEntry::Home, None),
};
let initial_fetch = match &entry {
ConsoleEntry::Library(h) => Some(ConsoleCmd::FetchLibrary {
addr: h.addr.clone(),
mgmt: h.mgmt_port,
fp_hex: h.fp_hex.clone(),
}),
ConsoleEntry::Home => None,
};
let opts = ConsoleOptions {
device_name: device_name(),
deck: is_steam_deck(),
};
let (overlay, handles) = match SkiaOverlay::console(opts, entry) {
Ok(v) => v,
Err(e) => {
eprintln!("console UI: {e:#}");
return crate::session_main::EXIT_PRESENTER_FAILED;
}
};
let ConsoleHandles {
console,
library: library_model,
bus,
} = handles;
// The service loop: discovery, probes, wake, pairing, persistence, fetches.
let service = Service::start(
console.clone(),
library_model.clone(),
bus.clone(),
identity.clone(),
seed,
);
if let Some(cmd) = initial_fetch {
bus.send(cmd);
}
// `--json-status`: a shell parent is reading stdout (the WinUI shell hides itself on
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
let json_status = arg_flag("--json-status");
let settings_at_start = trust::Settings::load();
let opts = pf_presenter::SessionOpts {
window_title: window_label.map_or_else(
|| "Punktfunk".to_string(),
|label| format!("Punktfunk · {label}"),
),
fullscreen: fullscreen_mode(),
window_pos: window_pos(),
print_stats: settings_at_start.show_stats || arg_flag("--stats"),
json_status,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
trust::touch_last_used(&trust::hex(&fingerprint));
})),
overlay: Some(Box::new(overlay)),
};
let result =
pf_presenter::run_browse(opts, |action, gamepad, native, force_software, vulkan| {
match action {
OverlayAction::Launch {
addr,
port,
fp_hex,
launch,
title,
} => {
let Some(pin) = trust::parse_hex32(&fp_hex) else {
// The console only offers Connect on paired rows; a pinless
// launch is a logic slip, never a silent TOFU.
tracing::warn!(%addr, "launch without a stored pin — refusing");
return ActionOutcome::Handled;
};
tracing::info!(%addr, %title, launch = launch.as_deref().unwrap_or("desktop"),
"launching from the console");
// Settings re-load per launch: the console's own settings screen
// may have changed them since the last stream.
let settings = trust::Settings::load();
ActionOutcome::Start(Box::new(session_params(
&settings,
addr,
port,
pin,
identity.clone(),
launch,
gamepad,
native,
force_software,
vulkan,
)))
}
OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side
OverlayAction::Quit => ActionOutcome::Quit,
}
});
service.stop();
match result {
Ok(()) => 0,
Err(e) => {
// The shell contract's terminal line (a clean quit needs none — stdout EOF
// already routes the shell back to its host list silently).
if json_status {
crate::session_main::json_line("error", &format!("{e:#}"), Some(false));
}
eprintln!("console: {e:#}");
crate::session_main::EXIT_PRESENTER_FAILED
}
}
}
/// The machine's name — what the host lists this client as after pairing.
fn device_name() -> String {
#[cfg(target_os = "linux")]
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "This device".into())
}
fn host_display_name(name: &str, addr: &str) -> String {
if name.trim().is_empty() {
addr.to_string()
} else {
name.to_string()
}
}
fn fake_host_row() -> HostRow {
HostRow {
key: "fake".into(),
name: "Demo Host".into(),
addr: "127.0.0.1".into(),
port: 9777,
fp_hex: String::new(),
paired: true,
saved: true,
online: true,
mgmt_port: library::DEFAULT_MGMT_PORT,
can_wake: false,
last_used: None,
}
}
/// The background service: owns discovery, probing, waking, pairing and persistence.
struct Service {
stop: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Service {
fn start(
console: ConsoleShared,
library_model: LibraryShared,
bus: pf_console_ui::ConsoleBus,
identity: (String, String),
seed: Option<HostRow>,
) -> Service {
let stop = Arc::new(AtomicBool::new(false));
let stop_w = stop.clone();
let thread = std::thread::Builder::new()
.name("punktfunk-console".into())
.spawn(move || {
ServiceState {
console,
library: library_model,
bus,
identity,
seed,
discovered: HashMap::new(),
probed: Arc::new(Mutex::new(HashMap::new())),
probe_inflight: Arc::new(AtomicBool::new(false)),
last_probe: Instant::now() - Duration::from_secs(60),
wake_cancel: None,
}
.run(stop_w)
})
.ok();
Service { stop, thread }
}
fn stop(mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
struct ServiceState {
console: ConsoleShared,
library: LibraryShared,
bus: pf_console_ui::ConsoleBus,
identity: (String, String),
/// A `--browse` target that isn't in the store yet — kept on the list until the
/// store or discovery covers it.
seed: Option<HostRow>,
discovered: HashMap<String, discovery::DiscoveredHost>,
/// Probe results by row key, written by sweep threads.
probed: Arc<Mutex<HashMap<String, bool>>>,
probe_inflight: Arc<AtomicBool>,
last_probe: Instant,
/// Cancels the active wake thread (it owns the model's wake status).
wake_cancel: Option<Arc<AtomicBool>>,
}
impl ServiceState {
fn run(mut self, stop: Arc<AtomicBool>) {
let discovery_rx = discovery::browse();
while !stop.load(Ordering::SeqCst) {
// mDNS churn.
while let Ok(ev) = discovery_rx.try_recv() {
match ev {
discovery::DiscoveryEvent::Resolved(host) => {
self.discovered.insert(host.fullname.clone(), host);
}
discovery::DiscoveryEvent::Removed { fullname } => {
self.discovered.remove(&fullname);
}
}
}
// Shell commands (plus the binary's own seeded initial fetch).
for cmd in self.bus.drain() {
self.handle(cmd);
}
// The 10 s reachability sweep — saved hosts that don't advertise (routed /
// multicast-filtered networks) still get honest presence pips.
if self.last_probe.elapsed() >= Duration::from_secs(10) {
self.last_probe = Instant::now();
self.sweep();
}
self.console.set_hosts(self.rows());
std::thread::sleep(Duration::from_millis(100));
}
if let Some(c) = &self.wake_cancel {
c.store(true, Ordering::SeqCst);
}
}
fn handle(&mut self, cmd: ConsoleCmd) {
match cmd {
ConsoleCmd::FetchLibrary { addr, mgmt, fp_hex } => {
spawn_fetch(
self.library.clone(),
addr,
mgmt,
self.identity.clone(),
trust::parse_hex32(&fp_hex),
);
}
ConsoleCmd::Pair {
addr,
port,
pin,
device_name,
} => {
// Prefer what the list already calls this host (advert or store).
let name = self
.rows()
.into_iter()
.find(|r| r.addr == addr && r.port == port)
.map_or_else(|| addr.clone(), |r| r.name);
self.console.set_pair(PairPhase::Busy);
let console = self.console.clone();
let identity = self.identity.clone();
std::thread::Builder::new()
.name("punktfunk-pair".into())
.spawn(move || {
match trust::pair_with_host(&addr, port, &identity, &pin, &device_name) {
Ok(fp) => {
let fp_hex = trust::hex(&fp);
trust::persist_host(&name, &addr, port, &fp_hex, true);
console.set_pair(PairPhase::Paired { key: fp_hex });
}
Err(e) => {
let msg = match e {
punktfunk_core::PunktfunkError::Crypto => {
"Wrong PIN — check the host's Pairing page and try again."
.to_string()
}
punktfunk_core::PunktfunkError::Timeout => {
"The host didn't answer. Is it running and reachable?"
.to_string()
}
other => format!("Pairing failed: {other:?}"),
};
console.set_pair(PairPhase::Failed(msg));
}
}
})
.ok();
}
ConsoleCmd::SaveHost { name, addr, port } => {
let mut known = trust::KnownHosts::load();
// Manual entries have no fingerprint yet, so `upsert` (fp-keyed) would
// collide two of them — key manual saves by address instead.
if let Some(h) = known
.hosts
.iter_mut()
.find(|h| h.addr == addr && h.port == port)
{
if !name.is_empty() {
h.name = name;
}
} else {
known.hosts.push(trust::KnownHost {
name: if name.is_empty() { addr.clone() } else { name },
addr,
port,
fp_hex: String::new(),
paired: false,
last_used: None,
mac: Vec::new(),
});
}
if let Err(e) = known.save() {
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
}
self.last_probe = Instant::now() - Duration::from_secs(60); // probe it now
}
ConsoleCmd::Wake { key, then_connect } => {
if let Some(c) = self.wake_cancel.take() {
c.store(true, Ordering::SeqCst);
}
let Some(row) = self.rows().into_iter().find(|r| r.key == key) else {
return;
};
let known = trust::KnownHosts::load();
let macs = known
.hosts
.iter()
.find(|h| {
h.fp_hex == row.fp_hex && !row.fp_hex.is_empty()
|| (h.addr == row.addr && h.port == row.port)
})
.map(|h| h.mac.clone())
.unwrap_or_default();
if macs.is_empty() {
self.console.set_pair(PairPhase::Idle); // no-op; keep state sane
return;
}
let cancel = Arc::new(AtomicBool::new(false));
self.wake_cancel = Some(cancel.clone());
spawn_wake(self.console.clone(), row, macs, then_connect, cancel);
}
ConsoleCmd::CancelWake => {
if let Some(c) = self.wake_cancel.take() {
c.store(true, Ordering::SeqCst);
}
self.console.set_wake(None);
}
ConsoleCmd::Probe => {
self.last_probe = Instant::now() - Duration::from_secs(60);
}
}
}
/// One parallel reachability pass over every non-advertising row (advertising ones
/// are online by definition). Runs on its own thread; at most one in flight.
fn sweep(&self) {
if self.probe_inflight.swap(true, Ordering::SeqCst) {
return;
}
let targets: Vec<(String, (String, u16))> = self
.rows()
.into_iter()
.filter(|r| !self.advertised(r))
.map(|r| (r.key.clone(), (r.addr.clone(), r.port)))
.collect();
let probed = self.probed.clone();
let inflight = self.probe_inflight.clone();
std::thread::Builder::new()
.name("punktfunk-probe".into())
.spawn(move || {
let (keys, addrs): (Vec<_>, Vec<_>) = targets.into_iter().unzip();
let results = trust::probe_reachable_many(addrs, Duration::from_millis(900));
let mut map = probed.lock().unwrap();
for (key, ok) in keys.into_iter().zip(results) {
map.insert(key, ok);
}
inflight.store(false, Ordering::SeqCst);
})
.ok();
}
fn advertised(&self, row: &HostRow) -> bool {
self.discovered.values().any(|d| {
(!row.fp_hex.is_empty() && d.fp_hex == row.fp_hex)
|| (d.addr == row.addr && d.port == row.port)
})
}
/// The console home's rows: saved hosts (most recent first), then
/// discovered-but-unsaved ones, then a still-uncovered `--browse` seed.
fn rows(&self) -> Vec<HostRow> {
let known = trust::KnownHosts::load();
let probed = self.probed.lock().unwrap();
let mut rows: Vec<HostRow> = known
.hosts
.iter()
.map(|h| {
let key = if h.fp_hex.is_empty() {
format!("{}:{}", h.addr, h.port)
} else {
h.fp_hex.clone()
};
let advert = self.discovered.values().find(|d| {
(!h.fp_hex.is_empty() && d.fp_hex == h.fp_hex)
|| (d.addr == h.addr && d.port == h.port)
});
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
HostRow {
key,
name: host_display_name(&h.name, &h.addr),
addr: h.addr.clone(),
port: h.port,
fp_hex: h.fp_hex.clone(),
paired: h.paired,
saved: true,
online,
mgmt_port: advert
.and_then(|d| d.mgmt_port)
.unwrap_or(library::DEFAULT_MGMT_PORT),
can_wake: !online && !h.mac.is_empty(),
last_used: h.last_used,
}
})
.collect();
rows.sort_by(|a, b| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
let mut extra: Vec<HostRow> = self
.discovered
.values()
.filter(|d| {
!known.hosts.iter().any(|h| {
(!h.fp_hex.is_empty() && h.fp_hex == d.fp_hex)
|| (h.addr == d.addr && h.port == d.port)
})
})
.map(|d| HostRow {
key: if d.fp_hex.is_empty() {
format!("{}:{}", d.addr, d.port)
} else {
d.fp_hex.clone()
},
name: host_display_name(&d.name, &d.addr),
addr: d.addr.clone(),
port: d.port,
fp_hex: d.fp_hex.clone(),
paired: false,
saved: false,
online: true,
mgmt_port: d.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
can_wake: false,
last_used: None,
})
.collect();
extra.sort_by(|a, b| a.name.cmp(&b.name));
rows.extend(extra);
if let Some(seed) = &self.seed {
if !rows
.iter()
.any(|r| r.addr == seed.addr && r.port == seed.port)
{
let mut seed = seed.clone();
seed.online = probed.get(&seed.key).copied().unwrap_or(false);
rows.push(seed);
}
}
rows
}
}
/// The wake-and-wait loop (one per wake): re-send the magic packet every 6 s, probe the
/// host once a second, 90 s timeout — the Apple `HostWaker`'s cadence. The thread owns
/// the model's wake status; the shell reads `online`/`timed_out` and acts.
fn spawn_wake(
console: ConsoleShared,
row: HostRow,
macs: Vec<String>,
then_connect: bool,
cancel: Arc<AtomicBool>,
) {
std::thread::Builder::new()
.name("punktfunk-wake".into())
.spawn(move || {
let last_ip = row.addr.parse::<Ipv4Addr>().ok();
let started = Instant::now();
let mut last_packet: Option<Instant> = None;
loop {
if cancel.load(Ordering::SeqCst) {
console.set_wake(None);
return;
}
let elapsed = started.elapsed();
let timed_out = elapsed >= Duration::from_secs(90);
if !timed_out && last_packet.is_none_or(|t| t.elapsed() >= Duration::from_secs(6)) {
wol::wake(&macs, last_ip);
last_packet = Some(Instant::now());
}
let online = trust::probe_reachable_many(
vec![(row.addr.clone(), row.port)],
Duration::from_millis(900),
)
.first()
.copied()
.unwrap_or(false);
console.set_wake(Some(WakeStatus {
key: row.key.clone(),
name: row.name.clone(),
seconds: elapsed.as_secs() as u32,
timed_out,
online,
then_connect,
}));
if online || timed_out {
// Awake → the shell connects and cancels; timed out → the card
// waits for Try Again / Cancel. Either way this thread is done —
// a retry spawns a fresh one.
return;
}
std::thread::sleep(Duration::from_millis(1000));
}
})
.ok();
}
/// Fetch the library off the service thread, then stream poster art into the shared
/// model as results land (the renderer drains `push_art` per frame).
fn spawn_fetch(
shared: LibraryShared,
addr: String,
mgmt: u16,
identity: (String, String),
pin: Option<[u8; 32]>,
) {
shared.set_phase(LibraryPhase::Loading);
std::thread::Builder::new()
.name("punktfunk-library".into())
.spawn(move || {
if let Ok(path) = std::env::var("PUNKTFUNK_FAKE_LIBRARY") {
load_fake(&shared, &path);
return;
}
match library::fetch_games(&addr, mgmt, &identity, pin) {
Ok(games) => {
let base = library::base_url(&addr, mgmt);
let jobs: VecDeque<(String, Vec<String>)> = games
.iter()
.map(|g| (g.id.clone(), g.art.poster_candidates(&base)))
.filter(|(_, candidates)| !candidates.is_empty())
.collect();
shared.set_games(
games
.iter()
.map(|g| LibraryGame {
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
})
.collect(),
);
if !jobs.is_empty() {
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
while let Ok((id, bytes)) = rx.recv_blocking() {
shared.push_art(id, bytes);
}
}
}
Err(e) => shared.set_phase(LibraryPhase::Error {
title: "Couldn't load the library".into(),
body: e.to_string(),
can_retry: true,
}),
}
})
.ok();
}
/// Dev hook: entries from a JSON file; portrait paths starting with `/` load from disk.
fn load_fake(shared: &LibraryShared, path: &str) {
let games: Vec<library::GameEntry> = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
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) {
shared.push_art(g.id.clone(), bytes);
}
}
}
shared.set_games(
games
.iter()
.map(|g| LibraryGame {
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
})
.collect(),
);
}
+12 -8
View File
@@ -14,7 +14,7 @@
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
mod browse;
mod console;
#[cfg(any(target_os = "linux", windows))]
mod session_main {
@@ -244,9 +244,12 @@ mod session_main {
}
}
if let Some(target) = arg_value("--browse") {
if arg_flag("--browse") {
// Bare `--browse` opens the console home (hosts, pairing, settings);
// `--browse host[:port]` opens straight into that host's library.
let target = arg_value("--browse");
#[cfg(feature = "ui")]
return crate::browse::run(&target);
return crate::console::run(target.as_deref());
#[cfg(not(feature = "ui"))]
{
let _ = target;
@@ -260,12 +263,13 @@ mod session_main {
let Some(target) = arg_value("--connect") else {
eprintln!(
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\
\x20 punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen] [--json-status]\n\
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
\n\
Streams from a paired punktfunk host in a Vulkan window; --browse opens the\n\
console game library instead (paired hosts only). Pair first via the\n\
desktop client or `punktfunk-client --pair <PIN> --connect host[:port]` —\n\
this binary never connects to a host it has no pinned fingerprint for."
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\
gamepad console instead: bare --browse is the host list (discovery, PIN\n\
pairing, settings, wake-on-LAN); with a target it opens that host's game\n\
library. --connect never dials a host it has no pinned fingerprint for\n\
pair in the console or via `punktfunk-client --pair <PIN> --connect …`."
);
return EXIT_CONNECT_FAILED;
};
+3 -2
View File
@@ -360,8 +360,9 @@ impl Default for Settings {
impl Settings {
fn path() -> Result<PathBuf> {
// The shell's settings file on each OS: the GTK shell's on Linux, the WinUI
// shell's on Windows. The shells own (and write) these files through this one
// struct; the session binary only reads them and must never call `save`.
// shell's on Windows. The desktop shells AND the session binary's console
// settings screen write it (load-modify-save per change — Gaming Mode has no
// other editor); a plain `--connect` stream only ever reads.
#[cfg(windows)]
return Ok(config_dir()?.join("client-windows-settings.json"));
#[cfg(not(windows))]
+2
View File
@@ -23,6 +23,8 @@ ash = { version = "0.38", features = ["loaded"] }
anyhow = "1"
tracing = "0.1"
# `config::GamepadPref` keys the button-glyph style (PlayStation shapes vs ABXY).
punktfunk-core = { path = "../punktfunk-core" }
# Linux links the system SDL3; Windows builds it from source (same choice as the rest
# of the workspace's Windows SDL consumers).
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+124
View File
@@ -0,0 +1,124 @@
//! The console shell's motion vocabulary. Two kinds of movement, deliberately kept
//! apart: **springs** (`Spring`, wrapping `library::spring_advance`) for anything the
//! user pushes around — cursors, trays, recoil — where velocity must carry across
//! retargets; and **timed progressions** (`Progress` + the easing functions) for
//! fire-and-forget choreography — screen entrances/exits, fades — where a deterministic
//! duration matters more than momentum.
use crate::library::spring_advance;
/// Ease-out cubic — fast start, gentle landing. The screen-transition curve (the WinUI
/// shell's entrance tween uses the same shape).
pub(crate) fn ease_out_cubic(t: f64) -> f64 {
let u = 1.0 - t.clamp(0.0, 1.0);
1.0 - u * u * u
}
/// Exponential approach: move `current` toward `target` with time-constant `tau`
/// seconds. Frame-rate independent, never overshoots — the focus-scale smoothing
/// (SwiftUI's `.smooth(0.18)` reads the same).
pub(crate) fn approach(current: f64, target: f64, dt: f64, tau: f64) -> f64 {
current + (target - current) * (1.0 - (-dt / tau).exp())
}
/// A damped spring with persistent velocity. `k`/`c` choose the feel; see the pairs in
/// [`crate::library`] (cursor chase, boundary bump) and [`TRAY_K`]/[`TRAY_C`] below.
#[derive(Clone, Copy)]
pub(crate) struct Spring {
pub pos: f64,
pub vel: f64,
}
impl Spring {
pub(crate) fn rest(pos: f64) -> Spring {
Spring { pos, vel: 0.0 }
}
pub(crate) fn step(&mut self, target: f64, k: f64, c: f64, dt: f64) {
(self.pos, self.vel) = spring_advance(self.pos, self.vel, target, k, c, dt);
}
/// Snap onto `target` once the motion is imperceptible (stops per-frame damage).
pub(crate) fn settle(&mut self, target: f64, eps_pos: f64, eps_vel: f64) {
if (target - self.pos).abs() < eps_pos && self.vel.abs() < eps_vel {
self.pos = target;
self.vel = 0.0;
}
}
}
/// The keyboard tray's slide (SwiftUI `.spring(response: 0.32, dampingFraction: 0.86)`:
/// k = (2π/response)², c = 2·ζ·√k).
pub(crate) const TRAY_K: f64 = 385.0;
pub(crate) const TRAY_C: f64 = 33.7;
/// A clamped 0→1 timer for fire-and-forget choreography. `advance` returns the RAW
/// progress — callers apply their easing so one Progress can drive several curves.
#[derive(Clone, Copy)]
pub(crate) struct Progress {
t: f64,
duration: f64,
}
impl Progress {
pub(crate) fn new(duration: f64) -> Progress {
Progress { t: 0.0, duration }
}
pub(crate) fn advance(&mut self, dt: f64) -> f64 {
self.t = (self.t + dt / self.duration).min(1.0);
self.t
}
pub(crate) fn value(&self) -> f64 {
self.t
}
pub(crate) fn done(&self) -> bool {
self.t >= 1.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ease_out_cubic_shape() {
assert_eq!(ease_out_cubic(0.0), 0.0);
assert_eq!(ease_out_cubic(1.0), 1.0);
assert!(ease_out_cubic(0.5) > 0.5, "front-loaded");
assert_eq!(ease_out_cubic(2.0), 1.0, "clamped");
}
#[test]
fn approach_converges_and_never_overshoots() {
let mut v = 0.0;
for _ in 0..120 {
v = approach(v, 1.0, 1.0 / 60.0, 0.06);
assert!(v <= 1.0);
}
assert!((v - 1.0).abs() < 1e-6);
}
#[test]
fn progress_completes_on_time() {
let mut p = Progress::new(0.3);
let mut steps = 0;
while !p.done() {
p.advance(1.0 / 60.0);
steps += 1;
}
assert!((17..=19).contains(&steps), "{steps}"); // 0.3 s at 60 Hz
}
#[test]
fn spring_settles() {
let mut s = Spring::rest(0.0);
for _ in 0..240 {
s.step(1.0, 200.0, 24.0, 1.0 / 60.0);
s.settle(1.0, 0.001, 0.01);
}
assert_eq!((s.pos, s.vel), (1.0, 0.0));
}
}
+354
View File
@@ -0,0 +1,354 @@
//! Controller button glyphs and the hint bar — the "controls legend" pill every console
//! screen pins bottom-leading (the Apple client resolves real SF glyphs per pad via
//! `sfSymbolsName`; here the shapes are drawn). The style follows the ACTIVE pad:
//! PlayStation controllers read ✕/○/□/△, everything else reads ABXY letters, and with
//! no pad at all the legend swaps to keyboard keycaps — the console stays fully
//! drivable either way.
use crate::theme::{white, Fonts, W};
use punktfunk_core::config::GamepadPref;
use skia_safe::{Canvas, Color4f, Paint, Path, Point, RRect, Rect};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GlyphStyle {
/// ABXY letter badges (Xbox / Steam Deck / generic).
Letters,
/// PlayStation face shapes (DualSense / DualShock 4).
Shapes,
/// No controller — keyboard keycaps.
Keyboard,
}
impl GlyphStyle {
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
match pref {
Some(GamepadPref::DualSense | GamepadPref::DualShock4) => GlyphStyle::Shapes,
Some(_) => GlyphStyle::Letters,
None => GlyphStyle::Keyboard,
}
}
}
/// What a hint's glyph depicts. `Key` renders a literal keycap chip in any style (used
/// for keyboard fallbacks and the Deck's "Steam + X" keyboard chord).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum HintKey {
Confirm,
Back,
Secondary,
Tertiary,
Shoulders,
/// ◀ ▶ — left/right adjusts the focused value.
Adjust,
Key(&'static str),
}
pub(crate) struct Hint {
pub key: HintKey,
pub label: String,
}
impl Hint {
pub(crate) fn new(key: HintKey, label: impl Into<String>) -> Hint {
Hint {
key,
label: label.into(),
}
}
}
const LABEL_SIZE: f64 = 14.0;
const BADGE_D: f64 = 22.0; // face-button badge diameter
/// The hint bar pill, anchored at its BOTTOM-LEFT corner. Returns the pill's size.
pub(crate) fn hint_bar(
canvas: &Canvas,
fonts: &Fonts,
hints: &[Hint],
style: GlyphStyle,
x: f64,
bottom: f64,
k: f64,
) -> (f64, f64) {
if hints.is_empty() {
return (0.0, 0.0);
}
let pad = 13.0 * k;
let gap_hint = 18.0 * k;
let gap_glyph = 7.0 * k;
let widths: Vec<(f64, f64)> = hints
.iter()
.map(|h| {
(
glyph_width(fonts, h.key, style, k),
fonts.measure(&h.label, W::SemiBold, LABEL_SIZE * k) as f64,
)
})
.collect();
let content_w: f64 = widths.iter().map(|(g, l)| g + gap_glyph + l).sum::<f64>()
+ gap_hint * (hints.len() - 1) as f64;
let h = BADGE_D * k + 2.0 * pad;
let w = content_w + 2.0 * pad;
let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.30), None),
);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&Paint::new(white(0.06), None),
);
let mut sp = Paint::new(white(0.12), None);
sp.set_style(skia_safe::PaintStyle::Stroke);
sp.set_stroke_width(1.0);
sp.set_anti_alias(true);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&sp,
);
let cy = bottom - h / 2.0;
let mut pen = x + pad;
for (hint, (gw, lw)) in hints.iter().zip(&widths) {
draw_glyph(canvas, fonts, hint.key, style, pen, cy, k);
pen += gw + gap_glyph;
// Baseline centered on the badge (cap height ≈ 0.72 em for Geist).
fonts.draw(
canvas,
&hint.label,
pen,
cy + LABEL_SIZE * k * 0.36,
W::SemiBold,
LABEL_SIZE * k,
white(0.85),
);
pen += lw + gap_hint;
}
(w, h)
}
fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
match resolved(key, style) {
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
Resolved::Key(text) => keycap_w(fonts, text, k),
}
}
fn shoulder_w(fonts: &Fonts, k: f64) -> f64 {
fonts.measure("L1", W::SemiBold, 10.0 * k) as f64 + 10.0 * k
}
fn keycap_w(fonts: &Fonts, text: &str, k: f64) -> f64 {
fonts.measure(text, W::SemiBold, 11.0 * k) as f64 + 14.0 * k
}
/// A hint key resolved against the glyph style.
enum Resolved {
/// A face-button badge: the letter (Letters) or shape index (Shapes).
Badge(Face),
Shoulders,
Adjust,
Key(&'static str),
}
#[derive(Clone, Copy)]
enum Face {
A,
B,
X,
Y,
}
fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
if style == GlyphStyle::Keyboard {
return match key {
HintKey::Confirm => Resolved::Key("Enter"),
HintKey::Back => Resolved::Key("Esc"),
HintKey::Secondary => Resolved::Key("Y"),
HintKey::Tertiary => Resolved::Key("X"),
HintKey::Shoulders => Resolved::Key("PgUp/PgDn"),
HintKey::Adjust => Resolved::Adjust,
HintKey::Key(t) => Resolved::Key(t),
};
}
match key {
HintKey::Confirm => Resolved::Badge(Face::A),
HintKey::Back => Resolved::Badge(Face::B),
HintKey::Tertiary => Resolved::Badge(Face::X),
HintKey::Secondary => Resolved::Badge(Face::Y),
HintKey::Shoulders => Resolved::Shoulders,
HintKey::Adjust => Resolved::Adjust,
HintKey::Key(t) => Resolved::Key(t),
}
}
/// Draw one glyph with its LEFT edge at `x`, vertically centered on `cy`.
fn draw_glyph(
canvas: &Canvas,
fonts: &Fonts,
key: HintKey,
style: GlyphStyle,
x: f64,
cy: f64,
k: f64,
) {
match resolved(key, style) {
Resolved::Badge(face) => {
let r = BADGE_D * k / 2.0;
let center = Point::new((x + r) as f32, cy as f32);
canvas.draw_circle(center, r as f32, &Paint::new(white(0.10), None));
let mut ring = Paint::new(white(0.32), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width((1.2 * k) as f32);
ring.set_anti_alias(true);
canvas.draw_circle(center, r as f32, &ring);
if style == GlyphStyle::Shapes {
draw_ps_shape(canvas, face, center, (4.6 * k) as f32, (1.7 * k) as f32);
} else {
let letter = match face {
Face::A => "A",
Face::B => "B",
Face::X => "X",
Face::Y => "Y",
};
let size = 12.0 * k;
let w = fonts.measure(letter, W::SemiBold, size) as f64;
fonts.draw(
canvas,
letter,
x + r - w / 2.0,
cy + size * 0.36,
W::SemiBold,
size,
white(0.92),
);
}
}
Resolved::Shoulders => {
let mut pen = x;
for label in ["L1", "R1"] {
let w = shoulder_w(fonts, k);
let h = 15.0 * k;
let rect = Rect::from_xywh(pen as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (4.0 * k) as f32, (4.0 * k) as f32),
&Paint::new(white(0.10), None),
);
let size = 10.0 * k;
let tw = fonts.measure(label, W::SemiBold, size) as f64;
fonts.draw(
canvas,
label,
pen + (w - tw) / 2.0,
cy + size * 0.36,
W::SemiBold,
size,
white(0.92),
);
pen += w + 3.0 * k;
}
}
Resolved::Adjust => {
// ◀ ▶ — two small solid triangles.
let r = BADGE_D * k / 2.0;
let (cx, cyf) = ((x + r) as f32, cy as f32);
let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32);
let gap = (2.6 * k) as f32;
let paint = Paint::new(white(0.85), None);
let mut left = Path::new();
left.move_to((cx - gap, cyf - th));
left.line_to((cx - gap - tw, cyf));
left.line_to((cx - gap, cyf + th));
left.close();
canvas.draw_path(&left, &paint);
let mut right = Path::new();
right.move_to((cx + gap, cyf - th));
right.line_to((cx + gap + tw, cyf));
right.line_to((cx + gap, cyf + th));
right.close();
canvas.draw_path(&right, &paint);
}
Resolved::Key(text) => {
let w = keycap_w(fonts, text, k);
let h = 18.0 * k;
let rect = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32),
&Paint::new(white(0.10), None),
);
let mut ring = Paint::new(white(0.28), 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(rect, (5.0 * k) as f32, (5.0 * k) as f32),
&ring,
);
let size = 11.0 * k;
let tw = fonts.measure(text, W::SemiBold, size) as f64;
fonts.draw(
canvas,
text,
x + (w - tw) / 2.0,
cy + size * 0.36,
W::SemiBold,
size,
white(0.92),
);
}
}
}
/// The PlayStation face shapes, stroked inside the badge: Confirm=✕, Back=○, X-position
/// =□, Y-position=△ (the DualSense's physical layout).
fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32) {
let mut p = Paint::new(white(0.92), None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width(stroke);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_anti_alias(true);
let (cx, cy) = (center.x, center.y);
match face {
Face::A => {
// ✕
canvas.draw_line((cx - r, cy - r), (cx + r, cy + r), &p);
canvas.draw_line((cx - r, cy + r), (cx + r, cy - r), &p);
}
Face::B => {
// ○
canvas.draw_circle(center, r * 1.1, &p);
}
Face::X => {
// □
canvas.draw_rect(Rect::from_xywh(cx - r, cy - r, 2.0 * r, 2.0 * r), &p);
}
Face::Y => {
// △
let mut tri = Path::new();
tri.move_to((cx, cy - r * 1.2));
tri.line_to((cx + r * 1.15, cy + r * 0.85));
tri.line_to((cx - r * 1.15, cy + r * 0.85));
tri.close();
canvas.draw_path(&tri, &p);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn style_follows_pad_kind() {
assert_eq!(
GlyphStyle::from_pref(Some(GamepadPref::DualSense)),
GlyphStyle::Shapes
);
assert_eq!(
GlyphStyle::from_pref(Some(GamepadPref::SteamDeck)),
GlyphStyle::Letters
);
assert_eq!(GlyphStyle::from_pref(None), GlyphStyle::Keyboard);
}
}
+27 -9
View File
@@ -1,21 +1,39 @@
//! The Skia console UI (punktfunk-planning `linux-client-rearchitecture.md` §6): an
//! [`Overlay`] implementation rendering on the PRESENTER's Vulkan device into offscreen
//! RGBA images the presenter composites as one premultiplied quad. Skia never touches
//! the swapchain, and nothing here runs while the overlay has nothing to show — the
//! §6.1 invariants live or die in this crate.
//! [`Overlay`](pf_presenter::overlay::Overlay) implementation rendering on the
//! PRESENTER's Vulkan device into offscreen RGBA images the presenter composites as one
//! premultiplied quad. Skia never touches the swapchain, and nothing here runs while
//! the overlay has nothing to show — the §6.1 invariants live or die in this crate.
//!
//! Milestone 1 (this file): the stats OSD panel + the capture-hint pill — small on
//! purpose, it proves the whole shared-device pipeline. The gamepad library moves in
//! next.
//! The console is a full couch shell now — home (host carousel), the game library
//! coverflow, settings, add-host, and PIN pairing, with screen transitions, per-pad
//! button glyphs, and a controller keyboard (suppressed on Steam Deck, where Steam's
//! own keyboard types through SDL text input) — plus the in-stream chrome: stats OSD,
//! capture hint, start banner.
#[cfg(any(target_os = "linux", windows))]
mod anim;
#[cfg(any(target_os = "linux", windows))]
mod glyphs;
#[cfg(any(target_os = "linux", windows))]
pub mod library;
#[cfg(any(target_os = "linux", windows))]
mod library_ui;
pub mod model;
#[cfg(any(target_os = "linux", windows))]
mod screens;
#[cfg(any(target_os = "linux", windows))]
mod shell;
#[cfg(any(target_os = "linux", windows))]
mod skia_overlay;
#[cfg(any(target_os = "linux", windows))]
mod theme;
#[cfg(any(target_os = "linux", windows))]
mod widgets;
#[cfg(any(target_os = "linux", windows))]
pub use library::{LibraryGame, LibraryPhase, LibraryShared};
#[cfg(any(target_os = "linux", windows))]
pub use skia_overlay::SkiaOverlay;
pub use model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
#[cfg(any(target_os = "linux", windows))]
pub use shell::ConsoleOptions;
#[cfg(any(target_os = "linux", windows))]
pub use skia_overlay::{ConsoleEntry, ConsoleHandles, SkiaOverlay};
-2
View File
@@ -291,8 +291,6 @@ pub fn mesh_sksl() -> String {
#[derive(Clone, PartialEq)]
pub enum LibraryPhase {
Loading,
/// Browse target isn't paired — pairing is the plugin's job, render the advice.
PairFirst,
Error {
title: String,
body: String,
-725
View File
@@ -1,725 +0,0 @@
//! The console library's Skia side: navigation state, scene selection, and rendering —
//! the GTK launcher (`ui_gamepad_library.rs`) re-homed onto the presenter surface. The
//! mesh-gradient background runs as an SkSL shader at full rate (the 30 Hz CPU path is
//! gone), the coverflow is `concat_44` perspective with paint order = draw order (the
//! restack hack is gone), and every state renders in-scene (gamescope maps no dialogs).
use crate::library::{
card_matrix, initials, mesh_sksl, spring_advance, step_cursor, store_label, LibraryGame,
LibraryPhase, LibraryShared, StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, JUMP, PERSPECTIVE,
POSTER_H, POSTER_W, RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K,
VISIBLE_RANGE,
};
use anyhow::{anyhow, Result};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use pf_presenter::overlay::OverlayAction;
use skia_safe::textlayout::{
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle,
};
use skia_safe::{
Canvas, Color4f, Data, Font, FontStyle, Image, Paint, Point, RRect, Rect, RuntimeEffect,
Typeface, M44,
};
use std::collections::{HashMap, VecDeque};
use std::time::Instant;
pub(crate) struct LibraryUi {
shared: LibraryShared,
host_label: String,
// Synced snapshot of the shared model (re-pulled when the generation bumps).
generation: u64,
phase: LibraryPhase,
games: Vec<LibraryGame>,
// Navigation: the integer cursor is the authority; the eased position chases it.
cursor: i32,
anim_pos: f64,
anim_vel: f64,
bump: f64,
bump_vel: f64,
last_frame: Option<Instant>,
t0: Instant,
/// Decoded posters by game id (decode once; Skia uploads lazily on first draw).
art: HashMap<String, Image>,
/// The animated mesh-gradient background (compiled once; drawn first each frame).
mesh: RuntimeEffect,
/// A launch is in flight — menu input parks, the hint bar says Connecting….
connecting: bool,
/// A session is on screen — the library doesn't render (stream chrome does).
pub(crate) in_stream: bool,
/// Transient error strip on the carousel (connect failures land here).
status: Option<String>,
actions: VecDeque<OverlayAction>,
}
impl LibraryUi {
pub(crate) fn new(shared: LibraryShared, host_label: String) -> Result<LibraryUi> {
let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None)
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
Ok(LibraryUi {
shared,
host_label,
generation: u64::MAX, // force the first sync
phase: LibraryPhase::Loading,
games: Vec::new(),
cursor: 0,
anim_pos: 0.0,
anim_vel: 0.0,
bump: 0.0,
bump_vel: 0.0,
last_frame: None,
t0: Instant::now(),
art: HashMap::new(),
mesh,
connecting: false,
in_stream: false,
status: None,
actions: VecDeque::new(),
})
}
/// Pull the shared model when it changed; decode any newly arrived poster bytes.
pub(crate) fn sync(&mut self) {
if self.shared.generation() != self.generation {
let (phase, games, generation) = self.shared.snapshot();
let fresh_games = self.games.len() != games.len()
|| self.games.iter().zip(&games).any(|(a, b)| a.id != b.id);
self.phase = phase;
self.games = games;
self.generation = generation;
if fresh_games {
// Fresh library: snap the sprung position onto the (reset) cursor.
self.cursor = 0;
self.anim_pos = 0.0;
self.anim_vel = 0.0;
self.bump = 0.0;
self.bump_vel = 0.0;
}
self.cursor = self.cursor.clamp(0, (self.games.len() as i32 - 1).max(0));
}
for (id, bytes) in self.shared.drain_art() {
match Image::from_encoded(Data::new_copy(&bytes)) {
Some(img) => {
self.art.insert(id, img);
}
None => tracing::debug!(%id, "undecodable poster"),
}
}
}
/// Menu-mode navigation (gamepad; the keyboard fallback funnels in here too).
pub(crate) fn menu(&mut self, ev: MenuEvent) -> Option<MenuPulse> {
if self.connecting {
return None; // a connect is in flight — input is parked
}
match &self.phase {
LibraryPhase::Ready => match ev {
MenuEvent::Move(MenuDir::Left) => self.step(-1, false),
MenuEvent::Move(MenuDir::Right) => self.step(1, false),
MenuEvent::JumpBack => self.step(-JUMP, true),
MenuEvent::JumpForward => self.step(JUMP, true),
MenuEvent::Confirm => {
let g = self.games.get(self.cursor as usize)?;
self.actions.push_back(OverlayAction::Launch {
id: g.id.clone(),
title: g.title.clone(),
});
self.status = None;
self.connecting = true;
Some(MenuPulse::Confirm)
}
MenuEvent::Back => {
self.actions.push_back(OverlayAction::Quit);
None
}
MenuEvent::Move(_) | MenuEvent::Secondary | MenuEvent::Tertiary => None,
},
LibraryPhase::Error { can_retry, .. } => match ev {
MenuEvent::Confirm if *can_retry => {
self.phase = LibraryPhase::Loading; // local; the fetch re-syncs it
self.actions.push_back(OverlayAction::Retry);
Some(MenuPulse::Confirm)
}
MenuEvent::Back => {
self.actions.push_back(OverlayAction::Quit);
None
}
_ => None,
},
LibraryPhase::Loading | LibraryPhase::Empty | LibraryPhase::PairFirst => {
if ev == MenuEvent::Back {
self.actions.push_back(OverlayAction::Quit);
}
None
}
}
}
/// Keyboard fallback (arrows/Enter/Esc/PageUp/PageDown) — the launcher is fully
/// drivable with no pad. Returns true when consumed.
pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, repeat: bool) -> bool {
use sdl3::keyboard::Scancode as S;
let ev = match sc {
S::Left => MenuEvent::Move(MenuDir::Left),
S::Right => MenuEvent::Move(MenuDir::Right),
S::Up => MenuEvent::Move(MenuDir::Up),
S::Down => MenuEvent::Move(MenuDir::Down),
S::Return | S::KpEnter | S::Space if !repeat => MenuEvent::Confirm,
S::Escape | S::Backspace if !repeat => MenuEvent::Back,
S::PageUp if !repeat => MenuEvent::JumpBack,
S::PageDown if !repeat => MenuEvent::JumpForward,
_ => return false,
};
self.menu(ev); // no pad to pulse
true
}
pub(crate) fn take_action(&mut self) -> Option<OverlayAction> {
self.actions.pop_front()
}
pub(crate) fn set_connecting(&mut self, on: bool) {
self.connecting = on;
if on {
self.status = None;
}
}
/// A launch that didn't stick (connect failed / session ended with a reason):
/// carousel-visible errors land on the transient strip, anything else becomes the
/// error scene (no retry — the library itself is fine).
pub(crate) fn session_error(&mut self, msg: &str) {
self.connecting = false;
self.in_stream = false;
if self.phase == LibraryPhase::Ready {
self.status = Some(format!("Couldn't connect — {msg}"));
} else {
self.phase = LibraryPhase::Error {
title: "Couldn't connect".into(),
body: msg.to_string(),
can_retry: false,
};
}
}
fn step(&mut self, delta: i32, clamp: bool) -> Option<MenuPulse> {
match step_cursor(self.cursor, self.games.len(), delta, clamp) {
StepResult::Moved(to) => {
self.cursor = to;
Some(MenuPulse::Move)
}
StepResult::Boundary => {
// Recoil against the push; the stiff bump spring wobbles it back.
self.bump = -BUMP_PX * f64::from(delta.signum());
self.bump_vel = 0.0;
Some(MenuPulse::Boundary)
}
}
}
/// Render the whole library scene. Always a full repaint — the aurora animates
/// every frame (that's the point of the GPU port).
pub(crate) fn render(&mut self, canvas: &Canvas, w: u32, h: u32, fonts: &Fonts) {
let (wf, hf) = (w as f64, h as f64);
// Uniform scale off the Deck's 800p design height; fonts and geometry follow.
let k = (hf / 800.0).clamp(0.75, 3.0);
// Springs (Ready only — other scenes have no strip).
let now = Instant::now();
let dt = self
.last_frame
.replace(now)
.map_or(1.0 / 60.0, |t| (now - t).as_secs_f64().clamp(0.0, 0.05));
if self.phase == LibraryPhase::Ready {
let target = f64::from(self.cursor);
(self.anim_pos, self.anim_vel) =
spring_advance(self.anim_pos, self.anim_vel, target, SPRING_K, SPRING_C, dt);
if (target - self.anim_pos).abs() < 0.001 && self.anim_vel.abs() < 0.01 {
self.anim_pos = target;
self.anim_vel = 0.0;
}
(self.bump, self.bump_vel) =
spring_advance(self.bump, self.bump_vel, 0.0, BUMP_K, BUMP_C, dt);
if self.bump.abs() < 0.3 && self.bump_vel.abs() < 4.0 {
self.bump = 0.0;
self.bump_vel = 0.0;
}
}
self.draw_background(canvas, wf, hf);
match self.phase.clone() {
LibraryPhase::Ready => self.draw_carousel(canvas, wf, hf, k, fonts),
LibraryPhase::Loading => {
self.draw_spinner(canvas, wf / 2.0, hf / 2.0 - 24.0 * k, 16.0 * k);
fonts.centered(
canvas,
"Loading library…",
14.0 * k,
DIM_TEXT,
wf / 2.0,
hf / 2.0 + 16.0 * k,
wf * 0.8,
);
}
LibraryPhase::PairFirst => {
fonts.centered_bold(
canvas,
"Not paired with this host",
22.0 * k,
WHITE,
wf / 2.0,
hf / 2.0 - 20.0 * k,
wf * 0.8,
);
fonts.centered(
canvas,
"Pair from the Punktfunk plugin first.",
14.0 * k,
DIM_TEXT,
wf / 2.0,
hf / 2.0 + 12.0 * k,
wf * 0.8,
);
}
LibraryPhase::Empty => {
fonts.centered_bold(
canvas,
"No games found",
22.0 * k,
WHITE,
wf / 2.0,
hf / 2.0 - 20.0 * k,
wf * 0.8,
);
fonts.centered(
canvas,
"Install Steam titles or add custom entries in the host's web console.",
14.0 * k,
DIM_TEXT,
wf / 2.0,
hf / 2.0 + 12.0 * k,
wf * 0.8,
);
}
LibraryPhase::Error { title, body, .. } => {
fonts.centered_bold(
canvas,
&title,
22.0 * k,
WHITE,
wf / 2.0,
hf / 2.0 - 32.0 * k,
wf * 0.8,
);
fonts.centered(
canvas,
&body,
14.0 * k,
DIM_TEXT,
wf / 2.0,
hf / 2.0 + 4.0 * k,
(600.0 * k).min(wf * 0.85),
);
}
}
self.draw_chrome(canvas, wf, hf, k, fonts);
}
fn draw_background(&self, canvas: &Canvas, w: f64, h: f64) {
let t = self.t0.elapsed().as_secs_f64();
// Uniform layout: float2 u_res, float u_t (declared order, no padding needed).
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
let data = Data::new_copy(bytemuck_bytes(&uniforms));
match self.mesh.make_shader(data, &[], None) {
Some(shader) => {
let mut paint = Paint::default();
paint.set_shader(shader);
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &paint);
}
None => {
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
}
}
}
fn draw_carousel(&mut self, canvas: &Canvas, w: f64, h: f64, k: f64, fonts: &Fonts) {
let (card_w, card_h) = (POSTER_W * k, POSTER_H * k);
// The strip's vertical center: the space between the top bar and the detail
// block (the GTK vexpand'ed scroller, approximated).
let cy = h * 0.44;
let pos = self.anim_pos;
let bump = self.bump * k;
// Paint order = draw order: farthest from the (integer) cursor first, so the
// dense side stacks overlap toward the focus. Stable → the equidistant
// neighbors keep a deterministic order.
let mut order: Vec<usize> = (0..self.games.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - self.cursor).abs()));
for i in order {
let d = i as f64 - pos;
let a = d.abs();
if a > VISIBLE_RANGE {
continue;
}
let prox = a.min(1.0);
let scale = 1.0 - prox * RECEDE_SCALE;
let angle = -d.clamp(-1.0, 1.0) * ROTATE_DEG;
// Piecewise strip: a full FOCUS_GAP around the focus, then the dense side
// stacks (the classic coverflow shelf).
let offset = if a <= 1.0 {
d * FOCUS_GAP * k
} else {
d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) * k
};
let cx = w / 2.0 + offset + bump;
let m = card_matrix(cx, cy, angle, scale, card_w, card_h, PERSPECTIVE * k);
let game = &self.games[i];
canvas.save();
canvas.concat_44(&M44::row_major(&m));
let rect = Rect::from_wh(card_w as f32, card_h as f32);
let rr = RRect::new_rect_xy(rect, 16.0 * k as f32, 16.0 * k as f32);
canvas.clip_rrect(rr, None, true);
match self.art.get(&game.id) {
Some(img) => {
// Cover-fit: center-crop the source to the card's 2:3.
let (iw, ih) = (img.width() as f32, img.height() as f32);
let card_aspect = rect.width() / rect.height();
let src = if iw / ih > card_aspect {
let sw = ih * card_aspect;
Rect::from_xywh((iw - sw) / 2.0, 0.0, sw, ih)
} else {
let sh = iw / card_aspect;
Rect::from_xywh(0.0, (ih - sh) / 2.0, iw, sh)
};
canvas.draw_image_rect(
img,
Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)),
rect,
&Paint::default(),
);
}
None => {
// Solid face, not glass: the side cards OVERLAP (GTK CSS note).
canvas.draw_rect(
rect,
&Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None),
);
let mono = initials(&game.title);
let font = fonts.sans_bold(38.0 * k);
let tw = font.measure_str(&mono, None).0;
canvas.draw_str(
&mono,
Point::new(
(card_w as f32 - tw) / 2.0,
card_h as f32 / 2.0 + 13.0 * k as f32,
),
&font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.45), None),
);
}
}
// Store badge, top-left.
{
let label = store_label(&game.store);
let font = fonts.sans_bold(11.0 * k);
let tw = font.measure_str(label, None).0;
let (px, py) = (8.0 * k as f32, 8.0 * k as f32);
let (bw, bh) = (tw + 16.0 * k as f32, 20.0 * k as f32);
canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(px, py, bw, bh), bh / 2.0, bh / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
);
canvas.draw_str(
label,
Point::new(px + 8.0 * k as f32, py + 14.0 * k as f32),
&font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 1.0), None),
);
}
// The brightness recede: an opaque-black veil, never whole-card alpha.
if prox > 0.0 {
canvas.draw_rect(
rect,
&Paint::new(
Color4f::new(0.0, 0.0, 0.0, (prox * RECEDE_DIM) as f32),
None,
),
);
}
canvas.restore();
}
// Detail block: focused title + store, centered between strip and hints.
if let Some(g) = self.games.get(self.cursor as usize) {
fonts.centered_bold(
canvas,
&g.title,
27.0 * k,
WHITE,
w / 2.0,
h - 96.0 * k,
w * 0.8,
);
fonts.centered(
canvas,
&store_label(&g.store).to_uppercase(),
12.0 * k,
Color4f::new(1.0, 1.0, 1.0, 0.5),
w / 2.0,
h - 66.0 * k,
w * 0.5,
);
}
if let Some(status) = &self.status {
fonts.centered(
canvas,
status,
13.0 * k,
Color4f::new(1.0, 0.576, 0.541, 1.0), // the GTK #ff938a
w / 2.0,
h - 44.0 * k,
w * 0.85,
);
}
}
/// Top bar (host + controller chip) and the bottom hint bar — per-scene affordances.
fn draw_chrome(&self, canvas: &Canvas, w: f64, h: f64, k: f64, fonts: &Fonts) {
let font = fonts.sans_bold(18.0 * k);
canvas.draw_str(
&self.host_label,
Point::new(24.0 * k as f32, 32.0 * k as f32),
&font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None),
);
if let Some(chip) = &fonts.chip_text {
let cf = fonts.sans(12.0 * k);
let tw = cf.measure_str(chip, None).0;
let (bh, pad) = (24.0 * k as f32, 12.0 * k as f32);
let bx = w as f32 - 24.0 * k as f32 - tw - 2.0 * pad;
canvas.draw_rrect(
RRect::new_rect_xy(
Rect::from_xywh(bx, 18.0 * k as f32, tw + 2.0 * pad, bh),
bh / 2.0,
bh / 2.0,
),
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.08), None),
);
canvas.draw_str(
chip,
Point::new(bx + pad, 18.0 * k as f32 + 16.0 * k as f32),
&cf,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.7), None),
);
}
let hint = if self.connecting {
"Connecting…".to_string()
} else {
match &self.phase {
LibraryPhase::Ready => "A Play B Quit L1 / R1 Jump".to_string(),
LibraryPhase::Error {
can_retry: true, ..
} => "A Retry B Quit".to_string(),
_ => "B Quit".to_string(),
}
};
fonts.left(
canvas,
&hint,
13.0 * k,
Color4f::new(1.0, 1.0, 1.0, 0.85),
24.0 * k,
h - 20.0 * k,
);
}
/// The loading spinner: a rotating arc off the aurora clock.
fn draw_spinner(&self, canvas: &Canvas, cx: f64, cy: f64, r: f64) {
let t = self.t0.elapsed().as_secs_f64();
let start = (t * 300.0) % 360.0;
let mut paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.85), None);
paint.set_style(skia_safe::PaintStyle::Stroke);
paint.set_stroke_width(3.0);
paint.set_anti_alias(true);
canvas.draw_arc(
Rect::from_xywh(
(cx - r) as f32,
(cy - r) as f32,
2.0 * r as f32,
2.0 * r as f32,
),
start as f32,
270.0,
false,
&paint,
);
}
}
const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0);
const DIM_TEXT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.7);
fn bytemuck_bytes(v: &[f32; 3]) -> &[u8] {
unsafe { std::slice::from_raw_parts(v.as_ptr().cast::<u8>(), 12) }
}
/// The text toolkit shared by every scene: typefaces + a paragraph-based centered-text
/// helper (shaping + font fallback — poster titles can be CJK; `draw_str` can't).
pub(crate) struct Fonts {
pub sans: Typeface,
pub collection: FontCollection,
/// The controller chip's current text (fed per frame by the overlay).
pub chip_text: Option<String>,
}
impl Fonts {
fn sans(&self, size: f64) -> Font {
Font::new(self.sans.clone(), size as f32)
}
fn sans_bold(&self, size: f64) -> Font {
let mut f = Font::new(self.sans.clone(), size as f32);
f.set_embolden(true);
f
}
fn paragraph(
&self,
text: &str,
size: f64,
color: Color4f,
bold: bool,
align: TextAlign,
max_w: f64,
) -> skia_safe::textlayout::Paragraph {
let mut style = ParagraphStyle::new();
style.set_text_align(align);
let mut ts = TextStyle::new();
ts.set_font_size(size as f32);
ts.set_color(color.to_color());
ts.set_font_style(if bold {
FontStyle::bold()
} else {
FontStyle::normal()
});
style.set_text_style(&ts);
let mut b = ParagraphBuilder::new(&style, self.collection.clone());
b.add_text(text);
let mut p = b.build();
p.layout(max_w as f32);
p
}
/// Centered paragraph with `y` as its top edge.
#[allow(clippy::too_many_arguments)]
fn centered(
&self,
canvas: &Canvas,
text: &str,
size: f64,
color: Color4f,
cx: f64,
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, size, color, false, TextAlign::Center, max_w);
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
}
#[allow(clippy::too_many_arguments)]
fn centered_bold(
&self,
canvas: &Canvas,
text: &str,
size: f64,
color: Color4f,
cx: f64,
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, size, color, true, TextAlign::Center, max_w);
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
}
fn left(&self, canvas: &Canvas, text: &str, size: f64, color: Color4f, x: f64, y: f64) {
canvas.draw_str(
text,
Point::new(x as f32, y as f32),
&self.sans(size),
&Paint::new(color, None),
);
}
}
/// Resolve the first available family. Generic aliases ("sans-serif", "monospace")
/// resolve through fontconfig on Linux; Windows' DirectWrite-backed FontMgr has no
/// generic aliases, so the list falls through to concrete family names there.
pub(crate) fn match_first_family(
mgr: &skia_safe::FontMgr,
families: &[&str],
style: FontStyle,
) -> Option<Typeface> {
families
.iter()
.find_map(|f| mgr.match_family_style(f, style))
}
pub(crate) fn build_fonts() -> Result<Fonts> {
let mgr = skia_safe::FontMgr::new();
let sans = match_first_family(
&mgr,
&["sans-serif", "Segoe UI", "Arial"],
FontStyle::normal(),
)
.ok_or_else(|| anyhow!("no sans-serif typeface (fontconfig alias or system family)"))?;
let mut collection = FontCollection::new();
collection.set_default_font_manager(mgr, None);
Ok(Fonts {
sans,
collection,
chip_text: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The generated mesh-gradient SkSL must actually compile (Skia's SkSL frontend runs
/// on the CPU — no GPU needed) — the shape test in `library` can't catch type errors.
#[test]
fn mesh_sksl_compiles() {
RuntimeEffect::make_for_shader(mesh_sksl(), None)
.unwrap_or_else(|e| panic!("mesh-gradient SkSL rejected:\n{e}"));
}
/// Render the background on a CPU raster surface at a few times and dump PNGs — a visual
/// check of the mesh-gradient look (ignored; run with `--ignored` + PF_MESH_DUMP=<dir>).
#[test]
#[ignore]
fn mesh_dump_png() {
let dir = std::env::var("PF_MESH_DUMP").expect("set PF_MESH_DUMP to an output dir");
let effect = RuntimeEffect::make_for_shader(mesh_sksl(), None).unwrap();
let (w, h) = (1280i32, 800i32);
for t in [0.0f32, 20.0, 60.0, 300.0] {
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
let uniforms: [f32; 3] = [w as f32, h as f32, t];
let data = Data::new_copy(bytemuck_bytes(&uniforms));
let shader = effect.make_shader(data, &[], None).unwrap();
let mut paint = Paint::default();
paint.set_shader(shader);
surface
.canvas()
.draw_rect(Rect::from_wh(w as f32, h as f32), &paint);
let png = surface
.image_snapshot()
.encode(None, skia_safe::EncodedImageFormat::PNG, 100)
.unwrap();
std::fs::write(format!("{dir}/mesh_t{t}.png"), png.as_bytes()).unwrap();
}
}
}
+202
View File
@@ -0,0 +1,202 @@
//! The console's shared binary↔overlay state and command bus — the widened sibling of
//! [`crate::library::LibraryShared`]. The session binary's service threads (discovery,
//! probing, pairing, waking, persistence) WRITE snapshots in; the shell reads them per
//! frame by generation stamp. The overlay never blocks: anything that touches the
//! network or disk rides a [`ConsoleCmd`] to the binary instead.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
/// One row on the console home carousel — a saved host, a discovered-but-unsaved one,
/// or (client-side) the trailing Add Host tile. Fully resolved by the service thread;
/// the shell renders it verbatim.
#[derive(Clone, Debug, PartialEq)]
pub struct HostRow {
/// Stable identity across refreshes: the pinned fingerprint when known, else
/// `addr:port` — keeps the cursor on "the same host" as snapshots churn.
pub key: String,
pub name: String,
pub addr: String,
pub port: u16,
/// Pinned certificate fingerprint (lowercase hex); empty = not pinned.
pub fp_hex: String,
pub paired: bool,
/// In the known-hosts store (vs. discovered-only).
pub saved: bool,
/// Advertising on mDNS or proven reachable by the probe sweep.
pub online: bool,
/// The management API's port (mDNS TXT or store), for the library fetch.
pub mgmt_port: u16,
/// Offline + a stored MAC → activating wakes first ("Wake & Connect").
pub can_wake: bool,
/// Last successful connect (UNIX seconds) — the most-recent accent.
pub last_used: Option<u64>,
}
/// The pairing ceremony's observable state (one at a time — the ceremony is modal).
#[derive(Clone, Debug, PartialEq, Default)]
pub enum PairPhase {
#[default]
Idle,
/// The SPAKE2 exchange is running (up to ~90 s on a mistyped-then-fixed PIN).
Busy,
Failed(String),
/// Paired and persisted; `key` addresses the host's refreshed row.
Paired {
key: String,
},
}
/// A wake-and-wait in progress (one at a time). The service thread re-sends magic
/// packets and probes; the shell renders the card and acts on `online`.
#[derive(Clone, Debug, PartialEq)]
pub struct WakeStatus {
pub key: String,
pub name: String,
/// Seconds since the wake started (the card's counter).
pub seconds: u32,
pub timed_out: bool,
/// The host answered a probe — the shell launches if the wake wanted a connect.
pub online: bool,
/// Connect once awake (A on an offline host) vs. a bare wake.
pub then_connect: bool,
}
#[derive(Default)]
struct ConsoleState {
hosts: Vec<HostRow>,
hosts_gen: u64,
pair: PairPhase,
wake: Option<WakeStatus>,
}
/// The shared handle. Service threads write; the shell polls per frame (cheap locks,
/// no rendering data inside).
#[derive(Clone, Default)]
pub struct ConsoleShared(Arc<Mutex<ConsoleState>>);
impl ConsoleShared {
pub fn set_hosts(&self, hosts: Vec<HostRow>) {
let mut s = self.0.lock().unwrap();
if s.hosts != hosts {
s.hosts = hosts;
s.hosts_gen += 1;
}
}
pub(crate) fn hosts_gen(&self) -> u64 {
self.0.lock().unwrap().hosts_gen
}
pub(crate) fn hosts_snapshot(&self) -> (Vec<HostRow>, u64) {
let s = self.0.lock().unwrap();
(s.hosts.clone(), s.hosts_gen)
}
pub fn set_pair(&self, phase: PairPhase) {
self.0.lock().unwrap().pair = phase;
}
pub(crate) fn pair(&self) -> PairPhase {
self.0.lock().unwrap().pair.clone()
}
pub fn set_wake(&self, wake: Option<WakeStatus>) {
self.0.lock().unwrap().wake = wake;
}
pub(crate) fn wake(&self) -> Option<WakeStatus> {
self.0.lock().unwrap().wake.clone()
}
}
/// Work the shell asks the binary to do. Everything here blocks (network/disk), so it
/// runs on the binary's service thread, never on the render path.
#[derive(Debug, Clone, PartialEq)]
pub enum ConsoleCmd {
/// (Re)fetch a host's game library into the shared library model.
FetchLibrary {
addr: String,
mgmt: u16,
fp_hex: String,
},
/// Run the SPAKE2 PIN ceremony; on success persist the pin and refresh hosts.
Pair {
addr: String,
port: u16,
pin: String,
device_name: String,
},
/// Save a manually entered host (unpaired) and refresh the rows.
SaveHost {
name: String,
addr: String,
port: u16,
},
/// Start the wake-and-wait loop for this saved host.
Wake { key: String, then_connect: bool },
/// Stop the wake loop (B on the wake card) and clear its status.
CancelWake,
/// Sweep reachability now (the home screen refreshes its presence pips).
Probe,
}
/// The overlay→binary command queue. A plain deque under the same locking discipline as
/// the shared models — the service thread drains it on a short cadence (it's never
/// latency-critical: every command's effect arrives via a model snapshot anyway).
#[derive(Clone, Default)]
pub struct ConsoleBus(Arc<Mutex<VecDeque<ConsoleCmd>>>);
impl ConsoleBus {
/// Queue a command. Normally the shell's side; the binary may also seed one (the
/// direct-entry library fetch) — same lane, same handler.
pub fn send(&self, cmd: ConsoleCmd) {
self.0.lock().unwrap().push_back(cmd);
}
/// Binary side: drain everything queued since the last call.
pub fn drain(&self) -> Vec<ConsoleCmd> {
self.0.lock().unwrap().drain(..).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosts_generation_bumps_only_on_change() {
let shared = ConsoleShared::default();
let row = HostRow {
key: "aa".into(),
name: "Tower".into(),
addr: "10.0.0.2".into(),
port: 9777,
fp_hex: "aa".into(),
paired: true,
saved: true,
online: false,
mgmt_port: 47990,
can_wake: false,
last_used: None,
};
shared.set_hosts(vec![row.clone()]);
let g1 = shared.hosts_gen();
shared.set_hosts(vec![row.clone()]);
assert_eq!(shared.hosts_gen(), g1, "identical snapshot doesn't churn");
shared.set_hosts(vec![HostRow {
online: true,
..row
}]);
assert_eq!(shared.hosts_gen(), g1 + 1);
}
#[test]
fn bus_drains_in_order() {
let bus = ConsoleBus::default();
bus.send(ConsoleCmd::Probe);
bus.send(ConsoleCmd::CancelWake);
assert_eq!(bus.drain(), vec![ConsoleCmd::Probe, ConsoleCmd::CancelWake]);
assert!(bus.drain().is_empty());
}
}
+184
View File
@@ -0,0 +1,184 @@
//! The console's screens and their shared contract. Each screen owns its focus state
//! and rendering; the [`crate::shell::Shell`] owns the stack, the transitions, the
//! chrome, and the overlays — a screen never draws its own background or hint bar, so
//! every screen animates and reads identically.
pub(crate) mod add_host;
pub(crate) mod home;
pub(crate) mod library;
pub(crate) mod pair;
pub(crate) mod settings;
use crate::glyphs::Hint;
use crate::library::LibraryShared;
use crate::model::{ConsoleCmd, HostRow};
use crate::theme::Fonts;
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use pf_client_core::{gamepad::PadInfo, trust};
use skia_safe::{Canvas, Rect};
/// What a screen draws over (the shell crossfades between them on push/pop).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Bg {
/// The living mesh aurora (home, library).
Aurora,
/// The quiet indigo form backdrop (settings, add-host, pair).
Form,
}
/// Everything a screen may read while handling input or rendering. Settings are
/// mutable — the settings screen edits and persists them in place.
pub(crate) struct Ctx<'a> {
pub hosts: &'a [HostRow],
/// The one live library model slot (the screen on top of the stack owns it).
pub library: &'a LibraryShared,
pub settings: &'a mut trust::Settings,
pub pads: &'a [PadInfo],
/// Steam Deck: never draw our keyboard — Steam's types via SDL text input.
pub deck: bool,
/// The name the HOST stores this client under when pairing (the machine's
/// hostname, resolved by the binary).
pub device_name: &'a str,
/// The shell clock, seconds (spinners, pulses).
pub t: f64,
}
/// A host a screen wants to start a session on (the shell turns this into an
/// `OverlayAction::Launch` + the connecting overlay).
pub(crate) struct ConnectIntent {
pub addr: String,
pub port: u16,
pub fp_hex: String,
/// Library title id (`None` streams the desktop).
pub launch: Option<String>,
/// What the connecting card says (host or game title).
pub title: String,
}
pub(crate) enum Nav {
Push(Box<Screen>),
/// Pop this screen; popping the root quits the console.
Pop,
}
/// Everything a screen's input handling may ask of the shell, collected per event and
/// applied AFTER the dispatch (no re-entrant stack mutation).
#[derive(Default)]
pub(crate) struct Outbox {
pub nav: Option<Nav>,
pub connect: Option<ConnectIntent>,
pub cmds: Vec<ConsoleCmd>,
pub toast: Option<String>,
}
impl Outbox {
pub(crate) fn push(&mut self, screen: Screen) {
self.nav = Some(Nav::Push(Box::new(screen)));
}
pub(crate) fn pop(&mut self) {
self.nav = Some(Nav::Pop);
}
}
pub(crate) enum Screen {
Home(home::HomeScreen),
Library(library::LibraryScreen),
Settings(settings::SettingsScreen),
AddHost(add_host::AddHostScreen),
Pair(pair::PairScreen),
}
impl Screen {
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
match self {
Screen::Home(s) => s.menu(ev, ctx, fx),
Screen::Library(s) => s.menu(ev, ctx, fx),
Screen::Settings(s) => s.menu(ev, ctx, fx),
Screen::AddHost(s) => s.menu(ev, ctx, fx),
Screen::Pair(s) => s.menu(ev, ctx, fx),
}
}
/// Committed text (SDL `TextInput` — hardware keyboards everywhere, Steam's
/// keyboard under gamescope). Only the editing screens consume it.
pub(crate) fn text_input(&mut self, text: &str) {
match self {
Screen::AddHost(s) => s.text_input(text),
Screen::Pair(s) => s.text_input(text),
_ => {}
}
}
/// Raw key edits while a field is editing (Backspace repeats, Return = done).
/// Returns true when consumed.
pub(crate) fn edit_key(&mut self, sc: sdl3::keyboard::Scancode) -> bool {
match self {
Screen::AddHost(s) => s.edit_key(sc),
Screen::Pair(s) => s.edit_key(sc),
_ => false,
}
}
/// A text field is being edited — the run loop keeps SDL text input started.
pub(crate) fn editing(&self) -> bool {
match self {
Screen::AddHost(s) => s.editing(),
Screen::Pair(s) => s.editing(),
_ => false,
}
}
pub(crate) fn background(&self) -> Bg {
match self {
Screen::Home(_) | Screen::Library(_) => Bg::Aurora,
_ => Bg::Form,
}
}
pub(crate) fn title(&self, _ctx: &Ctx) -> String {
match self {
Screen::Home(_) => "Select a Host".into(),
Screen::Library(s) => s.host_name().to_string(),
Screen::Settings(_) => "Settings".into(),
Screen::AddHost(_) => "Add Host".into(),
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
}
}
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
match self {
Screen::Home(s) => s.hints(ctx),
Screen::Library(s) => s.hints(ctx),
Screen::Settings(s) => s.hints(ctx),
Screen::AddHost(s) => s.hints(ctx),
Screen::Pair(s) => s.hints(ctx),
}
}
/// Render the screen's content into `rect` (between the title bar and hint bar).
/// Backgrounds and chrome are the shell's.
#[allow(clippy::too_many_arguments)]
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
match self {
Screen::Home(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Library(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Settings(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
}
}
}
@@ -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");
}
}
+573
View File
@@ -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(_)))
);
}
}
+407
View File
@@ -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,
);
}
}
}
+407
View File
@@ -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");
}
}
File diff suppressed because it is too large Load Diff
+168 -56
View File
@@ -2,9 +2,15 @@
//! two offscreen render-target surfaces (the presenter runs one frame in flight and may
//! still be sampling the previous image while we render the next), damage-driven
//! redraws (content/size change only — an unchanged OSD costs zero GPU work per frame).
//!
//! Two personas over one `Overlay`: the CONSOLE (the full shell — home, library,
//! settings, pairing — always dirty, the aurora animates) and the stream chrome (stats
//! OSD, capture hint, the auto-fading start banner).
use crate::library::LibraryShared;
use crate::library_ui::{build_fonts, Fonts, LibraryUi};
use crate::model::{ConsoleBus, ConsoleShared, HostRow};
use crate::screens::Screen;
use crate::shell::{ConsoleOptions, Shell};
use crate::theme::{match_first_family, Fonts};
use anyhow::{anyhow, Context as _, Result};
use ash::vk as avk;
use ash::vk::Handle as _;
@@ -15,11 +21,16 @@ use pf_presenter::overlay::{
use skia_safe::gpu::vk as skvk;
use skia_safe::gpu::{self, DirectContext, SurfaceOrigin};
use skia_safe::{Canvas, Color4f, Font, FontMgr, Paint, Point, RRect, Rect, Surface};
use std::time::Instant;
/// Skia's GPU resource budget — the OSD/HUD need a few MB; the console library will
/// revisit (the plan budgets 64 MB on Deck-class shared memory).
/// Skia's GPU resource budget — poster art plus a few screen layers; 64 MB fits
/// Deck-class shared memory.
const RESOURCE_CACHE_BYTES: usize = 64 << 20;
/// How long the start-of-stream banner lingers (fading through the tail).
const BANNER_S: f64 = 6.0;
const BANNER_FADE_S: f64 = 0.6;
/// One offscreen target: the Skia surface + the raw Vulkan handles the presenter
/// samples. The image is Skia-owned (freed with the surface); the view is ours.
struct Slot {
@@ -37,6 +48,24 @@ struct Drawn {
height: u32,
stats: Option<String>,
hint: Option<String>,
/// The start banner's alpha, quantized — a fade step is a redraw, steady is not.
banner_step: u8,
}
/// Where the console starts (the session binary's `--browse` forms).
pub enum ConsoleEntry {
/// The host list (bare `--browse`).
Home,
/// Home with this host's library already pushed (`--browse host` — the Decky
/// per-host launch; B backs out to Home).
Library(HostRow),
}
/// The binary's ends of the console: models to write, commands to serve.
pub struct ConsoleHandles {
pub console: ConsoleShared,
pub library: crate::library::LibraryShared,
pub bus: ConsoleBus,
}
pub struct SkiaOverlay {
@@ -47,10 +76,15 @@ pub struct SkiaOverlay {
/// Which slot the LAST returned frame lives in — the next render takes the other.
current: usize,
drawn: Drawn,
/// The stats OSD's monospace face (system-resolved; the console uses Geist).
font: Option<Font>,
/// The console library (`--browse`) — `None` for a plain `--connect` session.
library: Option<LibraryUi>,
fonts: Option<Fonts>,
/// The console shell (`--browse`) — `None` for a plain `--connect` session.
shell: Option<Shell>,
/// When the current stream started presenting — drives the start banner.
streaming_since: Option<Instant>,
/// The banner's words (set per stream from the active-pad state).
banner_text: Option<String>,
}
struct Gpu {
@@ -72,22 +106,44 @@ impl SkiaOverlay {
current: 0,
drawn: Drawn::default(),
font: None,
library: None,
fonts: None,
shell: None,
streaming_since: None,
banner_text: None,
}
}
/// The `--browse` overlay: the console library between streams, stream chrome
/// during them. Returns the shared model the binary's fetch threads write into.
pub fn with_library(host_label: String) -> Result<(SkiaOverlay, LibraryShared)> {
let shared = LibraryShared::default();
/// The `--browse` overlay: the full console shell between streams, stream chrome
/// during them. Returns the binary's handles (models + command bus).
pub fn console(
opts: ConsoleOptions,
entry: ConsoleEntry,
) -> Result<(SkiaOverlay, ConsoleHandles)> {
let console = ConsoleShared::default();
let library = crate::library::LibraryShared::default();
let bus = ConsoleBus::default();
let stack = match entry {
ConsoleEntry::Home => vec![Screen::Home(crate::screens::home::HomeScreen::new())],
ConsoleEntry::Library(host) => vec![
Screen::Home(crate::screens::home::HomeScreen::new()),
Screen::Library(crate::screens::library::LibraryScreen::new(&host)),
],
};
let shell = Shell::new(console.clone(), library.clone(), bus.clone(), opts, stack)?;
let mut o = SkiaOverlay::new();
o.library = Some(LibraryUi::new(shared.clone(), host_label)?);
Ok((o, shared))
o.shell = Some(shell);
Ok((
o,
ConsoleHandles {
console,
library,
bus,
},
))
}
fn library_visible(&self) -> bool {
self.library.as_ref().is_some_and(|l| !l.in_stream)
fn console_visible(&self) -> bool {
self.shell.as_ref().is_some_and(|s| !s.in_stream)
}
}
@@ -146,14 +202,14 @@ impl Overlay for SkiaOverlay {
.ok_or_else(|| anyhow!("Skia DirectContext over the shared device"))?;
context.set_resource_cache_limit(RESOURCE_CACHE_BYTES);
let typeface = crate::library_ui::match_first_family(
let typeface = match_first_family(
&FontMgr::new(),
&["monospace", "Consolas", "Cascadia Mono", "Courier New"],
skia_safe::FontStyle::normal(),
)
.context("no monospace typeface (fontconfig alias or system family)")?;
self.font = Some(Font::new(typeface, 14.0));
self.fonts = Some(build_fonts()?);
self.fonts = Some(crate::theme::build_fonts()?);
self.gpu = Some(Gpu {
device: shared.device.clone(),
@@ -167,77 +223,93 @@ impl Overlay for SkiaOverlay {
}
fn handle_event(&mut self, event: &sdl3::event::Event) -> bool {
// The library's keyboard fallback (arrows/Enter/Esc) — only while it's on
// screen, and never for chord-modified keys (those stay the run loop's).
if self.library_visible() {
if let sdl3::event::Event::KeyDown {
// Keyboard + text into the console while it's on screen — never for
// chord-modified keys (those stay the run loop's), never during a stream.
if !self.console_visible() {
return false;
}
let Some(shell) = &mut self.shell else {
return false;
};
match event {
sdl3::event::Event::KeyDown {
scancode: Some(sc),
keymod,
repeat,
..
} = event
{
} => {
use sdl3::keyboard::Mod;
if !keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD | Mod::LALTMOD | Mod::RALTMOD) {
return self.library.as_mut().is_some_and(|l| l.key(*sc, *repeat));
if keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD | Mod::LALTMOD | Mod::RALTMOD) {
return false;
}
shell.key(*sc, *repeat)
}
sdl3::event::Event::TextInput { text, .. } => {
shell.text_input(text);
true
}
_ => false,
}
false
}
fn handle_menu(&mut self, event: MenuEvent) -> Option<MenuPulse> {
if self.library_visible() {
self.library.as_mut().and_then(|l| l.menu(event))
if self.console_visible() {
self.shell.as_mut().and_then(|s| s.handle_menu(event))
} else {
None
}
}
fn take_action(&mut self) -> Option<OverlayAction> {
self.library.as_mut().and_then(|l| l.take_action())
self.shell.as_mut().and_then(|s| s.take_action())
}
fn text_input_active(&self) -> bool {
self.console_visible() && self.shell.as_ref().is_some_and(Shell::editing)
}
fn session_phase(&mut self, phase: SessionPhase) {
let Some(lib) = &mut self.library else { return };
let Some(shell) = &mut self.shell else { return };
match phase {
SessionPhase::Connecting => lib.set_connecting(true),
SessionPhase::Connecting => {} // the shell raised the Launch; already showing
SessionPhase::Streaming => {
lib.in_stream = true;
lib.set_connecting(false);
shell.session_streaming();
self.streaming_since = Some(Instant::now());
}
SessionPhase::Failed(msg) => lib.session_error(msg),
SessionPhase::Ended(None) => {
lib.in_stream = false;
lib.set_connecting(false);
SessionPhase::Failed(msg) => shell.session_failed(msg),
SessionPhase::Ended(reason) => {
shell.session_ended(reason);
self.streaming_since = None;
}
SessionPhase::Ended(Some(reason)) => lib.session_error(reason),
}
}
fn frame(&mut self, ctx: &FrameCtx) -> Result<Option<OverlayFrame>> {
// The console library: full-screen, opaque, and always dirty (the aurora
// animates every frame — the GPU port's whole point).
if self.library_visible() {
// The console: full-screen, opaque, and always dirty (the aurora animates
// every frame — the GPU port's whole point).
if self.console_visible() {
let next = 1 - self.current;
self.ensure_slot(next, ctx.width, ctx.height)?;
let Self {
gpu,
slots,
library,
shell,
fonts,
..
} = self;
let gpu = gpu.as_mut().expect("init ran");
let slot = slots[next].as_mut().expect("just ensured");
let lib = library.as_mut().expect("library_visible");
let fonts = fonts.as_mut().expect("init ran");
fonts.chip_text = Some(ctx.pad.map_or(
"No controller — keyboard works too".to_string(),
str::to_owned,
));
lib.sync();
lib.render(slot.surface.canvas(), ctx.width, ctx.height, fonts);
let shell = shell.as_mut().expect("console_visible");
let fonts = fonts.as_ref().expect("init ran");
shell.render(
slot.surface.canvas(),
ctx.width,
ctx.height,
fonts,
ctx.pad,
ctx.pad_pref,
ctx.pads,
);
gpu.context.flush_surface_with_texture_state(
&mut slot.surface,
&gpu::FlushInfo::default(),
@@ -258,7 +330,10 @@ impl Overlay for SkiaOverlay {
}));
}
if ctx.stats.is_none() && ctx.hint.is_none() {
// --- Stream chrome: stats OSD + capture hint + the start banner ---------------
let banner_alpha = self.banner_alpha(ctx);
let banner_step = (banner_alpha * 32.0).round() as u8;
if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 {
self.drawn = Drawn::default(); // forget content so re-show re-renders
return Ok(None);
}
@@ -267,6 +342,7 @@ impl Overlay for SkiaOverlay {
height: ctx.height,
stats: ctx.stats.map(str::to_owned),
hint: ctx.hint.map(str::to_owned),
banner_step,
};
if want == self.drawn {
// Unchanged — hand the presenter the already-rendered image.
@@ -292,7 +368,20 @@ impl Overlay for SkiaOverlay {
draw_osd_panel(canvas, font, stats, 12.0, 12.0);
}
if let Some(hint) = &want.hint {
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height);
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0);
} else if banner_step > 0 {
// The start banner: the leave/stats shortcuts, fading out on its own —
// discoverable without the stats overlay, gone before it annoys.
if let Some(text) = &self.banner_text {
draw_hint_pill(
canvas,
font,
text,
ctx.width,
ctx.height,
banner_alpha as f32,
);
}
}
// Flush on the shared queue, ending in SHADER_READ_ONLY on our family — the
@@ -309,6 +398,7 @@ impl Overlay for SkiaOverlay {
self.current = next;
self.drawn = want;
let slot = self.slots[next].as_ref().expect("just rendered");
Ok(Some(OverlayFrame {
image: slot.image,
view: slot.view,
@@ -319,6 +409,28 @@ impl Overlay for SkiaOverlay {
}
impl SkiaOverlay {
/// The start banner's current alpha (1 → 0 across the fade tail), refreshing its
/// words while fully visible so a pad hot-plug updates the leave hint.
fn banner_alpha(&mut self, ctx: &FrameCtx) -> f64 {
let Some(since) = self.streaming_since else {
self.banner_text = None;
return 0.0;
};
let age = since.elapsed().as_secs_f64();
if age >= BANNER_S {
self.streaming_since = None;
self.banner_text = None;
return 0.0;
}
self.banner_text = Some(if ctx.pad.is_some() {
"Hold L1 + R1 + Start + Select to leave · Ctrl+Alt+Shift+S stats".to_string()
} else {
"Ctrl+Alt+Shift+Q releases input · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats"
.to_string()
});
((BANNER_S - age) / BANNER_FADE_S).min(1.0)
}
/// Make `slots[i]` a render target of exactly `width`×`height` (rebuilt on resize).
fn ensure_slot(&mut self, i: usize, width: u32, height: u32) -> Result<()> {
if self.slots[i]
@@ -414,8 +526,8 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
}
}
/// The capture hint: a centered pill near the bottom edge (the GTK hint's position).
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32) {
/// The capture hint / start banner: a centered pill near the bottom edge.
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) {
let (_, metrics) = font.metrics();
let line_h = metrics.descent - metrics.ascent;
let text_w = font.measure_str(text, None).0;
@@ -426,12 +538,12 @@ fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height:
let y = height as f32 - h - 24.0;
canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None),
);
canvas.draw_str(
text,
Point::new(x + pad_x, y + pad_y - metrics.ascent),
font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None),
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92 * alpha), None),
);
}
+435
View File
@@ -0,0 +1,435 @@
//! The console shell's shared look: the brand palette, the embedded Geist typography
//! (the same OFL faces the Apple client bundles — one brand voice on every platform),
//! the dark-glass panel primitive standing in for Liquid Glass (translucent fill +
//! hairline stroke; the backdrops here are soft gradients, so a real backdrop blur
//! would be indistinguishable and costs Deck GPU), and the quiet form backdrop the
//! settings/add-host/pair screens sit on.
use anyhow::{anyhow, Result};
use skia_safe::textlayout::{
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle, TypefaceFontProvider,
};
use skia_safe::{
gradient_shader, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect,
Point, RRect, Rect, TileMode, Typeface,
};
// --- Palette -----------------------------------------------------------------------------
/// The punktfunk brand violet — the DARK-appearance value (#8678F5); the console UI is
/// always dark. (Light surfaces use #6656F2; nothing here is light.)
pub(crate) const BRAND: Color4f = Color4f::new(0.525, 0.471, 0.961, 1.0);
pub(crate) const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0);
pub(crate) const DIM: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.55);
pub(crate) const FAINT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.35);
/// The error/status red (the GTK client's #ff938a).
pub(crate) const ERROR: Color4f = Color4f::new(1.0, 0.576, 0.541, 1.0);
pub(crate) const ONLINE_GREEN: Color4f = Color4f::new(0.20, 0.84, 0.29, 1.0);
pub(crate) fn white(alpha: f32) -> Color4f {
Color4f::new(1.0, 1.0, 1.0, alpha)
}
pub(crate) fn brand(alpha: f32) -> Color4f {
Color4f::new(BRAND.r, BRAND.g, BRAND.b, alpha)
}
/// The dark-glass base fill every panel starts from.
const GLASS_BASE: Color4f = Color4f::new(0.086, 0.086, 0.125, 0.62);
// --- Panels (the Liquid Glass stand-in) --------------------------------------------------
pub(crate) enum PanelStroke {
/// Hairline white at this alpha (rows, pills).
Plain(f32),
/// White .22 → .04 top→bottom (the host tiles' gradient edge).
Gradient,
/// The gradient edge dashed `[6,5]` (discovered / Add-Host tiles).
GradientDashed,
/// Brand-colored hairline (the actively edited field).
Brand(f32),
}
/// One glass panel: base fill, optional tint wash, hairline stroke. `corner` and the
/// dash pattern are DESIGN units — the caller's `k` scales them.
pub(crate) fn panel(
canvas: &Canvas,
rect: Rect,
corner: f32,
tint: Option<Color4f>,
stroke: PanelStroke,
k: f32,
) {
let rr = RRect::new_rect_xy(rect, corner * k, corner * k);
canvas.draw_rrect(rr, &Paint::new(GLASS_BASE, None));
if let Some(tint) = tint {
canvas.draw_rrect(rr, &Paint::new(tint, None));
}
let mut sp = Paint::default();
sp.set_style(skia_safe::PaintStyle::Stroke);
sp.set_stroke_width(1.0);
sp.set_anti_alias(true);
match stroke {
PanelStroke::Plain(alpha) => {
sp.set_color4f(white(alpha), None);
}
PanelStroke::Brand(alpha) => {
sp.set_color4f(brand(alpha), None);
}
PanelStroke::Gradient | PanelStroke::GradientDashed => {
sp.set_shader(gradient_shader::linear(
(
Point::new(rect.left, rect.top),
Point::new(rect.left, rect.bottom),
),
gradient_shader::GradientShaderColors::Colors(&[
white(0.22).to_color(),
white(0.04).to_color(),
]),
None,
TileMode::Clamp,
None,
None,
));
if matches!(stroke, PanelStroke::GradientDashed) {
sp.set_path_effect(PathEffect::dash(&[6.0 * k, 5.0 * k], 0.0));
}
}
}
canvas.draw_rrect(rr, &sp);
}
/// The soft drop shadow under a focused tile — a blurred black round-rect behind it.
pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alpha: f32) {
let mut p = Paint::new(Color4f::new(0.0, 0.0, 0.0, alpha), None);
p.set_mask_filter(MaskFilter::blur(
skia_safe::BlurStyle::Normal,
10.0 * k,
None,
));
let shifted = rect.with_offset((0.0, 10.0 * k));
canvas.draw_rrect(RRect::new_rect_xy(shifted, corner * k, corner * k), &p);
}
// --- The form backdrop (settings / add-host / pair) --------------------------------------
/// The calm backdrop for the form screens — NOT the launcher's aurora (this stays still
/// and quiet), and deliberately not near-black: a deep indigo base plus two soft static
/// glows give the glass rows real color to sit on. A light top/bottom scrim grounds the
/// pinned title and hint bar (the Swift build blurs a tray instead; same job).
pub(crate) fn draw_form_background(canvas: &Canvas, w: f64, h: f64) {
let (wf, hf) = (w as f32, h as f32);
canvas.draw_rect(
Rect::from_wh(wf, hf),
&Paint::new(Color4f::new(0.075, 0.062, 0.150, 1.0), None),
);
// Violet lift top-leading, cooler indigo bottom-trailing — elliptical (window
// aspect) via a unit-radius radial gradient under a scale.
for (cx, cy, color, alpha) in [
(0.26, 0.14, Color4f::new(0.40, 0.31, 0.68, 1.0), 0.9f32),
(0.82, 0.90, Color4f::new(0.20, 0.24, 0.58, 1.0), 0.75),
] {
let mut paint = Paint::default();
let c = Color4f::new(color.r, color.g, color.b, alpha);
paint.set_shader(gradient_shader::radial(
Point::new(0.0, 0.0),
0.78,
gradient_shader::GradientShaderColors::Colors(&[
c.to_color(),
Color4f::new(color.r, color.g, color.b, 0.0).to_color(),
]),
None,
TileMode::Clamp,
None,
None,
));
canvas.save();
canvas.translate((wf * cx, hf * cy));
canvas.scale((wf, hf));
canvas.draw_rect(Rect::from_ltrb(-1.0, -1.0, 1.0, 1.0), &paint);
canvas.restore();
}
let mut scrim = Paint::default();
scrim.set_shader(gradient_shader::linear(
(Point::new(0.0, 0.0), Point::new(0.0, hf)),
gradient_shader::GradientShaderColors::Colors(&[
Color4f::new(0.0, 0.0, 0.0, 0.30).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.32).to_color(),
]),
Some(&[0.0, 0.22, 0.74, 1.0][..]),
TileMode::Clamp,
None,
None,
));
canvas.draw_rect(Rect::from_wh(wf, hf), &scrim);
}
/// The loading/connecting spinner: a rotating 270° arc driven by the shell clock.
pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) {
let start = (t * 300.0) % 360.0;
let mut paint = Paint::new(white(0.85), None);
paint.set_style(skia_safe::PaintStyle::Stroke);
paint.set_stroke_width((r / 5.0) as f32);
paint.set_stroke_cap(skia_safe::PaintCap::Round);
paint.set_anti_alias(true);
canvas.draw_arc(
Rect::from_xywh(
(cx - r) as f32,
(cy - r) as f32,
2.0 * r as f32,
2.0 * r as f32,
),
start as f32,
270.0,
false,
&paint,
);
}
// --- Typography ---------------------------------------------------------------------------
/// Geist weights the console uses (matching the Apple client's `.geist(size, weight)`).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum W {
Regular,
Medium,
SemiBold,
Bold,
}
/// The text toolkit shared by every screen: the four embedded Geist faces for direct
/// `draw_str` work, plus a paragraph collection ("Geist" with system fallback — game
/// titles can be CJK; `draw_str` can't shape those).
pub(crate) struct Fonts {
regular: Typeface,
medium: Typeface,
semibold: Typeface,
bold: Typeface,
collection: FontCollection,
}
/// The Geist faces ride in the binary — the console must look right on a bare gamescope
/// session with no font packages to lean on (fontconfig still serves CJK fallback where
/// it exists).
const GEIST_REGULAR: &[u8] = include_bytes!("../assets/fonts/Geist-Regular.otf");
const GEIST_MEDIUM: &[u8] = include_bytes!("../assets/fonts/Geist-Medium.otf");
const GEIST_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Geist-SemiBold.otf");
const GEIST_BOLD: &[u8] = include_bytes!("../assets/fonts/Geist-Bold.otf");
pub(crate) fn build_fonts() -> Result<Fonts> {
let mgr = FontMgr::new();
let load = |bytes: &[u8], which: &str| {
mgr.new_from_data(bytes, None)
.ok_or_else(|| anyhow!("embedded Geist face rejected: {which}"))
};
let regular = load(GEIST_REGULAR, "Regular")?;
let medium = load(GEIST_MEDIUM, "Medium")?;
let semibold = load(GEIST_SEMIBOLD, "SemiBold")?;
let bold = load(GEIST_BOLD, "Bold")?;
// Paragraphs resolve "Geist" from the asset provider first (all four weights under
// one alias — style matching picks the face), then fall back to the system manager.
let mut provider = TypefaceFontProvider::new();
for tf in [&regular, &medium, &semibold, &bold] {
provider.register_typeface(tf.clone(), Some("Geist"));
}
let mut collection = FontCollection::new();
collection.set_asset_font_manager(Some(provider.into()));
collection.set_default_font_manager(FontMgr::new(), None);
Ok(Fonts {
regular,
medium,
semibold,
bold,
collection,
})
}
impl Fonts {
pub(crate) fn font(&self, w: W, size: f64) -> Font {
let tf = match w {
W::Regular => &self.regular,
W::Medium => &self.medium,
W::SemiBold => &self.semibold,
W::Bold => &self.bold,
};
let mut f = Font::new(tf.clone(), size as f32);
f.set_subpixel(true);
f
}
pub(crate) fn measure(&self, text: &str, w: W, size: f64) -> f32 {
self.font(w, size).measure_str(text, None).0
}
/// `draw_str` at a BASELINE. Returns the advance so callers can chain runs.
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw(
&self,
canvas: &Canvas,
text: &str,
x: f64,
baseline: f64,
w: W,
size: f64,
color: Color4f,
) -> f32 {
let font = self.font(w, size);
canvas.draw_str(
text,
Point::new(x as f32, baseline as f32),
&font,
&Paint::new(color, None),
);
font.measure_str(text, None).0
}
/// Letter-spaced small caps (the section headers' `tracking(1.4)` look) — Skia's
/// `draw_str` has no tracking, so the run is placed char by char.
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw_tracked(
&self,
canvas: &Canvas,
text: &str,
x: f64,
baseline: f64,
w: W,
size: f64,
tracking: f64,
color: Color4f,
) {
let font = self.font(w, size);
let paint = Paint::new(color, None);
let mut pen = x as f32;
let mut buf = [0u8; 4];
for ch in text.chars() {
let s = ch.encode_utf8(&mut buf);
canvas.draw_str(&*s, Point::new(pen, baseline as f32), &font, &paint);
pen += font.measure_str(&*s, None).0 + tracking as f32;
}
}
fn paragraph(
&self,
text: &str,
w: W,
size: f64,
color: Color4f,
align: TextAlign,
max_w: f64,
) -> skia_safe::textlayout::Paragraph {
let mut style = ParagraphStyle::new();
style.set_text_align(align);
let mut ts = TextStyle::new();
ts.set_font_families(&["Geist"]);
ts.set_font_size(size as f32);
ts.set_color(color.to_color());
ts.set_font_style(match w {
W::Regular => FontStyle::normal(),
W::Medium => FontStyle::new(
skia_safe::font_style::Weight::MEDIUM,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::SemiBold => FontStyle::new(
skia_safe::font_style::Weight::SEMI_BOLD,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::Bold => FontStyle::bold(),
});
style.set_text_style(&ts);
let mut b = ParagraphBuilder::new(&style, self.collection.clone());
b.add_text(text);
let mut p = b.build();
p.layout(max_w as f32);
p
}
/// Centered, wrapping paragraph with `y` as its TOP edge (shaping + CJK fallback).
#[allow(clippy::too_many_arguments)]
pub(crate) fn centered(
&self,
canvas: &Canvas,
text: &str,
w: W,
size: f64,
color: Color4f,
cx: f64,
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, w, size, color, TextAlign::Center, max_w);
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
}
/// A single shaped line, middle-ellipsized to `max_w`, drawn at a baseline. For
/// host/game titles that may exceed their tile.
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw_clipped(
&self,
canvas: &Canvas,
text: &str,
x: f64,
baseline: f64,
w: W,
size: f64,
color: Color4f,
max_w: f64,
) {
let font = self.font(w, size);
if font.measure_str(text, None).0 <= max_w as f32 {
self.draw(canvas, text, x, baseline, w, size, color);
return;
}
let ell = "";
let ell_w = font.measure_str(ell, None).0;
let mut fitted = String::new();
let mut used = 0.0f32;
for ch in text.chars() {
let cw = font.measure_str(ch.to_string().as_str(), None).0;
if used + cw + ell_w > max_w as f32 {
break;
}
fitted.push(ch);
used += cw;
}
fitted.push_str(ell);
self.draw(canvas, &fitted, x, baseline, w, size, color);
}
}
/// Resolve the first available family. Generic aliases ("sans-serif", "monospace")
/// resolve through fontconfig on Linux; Windows' DirectWrite-backed FontMgr has no
/// generic aliases, so the list falls through to concrete family names there.
pub(crate) fn match_first_family(
mgr: &FontMgr,
families: &[&str],
style: FontStyle,
) -> Option<Typeface> {
families
.iter()
.find_map(|f| mgr.match_family_style(f, style))
}
#[cfg(test)]
mod tests {
use super::*;
/// The embedded Geist faces must all parse — a bad asset would take out every
/// console screen at init.
#[test]
fn embedded_fonts_load() {
let fonts = build_fonts().expect("Geist faces load");
for w in [W::Regular, W::Medium, W::SemiBold, W::Bold] {
assert!(fonts.measure("Punktfunk", w, 16.0) > 0.0);
}
// Heavier faces render wider at the same size — proves four distinct faces.
assert!(
fonts.measure("Punktfunk", W::Bold, 16.0)
> fonts.measure("Punktfunk", W::Regular, 16.0)
);
}
}
+687
View File
@@ -0,0 +1,687 @@
//! The console's focusable widgets: the vertical menu list (settings / add-host / pair
//! screens) and the controller-driven on-screen keyboard — ports of the Swift
//! `GamepadMenuList` / `GamepadKeyboard`, immediate-mode: the WIDGET owns cursor,
//! springs and scroll, the SCREEN owns the domain (row content and what an activation
//! means), and every frame the screen hands the widget fresh row specs.
use crate::anim::{approach, Spring, TRAY_C, TRAY_K};
use crate::library::{BUMP_C, BUMP_K};
use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, FAINT, W, WHITE};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Paint, Path, RRect, Rect};
// --- Menu list -----------------------------------------------------------------------------
/// What a menu-list consumed event means for the owning screen.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ListMsg {
None,
/// Left/right on the focused row (the screen returns whether the value changed).
Adjust(i32),
/// A on the focused row.
Activate,
}
/// One row's look, rebuilt by the screen each frame (never stale).
pub(crate) struct RowSpec {
/// Section header drawn above this row (the first row of each group carries it).
pub header: Option<&'static str>,
pub label: String,
/// `None` = an action row (its label draws centered, brand-tinted).
pub value: Option<String>,
/// Placeholder styling for an empty field's value.
pub value_dim: bool,
/// The live-edit caret after the value (this row is what the keyboard types into).
pub caret: bool,
/// Show chevrons while focused (left/right steps the value).
pub adjustable: bool,
/// Action rows render dimmed when not yet actionable.
pub enabled: bool,
}
impl RowSpec {
pub(crate) fn field(label: impl Into<String>, value: String, placeholder: &str) -> RowSpec {
let empty = value.is_empty();
RowSpec {
header: None,
label: label.into(),
value: Some(if empty {
placeholder.to_string()
} else {
value
}),
value_dim: empty,
caret: false,
adjustable: false,
enabled: true,
}
}
pub(crate) fn action(label: impl Into<String>, enabled: bool) -> RowSpec {
RowSpec {
header: None,
label: label.into(),
value: None,
value_dim: false,
caret: false,
adjustable: false,
enabled,
}
}
}
pub(crate) const ROW_H: f64 = 50.0;
const ROW_GAP: f64 = 6.0;
const HEADER_H: f64 = 34.0;
pub(crate) const ROW_MAX_W: f64 = 620.0;
/// The focus list: authoritative cursor, spring recoil at the ends, a scroll offset
/// that chases the focused row, and a per-row focus amount for the scale/tint ease.
pub(crate) struct MenuList {
pub cursor: usize,
bump: Spring,
scroll: f64,
focus: Vec<f64>,
}
impl MenuList {
pub(crate) fn new() -> MenuList {
MenuList {
cursor: 0,
bump: Spring::rest(0.0),
scroll: 0.0,
focus: Vec::new(),
}
}
/// Route a menu event. Up/down move focus (Boundary = recoil), left/right become
/// [`ListMsg::Adjust`], A becomes [`ListMsg::Activate`]. B is the SCREEN's.
pub(crate) fn menu(&mut self, ev: MenuEvent, len: usize) -> (ListMsg, Option<MenuPulse>) {
match ev {
MenuEvent::Move(MenuDir::Up) => (ListMsg::None, self.step(-1, len)),
MenuEvent::Move(MenuDir::Down) => (ListMsg::None, self.step(1, len)),
MenuEvent::Move(MenuDir::Left) => (ListMsg::Adjust(-1), None),
MenuEvent::Move(MenuDir::Right) => (ListMsg::Adjust(1), None),
MenuEvent::Confirm => (ListMsg::Activate, Some(MenuPulse::Confirm)),
_ => (ListMsg::None, None),
}
}
fn step(&mut self, delta: i32, len: usize) -> Option<MenuPulse> {
let target = self.cursor as i32 + delta;
if len == 0 || target < 0 || target >= len as i32 {
// Refused at an end: the dull thud plus a short vertical recoil.
self.bump = Spring {
pos: -14.0 * f64::from(delta.signum()),
vel: 0.0,
};
return Some(MenuPulse::Boundary);
}
self.cursor = target as usize;
Some(MenuPulse::Move)
}
/// Render the rows into `rect`. `active` = this list owns focus (a keyboard tray on
/// top parks it — rows keep their look but the focus ring rests).
#[allow(clippy::too_many_arguments)]
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
rows: &[RowSpec],
fonts: &Fonts,
k: f64,
dt: f64,
active: bool,
) {
self.focus.resize(rows.len(), 0.0);
for (i, f) in self.focus.iter_mut().enumerate() {
let target = if active && i == self.cursor { 1.0 } else { 0.0 };
*f = approach(*f, target, dt, 0.06);
}
self.bump.step(0.0, BUMP_K, BUMP_C, dt);
self.bump.settle(0.0, 0.3, 4.0);
// Row tops (design units) incl. headers, so scroll math and drawing agree.
let mut tops = Vec::with_capacity(rows.len());
let mut y = 0.0;
for row in rows {
if row.header.is_some() {
y += HEADER_H;
}
tops.push(y);
y += ROW_H + ROW_GAP;
}
let content_h = (y - ROW_GAP).max(0.0) * k;
let view_h = f64::from(rect.height());
// The scroll chases the focused row into the middle band, clamped to content.
let focused_center = tops.get(self.cursor).map_or(0.0, |t| (t + ROW_H / 2.0) * k);
let target = (focused_center - view_h / 2.0).clamp(0.0, (content_h - view_h).max(0.0));
self.scroll = approach(self.scroll, target, dt, 0.08);
let row_w = (ROW_MAX_W * k).min(f64::from(rect.width()) - 48.0 * k);
let x0 = f64::from(rect.left) + (f64::from(rect.width()) - row_w) / 2.0;
canvas.save();
canvas.clip_rect(rect, None, true);
for (i, row) in rows.iter().enumerate() {
let f = self.focus[i];
let top = f64::from(rect.top) + tops[i] * k - self.scroll + self.bump.pos * k;
if top + ROW_H * k < f64::from(rect.top) - 8.0 * k
|| top > f64::from(rect.bottom) + 8.0 * k
{
continue;
}
if let Some(header) = row.header {
fonts.draw_tracked(
canvas,
&header.to_uppercase(),
x0 + 16.0 * k,
top - 12.0 * k,
W::SemiBold,
12.0 * k,
1.4 * k,
white(0.45),
);
}
// Focus scale eases 0.98 → 1.0 about the row center.
let scale = 0.98 + 0.02 * f;
let (cx, cy) = (x0 + row_w / 2.0, top + ROW_H * k / 2.0);
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));
let r = Rect::from_xywh(x0 as f32, top as f32, row_w as f32, (ROW_H * k) as f32);
let stroke = if row.caret {
PanelStroke::Brand(0.7)
} else {
PanelStroke::Plain(0.06 + 0.22 * f as f32)
};
let tint = if row.caret {
Some(brand(0.30))
} else if f > 0.01 {
Some(brand(0.30 * f as f32))
} else {
None
};
crate::theme::panel(canvas, r, 14.0, tint, stroke, k as f32);
let baseline = cy + 16.0 * k * 0.36;
if row.value.is_none() {
// Action row: centered label, brand when actionable.
let color = if row.enabled { BRAND } else { FAINT };
let tw = fonts.measure(&row.label, W::SemiBold, 16.0 * k) as f64;
fonts.draw(
canvas,
&row.label,
cx - tw / 2.0,
baseline,
W::SemiBold,
16.0 * k,
color,
);
} else {
fonts.draw(
canvas,
&row.label,
x0 + 16.0 * k,
baseline,
W::SemiBold,
16.0 * k,
WHITE,
);
let value = row.value.as_deref().unwrap_or_default();
let vcolor = if row.value_dim {
FAINT
} else if f > 0.5 {
WHITE
} else {
white(0.6 + 0.4 * f as f32)
};
let chevron_w = if row.adjustable { 18.0 * k } else { 0.0 };
let caret_w = if row.caret { 8.0 * k } else { 0.0 };
let vmax = row_w * 0.55;
let vw = (fonts.measure(value, W::Medium, 15.0 * k) as f64).min(vmax);
let vx = x0 + row_w - 16.0 * k - chevron_w - caret_w - vw;
// Head-truncate: keep the END of a long address visible while typing.
let shown = truncate_head(fonts, value, W::Medium, 15.0 * k, vmax);
fonts.draw(canvas, &shown, vx, baseline, W::Medium, 15.0 * k, vcolor);
if row.caret {
canvas.draw_rect(
Rect::from_xywh(
(vx + vw + 3.0 * k) as f32,
(cy - 9.0 * k) as f32,
(2.0 * k) as f32,
(18.0 * k) as f32,
),
&Paint::new(BRAND, None),
);
}
if row.adjustable && f > 0.01 {
let alpha = 0.6 * f as f32;
chevron(canvas, vx - 11.0 * k, cy, 4.0 * k, true, alpha);
chevron(canvas, x0 + row_w - 16.0 * k, cy, 4.0 * k, false, alpha);
}
}
canvas.restore();
}
canvas.restore();
}
}
/// Middle-of-nowhere helper: drop chars from the FRONT until the tail fits.
fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> String {
if f64::from(fonts.measure(text, w, size)) <= max_w {
return text.to_string();
}
let mut s: Vec<char> = text.chars().collect();
while s.len() > 1 {
s.remove(0);
let candidate: String = std::iter::once('…').chain(s.iter().copied()).collect();
if f64::from(fonts.measure(&candidate, w, size)) <= max_w {
return candidate;
}
}
"".into()
}
fn chevron(canvas: &Canvas, x: f64, cy: f64, r: f64, left: bool, alpha: f32) {
let dir = if left { -1.0 } else { 1.0 };
let mut p = Paint::new(white(alpha), None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((1.8 * r / 4.0) as f32);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_anti_alias(true);
let mut path = Path::new();
path.move_to(((x - dir * r / 2.0) as f32, (cy - r) as f32));
path.line_to(((x + dir * r / 2.0) as f32, cy as f32));
path.line_to(((x - dir * r / 2.0) as f32, (cy + r) as f32));
canvas.draw_path(&path, &p);
}
// --- On-screen keyboard ----------------------------------------------------------------------
/// What the keyboard may type per field (backspace always works).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Charset {
Free,
/// Addresses/hostnames — everything but whitespace.
Hostname,
Digits,
}
pub(crate) fn permits(charset: Charset, ch: char) -> bool {
match charset {
Charset::Free => true,
Charset::Hostname => !ch.is_whitespace(),
Charset::Digits => ch.is_ascii_digit(),
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum KeyMsg {
None,
Type(char),
Backspace,
Done,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Key {
Char(char),
Space,
Backspace,
Done,
}
/// Digits first (addresses/ports), then letters; the last char column carries the
/// hostname/address punctuation — the Swift grid, verbatim.
fn key_rows() -> &'static [Vec<Key>] {
use std::sync::OnceLock;
static ROWS: OnceLock<Vec<Vec<Key>>> = OnceLock::new();
ROWS.get_or_init(|| {
let chars = |s: &str| s.chars().map(Key::Char).collect::<Vec<_>>();
vec![
chars("1234567890"),
chars("qwertyuiop"),
chars("asdfghjkl-"),
chars("zxcvbnm._:"),
vec![Key::Space, Key::Backspace, Key::Done],
]
})
}
/// The controller keyboard: a fixed key grid in a bottom tray. Dpad/stick moves the key
/// cursor, A types, X backspaces, B/Y/Done confirms. Edits apply live — closing IS done.
pub(crate) struct Keyboard {
row: usize,
col: usize,
/// Tray slide-in (0 hidden → 1 seated), the Swift `.spring(0.32, 0.86)`.
tray: Spring,
key_flash: f64,
}
impl Keyboard {
pub(crate) fn new() -> Keyboard {
Keyboard {
row: 1, // opens on "q"
col: 0,
tray: Spring::rest(0.0),
key_flash: 0.0,
}
}
/// Route a menu event; the SCREEN applies `Type`/`Backspace` to its field (charset
/// checks included — a refusal comes back as a boundary pulse from the screen).
pub(crate) fn menu(&mut self, ev: MenuEvent) -> (KeyMsg, Option<MenuPulse>) {
let rows = key_rows();
match ev {
MenuEvent::Move(dir) => {
let (mut row, mut col) = (self.row as i32, self.col as i32);
match dir {
MenuDir::Left => col -= 1,
MenuDir::Right => col += 1,
MenuDir::Up | MenuDir::Down => {
let next = row + if dir == MenuDir::Down { 1 } else { -1 };
if next < 0 || next >= rows.len() as i32 {
return (KeyMsg::None, Some(MenuPulse::Boundary));
}
// Map the column proportionally between rows of different
// widths (Done goes up to the rightmost letters, not "e").
let from = (rows[row as usize].len() - 1).max(1) as f64;
let to = (rows[next as usize].len() - 1) as f64;
col = (col as f64 * to / from).round() as i32;
row = next;
}
}
if row < 0
|| row >= rows.len() as i32
|| col < 0
|| col >= rows[row as usize].len() as i32
{
return (KeyMsg::None, Some(MenuPulse::Boundary));
}
self.row = row as usize;
self.col = col as usize;
(KeyMsg::None, Some(MenuPulse::Move))
}
MenuEvent::Confirm => {
self.key_flash = 1.0;
match rows[self.row][self.col] {
Key::Char(c) => (KeyMsg::Type(c), None),
Key::Space => (KeyMsg::Type(' '), None),
Key::Backspace => (KeyMsg::Backspace, None),
Key::Done => (KeyMsg::Done, Some(MenuPulse::Confirm)),
}
}
MenuEvent::Tertiary => (KeyMsg::Backspace, None),
MenuEvent::Secondary | MenuEvent::Back => (KeyMsg::Done, Some(MenuPulse::Confirm)),
_ => (KeyMsg::None, None),
}
}
/// Advance the tray spring toward shown/hidden. Returns the current seat 0..1 —
/// at exactly 0 while hidden, so the caller can skip rendering.
pub(crate) fn seat(&mut self, shown: bool, dt: f64) -> f64 {
self.tray
.step(if shown { 1.0 } else { 0.0 }, TRAY_K, TRAY_C, dt);
self.tray.settle(if shown { 1.0 } else { 0.0 }, 0.001, 0.01);
self.key_flash = approach(self.key_flash, 0.0, dt, 0.10);
self.tray.pos.clamp(0.0, 1.2)
}
/// The tray's design height (pre-`k`), for layout above it.
pub(crate) fn tray_height() -> f64 {
5.0 * 42.0 + 4.0 * 7.0 + 2.0 * 14.0
}
/// Render the tray with its bottom edge at `bottom`, horizontally centered, slid by
/// `seat` (0..1). The caller clips nothing — the tray rises from below the screen.
#[allow(clippy::too_many_arguments)]
pub(crate) fn render(
&self,
canvas: &Canvas,
fonts: &Fonts,
w: f64,
bottom: f64,
seat: f64,
k: f64,
) {
let rows = key_rows();
let tray_w = (560.0 * k).min(w - 32.0 * k);
let tray_h = Self::tray_height() * k;
let x0 = (w - tray_w) / 2.0;
let y0 = bottom - tray_h * seat;
let rect = Rect::from_xywh(x0 as f32, y0 as f32, tray_w as f32, tray_h as f32);
crate::theme::panel(
canvas,
rect,
22.0,
Some(skia_safe::Color4f::new(0.05, 0.045, 0.09, 0.55)),
PanelStroke::Plain(0.12),
k as f32,
);
let pad = 14.0 * k;
let gap = 7.0 * k;
let key_h = 42.0 * k;
for (r, row) in rows.iter().enumerate() {
let n = row.len() as f64;
let key_w = (tray_w - 2.0 * pad - (n - 1.0) * gap) / n;
let y = y0 + pad + r as f64 * (key_h + gap);
for (c, key) in row.iter().enumerate() {
let x = x0 + pad + c as f64 * (key_w + gap);
let focused = r == self.row && c == self.col;
let kr = Rect::from_xywh(x as f32, y as f32, key_w as f32, key_h as f32);
let fill = if focused {
let mut b = BRAND;
if self.key_flash > 0.02 {
// A just-typed key flashes brighter, then eases back.
let f = self.key_flash as f32;
b = skia_safe::Color4f::new(
b.r + (1.0 - b.r) * 0.5 * f,
b.g + (1.0 - b.g) * 0.5 * f,
b.b,
1.0,
);
}
b
} else {
white(0.08)
};
canvas.draw_rrect(
RRect::new_rect_xy(kr, (9.0 * k) as f32, (9.0 * k) as f32),
&Paint::new(fill, None),
);
let ink = if focused {
skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0)
} else {
WHITE
};
let (cx, cy) = (x + key_w / 2.0, y + key_h / 2.0);
match key {
Key::Char(ch) => {
let s = ch.to_string();
let size = 18.0 * k;
let tw = fonts.measure(&s, W::Medium, size) as f64;
fonts.draw(
canvas,
&s,
cx - tw / 2.0,
cy + size * 0.36,
W::Medium,
size,
ink,
);
}
Key::Space => draw_space_icon(canvas, cx, cy, k, ink),
Key::Backspace => draw_backspace_icon(canvas, cx, cy, k, ink),
Key::Done => {
let size = 15.0 * k;
let label = "Done";
let tw = fonts.measure(label, W::SemiBold, size) as f64;
let check_w = 14.0 * k;
let total = check_w + 6.0 * k + tw;
draw_check(canvas, cx - total / 2.0 + check_w / 2.0, cy, k, ink);
fonts.draw(
canvas,
label,
cx - total / 2.0 + check_w + 6.0 * k,
cy + size * 0.36,
W::SemiBold,
size,
ink,
);
}
}
}
}
}
}
fn stroke_paint(ink: skia_safe::Color4f, width: f32) -> Paint {
let mut p = Paint::new(ink, None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width(width);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_stroke_join(skia_safe::PaintJoin::Round);
p.set_anti_alias(true);
p
}
fn draw_space_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
// ⎵ — an underline bracket.
let (w, h) = (16.0 * k, 5.0 * k);
let p = stroke_paint(ink, (1.6 * k) as f32);
let mut path = Path::new();
path.move_to(((cx - w / 2.0) as f32, (cy - h / 2.0) as f32));
path.line_to(((cx - w / 2.0) as f32, (cy + h / 2.0) as f32));
path.line_to(((cx + w / 2.0) as f32, (cy + h / 2.0) as f32));
path.line_to(((cx + w / 2.0) as f32, (cy - h / 2.0) as f32));
canvas.draw_path(&path, &p);
}
fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
// ⌫ — the left-pointing cap with an ✕ inside.
let (w, h) = (18.0 * k, 12.0 * k);
let nose = 6.0 * k;
let p = stroke_paint(ink, (1.6 * k) as f32);
let (l, r, t, b) = (cx - w / 2.0, cx + w / 2.0, cy - h / 2.0, cy + h / 2.0);
let mut path = Path::new();
path.move_to(((l + nose) as f32, t as f32));
path.line_to((r as f32, t as f32));
path.line_to((r as f32, b as f32));
path.line_to(((l + nose) as f32, b as f32));
path.line_to((l as f32, cy as f32));
path.close();
canvas.draw_path(&path, &p);
let (xc, xr) = (cx + nose / 2.0, 2.6 * k);
canvas.draw_line(
((xc - xr) as f32, (cy - xr) as f32),
((xc + xr) as f32, (cy + xr) as f32),
&p,
);
canvas.draw_line(
((xc - xr) as f32, (cy + xr) as f32),
((xc + xr) as f32, (cy - xr) as f32),
&p,
);
}
fn draw_check(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
let p = stroke_paint(ink, (1.8 * k) as f32);
let r = 5.0 * k;
let mut path = Path::new();
path.move_to(((cx - r) as f32, cy as f32));
path.line_to(((cx - r * 0.25) as f32, (cy + r * 0.7) as f32));
path.line_to(((cx + r) as f32, (cy - r * 0.7) as f32));
canvas.draw_path(&path, &p);
}
#[cfg(test)]
mod tests {
use super::*;
fn kb() -> Keyboard {
Keyboard::new()
}
#[test]
fn keyboard_opens_on_q_and_types() {
let mut k = kb();
let (msg, _) = k.menu(MenuEvent::Confirm);
assert_eq!(msg, KeyMsg::Type('q'));
}
#[test]
fn keyboard_proportional_column_mapping() {
let mut k = kb();
// Walk to the digits row's far right ("0", col 9), then down to the bottom row:
// col must land on Done (rightmost of 3), not clamp to some middle key.
for _ in 0..9 {
k.menu(MenuEvent::Move(MenuDir::Right));
}
k.menu(MenuEvent::Move(MenuDir::Up)); // digits row
assert_eq!((k.row, k.col), (0, 9));
for _ in 0..4 {
k.menu(MenuEvent::Move(MenuDir::Down));
}
assert_eq!(k.row, 4);
assert_eq!(k.col, 2, "rightmost column maps onto Done");
let (msg, _) = k.menu(MenuEvent::Confirm);
assert_eq!(msg, KeyMsg::Done);
}
#[test]
fn keyboard_edges_refuse() {
let mut k = kb();
let (_, pulse) = k.menu(MenuEvent::Move(MenuDir::Left));
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
// X backspaces, B/Y are done — from anywhere.
assert_eq!(k.menu(MenuEvent::Tertiary).0, KeyMsg::Backspace);
assert_eq!(k.menu(MenuEvent::Secondary).0, KeyMsg::Done);
assert_eq!(k.menu(MenuEvent::Back).0, KeyMsg::Done);
}
#[test]
fn charsets() {
assert!(permits(Charset::Digits, '7'));
assert!(!permits(Charset::Digits, 'a'));
assert!(permits(Charset::Hostname, '-'));
assert!(!permits(Charset::Hostname, ' '));
assert!(permits(Charset::Free, ' '));
}
#[test]
fn menu_list_moves_and_recoils() {
let mut l = MenuList::new();
assert!(matches!(
l.menu(MenuEvent::Move(MenuDir::Down), 3).1,
Some(MenuPulse::Move)
));
assert_eq!(l.cursor, 1);
assert!(matches!(
l.menu(MenuEvent::Move(MenuDir::Up), 3).1,
Some(MenuPulse::Move)
));
assert!(matches!(
l.menu(MenuEvent::Move(MenuDir::Up), 3).1,
Some(MenuPulse::Boundary)
));
assert!(l.bump.pos.abs() > 1.0, "recoil engaged");
assert_eq!(
l.menu(MenuEvent::Move(MenuDir::Right), 3).0,
ListMsg::Adjust(1)
);
assert_eq!(l.menu(MenuEvent::Confirm, 3).0, ListMsg::Activate);
}
#[test]
fn head_truncation_keeps_the_tail() {
let fonts = crate::theme::build_fonts().unwrap();
let t = truncate_head(&fonts, "verylonghostname.local", W::Medium, 15.0, 60.0);
assert!(t.starts_with('…'));
assert!(t.ends_with("local"));
}
}
+28 -6
View File
@@ -7,6 +7,7 @@
use ash::vk;
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use punktfunk_core::config::GamepadPref;
/// The presenter's device, shared with the overlay so its renderer (Skia's
/// `DirectContext`) creates resources on the same VkDevice/queue. Handles stay valid for
@@ -34,6 +35,11 @@ pub struct FrameCtx<'a> {
pub hint: Option<&'a str>,
/// The active gamepad's name (the console library's controller chip).
pub pad: Option<&'a str>,
/// The active pad's resolved kind — drives the console UI's button glyphs
/// (PlayStation shapes for DualSense/DualShock, ABXY letters otherwise).
pub pad_pref: Option<GamepadPref>,
/// Every connected pad (the console settings' "Use controller" row).
pub pads: &'a [pf_client_core::gamepad::PadInfo],
}
/// One overlay image ready to composite: RGBA, PREMULTIPLIED alpha, already in
@@ -46,13 +52,22 @@ pub struct OverlayFrame {
pub height: u32,
}
/// An action the overlay raises out of its input handling (browse mode: the console
/// library's A/B/retry). The run loop hands each to the session binary's callback.
/// An action the overlay raises out of its input handling (browse mode). Only actions
/// the RUN LOOP must act on live here — starting/canceling sessions and quitting; data
/// work (pairing, discovery, library fetches…) rides the console command bus instead.
pub enum OverlayAction {
/// Launch this library title as a session (`id` rides the Hello).
Launch { id: String, title: String },
/// Retry whatever failed (the library fetch).
Retry,
/// Start a session on this host. `launch` carries a library title id on the Hello
/// (`None` streams the desktop); `title` is display-only (window title).
Launch {
addr: String,
port: u16,
fp_hex: String,
launch: Option<String>,
title: String,
},
/// Abort an in-flight connect (B while Connecting) — the console keeps browsing.
/// The run loop stops the pump; a dial that already won the race is quit-closed.
CancelConnect,
/// Quit the launcher (B at the root) — ends the process, Gaming Mode returns.
Quit,
}
@@ -95,6 +110,13 @@ pub trait Overlay {
/// A session lifecycle edge (browse mode scene driving).
fn session_phase(&mut self, _phase: SessionPhase) {}
/// True while a text field is being edited — the run loop starts/stops SDL text
/// input to match (IME + `Event::TextInput` delivery on desktop; under gamescope
/// this is also what lets Steam's on-screen keyboard type into the app).
fn text_input_active(&self) -> bool {
false
}
/// Once per presenter iteration. Damage-driven: re-render (flush + transition to
/// SHADER_READ_ONLY) only when the content or size changed, else return the previous
/// image. `None` = nothing to composite. The returned image must stay untouched
+64 -6
View File
@@ -145,6 +145,9 @@ struct StreamState {
connector: Option<Arc<NativeClient>>,
capture: Option<Capture>,
force_software: Arc<AtomicBool>,
/// The user canceled this connect from the console — never engage the stream
/// (skip capture/attach on a late `Connected`) and route its end back silently.
canceled: bool,
ready_announced: bool,
mode_line: String,
clock_offset_ns: i64,
@@ -192,6 +195,7 @@ impl StreamState {
connector: None,
capture: None,
force_software,
canceled: false,
ready_announced: false,
mode_line: String::new(),
clock_offset_ns: 0,
@@ -337,6 +341,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
let mut fullscreen = opts.fullscreen;
let mut print_stats = opts.print_stats;
let mut overlay_frame: Option<OverlayFrame> = None;
// SDL text input tracks the overlay's editing state (started = IME/`TextInput`
// events on desktop, and the door Steam's on-screen keyboard types through under
// gamescope). Toggled edge-wise — start/stop are not free on Wayland.
let mut text_input_on = false;
let outcome = 'main: loop {
// --- SDL events (input, window, gamepads) ---------------------------------------
@@ -499,6 +507,18 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
cap.flush_motion();
}
// Text input follows the overlay's editing state (edge-triggered).
let want_text = overlay.as_ref().is_some_and(|o| o.text_input_active());
if want_text != text_input_on {
text_input_on = want_text;
let ti = video.text_input();
if want_text {
ti.start(&window);
} else {
ti.stop(&window);
}
}
// Controller escape chord: release capture (+ leave fullscreen on desktop — under
// a `--fullscreen` gamescope launch there is nothing to release into). Only
// emitted while a session is attached.
@@ -522,9 +542,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
}
// --- Browse: menu navigation + overlay actions (library visible only) ------------
// --- Browse: menu navigation + overlay actions (console visible only) ------------
if let ModeCtl::Browse(on_action) = &mut mode {
if stream.is_none() {
// Menu events flow while no stream is engaged — including a connect in
// flight (connector still None), so B can cancel the dial. Once attached,
// the gamepad worker forwards raw input instead of translating.
if stream.as_ref().is_none_or(|s| s.connector.is_none()) {
while let Ok(ev) = menu_rx.try_recv() {
if let Some(o) = overlay.as_mut() {
if let Some(pulse) = o.handle_menu(ev) {
@@ -534,6 +557,17 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
}
if let Some(action) = overlay.as_mut().and_then(|o| o.take_action()) {
match action {
OverlayAction::CancelConnect => {
if let Some(st) = &mut stream {
if st.connector.is_none() && !st.canceled {
tracing::info!("connect canceled from the console");
st.canceled = true;
st.handle.stop.store(true, Ordering::SeqCst);
}
}
}
action => {
let force_software = Arc::new(AtomicBool::new(false));
match on_action(
action,
@@ -557,6 +591,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
}
}
}
}
// --- Session events --------------------------------------------------------------
// `stream` may become None mid-drain (browse-mode session end) — re-borrow each
@@ -571,6 +607,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
mode: m,
fingerprint,
} => {
if st.canceled {
// The dial won the race against the cancel: quit-close the host
// side now; the stop flag (already set) ends the pump and the
// Ended path routes back to the console without ever engaging.
c.disconnect_quit();
continue;
}
st.mode_line = format!("{}×{}@{}", m.width, m.height, m.refresh_hz);
tracing::info!(mode = %st.mode_line, "connected");
window
@@ -613,14 +656,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
})
}
ModeCtl::Browse(_) => {
tracing::warn!(%msg, "connect failed — back to the library");
tracing::warn!(%msg, "connect failed — back to the console");
let canceled = st.canceled;
if let Some(st) = stream.take() {
st.shutdown();
}
apply_capture(&mut window, &mouse, false);
if let Some(o) = overlay.as_mut() {
// A user-canceled dial ends silently — no error scene.
if canceled {
o.session_phase(SessionPhase::Ended(None));
} else {
o.session_phase(SessionPhase::Failed(&msg));
}
}
break;
}
},
@@ -634,11 +683,17 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)),
ModeCtl::Browse(_) => {
window.set_title(&opts.window_title).ok();
let canceled = st.canceled;
if let Some(st) = stream.take() {
st.shutdown();
}
if let Some(o) = overlay.as_mut() {
o.session_phase(SessionPhase::Ended(reason.as_deref()));
// A canceled connect's end carries no reason strip.
o.session_phase(SessionPhase::Ended(if canceled {
None
} else {
reason.as_deref()
}));
}
break;
}
@@ -667,13 +722,16 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
_ => (None, None),
};
let pad_name = gamepad.active().map(|p| p.name);
let pad = gamepad.active();
let pads = gamepad.pads();
let ctx = FrameCtx {
width: pw,
height: ph,
stats,
hint,
pad: pad_name.as_deref(),
pad: pad.as_ref().map(|p| p.name.as_str()),
pad_pref: pad.as_ref().map(|p| p.pref),
pads: &pads,
};
match o.frame(&ctx) {
Ok(f) => overlay_frame = f,