180ac3aa61
apple / swift (push) Successful in 1m6s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
decky / build-publish (push) Successful in 25s
flatpak / build-publish (push) Successful in 4m31s
The Skia console UI grows from the single library coverflow into a complete couch shell (Apple gamepad-UI parity), so a Deck/gamescope session is self-sufficient end to end: - Home: host carousel (saved + discovered + Add Host tile) with presence pips, paired locks, wake & connect; A/Y/X/B per the Apple launcher. - In-console SPAKE2 PIN pairing (previously needed the Decky plugin or a desktop), add-host with a controller keyboard, couch settings over the shared Settings store, wake-on-LAN overlay with retry, cancelable dials. - Shell chrome: screen stack with push/pop entrance/exit choreography (slide + fade + recede; aurora↔form backdrop crossfade), per-pad button glyphs (PS shapes vs ABXY, keycaps with no pad), menu haptics, toasts, embedded Geist typography, in-stream start banner. - Steam Deck: our OSK never draws — fields start SDL text input (the new Overlay::text_input_active hook) and Steam's keyboard types; a hint chip points at STEAM + X. Other Linux gets the full gamepad keyboard tray. - punktfunk-session: bare --browse opens Home; --browse host opens that host's library with Home behind it (Decky launches keep working, B now pops instead of quitting). Service threads run discovery, 10 s probes, pairing, wake loops, fetches, and known-hosts persistence. - Presenter contract: Launch actions carry the host, CancelConnect never engages a won race, pad kind/list ride FrameCtx, menu events flow while dialing so B can cancel. Every screen renders to PNG on CPU raster for review (PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui -- --ignored dump). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1098 lines
37 KiB
Rust
1098 lines
37 KiB
Rust
//! The console shell: the screen stack, the push/pop entrance/exit choreography, the
|
|
//! chrome every screen shares (pinned title, controller chip, hint bar), and the modal
|
|
//! overlays (connecting, waking, toasts). Screens draw CONTENT; the shell makes them
|
|
//! read — and move — as one coherent console.
|
|
//!
|
|
//! Transitions: a push slides the incoming screen up out of a fade while the outgoing
|
|
//! one recedes; a pop mirrors it. One eased 0→1 progress drives both layers (0.26 s,
|
|
//! ease-out cubic — the WinUI shell's entrance feel), each composited through a
|
|
//! `save_layer_alpha` so a screen fades as a unit, never element by element. The
|
|
//! backdrop crossfades in parallel when the screens disagree (aurora ↔ form).
|
|
|
|
use crate::anim::{approach, ease_out_cubic, Progress};
|
|
use crate::glyphs::{hint_bar, GlyphStyle, Hint, HintKey};
|
|
use crate::library::{mesh_sksl, LibraryShared};
|
|
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
|
|
use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen};
|
|
use crate::theme::{white, Fonts, PanelStroke, DIM, W, WHITE};
|
|
use anyhow::{anyhow, Result};
|
|
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
|
|
use pf_client_core::trust;
|
|
use pf_presenter::overlay::OverlayAction;
|
|
use skia_safe::{Canvas, Color4f, Data, Paint, Rect, RuntimeEffect};
|
|
use std::collections::VecDeque;
|
|
use std::time::Instant;
|
|
|
|
const TRANSITION_S: f64 = 0.26;
|
|
/// Chrome bands (design units): the pinned title above, hints below.
|
|
const TOP_BAND: f64 = 64.0;
|
|
const BOTTOM_BAND: f64 = 86.0;
|
|
|
|
enum Motion {
|
|
None,
|
|
Push(Progress),
|
|
Pop { leaving: Box<Screen>, t: Progress },
|
|
}
|
|
|
|
struct Toast {
|
|
text: String,
|
|
at: f64,
|
|
}
|
|
|
|
struct Connecting {
|
|
title: String,
|
|
canceling: bool,
|
|
appear: f64,
|
|
}
|
|
|
|
/// What the session binary hands the shell at construction.
|
|
pub struct ConsoleOptions {
|
|
/// The machine's hostname — the default device name pairing registers.
|
|
pub device_name: String,
|
|
/// Steam Deck: Steam's keyboard types (SDL text input); ours never draws.
|
|
pub deck: bool,
|
|
}
|
|
|
|
pub(crate) struct Shell {
|
|
stack: Vec<Screen>,
|
|
motion: Motion,
|
|
console: ConsoleShared,
|
|
library: LibraryShared,
|
|
bus: ConsoleBus,
|
|
actions: VecDeque<OverlayAction>,
|
|
settings: trust::Settings,
|
|
hosts: Vec<HostRow>,
|
|
hosts_gen: u64,
|
|
device_name: String,
|
|
deck: bool,
|
|
pub(crate) in_stream: bool,
|
|
connecting: Option<Connecting>,
|
|
wake: Option<WakeStatus>,
|
|
toast: Option<Toast>,
|
|
mesh: RuntimeEffect,
|
|
/// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition.
|
|
bg_mix: f64,
|
|
glyphs: GlyphStyle,
|
|
chip: Option<String>,
|
|
pads: Vec<PadInfo>,
|
|
t0: Instant,
|
|
last_frame: Option<Instant>,
|
|
}
|
|
|
|
impl Shell {
|
|
pub(crate) fn new(
|
|
console: ConsoleShared,
|
|
library: LibraryShared,
|
|
bus: ConsoleBus,
|
|
opts: ConsoleOptions,
|
|
stack: Vec<Screen>,
|
|
) -> Result<Shell> {
|
|
anyhow::ensure!(!stack.is_empty(), "the console needs a root screen");
|
|
let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None)
|
|
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
|
|
let bg_mix = match stack.last().expect("non-empty").background() {
|
|
Bg::Aurora => 0.0,
|
|
Bg::Form => 1.0,
|
|
};
|
|
Ok(Shell {
|
|
stack,
|
|
motion: Motion::None,
|
|
console,
|
|
library,
|
|
bus,
|
|
actions: VecDeque::new(),
|
|
settings: trust::Settings::load(),
|
|
hosts: Vec::new(),
|
|
hosts_gen: u64::MAX,
|
|
device_name: opts.device_name,
|
|
deck: opts.deck,
|
|
in_stream: false,
|
|
connecting: None,
|
|
wake: None,
|
|
toast: None,
|
|
mesh,
|
|
bg_mix,
|
|
glyphs: GlyphStyle::Keyboard,
|
|
chip: None,
|
|
pads: Vec::new(),
|
|
t0: Instant::now(),
|
|
last_frame: None,
|
|
})
|
|
}
|
|
|
|
fn t(&self) -> f64 {
|
|
self.t0.elapsed().as_secs_f64()
|
|
}
|
|
|
|
pub(crate) fn editing(&self) -> bool {
|
|
!self.in_stream
|
|
&& self.connecting.is_none()
|
|
&& self.stack.last().is_some_and(Screen::editing)
|
|
}
|
|
|
|
pub(crate) fn take_action(&mut self) -> Option<OverlayAction> {
|
|
self.actions.pop_front()
|
|
}
|
|
|
|
// --- Session lifecycle edges (from the overlay's `session_phase`) --------------------
|
|
|
|
pub(crate) fn set_connecting(&mut self, title: Option<String>) {
|
|
match title {
|
|
Some(title) => {
|
|
self.connecting = Some(Connecting {
|
|
title,
|
|
canceling: false,
|
|
appear: 0.0,
|
|
})
|
|
}
|
|
None => self.connecting = None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn session_failed(&mut self, msg: &str) {
|
|
self.connecting = None;
|
|
self.in_stream = false;
|
|
self.show_toast(format!("Couldn't connect — {msg}"));
|
|
}
|
|
|
|
pub(crate) fn session_streaming(&mut self) {
|
|
self.connecting = None;
|
|
self.in_stream = true;
|
|
}
|
|
|
|
pub(crate) fn session_ended(&mut self, reason: Option<&str>) {
|
|
self.connecting = None;
|
|
self.in_stream = false;
|
|
if let Some(reason) = reason {
|
|
self.show_toast(format!("Session ended — {reason}"));
|
|
}
|
|
}
|
|
|
|
fn show_toast(&mut self, text: String) {
|
|
self.toast = Some(Toast { text, at: self.t() });
|
|
}
|
|
|
|
// --- Model sync (hosts, pairing, wake) — before input and before render --------------
|
|
|
|
fn sync(&mut self) {
|
|
if self.console.hosts_gen() != self.hosts_gen {
|
|
(self.hosts, self.hosts_gen) = self.console.hosts_snapshot();
|
|
}
|
|
|
|
let pair = self.console.pair();
|
|
match &pair {
|
|
PairPhase::Idle => {}
|
|
PairPhase::Paired { key } => {
|
|
let name = self
|
|
.hosts
|
|
.iter()
|
|
.find(|h| &h.key == key)
|
|
.map_or_else(|| "the host".to_string(), |h| h.name.clone());
|
|
self.show_toast(format!("Paired with {name}"));
|
|
self.console.set_pair(PairPhase::Idle);
|
|
if matches!(self.stack.last(), Some(Screen::Pair(_))) {
|
|
self.apply_nav(Nav::Pop);
|
|
}
|
|
}
|
|
phase => {
|
|
if let Some(Screen::Pair(p)) = self.stack.last_mut() {
|
|
p.apply_phase(phase);
|
|
}
|
|
if matches!(phase, PairPhase::Failed(_)) {
|
|
self.console.set_pair(PairPhase::Idle);
|
|
}
|
|
}
|
|
}
|
|
|
|
self.wake = self.console.wake();
|
|
if let Some(w) = &self.wake {
|
|
if w.online {
|
|
// Awake: stop the wake loop, and connect if that's what A meant.
|
|
let intent = w.then_connect.then(|| {
|
|
self.hosts
|
|
.iter()
|
|
.find(|h| h.key == w.key)
|
|
.map(|h| ConnectIntent {
|
|
addr: h.addr.clone(),
|
|
port: h.port,
|
|
fp_hex: h.fp_hex.clone(),
|
|
launch: None,
|
|
title: h.name.clone(),
|
|
})
|
|
});
|
|
self.bus.send(ConsoleCmd::CancelWake);
|
|
self.wake = None;
|
|
if let Some(Some(intent)) = intent {
|
|
self.start_connect(intent);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn start_connect(&mut self, intent: ConnectIntent) {
|
|
self.set_connecting(Some(intent.title.clone()));
|
|
self.actions.push_back(OverlayAction::Launch {
|
|
addr: intent.addr,
|
|
port: intent.port,
|
|
fp_hex: intent.fp_hex,
|
|
launch: intent.launch,
|
|
title: intent.title,
|
|
});
|
|
}
|
|
|
|
// --- Input ---------------------------------------------------------------------------
|
|
|
|
pub(crate) fn handle_menu(&mut self, ev: MenuEvent) -> Option<MenuPulse> {
|
|
self.sync();
|
|
// Modal precedence: the connect card, then the wake card, then the screens.
|
|
if let Some(c) = &mut self.connecting {
|
|
if ev == MenuEvent::Back && !c.canceling {
|
|
c.canceling = true;
|
|
self.actions.push_back(OverlayAction::CancelConnect);
|
|
return Some(MenuPulse::Confirm);
|
|
}
|
|
return None;
|
|
}
|
|
if let Some(w) = &self.wake {
|
|
match ev {
|
|
MenuEvent::Back => {
|
|
self.bus.send(ConsoleCmd::CancelWake);
|
|
self.wake = None;
|
|
return Some(MenuPulse::Confirm);
|
|
}
|
|
MenuEvent::Confirm if w.timed_out => {
|
|
self.bus.send(ConsoleCmd::Wake {
|
|
key: w.key.clone(),
|
|
then_connect: w.then_connect,
|
|
});
|
|
return Some(MenuPulse::Confirm);
|
|
}
|
|
_ => return None,
|
|
}
|
|
}
|
|
// Mid-transition input is dropped — 0.26 s, and it keeps a double-tapped A
|
|
// from pushing two screens.
|
|
if !matches!(self.motion, Motion::None) {
|
|
return None;
|
|
}
|
|
|
|
let mut fx = Outbox::default();
|
|
let pulse = {
|
|
let mut ctx = Ctx {
|
|
hosts: &self.hosts,
|
|
library: &self.library,
|
|
settings: &mut self.settings,
|
|
pads: &self.pads,
|
|
deck: self.deck,
|
|
device_name: &self.device_name,
|
|
t: self.t0.elapsed().as_secs_f64(),
|
|
};
|
|
self.stack
|
|
.last_mut()
|
|
.expect("non-empty stack")
|
|
.menu(ev, &mut ctx, &mut fx)
|
|
};
|
|
self.apply(fx);
|
|
pulse
|
|
}
|
|
|
|
/// The keyboard fallback — the console is fully drivable with no pad. Arrows and
|
|
/// Enter/Esc map onto menu events; Y/X mirror the pad's Secondary/Tertiary
|
|
/// (suppressed while editing, where letters are text).
|
|
pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, repeat: bool) -> bool {
|
|
use sdl3::keyboard::Scancode as S;
|
|
if self.editing() {
|
|
if let Some(top) = self.stack.last_mut() {
|
|
if top.edit_key(sc) {
|
|
return true;
|
|
}
|
|
}
|
|
// Arrows etc. still drive the OSK grid below.
|
|
}
|
|
let editing = self.stack.last().is_some_and(Screen::editing);
|
|
let ev = match sc {
|
|
S::Left => MenuEvent::Move(MenuDir::Left),
|
|
S::Right => MenuEvent::Move(MenuDir::Right),
|
|
S::Up => MenuEvent::Move(MenuDir::Up),
|
|
S::Down => MenuEvent::Move(MenuDir::Down),
|
|
S::Return | S::KpEnter | S::Space if !repeat => MenuEvent::Confirm,
|
|
S::Escape | S::Backspace if !repeat => MenuEvent::Back,
|
|
S::PageUp if !repeat => MenuEvent::JumpBack,
|
|
S::PageDown if !repeat => MenuEvent::JumpForward,
|
|
S::Y if !repeat && !editing => MenuEvent::Secondary,
|
|
S::X if !repeat && !editing => MenuEvent::Tertiary,
|
|
_ => return false,
|
|
};
|
|
self.handle_menu(ev); // no pad to pulse
|
|
true
|
|
}
|
|
|
|
pub(crate) fn text_input(&mut self, text: &str) {
|
|
if let Some(top) = self.stack.last_mut() {
|
|
top.text_input(text);
|
|
}
|
|
}
|
|
|
|
fn apply(&mut self, fx: Outbox) {
|
|
for cmd in fx.cmds {
|
|
self.bus.send(cmd);
|
|
}
|
|
if let Some(text) = fx.toast {
|
|
self.show_toast(text);
|
|
}
|
|
if let Some(intent) = fx.connect {
|
|
self.start_connect(intent);
|
|
}
|
|
if let Some(nav) = fx.nav {
|
|
self.apply_nav(nav);
|
|
}
|
|
}
|
|
|
|
fn apply_nav(&mut self, nav: Nav) {
|
|
match nav {
|
|
Nav::Push(screen) => {
|
|
self.stack.push(*screen);
|
|
self.motion = Motion::Push(Progress::new(TRANSITION_S));
|
|
}
|
|
Nav::Pop => {
|
|
if self.stack.len() > 1 {
|
|
let leaving = self.stack.pop().expect("len > 1");
|
|
self.motion = Motion::Pop {
|
|
leaving: Box::new(leaving),
|
|
t: Progress::new(TRANSITION_S),
|
|
};
|
|
} else {
|
|
// Popping the root quits the console (B at home).
|
|
self.actions.push_back(OverlayAction::Quit);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Rendering -------------------------------------------------------------------------
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn render(
|
|
&mut self,
|
|
canvas: &Canvas,
|
|
width: u32,
|
|
height: u32,
|
|
fonts: &Fonts,
|
|
pad: Option<&str>,
|
|
pad_pref: Option<punktfunk_core::config::GamepadPref>,
|
|
pads: &[PadInfo],
|
|
) {
|
|
let now = Instant::now();
|
|
let dt = self
|
|
.last_frame
|
|
.replace(now)
|
|
.map_or(1.0 / 60.0, |t| (now - t).as_secs_f64().clamp(0.0, 0.05));
|
|
self.sync();
|
|
self.pads = pads.to_vec();
|
|
self.glyphs = GlyphStyle::from_pref(pad_pref);
|
|
self.chip = Some(pad.map_or_else(
|
|
|| "No controller — keyboard works too".to_string(),
|
|
str::to_owned,
|
|
));
|
|
|
|
let (w, h) = (f64::from(width), f64::from(height));
|
|
let k = (h / 800.0).clamp(0.75, 3.0);
|
|
let t = self.t();
|
|
|
|
// Advance the transition; a finished pop finally drops its leaving screen.
|
|
let motion_p = match &mut self.motion {
|
|
Motion::None => None,
|
|
Motion::Push(p) => {
|
|
p.advance(dt);
|
|
let v = p.value();
|
|
if p.done() {
|
|
self.motion = Motion::None;
|
|
None
|
|
} else {
|
|
Some(v)
|
|
}
|
|
}
|
|
Motion::Pop { t, .. } => {
|
|
t.advance(dt);
|
|
let v = t.value();
|
|
if t.done() {
|
|
self.motion = Motion::None;
|
|
None
|
|
} else {
|
|
Some(v)
|
|
}
|
|
}
|
|
};
|
|
|
|
// Backdrop crossfade follows the top screen.
|
|
let bg_target = match self.stack.last().expect("non-empty").background() {
|
|
Bg::Aurora => 0.0,
|
|
Bg::Form => 1.0,
|
|
};
|
|
self.bg_mix = approach(self.bg_mix, bg_target, dt, 0.12);
|
|
if (self.bg_mix - bg_target).abs() < 0.005 {
|
|
self.bg_mix = bg_target;
|
|
}
|
|
if self.bg_mix < 1.0 {
|
|
self.draw_aurora(canvas, w, h, t);
|
|
} else {
|
|
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
|
|
}
|
|
if self.bg_mix > 0.0 {
|
|
canvas.save_layer_alpha_f(None, self.bg_mix as f32);
|
|
crate::theme::draw_form_background(canvas, w, h);
|
|
canvas.restore();
|
|
}
|
|
|
|
// The screens, through the transition choreography.
|
|
let content = Rect::from_ltrb(
|
|
0.0,
|
|
(TOP_BAND * k) as f32,
|
|
w as f32,
|
|
(h - BOTTOM_BAND * k) as f32,
|
|
);
|
|
// One paint recipe per layer: (alpha, slide, scale). Everything below borrows
|
|
// disjoint fields of `self` per call, so the borrow checker stays happy.
|
|
let mut env = LayerEnv {
|
|
canvas,
|
|
w,
|
|
h,
|
|
content,
|
|
k,
|
|
dt,
|
|
fonts,
|
|
hosts: &self.hosts,
|
|
library: &self.library,
|
|
settings: &mut self.settings,
|
|
pads: &self.pads,
|
|
deck: self.deck,
|
|
device_name: &self.device_name,
|
|
t,
|
|
glyphs: self.glyphs,
|
|
// A modal card owns B/A while it's up — the screen's legend would lie.
|
|
show_hints: self.connecting.is_none() && self.wake.is_none(),
|
|
};
|
|
match (&mut self.motion, motion_p) {
|
|
(Motion::Push(_), Some(raw)) => {
|
|
let p = ease_out_cubic(raw);
|
|
let n = self.stack.len();
|
|
// Outgoing recedes underneath…
|
|
if n >= 2 {
|
|
let (below, top) = self.stack.split_at_mut(n - 1);
|
|
env.paint(&mut below[n - 2], 1.0 - p, 0.0, 1.0 - 0.04 * p);
|
|
// …while the incoming slides up out of a fade.
|
|
env.paint(&mut top[0], p, 36.0 * k * (1.0 - p), 0.985 + 0.015 * p);
|
|
} else {
|
|
env.paint(
|
|
&mut self.stack[0],
|
|
p,
|
|
36.0 * k * (1.0 - p),
|
|
0.985 + 0.015 * p,
|
|
);
|
|
}
|
|
}
|
|
(Motion::Pop { leaving, .. }, Some(raw)) => {
|
|
let p = ease_out_cubic(raw);
|
|
// The revealed screen grows back in…
|
|
let n = self.stack.len();
|
|
env.paint(&mut self.stack[n - 1], 0.4 + 0.6 * p, 0.0, 0.96 + 0.04 * p);
|
|
// …while the leaving one slides down into a fade.
|
|
env.paint(leaving.as_mut(), 1.0 - p, 36.0 * k * p, 1.0);
|
|
}
|
|
_ => {
|
|
let n = self.stack.len();
|
|
env.paint(&mut self.stack[n - 1], 1.0, 0.0, 1.0);
|
|
}
|
|
}
|
|
|
|
// Persistent chrome: the controller chip (top-right, above every layer).
|
|
if let Some(chip) = &self.chip {
|
|
let size = 12.0 * k;
|
|
let tw = f64::from(fonts.measure(chip, W::Medium, size));
|
|
let (bh, pad_x) = (24.0 * k, 12.0 * k);
|
|
let bx = w - 24.0 * k - tw - 2.0 * pad_x;
|
|
let rect = Rect::from_xywh(
|
|
bx as f32,
|
|
(18.0 * k) as f32,
|
|
(tw + 2.0 * pad_x) as f32,
|
|
bh as f32,
|
|
);
|
|
crate::theme::panel(
|
|
canvas,
|
|
rect,
|
|
(bh / 2.0 / k) as f32,
|
|
None,
|
|
PanelStroke::Plain(0.12),
|
|
k as f32,
|
|
);
|
|
fonts.draw(
|
|
canvas,
|
|
chip,
|
|
bx + pad_x,
|
|
18.0 * k + 16.0 * k,
|
|
W::Medium,
|
|
size,
|
|
white(0.7),
|
|
);
|
|
}
|
|
|
|
self.draw_overlays(canvas, w, h, k, dt, t, fonts);
|
|
}
|
|
|
|
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64) {
|
|
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
|
|
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 12) };
|
|
match self.mesh.make_shader(Data::new_copy(bytes), &[], None) {
|
|
Some(shader) => {
|
|
let mut paint = Paint::default();
|
|
paint.set_shader(shader);
|
|
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &paint);
|
|
}
|
|
None => {
|
|
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Overlays (connecting / waking / toast) --------------------------------------------
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn draw_overlays(
|
|
&mut self,
|
|
canvas: &Canvas,
|
|
w: f64,
|
|
h: f64,
|
|
k: f64,
|
|
dt: f64,
|
|
t: f64,
|
|
fonts: &Fonts,
|
|
) {
|
|
if let Some(c) = &mut self.connecting {
|
|
c.appear = approach(c.appear, 1.0, dt, 0.07);
|
|
let a = c.appear;
|
|
canvas.draw_rect(
|
|
Rect::from_wh(w as f32, h as f32),
|
|
&Paint::new(Color4f::new(0.0, 0.0, 0.0, (0.45 * a) as f32), None),
|
|
);
|
|
let title = if c.canceling {
|
|
"Canceling…".to_string()
|
|
} else {
|
|
format!("Connecting to {}…", c.title)
|
|
};
|
|
let hints = if c.canceling {
|
|
vec![]
|
|
} else {
|
|
vec![Hint::new(HintKey::Back, "Cancel")]
|
|
};
|
|
card(
|
|
canvas,
|
|
fonts,
|
|
w,
|
|
h,
|
|
k,
|
|
a,
|
|
t,
|
|
self.glyphs,
|
|
true,
|
|
&title,
|
|
"Starting the stream in this window.",
|
|
&hints,
|
|
);
|
|
} else if let Some(wk) = &self.wake {
|
|
let a = 1.0; // the wake card is service-driven; it appears settled
|
|
canvas.draw_rect(
|
|
Rect::from_wh(w as f32, h as f32),
|
|
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.45), None),
|
|
);
|
|
if wk.timed_out {
|
|
card(
|
|
canvas,
|
|
fonts,
|
|
w,
|
|
h,
|
|
k,
|
|
a,
|
|
t,
|
|
self.glyphs,
|
|
false,
|
|
&format!("{} didn't wake", wk.name),
|
|
"Check its power settings, or wake it manually and try again.",
|
|
&[
|
|
Hint::new(HintKey::Confirm, "Try Again"),
|
|
Hint::new(HintKey::Back, "Cancel"),
|
|
],
|
|
);
|
|
} else {
|
|
card(
|
|
canvas,
|
|
fonts,
|
|
w,
|
|
h,
|
|
k,
|
|
a,
|
|
t,
|
|
self.glyphs,
|
|
true,
|
|
&format!("Waking {}…", wk.name),
|
|
&format!("Waiting for it to come online · {} s", wk.seconds),
|
|
&[Hint::new(HintKey::Back, "Cancel")],
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
/// A centered modal card: spinner (or not), a title, one detail line, and its own hint
|
|
/// row — the connect/wake overlays share this one shape.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn card(
|
|
canvas: &Canvas,
|
|
fonts: &Fonts,
|
|
w: f64,
|
|
h: f64,
|
|
k: f64,
|
|
appear: f64,
|
|
t: f64,
|
|
glyphs: GlyphStyle,
|
|
spinner: bool,
|
|
title: &str,
|
|
body: &str,
|
|
hints: &[Hint],
|
|
) {
|
|
let cw = (440.0 * k).min(w * 0.86);
|
|
let ch = 190.0 * k;
|
|
let cx = w / 2.0;
|
|
let top = h / 2.0 - ch / 2.0 + (1.0 - appear) * 14.0 * k;
|
|
canvas.save_layer_alpha_f(None, appear as f32);
|
|
let rect = Rect::from_xywh((cx - cw / 2.0) as f32, top as f32, cw as f32, ch as f32);
|
|
crate::theme::drop_shadow(canvas, rect, 22.0, k as f32, 0.5);
|
|
crate::theme::panel(
|
|
canvas,
|
|
rect,
|
|
22.0,
|
|
Some(Color4f::new(0.07, 0.06, 0.12, 0.85)),
|
|
PanelStroke::Plain(0.14),
|
|
k as f32,
|
|
);
|
|
let mut y = top + 44.0 * k;
|
|
if spinner {
|
|
crate::theme::spinner(canvas, cx, y, 14.0 * k, t);
|
|
y += 34.0 * k;
|
|
} else {
|
|
y += 6.0 * k;
|
|
}
|
|
fonts.centered(canvas, title, W::SemiBold, 19.0 * k, WHITE, cx, y, cw * 0.9);
|
|
fonts.centered(
|
|
canvas,
|
|
body,
|
|
W::Regular,
|
|
13.0 * k,
|
|
DIM,
|
|
cx,
|
|
y + 30.0 * k,
|
|
cw * 0.86,
|
|
);
|
|
if !hints.is_empty() {
|
|
// Centered inside the card's bottom band.
|
|
let probe = hint_bar(canvas, fonts, hints, glyphs, -10_000.0, -10_000.0, k);
|
|
hint_bar(
|
|
canvas,
|
|
fonts,
|
|
hints,
|
|
glyphs,
|
|
cx - probe.0 / 2.0,
|
|
top + ch - 16.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;
|
|
}
|
|
|
|
/// 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(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);
|
|
}
|
|
}
|