//! 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::Progress; use crate::glyphs::GlyphStyle; use crate::library::{mesh_sksl, LibraryShared}; use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus}; use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen}; use 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; mod overlays; mod render; const TRANSITION_S: f64 = 0.26; /// Chrome bands (design units): the pinned title above, hints below. const TOP_BAND: f64 = 64.0; const BOTTOM_BAND: f64 = 86.0; enum Motion { None, Push(Progress), Pop { leaving: Box, t: Progress }, } struct Toast { text: String, at: f64, } struct Connecting { title: String, canceling: bool, appear: f64, /// A request-access wait (parked on the host until the operator approves) — the /// takeover reads "Waiting for approval" rather than "Connecting". request_access: bool, } /// 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, motion: Motion, console: ConsoleShared, library: LibraryShared, bus: ConsoleBus, actions: VecDeque, settings: trust::Settings, hosts: Vec, hosts_gen: u64, device_name: String, deck: bool, pub(crate) in_stream: bool, connecting: Option, wake: Option, /// True while `wake` is the shell's own optimistic placeholder — raised the instant a /// screen queues `ConsoleCmd::Wake` (see [`Self::apply`]), before the service thread has /// round-tripped its first real `WakeStatus` (~100 ms–1 s). `sync` must not clear the /// placeholder in that window, or navigation would race the wake ungated (the "pressed A, /// cursor drifted to Add Host, then got thrust into the stream" bug). wake_optimistic: bool, toast: Option, mesh: RuntimeEffect, /// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition. bg_mix: f64, glyphs: GlyphStyle, chip: Option, pads: Vec, t0: Instant, last_frame: Option, } impl Shell { pub(crate) fn new( console: ConsoleShared, library: LibraryShared, bus: ConsoleBus, opts: ConsoleOptions, stack: Vec, ) -> Result { anyhow::ensure!(!stack.is_empty(), "the console needs a root screen"); let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None) .map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?; let bg_mix = match stack.last().expect("non-empty").background() { Bg::Aurora => 0.0, Bg::Form => 1.0, }; Ok(Shell { stack, motion: Motion::None, console, library, bus, actions: VecDeque::new(), settings: trust::Settings::load(), hosts: Vec::new(), hosts_gen: u64::MAX, device_name: opts.device_name, deck: opts.deck, in_stream: false, connecting: None, wake: None, wake_optimistic: false, toast: None, mesh, bg_mix, glyphs: GlyphStyle::Keyboard, chip: None, pads: Vec::new(), t0: Instant::now(), last_frame: None, }) } fn t(&self) -> f64 { self.t0.elapsed().as_secs_f64() } pub(crate) fn editing(&self) -> bool { !self.in_stream && self.connecting.is_none() && self.stack.last().is_some_and(Screen::editing) } pub(crate) fn take_action(&mut self) -> Option { self.actions.pop_front() } // --- Session lifecycle edges (from the overlay's `session_phase`) -------------------- pub(crate) fn set_connecting(&mut self, title: Option) { match title { Some(title) => { self.connecting = Some(Connecting { title, canceling: false, appear: 0.0, request_access: false, }) } None => self.connecting = None, } } pub(crate) fn session_failed(&mut self, msg: &str) { self.connecting = None; self.in_stream = false; self.show_toast(format!("Couldn't connect — {msg}")); } pub(crate) fn session_streaming(&mut self) { self.connecting = None; self.in_stream = true; } pub(crate) fn session_ended(&mut self, reason: Option<&str>) { self.connecting = None; self.in_stream = false; if let Some(reason) = reason { self.show_toast(format!("Session ended — {reason}")); } } fn show_toast(&mut self, text: String) { self.toast = Some(Toast { text, at: self.t() }); } // --- Model sync (hosts, pairing, wake) — before input and before render -------------- fn sync(&mut self) { if self.console.hosts_gen() != self.hosts_gen { (self.hosts, self.hosts_gen) = self.console.hosts_snapshot(); } let pair = self.console.pair(); match &pair { PairPhase::Idle => {} PairPhase::Paired { key } => { let name = self .hosts .iter() .find(|h| &h.key == key) .map_or_else(|| "the host".to_string(), |h| h.name.clone()); self.show_toast(format!("Paired with {name}")); self.console.set_pair(PairPhase::Idle); if matches!(self.stack.last(), Some(Screen::Pair(_))) { self.apply_nav(Nav::Pop); } } phase => { if let Some(Screen::Pair(p)) = self.stack.last_mut() { p.apply_phase(phase); } if matches!(phase, PairPhase::Failed(_)) { self.console.set_pair(PairPhase::Idle); } } } match self.console.wake() { Some(w) => { self.wake_optimistic = false; self.wake = Some(w); } // No service status yet: keep an optimistic placeholder alive — clearing it here // would reopen the ungated window it exists to close. None if !self.wake_optimistic => self.wake = None, None => {} } if let Some(w) = &self.wake { if w.online { // Awake: stop the wake loop, and connect if that's what A meant. let intent = w.then_connect.then(|| { self.hosts .iter() .find(|h| h.key == w.key) .map(|h| ConnectIntent { addr: h.addr.clone(), port: h.port, fp_hex: h.fp_hex.clone(), launch: None, title: h.name.clone(), request_access: false, }) }); self.bus.send(ConsoleCmd::CancelWake); self.wake = None; if let Some(Some(intent)) = intent { self.start_connect(intent); // The wake takeover was already full-screen; skip the connect fade-in so the // Waking → Connecting handoff is seamless (no flash of the home behind). if let Some(c) = &mut self.connecting { c.appear = 1.0; } } } } } fn start_connect(&mut self, intent: ConnectIntent) { self.set_connecting(Some(intent.title.clone())); if let Some(c) = &mut self.connecting { c.request_access = intent.request_access; } self.actions.push_back(OverlayAction::Launch { addr: intent.addr, port: intent.port, fp_hex: intent.fp_hex, launch: intent.launch, title: intent.title, request_access: intent.request_access, }); } // --- Input --------------------------------------------------------------------------- pub(crate) fn handle_menu(&mut self, ev: MenuEvent) -> Option { self.sync(); // Modal precedence: the connect card, then the wake card, then the screens. if let Some(c) = &mut self.connecting { if ev == MenuEvent::Back && !c.canceling { c.canceling = true; self.actions.push_back(OverlayAction::CancelConnect); return Some(MenuPulse::Confirm); } return None; } if let Some(w) = &self.wake { match ev { MenuEvent::Back => { self.bus.send(ConsoleCmd::CancelWake); self.wake = None; self.wake_optimistic = false; return Some(MenuPulse::Confirm); } MenuEvent::Confirm if w.timed_out => { self.bus.send(ConsoleCmd::Wake { key: w.key.clone(), then_connect: w.then_connect, }); return Some(MenuPulse::Confirm); } _ => return None, } } // Mid-transition input is dropped — 0.26 s, and it keeps a double-tapped A // from pushing two screens. if !matches!(self.motion, Motion::None) { return None; } let mut fx = Outbox::default(); let pulse = { let mut ctx = Ctx { hosts: &self.hosts, library: &self.library, settings: &mut self.settings, pads: &self.pads, deck: self.deck, device_name: &self.device_name, t: self.t0.elapsed().as_secs_f64(), }; self.stack .last_mut() .expect("non-empty stack") .menu(ev, &mut ctx, &mut fx) }; self.apply(fx); pulse } /// The keyboard fallback — the console is fully drivable with no pad. Arrows and /// Enter/Esc map onto menu events; Y/X mirror the pad's Secondary/Tertiary /// (suppressed while editing, where letters are text). pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, repeat: bool) -> bool { use sdl3::keyboard::Scancode as S; if self.editing() { if let Some(top) = self.stack.last_mut() { if top.edit_key(sc) { return true; } } // Arrows etc. still drive the OSK grid below. } let editing = self.stack.last().is_some_and(Screen::editing); let ev = match sc { S::Left => MenuEvent::Move(MenuDir::Left), S::Right => MenuEvent::Move(MenuDir::Right), S::Up => MenuEvent::Move(MenuDir::Up), S::Down => MenuEvent::Move(MenuDir::Down), S::Return | S::KpEnter | S::Space if !repeat => MenuEvent::Confirm, S::Escape | S::Backspace if !repeat => MenuEvent::Back, S::PageUp if !repeat => MenuEvent::JumpBack, S::PageDown if !repeat => MenuEvent::JumpForward, S::Y if !repeat && !editing => MenuEvent::Secondary, S::X if !repeat && !editing => MenuEvent::Tertiary, _ => return false, }; self.handle_menu(ev); // no pad to pulse true } pub(crate) fn text_input(&mut self, text: &str) { if let Some(top) = self.stack.last_mut() { top.text_input(text); } } fn apply(&mut self, fx: Outbox) { for cmd in fx.cmds { // An input-initiated wake must gate input in the SAME call, exactly like // `start_connect` gates via `connecting`: the service's first WakeStatus is // ~100 ms–1 s away, and until it lands the screen would keep navigating — // then the arriving status freezes the UI wherever the cursor drifted, with // the "Waking…" card never shown for a fast wake. Raise it optimistically; // `sync` lets the service's real status supersede this placeholder. if let ConsoleCmd::Wake { key, then_connect } = &cmd { let name = self .hosts .iter() .find(|h| &h.key == key) .map(|h| h.name.clone()) .unwrap_or_default(); self.wake = Some(WakeStatus { key: key.clone(), name, seconds: 0, timed_out: false, online: false, then_connect: *then_connect, }); self.wake_optimistic = true; } self.bus.send(cmd); } if let Some(text) = fx.toast { self.show_toast(text); } if let Some(intent) = fx.connect { self.start_connect(intent); } if let Some(nav) = fx.nav { self.apply_nav(nav); } } fn apply_nav(&mut self, nav: Nav) { match nav { Nav::Push(screen) => { self.stack.push(*screen); self.motion = Motion::Push(Progress::new(TRANSITION_S)); } Nav::Pop => { if self.stack.len() > 1 { let leaving = self.stack.pop().expect("len > 1"); self.motion = Motion::Pop { leaving: Box::new(leaving), t: Progress::new(TRANSITION_S), }; } else { // Popping the root quits the console (B at home). self.actions.push_back(OverlayAction::Quit); } } } } 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::(), 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)); } } } } #[cfg(test)] mod tests;