Files
punktfunk/crates/pf-console-ui/src/screens.rs
T
enricobuehler 5a7f7f0fc5
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m42s
ci / rust-arm64 (pull_request) Successful in 2m36s
android / android (pull_request) Successful in 3m33s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 8m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m47s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
feat(clients/gamepad-ui): section tabs, background palettes, and a backdrop that moves everywhere
The console settings were one 30-row scroll, which on a Deck meant thumbing past
Video and Audio to reach the pad settings. They are now split across sections —
Stream · Video · Audio · Controller · Interface · Profiles, plus Input on the
desktop console, which alone carries the touch/mouse rows. L1/R1 walks them,
each section remembers where its cursor was, and the names are the same word on
every client so a setting is where you looked for it last.

Shoulders are not the only route, because a D-pad remote hasn't got any: on
Android, Up from the first row moves onto the strip (left/right walks sections
there, A drops back in), and on tvOS the pills are focusable, so the focus
engine handles it — a Siri Remote has no extended gamepad profile and never
reaches the input poll at all. The desktop console needs neither; PageUp and
PageDown already map to the same events.

New "Background" row, six palettes: Violet (the brand default), Tide, Forest,
Ember, Rose, Graphite. A palette is a hue rotation plus a saturation scale over
the ONE colour field each client already draws, so every palette inherits its
structure and Violet is the identity transform — existing installs see exactly
what they see today. The maths is ported three times (Rust/Swift/Kotlin) under
one shared `ui_palette` key, with the same assertions pinned in each language.
It is presentation only, so it is a device preference and never part of a
profile.

The form screens no longer have a backdrop of their own. Settings, add-host and
pair used to sit on a still gradient; they now wear the same living field at a
calm mix — pools dimmed onto the palette's own corner colour, vignette halved so
rows that run to the edges don't get crushed. On the desktop console that
collapsed the old aurora-over-static crossfade into one shader pass with a
chased uniform. Motion speed is identical in both modes on purpose: changing it
would make the field jump mid-transition. Nothing in the gamepad UI is backed by
a static image now, and Reduce Motion (Apple) / "remove animations" (Android)
still freeze it.

Also: the settings screen had no raster coverage at all — the eyeball dump is
`#[ignore]`d — so a new test draws every tab, and the Android screenshot set
gains a console-settings scene. Both earned their keep immediately: the renders
showed the extra hint pushing "Done" off a 360 dp phone (the legend scrolls now,
and the Section cell only appears where shoulders exist) and the form backdrop
crushing its own edges.
2026-08-06 12:39:30 +02:00

199 lines
6.8 KiB
Rust

