Files
punktfunk/crates/pf-console-ui/src/shell.rs
T
enricobuehler dd02e1f402
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m8s
decky / build-publish (push) Successful in 20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
android / android (push) Successful in 12m23s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m3s
arch / build-publish (push) Successful in 12m57s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m40s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7m4s
docker / deploy-docs (push) Successful in 11s
flatpak / build-publish (push) Successful in 5m57s
deb / build-publish (push) Successful in 13m26s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m50s
apple / swift (push) Successful in 4m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m44s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m9s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
release / apple (push) Successful in 21m27s
ci / rust (push) Failing after 6m47s
apple / screenshots (push) Successful in 20m18s
feat(clients): unified full-screen connect/wake takeover + iOS/tvOS Wake-on-LAN
Give instant feedback the moment a host is picked, and make the wake wait a
full-screen takeover instead of a modal card — unified into one ConnectOverlay
across every client:

- android: new ConnectOverlay (aurora backdrop; Connecting / Waking / timed-out
  phases) replaces the tiny inline "Connecting…" row and the WakeOverlay card.
  The dial phase is now cancelable and hands off to the wake wait in one frame.
- console (pf-console-ui): the connect/wake overlays become a full-screen aurora
  takeover (draw_takeover) instead of a centered card over a dim scrim; the
  Waking → Connecting handoff no longer blinks.
- apple: new ConnectOverlay mirrors it (macOS / iOS / tvOS), replacing the
  per-tile connecting spinner + the WakeOverlay card; instant "Connecting…" from
  model.phase, and the carousel is gated inactive during the dial.

Also enable Wake-on-LAN on iOS/tvOS now that the multicast entitlement is
approved: enable com.apple.developer.networking.multicast and flip
wakeOnLANAvailable to true on every platform (MACs were already learned from
mDNS, so wake works immediately).

Verified: Android compileDebugKotlin + screenshot renders; console clippy +
36 tests + rendered phases on Linux; Apple swift build + 121 tests + rendered
phases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:29:35 +02:00

