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:
@@ -9,22 +9,22 @@
|
||||
//! `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::anim::Progress;
|
||||
use crate::glyphs::GlyphStyle;
|
||||
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 skia_safe::{Canvas, Color4f, Data, Paint, Rect, RuntimeEffect};
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
|
||||
mod overlays;
|
||||
mod render;
|
||||
|
||||
const TRANSITION_S: f64 = 0.26;
|
||||
/// Chrome bands (design units): the pinned title above, hints below.
|
||||
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) {
|
||||
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) };
|
||||
@@ -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)]
|
||||
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);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user