//! The console's screens and their shared contract. Each screen owns its focus state
//! and rendering; the [`crate::shell::Shell`] owns the stack, the transitions, the
//! chrome, and the overlays — a screen never draws its own background or hint bar, so
//! every screen animates and reads identically.
pub(crate) mod add_host;
pub(crate) mod home;
pub(crate) mod library;
pub(crate) mod pair;
pub(crate) mod pin_hosts;
pub(crate) mod settings;
use crate::glyphs::Hint;
use crate::library::LibraryShared;
use crate::model::{ConsoleCmd, HostRow};
use crate::theme::Fonts;
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use pf_client_core::{gamepad::PadInfo, trust};
use skia_safe::{Canvas, Rect};
/// What a screen draws over (the shell crossfades between them on push/pop).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Bg {
/// The living mesh aurora at full contrast (home, library).
Aurora,
/// The SAME living mesh, calmed — dimmed pools, lifted corners (settings, add-host,
/// pair). Not a second backdrop: the shell chases one `calm` uniform between the two.
Form,
}
/// Everything a screen may read while handling input or rendering. Settings are
/// mutable — the settings screen edits and persists them in place.
pub(crate) struct Ctx<'a> {
pub hosts: &'a [HostRow],
/// The one live library model slot (the screen on top of the stack owns it).
pub library: &'a LibraryShared,
pub settings: &'a mut trust::Settings,
pub pads: &'a [PadInfo],
/// Steam Deck: never draw our keyboard — Steam's types via SDL text input.
pub deck: bool,
/// The name the HOST stores this client under when pairing (the machine's
/// hostname, resolved by the binary).
pub device_name: &'a str,
/// The shell clock, seconds (spinners, pulses).
pub t: f64,
}
/// A host a screen wants to start a session on (the shell turns this into an
/// `OverlayAction::Launch` + the connecting overlay).
pub(crate) struct ConnectIntent {
pub addr: String,
pub port: u16,
pub fp_hex: String,
/// Library title id (`None` streams the desktop).
pub launch: Option<String>,
/// What the connecting card says (host or game title).
pub title: String,
/// The no-PIN delegated-approval connect (the pair screen's "Request access"): the
/// shell shows a "waiting for approval" takeover instead of "connecting", and the
/// binary parks on a long budget and persists the host as paired once let in.
pub request_access: bool,
/// One-off settings-profile id for this launch (a pinned card's connect); `None`
/// keeps the host's default binding.
pub profile: Option<String>,
}
pub(crate) enum Nav {
Push(Box<Screen>),
/// Pop this screen; popping the root quits the console.
Pop,
}
/// Everything a screen's input handling may ask of the shell, collected per event and
/// applied AFTER the dispatch (no re-entrant stack mutation).
#[derive(Default)]
pub(crate) struct Outbox {
pub nav: Option<Nav>,
pub connect: Option<ConnectIntent>,
pub cmds: Vec<ConsoleCmd>,
pub toast: Option<String>,
}
impl Outbox {
pub(crate) fn push(&mut self, screen: Screen) {
self.nav = Some(Nav::Push(Box::new(screen)));
}
pub(crate) fn pop(&mut self) {
self.nav = Some(Nav::Pop);
}
}
pub(crate) enum Screen {
Home(home::HomeScreen),
Library(library::LibraryScreen),
Settings(settings::SettingsScreen),
AddHost(add_host::AddHostScreen),
Pair(pair::PairScreen),
PinHosts(pin_hosts::PinHostsScreen),
}
impl Screen {
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
match self {
Screen::Home(s) => s.menu(ev, ctx, fx),
Screen::Library(s) => s.menu(ev, ctx, fx),
Screen::Settings(s) => s.menu(ev, ctx, fx),
Screen::AddHost(s) => s.menu(ev, ctx, fx),
Screen::Pair(s) => s.menu(ev, ctx, fx),
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
}
}
/// Committed text (SDL `TextInput` — hardware keyboards everywhere, Steam's
/// keyboard under gamescope). Only the editing screens consume it.
pub(crate) fn text_input(&mut self, text: &str) {
match self {
Screen::AddHost(s) => s.text_input(text),
Screen::Pair(s) => s.text_input(text),
_ => {}
}
}
/// Raw key edits while a field is editing (Backspace repeats, Return = done).
/// Returns true when consumed.
pub(crate) fn edit_key(&mut self, sc: sdl3::keyboard::Scancode) -> bool {
match self {
Screen::AddHost(s) => s.edit_key(sc),
Screen::Pair(s) => s.edit_key(sc),
_ => false,
}
}
/// A text field is being edited — the run loop keeps SDL text input started.
pub(crate) fn editing(&self) -> bool {
match self {
Screen::AddHost(s) => s.editing(),
Screen::Pair(s) => s.editing(),
_ => false,
}
}
pub(crate) fn background(&self) -> Bg {
match self {
Screen::Home(_) | Screen::Library(_) => Bg::Aurora,
_ => Bg::Form,
}
}
pub(crate) fn title(&self, _ctx: &Ctx) -> String {
match self {
Screen::Home(_) => "Select a Host".into(),
Screen::Library(s) => s.host_name().to_string(),
Screen::Settings(_) => "Settings".into(),
Screen::AddHost(_) => "Add Host".into(),
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
}
}
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
match self {
Screen::Home(s) => s.hints(ctx),
Screen::Library(s) => s.hints(ctx),
Screen::Settings(s) => s.hints(ctx),
Screen::AddHost(s) => s.hints(ctx),
Screen::Pair(s) => s.hints(ctx),
Screen::PinHosts(s) => s.hints(ctx),
}
}
/// Render the screen's content into `rect` (between the title bar and hint bar).
/// Backgrounds and chrome are the shell's.
#[allow(clippy::too_many_arguments)]
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
match self {
Screen::Home(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Library(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Settings(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx),
}
}
}