1193 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The console shell: the screen stack, the push/pop entrance/exit choreography, the
//! chrome every screen shares (pinned title, controller chip, hint bar), and the modal
//! overlays (connecting, waking, toasts). Screens draw CONTENT; the shell makes them
//! read — and move — as one coherent console.
//!
//! Transitions: a push slides the incoming screen up out of a fade while the outgoing
//! one recedes; a pop mirrors it. One eased 0→1 progress drives both layers (0.26 s,
//! ease-out cubic — the WinUI shell's entrance feel), each composited through a
//! `save_layer_alpha` so a screen fades as a unit, never element by element. The
//! backdrop crossfades in parallel when the screens disagree (aurora ↔ form).
use crate::anim::{approach, ease_out_cubic, Progress};
use crate::glyphs::{hint_bar, GlyphStyle, Hint, HintKey};
use crate::library::{mesh_sksl, LibraryShared};
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen};
use crate::theme::{white, Fonts, PanelStroke, DIM, W, WHITE};
use anyhow::{anyhow, Result};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
use pf_client_core::trust;
use pf_presenter::overlay::OverlayAction;
use skia_safe::{
gradient_shader, Canvas, Color4f, Data, Paint, Point, Rect, RuntimeEffect, TileMode,
};
use std::collections::VecDeque;
use std::time::Instant;
const TRANSITION_S: f64 = 0.26;
/// Chrome bands (design units): the pinned title above, hints below.
const TOP_BAND: f64 = 64.0;
const BOTTOM_BAND: f64 = 86.0;
enum Motion {
None,
Push(Progress),
Pop { leaving: Box<Screen>, t: Progress },
}
struct Toast {
text: String,
at: f64,
}
struct Connecting {
title: String,
canceling: bool,
appear: f64,
}
/// What the session binary hands the shell at construction.
pub struct ConsoleOptions {
/// The machine's hostname — the default device name pairing registers.
pub device_name: String,
/// Steam Deck: Steam's keyboard types (SDL text input); ours never draws.
pub deck: bool,
}
pub(crate) struct Shell {
stack: Vec<Screen>,
motion: Motion,
console: ConsoleShared,
library: LibraryShared,
bus: ConsoleBus,
actions: VecDeque<OverlayAction>,
settings: trust::Settings,
hosts: Vec<HostRow>,
hosts_gen: u64,
device_name: String,
deck: bool,
pub(crate) in_stream: bool,
connecting: Option<Connecting>,
wake: Option<WakeStatus>,
/// True while `wake` is the shell's own optimistic placeholder — raised the instant a
/// screen queues `ConsoleCmd::Wake` (see [`Self::apply`]), before the service thread has
/// round-tripped its first real `WakeStatus` (~100 ms1 s). `sync` must not clear the
/// placeholder in that window, or navigation would race the wake ungated (the "pressed A,
/// cursor drifted to Add Host, then got thrust into the stream" bug).
wake_optimistic: bool,
toast: Option<Toast>,
mesh: RuntimeEffect,
/// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition.
bg_mix: f64,
glyphs: GlyphStyle,
chip: Option<String>,
pads: Vec<PadInfo>,
t0: Instant,
last_frame: Option<Instant>,
}
impl Shell {
pub(crate) fn new(
console: ConsoleShared,
library: LibraryShared,
bus: ConsoleBus,
opts: ConsoleOptions,
stack: Vec<Screen>,
) -> Result<Shell> {
anyhow::ensure!(!stack.is_empty(), "the console needs a root screen");
let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None)
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
let bg_mix = match stack.last().expect("non-empty").background() {
Bg::Aurora => 0.0,
Bg::Form => 1.0,
};
Ok(Shell {
stack,
motion: Motion::None,
console,
library,
bus,
actions: VecDeque::new(),
settings: trust::Settings::load(),
hosts: Vec::new(),
hosts_gen: u64::MAX,
device_name: opts.device_name,
deck: opts.deck,
in_stream: false,
connecting: None,
wake: None,
wake_optimistic: false,
toast: None,
mesh,
bg_mix,
glyphs: GlyphStyle::Keyboard,
chip: None,
pads: Vec::new(),
t0: Instant::now(),
last_frame: None,
})
}
fn t(&self) -> f64 {
self.t0.elapsed().as_secs_f64()
}
pub(crate) fn editing(&self) -> bool {
!self.in_stream
&& self.connecting.is_none()
&& self.stack.last().is_some_and(Screen::editing)
}
pub(crate) fn take_action(&mut self) -> Option<OverlayAction> {
self.actions.pop_front()
}
// --- Session lifecycle edges (from the overlay's `session_phase`) --------------------
pub(crate) fn set_connecting(&mut self, title: Option<String>) {
match title {
Some(title) => {
self.connecting = Some(Connecting {
title,
canceling: false,
appear: 0.0,
})
}
None => self.connecting = None,
}
}
pub(crate) fn session_failed(&mut self, msg: &str) {
self.connecting = None;
self.in_stream = false;
self.show_toast(format!("Couldn't connect — {msg}"));
}
pub(crate) fn session_streaming(&mut self) {
self.connecting = None;
self.in_stream = true;
}
pub(crate) fn session_ended(&mut self, reason: Option<&str>) {
self.connecting = None;
self.in_stream = false;
if let Some(reason) = reason {
self.show_toast(format!("Session ended — {reason}"));
}
}
fn show_toast(&mut self, text: String) {
self.toast = Some(Toast { text, at: self.t() });
}
// --- Model sync (hosts, pairing, wake) — before input and before render --------------
fn sync(&mut self) {
if self.console.hosts_gen() != self.hosts_gen {
(self.hosts, self.hosts_gen) = self.console.hosts_snapshot();
}
let pair = self.console.pair();
match &pair {
PairPhase::Idle => {}
PairPhase::Paired { key } => {
let name = self
.hosts
.iter()
.find(|h| &h.key == key)
.map_or_else(|| "the host".to_string(), |h| h.name.clone());
self.show_toast(format!("Paired with {name}"));
self.console.set_pair(PairPhase::Idle);
if matches!(self.stack.last(), Some(Screen::Pair(_))) {
self.apply_nav(Nav::Pop);
}
}
phase => {
if let Some(Screen::Pair(p)) = self.stack.last_mut() {
p.apply_phase(phase);
}
if matches!(phase, PairPhase::Failed(_)) {
self.console.set_pair(PairPhase::Idle);
}
}
}
match self.console.wake() {
Some(w) => {
self.wake_optimistic = false;
self.wake = Some(w);
}
// No service status yet: keep an optimistic placeholder alive — clearing it here
// would reopen the ungated window it exists to close.
None if !self.wake_optimistic => self.wake = None,
None => {}
}
if let Some(w) = &self.wake {
if w.online {
// Awake: stop the wake loop, and connect if that's what A meant.
let intent = w.then_connect.then(|| {
self.hosts
.iter()
.find(|h| h.key == w.key)
.map(|h| ConnectIntent {
addr: h.addr.clone(),
port: h.port,
fp_hex: h.fp_hex.clone(),
launch: None,
title: h.name.clone(),
})
});
self.bus.send(ConsoleCmd::CancelWake);
self.wake = None;
if let Some(Some(intent)) = intent {
self.start_connect(intent);
// The wake takeover was already full-screen; skip the connect fade-in so the
// Waking → Connecting handoff is seamless (no flash of the home behind).
if let Some(c) = &mut self.connecting {
c.appear = 1.0;
}
}
}
}
}
fn start_connect(&mut self, intent: ConnectIntent) {
self.set_connecting(Some(intent.title.clone()));
self.actions.push_back(OverlayAction::Launch {
addr: intent.addr,
port: intent.port,
fp_hex: intent.fp_hex,
launch: intent.launch,
title: intent.title,
});
}
// --- Input ---------------------------------------------------------------------------
pub(crate) fn handle_menu(&mut self, ev: MenuEvent) -> Option<MenuPulse> {
self.sync();
// Modal precedence: the connect card, then the wake card, then the screens.
if let Some(c) = &mut self.connecting {
if ev == MenuEvent::Back && !c.canceling {
c.canceling = true;
self.actions.push_back(OverlayAction::CancelConnect);
return Some(MenuPulse::Confirm);
}
return None;
}
if let Some(w) = &self.wake {
match ev {
MenuEvent::Back => {
self.bus.send(ConsoleCmd::CancelWake);
self.wake = None;
self.wake_optimistic = false;
return Some(MenuPulse::Confirm);
}
MenuEvent::Confirm if w.timed_out => {
self.bus.send(ConsoleCmd::Wake {
key: w.key.clone(),
then_connect: w.then_connect,
});
return Some(MenuPulse::Confirm);
}
_ => return None,
}
}
// Mid-transition input is dropped — 0.26 s, and it keeps a double-tapped A
// from pushing two screens.
if !matches!(self.motion, Motion::None) {
return None;
}
let mut fx = Outbox::default();
let pulse = {
let mut ctx = Ctx {
hosts: &self.hosts,
library: &self.library,
settings: &mut self.settings,
pads: &self.pads,
deck: self.deck,
device_name: &self.device_name,
t: self.t0.elapsed().as_secs_f64(),
};
self.stack
.last_mut()
.expect("non-empty stack")
.menu(ev, &mut ctx, &mut fx)
};
self.apply(fx);
pulse
}
/// The keyboard fallback — the console is fully drivable with no pad. Arrows and
/// Enter/Esc map onto menu events; Y/X mirror the pad's Secondary/Tertiary
/// (suppressed while editing, where letters are text).
pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, repeat: bool) -> bool {
use sdl3::keyboard::Scancode as S;
if self.editing() {
if let Some(top) = self.stack.last_mut() {
if top.edit_key(sc) {
return true;
}
}
// Arrows etc. still drive the OSK grid below.
}
let editing = self.stack.last().is_some_and(Screen::editing);
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,
S::Y if !repeat && !editing => MenuEvent::Secondary,
S::X if !repeat && !editing => MenuEvent::Tertiary,
_ => return false,
};
self.handle_menu(ev); // no pad to pulse
true
}
pub(crate) fn text_input(&mut self, text: &str) {
if let Some(top) = self.stack.last_mut() {
top.text_input(text);
}
}
fn apply(&mut self, fx: Outbox) {
for cmd in fx.cmds {
// An input-initiated wake must gate input in the SAME call, exactly like
// `start_connect` gates via `connecting`: the service's first WakeStatus is
// ~100 ms1 s away, and until it lands the screen would keep navigating —
// then the arriving status freezes the UI wherever the cursor drifted, with
// the "Waking…" card never shown for a fast wake. Raise it optimistically;
// `sync` lets the service's real status supersede this placeholder.
if let ConsoleCmd::Wake { key, then_connect } = &cmd {
let name = self
.hosts
.iter()
.find(|h| &h.key == key)
.map(|h| h.name.clone())
.unwrap_or_default();
self.wake = Some(WakeStatus {
key: key.clone(),
name,
seconds: 0,
timed_out: false,
online: false,
then_connect: *then_connect,
});
self.wake_optimistic = true;
}
self.bus.send(cmd);
}
if let Some(text) = fx.toast {
self.show_toast(text);
}
if let Some(intent) = fx.connect {
self.start_connect(intent);
}
if let Some(nav) = fx.nav {
self.apply_nav(nav);
}
}
fn apply_nav(&mut self, nav: Nav) {
match nav {
Nav::Push(screen) => {
self.stack.push(*screen);
self.motion = Motion::Push(Progress::new(TRANSITION_S));
}
Nav::Pop => {
if self.stack.len() > 1 {
let leaving = self.stack.pop().expect("len > 1");
self.motion = Motion::Pop {
leaving: Box::new(leaving),
t: Progress::new(TRANSITION_S),
};
} else {
// Popping the root quits the console (B at home).
self.actions.push_back(OverlayAction::Quit);
}
}
}
}
// --- Rendering -------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
pub(crate) fn render(
&mut self,
canvas: &Canvas,
width: u32,
height: u32,
fonts: &Fonts,
pad: Option<&str>,
pad_pref: Option<punktfunk_core::config::GamepadPref>,
pads: &[PadInfo],
) {
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));
self.sync();
self.pads = pads.to_vec();
self.glyphs = GlyphStyle::from_pref(pad_pref);
self.chip = Some(pad.map_or_else(
|| "No controller — keyboard works too".to_string(),
str::to_owned,
));
let (w, h) = (f64::from(width), f64::from(height));
let k = (h / 800.0).clamp(0.75, 3.0);
let t = self.t();
// Advance the transition; a finished pop finally drops its leaving screen.
let motion_p = match &mut self.motion {
Motion::None => None,
Motion::Push(p) => {
p.advance(dt);
let v = p.value();
if p.done() {
self.motion = Motion::None;
None
} else {
Some(v)
}
}
Motion::Pop { t, .. } => {
t.advance(dt);
let v = t.value();
if t.done() {
self.motion = Motion::None;
None
} else {
Some(v)
}
}
};
// Backdrop crossfade follows the top screen.
let bg_target = match self.stack.last().expect("non-empty").background() {
Bg::Aurora => 0.0,
Bg::Form => 1.0,
};
self.bg_mix = approach(self.bg_mix, bg_target, dt, 0.12);
if (self.bg_mix - bg_target).abs() < 0.005 {
self.bg_mix = bg_target;
}
if self.bg_mix < 1.0 {
self.draw_aurora(canvas, w, h, t);
} else {
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
}
if self.bg_mix > 0.0 {
canvas.save_layer_alpha_f(None, self.bg_mix as f32);
crate::theme::draw_form_background(canvas, w, h);
canvas.restore();
}
// The screens, through the transition choreography.
let content = Rect::from_ltrb(
0.0,
(TOP_BAND * k) as f32,
w as f32,
(h - BOTTOM_BAND * k) as f32,
);
// One paint recipe per layer: (alpha, slide, scale). Everything below borrows
// disjoint fields of `self` per call, so the borrow checker stays happy.
let mut env = LayerEnv {
canvas,
w,
h,
content,
k,
dt,
fonts,
hosts: &self.hosts,
library: &self.library,
settings: &mut self.settings,
pads: &self.pads,
deck: self.deck,
device_name: &self.device_name,
t,
glyphs: self.glyphs,
// A modal card owns B/A while it's up — the screen's legend would lie.
show_hints: self.connecting.is_none() && self.wake.is_none(),
};
match (&mut self.motion, motion_p) {
(Motion::Push(_), Some(raw)) => {
let p = ease_out_cubic(raw);
let n = self.stack.len();
// Outgoing recedes underneath…
if n >= 2 {
let (below, top) = self.stack.split_at_mut(n - 1);
env.paint(&mut below[n - 2], 1.0 - p, 0.0, 1.0 - 0.04 * p);
// …while the incoming slides up out of a fade.
env.paint(&mut top[0], p, 36.0 * k * (1.0 - p), 0.985 + 0.015 * p);
} else {
env.paint(
&mut self.stack[0],
p,
36.0 * k * (1.0 - p),
0.985 + 0.015 * p,
);
}
}
(Motion::Pop { leaving, .. }, Some(raw)) => {
let p = ease_out_cubic(raw);
// The revealed screen grows back in…
let n = self.stack.len();
env.paint(&mut self.stack[n - 1], 0.4 + 0.6 * p, 0.0, 0.96 + 0.04 * p);
// …while the leaving one slides down into a fade.
env.paint(leaving.as_mut(), 1.0 - p, 36.0 * k * p, 1.0);
}
_ => {
let n = self.stack.len();
env.paint(&mut self.stack[n - 1], 1.0, 0.0, 1.0);
}
}
// Persistent chrome: the controller chip (top-right, above every layer).
if let Some(chip) = &self.chip {
let size = 12.0 * k;
let tw = f64::from(fonts.measure(chip, W::Medium, size));
let (bh, pad_x) = (24.0 * k, 12.0 * k);
let bx = w - 24.0 * k - tw - 2.0 * pad_x;
let rect = Rect::from_xywh(
bx as f32,
(18.0 * k) as f32,
(tw + 2.0 * pad_x) as f32,
bh as f32,
);
crate::theme::panel(
canvas,
rect,
(bh / 2.0 / k) as f32,
None,
PanelStroke::Plain(0.12),
k as f32,
);
fonts.draw(
canvas,
chip,
bx + pad_x,
18.0 * k + 16.0 * k,
W::Medium,
size,
white(0.7),
);
}
self.draw_overlays(canvas, w, h, k, dt, t, fonts);
}
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64) {
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 12) };
match self.mesh.make_shader(Data::new_copy(bytes), &[], 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));
}
}
}
// --- Overlays (connecting / waking / toast) --------------------------------------------
#[allow(clippy::too_many_arguments)]
fn draw_overlays(
&mut self,
canvas: &Canvas,
w: f64,
h: f64,
k: f64,
dt: f64,
t: f64,
fonts: &Fonts,
) {
// Resolve the connect/wake takeover — the two phases of reaching a host — into one
// full-screen shape (spinner, title, one detail line, its own hints). Connecting flows
// straight out of a wake (see `sync`) so they share the same backdrop and never blink
// between them. Mirrors the Android client's unified `ConnectOverlay`.
let takeover: Option<(f64, bool, String, String, Vec<Hint>)> =
if let Some(c) = &mut self.connecting {
c.appear = approach(c.appear, 1.0, dt, 0.07);
if c.canceling {
Some((
c.appear,
true,
"Canceling…".to_string(),
String::new(),
vec![],
))
} else {
Some((
c.appear,
true,
format!("Connecting to {}…", c.title),
"Starting the stream in this window.".to_string(),
vec![Hint::new(HintKey::Back, "Cancel")],
))
}
} else if let Some(wk) = &self.wake {
// Service-driven, so it appears settled (no fade-in).
if wk.timed_out {
Some((
1.0,
false,
format!("{} didn't wake", wk.name),
"Check its power settings, or wake it manually and try again.".to_string(),
vec![
Hint::new(HintKey::Confirm, "Try Again"),
Hint::new(HintKey::Back, "Cancel"),
],
))
} else {
Some((
1.0,
true,
format!("Waking {}…", wk.name),
format!("Waiting for it to come online · {} s", wk.seconds),
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect
// is a plain "Cancel".
vec![Hint::new(
HintKey::Back,
if wk.then_connect {
"Cancel"
} else {
"Stop Waiting"
},
)],
))
}
} else {
None
};
if let Some((appear, spinner, title, body, hints)) = takeover {
self.draw_takeover(
canvas, w, h, k, appear, t, fonts, spinner, &title, &body, &hints,
);
}
// The toast: a transient pill above the hint bar; slides in, fades out.
if self.toast.as_ref().is_some_and(|toast| t - toast.at > 4.0) {
self.toast = None;
}
if let Some(toast) = &self.toast {
let age = t - toast.at;
{
let slide = ease_out_cubic((age / 0.25).min(1.0));
let fade = if age > 3.4 {
(1.0 - (age - 3.4) / 0.6).max(0.0)
} else {
1.0
};
let alpha = (slide * fade) as f32;
let size = 13.0 * k;
let tw = f64::from(fonts.measure(&toast.text, W::Medium, size));
let (pad_x, bh) = (16.0 * k, 34.0 * k);
let bw = tw + 2.0 * pad_x;
let bx = (w - bw) / 2.0;
let by = h - BOTTOM_BAND * k - bh - 8.0 * k + (1.0 - slide) * 12.0 * k;
canvas.save_layer_alpha_f(None, alpha);
let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32);
canvas.draw_rrect(
skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.6), None),
);
crate::theme::panel(
canvas,
rect,
(bh / 2.0 / k) as f32,
None,
PanelStroke::Plain(0.14),
k as f32,
);
fonts.draw(
canvas,
&toast.text,
bx + pad_x,
by + bh / 2.0 + size * 0.36,
W::Medium,
size,
white(0.92),
);
canvas.restore();
}
}
}
}
/// Everything one screen layer needs to paint — bundled so the transition arms stay
/// readable and each `paint` call borrows `Shell` fields disjointly.
struct LayerEnv<'a> {
canvas: &'a Canvas,
w: f64,
h: f64,
content: Rect,
k: f64,
dt: f64,
fonts: &'a Fonts,
hosts: &'a [HostRow],
library: &'a LibraryShared,
settings: &'a mut trust::Settings,
pads: &'a [PadInfo],
deck: bool,
device_name: &'a str,
t: f64,
glyphs: GlyphStyle,
show_hints: bool,
}
impl LayerEnv<'_> {
/// One screen composited as a unit: `alpha` fade, `dy` vertical slide, `scale`
/// about the screen center — its pinned title and hint bar ride inside the layer,
/// so chrome travels with content through a transition.
fn paint(&mut self, screen: &mut Screen, alpha: f64, dy: f64, scale: f64) {
let canvas = self.canvas;
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
canvas.translate((0.0, dy as f32));
let (cx, cy) = ((self.w / 2.0) as f32, (self.h / 2.0) as f32);
canvas.translate((cx, cy));
canvas.scale((scale as f32, scale as f32));
canvas.translate((-cx, -cy));
let mut ctx = Ctx {
hosts: self.hosts,
library: self.library,
settings: self.settings,
pads: self.pads,
deck: self.deck,
device_name: self.device_name,
t: self.t,
};
self.fonts.centered(
canvas,
&screen.title(&ctx),
W::Bold,
30.0 * self.k,
WHITE,
self.w / 2.0,
18.0 * self.k,
self.w * 0.7,
);
screen.render(canvas, self.content, self.k, self.dt, self.fonts, &mut ctx);
if self.show_hints {
let hints = screen.hints(&ctx);
hint_bar(
canvas,
self.fonts,
&hints,
self.glyphs,
18.0 * self.k,
self.h - 18.0 * self.k,
self.k,
);
}
canvas.restore();
}
}
impl Shell {
/// A full-screen connect/wake takeover: a fresh aurora over everything (so the carousel and
/// chrome fall away), a centered spinner (or none, when a wake has timed out), a title, one
/// detail line, and its own bottom hint row. `appear` fades the whole thing in over the home;
/// a wake that hands off to a connect passes 1.0 so the two never blink between them. The
/// console counterpart of the Android/Apple `ConnectOverlay` — one full-screen shape, not a
/// centered modal card.
#[allow(clippy::too_many_arguments)]
fn draw_takeover(
&self,
canvas: &Canvas,
w: f64,
h: f64,
k: f64,
appear: f64,
t: f64,
fonts: &Fonts,
spinner: bool,
title: &str,
body: &str,
hints: &[Hint],
) {
let cx = w / 2.0;
canvas.save_layer_alpha_f(None, appear as f32);
// Opaque aurora — the same living backdrop the home wears, so the takeover reads as the
// console taking over rather than a card popping up.
self.draw_aurora(canvas, w, h, t);
// A soft pool of shade under the centre seats the white text against a bright aurora.
let mut vignette = Paint::default();
vignette.set_shader(gradient_shader::radial(
Point::new(cx as f32, (h / 2.0) as f32),
(w.max(h) * 0.42) as f32,
gradient_shader::GradientShaderColors::Colors(&[
Color4f::new(0.0, 0.0, 0.0, 0.5).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
]),
None,
TileMode::Clamp,
None,
None,
));
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &vignette);
// Centre the spinner + title + detail as a group around the middle of the screen.
let title_y = h / 2.0 + if spinner { 14.0 * k } else { 0.0 };
if spinner {
crate::theme::spinner(canvas, cx, title_y - 52.0 * k, 22.0 * k, t);
}
fonts.centered(
canvas,
title,
W::SemiBold,
23.0 * k,
WHITE,
cx,
title_y,
w * 0.82,
);
if !body.is_empty() {
fonts.centered(
canvas,
body,
W::Regular,
14.0 * k,
DIM,
cx,
title_y + 32.0 * k,
w * 0.66,
);
}
if !hints.is_empty() {
// Centered near the bottom, where every console screen's legend sits.
let probe = hint_bar(canvas, fonts, hints, self.glyphs, -10_000.0, -10_000.0, k);
hint_bar(
canvas,
fonts,
hints,
self.glyphs,
cx - probe.0 / 2.0,
h - 34.0 * k,
k,
);
}
canvas.restore();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::WakeStatus;
use crate::screens::home::HomeScreen;
use crate::screens::library::LibraryScreen;
use punktfunk_core::config::GamepadPref;
/// Point the settings/known-hosts stores at a throwaway HOME — the settings screen
/// SAVES on adjust, and a test must never write the developer's real config.
fn fake_home() {
use std::sync::OnceLock;
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
let dir = HOME.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("pf-console-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("HOME", &dir);
dir.clone()
});
std::env::set_var("HOME", dir);
}
fn hosts() -> Vec<HostRow> {
let base = HostRow {
key: String::new(),
name: String::new(),
addr: "10.0.0.20".into(),
port: 9777,
fp_hex: String::new(),
paired: false,
saved: true,
online: false,
mgmt_port: 47990,
can_wake: false,
last_used: None,
};
vec![
HostRow {
key: "aa11".into(),
name: "Living Room PC".into(),
fp_hex: "aa11".into(),
paired: true,
online: true,
last_used: Some(1),
..base.clone()
},
HostRow {
key: "bb22".into(),
name: "Office Tower".into(),
addr: "10.0.0.21".into(),
fp_hex: "bb22".into(),
paired: true,
can_wake: true,
..base.clone()
},
HostRow {
key: "10.0.0.30:9777".into(),
name: "steambox".into(),
addr: "10.0.0.30".into(),
saved: false,
online: true,
..base
},
]
}
fn shell(stack: Vec<Screen>) -> (Shell, ConsoleShared, LibraryShared) {
fake_home();
let console = ConsoleShared::default();
console.set_hosts(hosts());
let library = LibraryShared::default();
let bus = ConsoleBus::default();
let shell = Shell::new(
console.clone(),
library.clone(),
bus,
ConsoleOptions {
device_name: "deck".into(),
deck: false,
},
stack,
)
.unwrap();
(shell, console, library)
}
/// The shell survives a full navigation lap (a smoke test over every screen's
/// input handling — no rendering, no GPU).
#[test]
fn navigation_lap() {
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.sync();
// Home → Settings (X), adjust something, back out.
s.handle_menu(MenuEvent::Tertiary);
assert_eq!(s.stack.len(), 2);
finish_motion(&mut s);
s.handle_menu(MenuEvent::Move(MenuDir::Down));
s.handle_menu(MenuEvent::Move(MenuDir::Right));
s.handle_menu(MenuEvent::Back);
finish_motion(&mut s);
assert_eq!(s.stack.len(), 1);
// Home → Library on the paired host (Y), then back.
s.handle_menu(MenuEvent::Secondary);
assert_eq!(s.stack.len(), 2);
finish_motion(&mut s);
s.handle_menu(MenuEvent::Back);
finish_motion(&mut s);
assert_eq!(s.stack.len(), 1);
// B at the root quits.
s.handle_menu(MenuEvent::Back);
assert!(matches!(s.take_action(), Some(OverlayAction::Quit)));
}
#[test]
fn connect_flow_raises_launch_and_cancel() {
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.sync();
s.handle_menu(MenuEvent::Confirm); // paired+online host focused first
assert!(matches!(
s.take_action(),
Some(OverlayAction::Launch { launch: None, .. })
));
assert!(s.connecting.is_some());
// While connecting: B cancels exactly once.
s.handle_menu(MenuEvent::Back);
assert!(matches!(
s.take_action(),
Some(OverlayAction::CancelConnect)
));
s.handle_menu(MenuEvent::Back);
assert!(s.take_action().is_none(), "cancel is idempotent");
// The canceled dial ends silently.
s.session_ended(None);
assert!(s.connecting.is_none());
}
fn finish_motion(s: &mut Shell) {
// Transitions block input; tests fast-forward them.
s.motion = Motion::None;
}
#[test]
fn wake_gates_input_in_the_same_press() {
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.sync();
// Focus "Office Tower" (offline + wakeable), then A: the wake starts.
s.handle_menu(MenuEvent::Move(MenuDir::Right));
s.handle_menu(MenuEvent::Confirm);
let w = s
.wake
.as_ref()
.expect("Waking card raised in the SAME call as the A press");
assert_eq!(w.name, "Office Tower");
assert!(!w.online);
// The very next input is modal-gated — the cursor can't drift onto Add Host —
// and sync (which runs first in handle_menu) must not clear the placeholder
// before the service thread reports its first real status.
assert!(s.handle_menu(MenuEvent::Move(MenuDir::Right)).is_none());
assert!(
s.wake.is_some(),
"optimistic card survived a sync with no service status"
);
// B cancels: the gate releases and navigation works again.
s.handle_menu(MenuEvent::Back);
assert!(s.wake.is_none());
assert!(s.handle_menu(MenuEvent::Move(MenuDir::Left)).is_some());
}
/// Render every console scene to PNGs for the eyeball pass (ignored; run with
/// `PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui --release -- --ignored dump`).
/// CPU raster — the SkSL aurora, layers and text all run without a GPU.
#[test]
#[ignore]
fn dump_console_screens() {
let dir = std::env::var("PF_CONSOLE_DUMP").expect("set PF_CONSOLE_DUMP to an output dir");
let fonts = crate::theme::build_fonts().unwrap();
let (w, h) = (1280, 800);
let pads: Vec<PadInfo> = Vec::new();
let dump = |shell: &mut Shell, frames: usize, sleep_ms: u64, name: &str, pad: bool| {
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
for _ in 0..frames {
shell.render(
surface.canvas(),
w as u32,
h as u32,
&fonts,
pad.then_some("Xbox Wireless Controller"),
pad.then_some(GamepadPref::Xbox360),
&pads,
);
std::thread::sleep(std::time::Duration::from_millis(sleep_ms));
}
let png = surface
.image_snapshot()
.encode(None, skia_safe::EncodedImageFormat::PNG, 100)
.unwrap();
std::fs::write(format!("{dir}/{name}.png"), png.as_bytes()).unwrap();
};
// Home, settled, with a pad (Letters glyphs).
let (mut s, console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
dump(&mut s, 40, 8, "01-home", true);
// Mid-push into Settings (the transition still): a couple of fast frames land
// the capture around p ≈ 0.4 — both layers visible.
s.handle_menu(MenuEvent::Tertiary);
dump(&mut s, 3, 25, "02-transition", true);
dump(&mut s, 40, 8, "03-settings", true);
// Add Host with the keyboard tray up (keyboard glyph style: no pad).
s.handle_menu(MenuEvent::Back);
dump(&mut s, 40, 8, "_back", true);
for _ in 0..3 {
s.handle_menu(MenuEvent::Move(MenuDir::Right));
}
s.handle_menu(MenuEvent::Confirm); // Add Host screen
dump(&mut s, 40, 8, "04-addhost", false);
s.handle_menu(MenuEvent::Confirm); // open the Name keyboard
for ev in [
MenuEvent::Move(MenuDir::Down),
MenuEvent::Confirm,
MenuEvent::Confirm,
] {
s.handle_menu(ev);
}
dump(&mut s, 40, 8, "05-addhost-keyboard", false);
// Pair (focused on the unpaired discovered host).
s.handle_menu(MenuEvent::Back); // close keyboard
s.handle_menu(MenuEvent::Back); // leave add-host
dump(&mut s, 40, 8, "_back2", true);
s.handle_menu(MenuEvent::Move(MenuDir::Left)); // onto "steambox"
s.handle_menu(MenuEvent::Confirm);
dump(&mut s, 40, 8, "06-pair", true);
// Library with placeholder posters.
library.set_games(
[
"Hades II",
"Elden Ring",
"Hollow Knight",
"Baldur's Gate 3",
"Celeste",
"Deep Rock Galactic",
"Portal 2",
]
.iter()
.enumerate()
.map(|(i, t)| crate::library::LibraryGame {
id: format!("steam:{i}"),
title: (*t).to_string(),
store: "steam".into(),
})
.collect(),
);
let (mut s2, _c2, _l2) = {
let console2 = ConsoleShared::default();
console2.set_hosts(hosts());
let bus = ConsoleBus::default();
let sh = Shell::new(
console2.clone(),
library.clone(),
bus,
ConsoleOptions {
device_name: "deck".into(),
deck: false,
},
vec![
Screen::Home(HomeScreen::new()),
Screen::Library(LibraryScreen::new(&hosts()[0])),
],
)
.unwrap();
(sh, console2, library.clone())
};
s2.handle_menu(MenuEvent::Move(MenuDir::Right));
s2.handle_menu(MenuEvent::Move(MenuDir::Right));
dump(&mut s2, 40, 8, "07-library", true);
// The wake and connecting overlays + a toast.
console.set_wake(Some(WakeStatus {
key: "bb22".into(),
name: "Office Tower".into(),
seconds: 12,
timed_out: false,
online: false,
then_connect: true,
}));
dump(&mut s, 10, 8, "08-waking", true);
console.set_wake(Some(WakeStatus {
key: "bb22".into(),
name: "Office Tower".into(),
seconds: 90,
timed_out: true,
online: false,
then_connect: true,
}));
dump(&mut s, 10, 8, "08b-wake-timed-out", true);
console.set_wake(None);
s.set_connecting(Some("Elden Ring".into()));
dump(&mut s, 10, 8, "09-connecting", true);
s.set_connecting(None);
s.session_failed("Connection timed out");
dump(&mut s, 10, 8, "10-toast", true);
}
}