Files
punktfunk/crates/pf-console-ui/src/shell.rs
T
enricobuehler 22a61e0b48 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>
2026-07-17 14:29:07 +02:00

446 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 ms1 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 ms1 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;