refactor(console-ui/W8): split shell.rs into shell/ facade + render/overlays/tests

Break the 1212-line pf-console-ui/src/shell.rs into a facade + shell/ subdir
(shell.rs stays the parent; `mod render;` resolves to shell/render.rs):
  - shell/render.rs   : the per-frame screen compose/transition path (Shell::render
                        + the LayerEnv paint helper)
  - shell/overlays.rs : the modal overlays (Shell::draw_overlays + draw_takeover)
  - shell/tests.rs    : the inline #[cfg(test)] module, extracted verbatim
The Shell struct + its public API + draw_aurora() stay in shell.rs (both children
reach draw_aurora + the private fields as descendants — no bumps). Sole visibility
change: draw_overlays -> pub(in crate::shell) (its caller Shell::render is now a
sibling). Zero re-exports needed (Shell/ConsoleOptions never leave the root). Pure
move; no behavior change.

Verified both platforms: Linux (home-worker-5) clippy -p pf-console-ui
(--all-targets -D warnings) + test; Windows (winbox, ASCII CARGO_HOME) clippy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 14:29:07 +02:00
parent 13b1f36d4a
commit 22a61e0b48
4 changed files with 792 additions and 774 deletions
+7 -774
View File
@@ -9,22 +9,22 @@
//! `save_layer_alpha` so a screen fades as a unit, never element by element. The //! `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). //! backdrop crossfades in parallel when the screens disagree (aurora ↔ form).
use crate::anim::{approach, ease_out_cubic, Progress}; use crate::anim::Progress;
use crate::glyphs::{hint_bar, GlyphStyle, Hint, HintKey}; use crate::glyphs::GlyphStyle;
use crate::library::{mesh_sksl, LibraryShared}; use crate::library::{mesh_sksl, LibraryShared};
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus}; use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen}; use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen};
use crate::theme::{white, Fonts, PanelStroke, DIM, W, WHITE};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
use pf_client_core::trust; use pf_client_core::trust;
use pf_presenter::overlay::OverlayAction; use pf_presenter::overlay::OverlayAction;
use skia_safe::{ use skia_safe::{Canvas, Color4f, Data, Paint, Rect, RuntimeEffect};
gradient_shader, Canvas, Color4f, Data, Paint, Point, Rect, RuntimeEffect, TileMode,
};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::time::Instant; use std::time::Instant;
mod overlays;
mod render;
const TRANSITION_S: f64 = 0.26; const TRANSITION_S: f64 = 0.26;
/// Chrome bands (design units): the pinned title above, hints below. /// Chrome bands (design units): the pinned title above, hints below.
const TOP_BAND: f64 = 64.0; const TOP_BAND: f64 = 64.0;
@@ -425,176 +425,6 @@ impl Shell {
} }
} }
// --- 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) { 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 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) }; let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 12) };
@@ -609,604 +439,7 @@ impl Shell {
} }
} }
} }
// --- 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 if c.request_access {
Some((
c.appear,
true,
"Waiting for approval…".to_string(),
format!(
"Approve this device in {}'s console or web UI — no PIN needed.",
c.title
),
vec![Hint::new(HintKey::Back, "Cancel")],
))
} 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)] #[cfg(test)]
mod tests { 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);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! The console shell's modal overlays (connecting / waking / toast / full-screen takeover).
use crate::anim::{approach, ease_out_cubic};
use crate::glyphs::{hint_bar, Hint, HintKey};
use crate::theme::{white, Fonts, PanelStroke, DIM, W, WHITE};
use skia_safe::{gradient_shader, Canvas, Color4f, Paint, Point, Rect, TileMode};
use super::{Shell, BOTTOM_BAND};
impl Shell {
#[allow(clippy::too_many_arguments)]
pub(in crate::shell) 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 if c.request_access {
Some((
c.appear,
true,
"Waiting for approval…".to_string(),
format!(
"Approve this device in {}'s console or web UI — no PIN needed.",
c.title
),
vec![Hint::new(HintKey::Back, "Cancel")],
))
} 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();
}
}
}
/// 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();
}
}
+254
View File
@@ -0,0 +1,254 @@
//! The console shell's per-frame screen compose/transition render path.
use crate::anim::{approach, ease_out_cubic};
use crate::glyphs::{hint_bar, GlyphStyle};
use crate::library::LibraryShared;
use crate::model::HostRow;
use crate::screens::{Bg, Ctx, Screen};
use crate::theme::{white, Fonts, PanelStroke, W, WHITE};
use pf_client_core::gamepad::PadInfo;
use pf_client_core::trust;
use skia_safe::{Canvas, Color4f, Rect};
use std::time::Instant;
use super::{Motion, Shell, BOTTOM_BAND, TOP_BAND};
impl Shell {
#[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);
}
}
/// 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();
}
}
+303
View File
@@ -0,0 +1,303 @@
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);
}