22a61e0b48
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>
446 lines
16 KiB
Rust
446 lines
16 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::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<Screen>, 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<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>,
|
||
/// 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<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,
|
||
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<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,
|
||
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<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;
|
||
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::<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));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|