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
|
//! `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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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