feat(console): full gamepad shell — hosts, PIN pairing, settings, OSK, screen transitions
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>
This commit is contained in:
2026-07-09 08:46:20 +02:00
parent c3fa6c1514
commit 180ac3aa61
31 changed files with 6406 additions and 1032 deletions
+124
View File
@@ -0,0 +1,124 @@
//! The console shell's motion vocabulary. Two kinds of movement, deliberately kept
//! apart: **springs** (`Spring`, wrapping `library::spring_advance`) for anything the
//! user pushes around — cursors, trays, recoil — where velocity must carry across
//! retargets; and **timed progressions** (`Progress` + the easing functions) for
//! fire-and-forget choreography — screen entrances/exits, fades — where a deterministic
//! duration matters more than momentum.
use crate::library::spring_advance;
/// Ease-out cubic — fast start, gentle landing. The screen-transition curve (the WinUI
/// shell's entrance tween uses the same shape).
pub(crate) fn ease_out_cubic(t: f64) -> f64 {
let u = 1.0 - t.clamp(0.0, 1.0);
1.0 - u * u * u
}
/// Exponential approach: move `current` toward `target` with time-constant `tau`
/// seconds. Frame-rate independent, never overshoots — the focus-scale smoothing
/// (SwiftUI's `.smooth(0.18)` reads the same).
pub(crate) fn approach(current: f64, target: f64, dt: f64, tau: f64) -> f64 {
current + (target - current) * (1.0 - (-dt / tau).exp())
}
/// A damped spring with persistent velocity. `k`/`c` choose the feel; see the pairs in
/// [`crate::library`] (cursor chase, boundary bump) and [`TRAY_K`]/[`TRAY_C`] below.
#[derive(Clone, Copy)]
pub(crate) struct Spring {
pub pos: f64,
pub vel: f64,
}
impl Spring {
pub(crate) fn rest(pos: f64) -> Spring {
Spring { pos, vel: 0.0 }
}
pub(crate) fn step(&mut self, target: f64, k: f64, c: f64, dt: f64) {
(self.pos, self.vel) = spring_advance(self.pos, self.vel, target, k, c, dt);
}
/// Snap onto `target` once the motion is imperceptible (stops per-frame damage).
pub(crate) fn settle(&mut self, target: f64, eps_pos: f64, eps_vel: f64) {
if (target - self.pos).abs() < eps_pos && self.vel.abs() < eps_vel {
self.pos = target;
self.vel = 0.0;
}
}
}
/// The keyboard tray's slide (SwiftUI `.spring(response: 0.32, dampingFraction: 0.86)`:
/// k = (2π/response)², c = 2·ζ·√k).
pub(crate) const TRAY_K: f64 = 385.0;
pub(crate) const TRAY_C: f64 = 33.7;
/// A clamped 0→1 timer for fire-and-forget choreography. `advance` returns the RAW
/// progress — callers apply their easing so one Progress can drive several curves.
#[derive(Clone, Copy)]
pub(crate) struct Progress {
t: f64,
duration: f64,
}
impl Progress {
pub(crate) fn new(duration: f64) -> Progress {
Progress { t: 0.0, duration }
}
pub(crate) fn advance(&mut self, dt: f64) -> f64 {
self.t = (self.t + dt / self.duration).min(1.0);
self.t
}
pub(crate) fn value(&self) -> f64 {
self.t
}
pub(crate) fn done(&self) -> bool {
self.t >= 1.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ease_out_cubic_shape() {
assert_eq!(ease_out_cubic(0.0), 0.0);
assert_eq!(ease_out_cubic(1.0), 1.0);
assert!(ease_out_cubic(0.5) > 0.5, "front-loaded");
assert_eq!(ease_out_cubic(2.0), 1.0, "clamped");
}
#[test]
fn approach_converges_and_never_overshoots() {
let mut v = 0.0;
for _ in 0..120 {
v = approach(v, 1.0, 1.0 / 60.0, 0.06);
assert!(v <= 1.0);
}
assert!((v - 1.0).abs() < 1e-6);
}
#[test]
fn progress_completes_on_time() {
let mut p = Progress::new(0.3);
let mut steps = 0;
while !p.done() {
p.advance(1.0 / 60.0);
steps += 1;
}
assert!((17..=19).contains(&steps), "{steps}"); // 0.3 s at 60 Hz
}
#[test]
fn spring_settles() {
let mut s = Spring::rest(0.0);
for _ in 0..240 {
s.step(1.0, 200.0, 24.0, 1.0 / 60.0);
s.settle(1.0, 0.001, 0.01);
}
assert_eq!((s.pos, s.vel), (1.0, 0.0));
}
}
+354
View File
@@ -0,0 +1,354 @@
//! Controller button glyphs and the hint bar — the "controls legend" pill every console
//! screen pins bottom-leading (the Apple client resolves real SF glyphs per pad via
//! `sfSymbolsName`; here the shapes are drawn). The style follows the ACTIVE pad:
//! PlayStation controllers read ✕/○/□/△, everything else reads ABXY letters, and with
//! no pad at all the legend swaps to keyboard keycaps — the console stays fully
//! drivable either way.
use crate::theme::{white, Fonts, W};
use punktfunk_core::config::GamepadPref;
use skia_safe::{Canvas, Color4f, Paint, Path, Point, RRect, Rect};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GlyphStyle {
/// ABXY letter badges (Xbox / Steam Deck / generic).
Letters,
/// PlayStation face shapes (DualSense / DualShock 4).
Shapes,
/// No controller — keyboard keycaps.
Keyboard,
}
impl GlyphStyle {
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
match pref {
Some(GamepadPref::DualSense | GamepadPref::DualShock4) => GlyphStyle::Shapes,
Some(_) => GlyphStyle::Letters,
None => GlyphStyle::Keyboard,
}
}
}
/// What a hint's glyph depicts. `Key` renders a literal keycap chip in any style (used
/// for keyboard fallbacks and the Deck's "Steam + X" keyboard chord).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum HintKey {
Confirm,
Back,
Secondary,
Tertiary,
Shoulders,
/// ◀ ▶ — left/right adjusts the focused value.
Adjust,
Key(&'static str),
}
pub(crate) struct Hint {
pub key: HintKey,
pub label: String,
}
impl Hint {
pub(crate) fn new(key: HintKey, label: impl Into<String>) -> Hint {
Hint {
key,
label: label.into(),
}
}
}
const LABEL_SIZE: f64 = 14.0;
const BADGE_D: f64 = 22.0; // face-button badge diameter
/// The hint bar pill, anchored at its BOTTOM-LEFT corner. Returns the pill's size.
pub(crate) fn hint_bar(
canvas: &Canvas,
fonts: &Fonts,
hints: &[Hint],
style: GlyphStyle,
x: f64,
bottom: f64,
k: f64,
) -> (f64, f64) {
if hints.is_empty() {
return (0.0, 0.0);
}
let pad = 13.0 * k;
let gap_hint = 18.0 * k;
let gap_glyph = 7.0 * k;
let widths: Vec<(f64, f64)> = hints
.iter()
.map(|h| {
(
glyph_width(fonts, h.key, style, k),
fonts.measure(&h.label, W::SemiBold, LABEL_SIZE * k) as f64,
)
})
.collect();
let content_w: f64 = widths.iter().map(|(g, l)| g + gap_glyph + l).sum::<f64>()
+ gap_hint * (hints.len() - 1) as f64;
let h = BADGE_D * k + 2.0 * pad;
let w = content_w + 2.0 * pad;
let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.30), None),
);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&Paint::new(white(0.06), None),
);
let mut sp = Paint::new(white(0.12), None);
sp.set_style(skia_safe::PaintStyle::Stroke);
sp.set_stroke_width(1.0);
sp.set_anti_alias(true);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&sp,
);
let cy = bottom - h / 2.0;
let mut pen = x + pad;
for (hint, (gw, lw)) in hints.iter().zip(&widths) {
draw_glyph(canvas, fonts, hint.key, style, pen, cy, k);
pen += gw + gap_glyph;
// Baseline centered on the badge (cap height ≈ 0.72 em for Geist).
fonts.draw(
canvas,
&hint.label,
pen,
cy + LABEL_SIZE * k * 0.36,
W::SemiBold,
LABEL_SIZE * k,
white(0.85),
);
pen += lw + gap_hint;
}
(w, h)
}
fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
match resolved(key, style) {
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
Resolved::Key(text) => keycap_w(fonts, text, k),
}
}
fn shoulder_w(fonts: &Fonts, k: f64) -> f64 {
fonts.measure("L1", W::SemiBold, 10.0 * k) as f64 + 10.0 * k
}
fn keycap_w(fonts: &Fonts, text: &str, k: f64) -> f64 {
fonts.measure(text, W::SemiBold, 11.0 * k) as f64 + 14.0 * k
}
/// A hint key resolved against the glyph style.
enum Resolved {
/// A face-button badge: the letter (Letters) or shape index (Shapes).
Badge(Face),
Shoulders,
Adjust,
Key(&'static str),
}
#[derive(Clone, Copy)]
enum Face {
A,
B,
X,
Y,
}
fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
if style == GlyphStyle::Keyboard {
return match key {
HintKey::Confirm => Resolved::Key("Enter"),
HintKey::Back => Resolved::Key("Esc"),
HintKey::Secondary => Resolved::Key("Y"),
HintKey::Tertiary => Resolved::Key("X"),
HintKey::Shoulders => Resolved::Key("PgUp/PgDn"),
HintKey::Adjust => Resolved::Adjust,
HintKey::Key(t) => Resolved::Key(t),
};
}
match key {
HintKey::Confirm => Resolved::Badge(Face::A),
HintKey::Back => Resolved::Badge(Face::B),
HintKey::Tertiary => Resolved::Badge(Face::X),
HintKey::Secondary => Resolved::Badge(Face::Y),
HintKey::Shoulders => Resolved::Shoulders,
HintKey::Adjust => Resolved::Adjust,
HintKey::Key(t) => Resolved::Key(t),
}
}
/// Draw one glyph with its LEFT edge at `x`, vertically centered on `cy`.
fn draw_glyph(
canvas: &Canvas,
fonts: &Fonts,
key: HintKey,
style: GlyphStyle,
x: f64,
cy: f64,
k: f64,
) {
match resolved(key, style) {
Resolved::Badge(face) => {
let r = BADGE_D * k / 2.0;
let center = Point::new((x + r) as f32, cy as f32);
canvas.draw_circle(center, r as f32, &Paint::new(white(0.10), None));
let mut ring = Paint::new(white(0.32), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width((1.2 * k) as f32);
ring.set_anti_alias(true);
canvas.draw_circle(center, r as f32, &ring);
if style == GlyphStyle::Shapes {
draw_ps_shape(canvas, face, center, (4.6 * k) as f32, (1.7 * k) as f32);
} else {
let letter = match face {
Face::A => "A",
Face::B => "B",
Face::X => "X",
Face::Y => "Y",
};
let size = 12.0 * k;
let w = fonts.measure(letter, W::SemiBold, size) as f64;
fonts.draw(
canvas,
letter,
x + r - w / 2.0,
cy + size * 0.36,
W::SemiBold,
size,
white(0.92),
);
}
}
Resolved::Shoulders => {
let mut pen = x;
for label in ["L1", "R1"] {
let w = shoulder_w(fonts, k);
let h = 15.0 * k;
let rect = Rect::from_xywh(pen as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (4.0 * k) as f32, (4.0 * k) as f32),
&Paint::new(white(0.10), None),
);
let size = 10.0 * k;
let tw = fonts.measure(label, W::SemiBold, size) as f64;
fonts.draw(
canvas,
label,
pen + (w - tw) / 2.0,
cy + size * 0.36,
W::SemiBold,
size,
white(0.92),
);
pen += w + 3.0 * k;
}
}
Resolved::Adjust => {
// ◀ ▶ — two small solid triangles.
let r = BADGE_D * k / 2.0;
let (cx, cyf) = ((x + r) as f32, cy as f32);
let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32);
let gap = (2.6 * k) as f32;
let paint = Paint::new(white(0.85), None);
let mut left = Path::new();
left.move_to((cx - gap, cyf - th));
left.line_to((cx - gap - tw, cyf));
left.line_to((cx - gap, cyf + th));
left.close();
canvas.draw_path(&left, &paint);
let mut right = Path::new();
right.move_to((cx + gap, cyf - th));
right.line_to((cx + gap + tw, cyf));
right.line_to((cx + gap, cyf + th));
right.close();
canvas.draw_path(&right, &paint);
}
Resolved::Key(text) => {
let w = keycap_w(fonts, text, k);
let h = 18.0 * k;
let rect = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32),
&Paint::new(white(0.10), None),
);
let mut ring = Paint::new(white(0.28), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width(1.0);
ring.set_anti_alias(true);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32),
&ring,
);
let size = 11.0 * k;
let tw = fonts.measure(text, W::SemiBold, size) as f64;
fonts.draw(
canvas,
text,
x + (w - tw) / 2.0,
cy + size * 0.36,
W::SemiBold,
size,
white(0.92),
);
}
}
}
/// The PlayStation face shapes, stroked inside the badge: Confirm=✕, Back=○, X-position
/// =□, Y-position=△ (the DualSense's physical layout).
fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32) {
let mut p = Paint::new(white(0.92), None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width(stroke);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_anti_alias(true);
let (cx, cy) = (center.x, center.y);
match face {
Face::A => {
// ✕
canvas.draw_line((cx - r, cy - r), (cx + r, cy + r), &p);
canvas.draw_line((cx - r, cy + r), (cx + r, cy - r), &p);
}
Face::B => {
// ○
canvas.draw_circle(center, r * 1.1, &p);
}
Face::X => {
// □
canvas.draw_rect(Rect::from_xywh(cx - r, cy - r, 2.0 * r, 2.0 * r), &p);
}
Face::Y => {
// △
let mut tri = Path::new();
tri.move_to((cx, cy - r * 1.2));
tri.line_to((cx + r * 1.15, cy + r * 0.85));
tri.line_to((cx - r * 1.15, cy + r * 0.85));
tri.close();
canvas.draw_path(&tri, &p);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn style_follows_pad_kind() {
assert_eq!(
GlyphStyle::from_pref(Some(GamepadPref::DualSense)),
GlyphStyle::Shapes
);
assert_eq!(
GlyphStyle::from_pref(Some(GamepadPref::SteamDeck)),
GlyphStyle::Letters
);
assert_eq!(GlyphStyle::from_pref(None), GlyphStyle::Keyboard);
}
}
+27 -9
View File
@@ -1,21 +1,39 @@
//! The Skia console UI (punktfunk-planning `linux-client-rearchitecture.md` §6): an
//! [`Overlay`] implementation rendering on the PRESENTER's Vulkan device into offscreen
//! RGBA images the presenter composites as one premultiplied quad. Skia never touches
//! the swapchain, and nothing here runs while the overlay has nothing to show — the
//! §6.1 invariants live or die in this crate.
//! [`Overlay`](pf_presenter::overlay::Overlay) implementation rendering on the
//! PRESENTER's Vulkan device into offscreen RGBA images the presenter composites as one
//! premultiplied quad. Skia never touches the swapchain, and nothing here runs while
//! the overlay has nothing to show — the §6.1 invariants live or die in this crate.
//!
//! Milestone 1 (this file): the stats OSD panel + the capture-hint pill — small on
//! purpose, it proves the whole shared-device pipeline. The gamepad library moves in
//! next.
//! The console is a full couch shell now — home (host carousel), the game library
//! coverflow, settings, add-host, and PIN pairing, with screen transitions, per-pad
//! button glyphs, and a controller keyboard (suppressed on Steam Deck, where Steam's
//! own keyboard types through SDL text input) — plus the in-stream chrome: stats OSD,
//! capture hint, start banner.
#[cfg(any(target_os = "linux", windows))]
mod anim;
#[cfg(any(target_os = "linux", windows))]
mod glyphs;
#[cfg(any(target_os = "linux", windows))]
pub mod library;
#[cfg(any(target_os = "linux", windows))]
mod library_ui;
pub mod model;
#[cfg(any(target_os = "linux", windows))]
mod screens;
#[cfg(any(target_os = "linux", windows))]
mod shell;
#[cfg(any(target_os = "linux", windows))]
mod skia_overlay;
#[cfg(any(target_os = "linux", windows))]
mod theme;
#[cfg(any(target_os = "linux", windows))]
mod widgets;
#[cfg(any(target_os = "linux", windows))]
pub use library::{LibraryGame, LibraryPhase, LibraryShared};
#[cfg(any(target_os = "linux", windows))]
pub use skia_overlay::SkiaOverlay;
pub use model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
#[cfg(any(target_os = "linux", windows))]
pub use shell::ConsoleOptions;
#[cfg(any(target_os = "linux", windows))]
pub use skia_overlay::{ConsoleEntry, ConsoleHandles, SkiaOverlay};
-2
View File
@@ -291,8 +291,6 @@ pub fn mesh_sksl() -> String {
#[derive(Clone, PartialEq)]
pub enum LibraryPhase {
Loading,
/// Browse target isn't paired — pairing is the plugin's job, render the advice.
PairFirst,
Error {
title: String,
body: String,
-725
View File
@@ -1,725 +0,0 @@
//! The console library's Skia side: navigation state, scene selection, and rendering —
//! the GTK launcher (`ui_gamepad_library.rs`) re-homed onto the presenter surface. The
//! mesh-gradient background runs as an SkSL shader at full rate (the 30 Hz CPU path is
//! gone), the coverflow is `concat_44` perspective with paint order = draw order (the
//! restack hack is gone), and every state renders in-scene (gamescope maps no dialogs).
use crate::library::{
card_matrix, initials, mesh_sksl, spring_advance, step_cursor, store_label, LibraryGame,
LibraryPhase, LibraryShared, StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, JUMP, PERSPECTIVE,
POSTER_H, POSTER_W, RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K,
VISIBLE_RANGE,
};
use anyhow::{anyhow, Result};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use pf_presenter::overlay::OverlayAction;
use skia_safe::textlayout::{
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle,
};
use skia_safe::{
Canvas, Color4f, Data, Font, FontStyle, Image, Paint, Point, RRect, Rect, RuntimeEffect,
Typeface, M44,
};
use std::collections::{HashMap, VecDeque};
use std::time::Instant;
pub(crate) struct LibraryUi {
shared: LibraryShared,
host_label: String,
// Synced snapshot of the shared model (re-pulled when the generation bumps).
generation: u64,
phase: LibraryPhase,
games: Vec<LibraryGame>,
// Navigation: the integer cursor is the authority; the eased position chases it.
cursor: i32,
anim_pos: f64,
anim_vel: f64,
bump: f64,
bump_vel: f64,
last_frame: Option<Instant>,
t0: Instant,
/// Decoded posters by game id (decode once; Skia uploads lazily on first draw).
art: HashMap<String, Image>,
/// The animated mesh-gradient background (compiled once; drawn first each frame).
mesh: RuntimeEffect,
/// A launch is in flight — menu input parks, the hint bar says Connecting….
connecting: bool,
/// A session is on screen — the library doesn't render (stream chrome does).
pub(crate) in_stream: bool,
/// Transient error strip on the carousel (connect failures land here).
status: Option<String>,
actions: VecDeque<OverlayAction>,
}
impl LibraryUi {
pub(crate) fn new(shared: LibraryShared, host_label: String) -> Result<LibraryUi> {
let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None)
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
Ok(LibraryUi {
shared,
host_label,
generation: u64::MAX, // force the first sync
phase: LibraryPhase::Loading,
games: Vec::new(),
cursor: 0,
anim_pos: 0.0,
anim_vel: 0.0,
bump: 0.0,
bump_vel: 0.0,
last_frame: None,
t0: Instant::now(),
art: HashMap::new(),
mesh,
connecting: false,
in_stream: false,
status: None,
actions: VecDeque::new(),
})
}
/// Pull the shared model when it changed; decode any newly arrived poster bytes.
pub(crate) fn sync(&mut self) {
if self.shared.generation() != self.generation {
let (phase, games, generation) = self.shared.snapshot();
let fresh_games = self.games.len() != games.len()
|| self.games.iter().zip(&games).any(|(a, b)| a.id != b.id);
self.phase = phase;
self.games = games;
self.generation = generation;
if fresh_games {
// Fresh library: snap the sprung position onto the (reset) cursor.
self.cursor = 0;
self.anim_pos = 0.0;
self.anim_vel = 0.0;
self.bump = 0.0;
self.bump_vel = 0.0;
}
self.cursor = self.cursor.clamp(0, (self.games.len() as i32 - 1).max(0));
}
for (id, bytes) in self.shared.drain_art() {
match Image::from_encoded(Data::new_copy(&bytes)) {
Some(img) => {
self.art.insert(id, img);
}
None => tracing::debug!(%id, "undecodable poster"),
}
}
}
/// Menu-mode navigation (gamepad; the keyboard fallback funnels in here too).
pub(crate) fn menu(&mut self, ev: MenuEvent) -> Option<MenuPulse> {
if self.connecting {
return None; // a connect is in flight — input is parked
}
match &self.phase {
LibraryPhase::Ready => match ev {
MenuEvent::Move(MenuDir::Left) => self.step(-1, false),
MenuEvent::Move(MenuDir::Right) => self.step(1, false),
MenuEvent::JumpBack => self.step(-JUMP, true),
MenuEvent::JumpForward => self.step(JUMP, true),
MenuEvent::Confirm => {
let g = self.games.get(self.cursor as usize)?;
self.actions.push_back(OverlayAction::Launch {
id: g.id.clone(),
title: g.title.clone(),
});
self.status = None;
self.connecting = true;
Some(MenuPulse::Confirm)
}
MenuEvent::Back => {
self.actions.push_back(OverlayAction::Quit);
None
}
MenuEvent::Move(_) | MenuEvent::Secondary | MenuEvent::Tertiary => None,
},
LibraryPhase::Error { can_retry, .. } => match ev {
MenuEvent::Confirm if *can_retry => {
self.phase = LibraryPhase::Loading; // local; the fetch re-syncs it
self.actions.push_back(OverlayAction::Retry);
Some(MenuPulse::Confirm)
}
MenuEvent::Back => {
self.actions.push_back(OverlayAction::Quit);
None
}
_ => None,
},
LibraryPhase::Loading | LibraryPhase::Empty | LibraryPhase::PairFirst => {
if ev == MenuEvent::Back {
self.actions.push_back(OverlayAction::Quit);
}
None
}
}
}
/// Keyboard fallback (arrows/Enter/Esc/PageUp/PageDown) — the launcher is fully
/// drivable with no pad. Returns true when consumed.
pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, repeat: bool) -> bool {
use sdl3::keyboard::Scancode as S;
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,
_ => return false,
};
self.menu(ev); // no pad to pulse
true
}
pub(crate) fn take_action(&mut self) -> Option<OverlayAction> {
self.actions.pop_front()
}
pub(crate) fn set_connecting(&mut self, on: bool) {
self.connecting = on;
if on {
self.status = None;
}
}
/// A launch that didn't stick (connect failed / session ended with a reason):
/// carousel-visible errors land on the transient strip, anything else becomes the
/// error scene (no retry — the library itself is fine).
pub(crate) fn session_error(&mut self, msg: &str) {
self.connecting = false;
self.in_stream = false;
if self.phase == LibraryPhase::Ready {
self.status = Some(format!("Couldn't connect — {msg}"));
} else {
self.phase = LibraryPhase::Error {
title: "Couldn't connect".into(),
body: msg.to_string(),
can_retry: false,
};
}
}
fn step(&mut self, delta: i32, clamp: bool) -> Option<MenuPulse> {
match step_cursor(self.cursor, self.games.len(), delta, clamp) {
StepResult::Moved(to) => {
self.cursor = to;
Some(MenuPulse::Move)
}
StepResult::Boundary => {
// Recoil against the push; the stiff bump spring wobbles it back.
self.bump = -BUMP_PX * f64::from(delta.signum());
self.bump_vel = 0.0;
Some(MenuPulse::Boundary)
}
}
}
/// Render the whole library scene. Always a full repaint — the aurora animates
/// every frame (that's the point of the GPU port).
pub(crate) fn render(&mut self, canvas: &Canvas, w: u32, h: u32, fonts: &Fonts) {
let (wf, hf) = (w as f64, h as f64);
// Uniform scale off the Deck's 800p design height; fonts and geometry follow.
let k = (hf / 800.0).clamp(0.75, 3.0);
// Springs (Ready only — other scenes have no strip).
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));
if self.phase == LibraryPhase::Ready {
let target = f64::from(self.cursor);
(self.anim_pos, self.anim_vel) =
spring_advance(self.anim_pos, self.anim_vel, target, SPRING_K, SPRING_C, dt);
if (target - self.anim_pos).abs() < 0.001 && self.anim_vel.abs() < 0.01 {
self.anim_pos = target;
self.anim_vel = 0.0;
}
(self.bump, self.bump_vel) =
spring_advance(self.bump, self.bump_vel, 0.0, BUMP_K, BUMP_C, dt);
if self.bump.abs() < 0.3 && self.bump_vel.abs() < 4.0 {
self.bump = 0.0;
self.bump_vel = 0.0;
}
}
self.draw_background(canvas, wf, hf);
match self.phase.clone() {
LibraryPhase::Ready => self.draw_carousel(canvas, wf, hf, k, fonts),
LibraryPhase::Loading => {
self.draw_spinner(canvas, wf / 2.0, hf / 2.0 - 24.0 * k, 16.0 * k);
fonts.centered(
canvas,
"Loading library…",
14.0 * k,
DIM_TEXT,
wf / 2.0,
hf / 2.0 + 16.0 * k,
wf * 0.8,
);
}
LibraryPhase::PairFirst => {
fonts.centered_bold(
canvas,
"Not paired with this host",
22.0 * k,
WHITE,
wf / 2.0,
hf / 2.0 - 20.0 * k,
wf * 0.8,
);
fonts.centered(
canvas,
"Pair from the Punktfunk plugin first.",
14.0 * k,
DIM_TEXT,
wf / 2.0,
hf / 2.0 + 12.0 * k,
wf * 0.8,
);
}
LibraryPhase::Empty => {
fonts.centered_bold(
canvas,
"No games found",
22.0 * k,
WHITE,
wf / 2.0,
hf / 2.0 - 20.0 * k,
wf * 0.8,
);
fonts.centered(
canvas,
"Install Steam titles or add custom entries in the host's web console.",
14.0 * k,
DIM_TEXT,
wf / 2.0,
hf / 2.0 + 12.0 * k,
wf * 0.8,
);
}
LibraryPhase::Error { title, body, .. } => {
fonts.centered_bold(
canvas,
&title,
22.0 * k,
WHITE,
wf / 2.0,
hf / 2.0 - 32.0 * k,
wf * 0.8,
);
fonts.centered(
canvas,
&body,
14.0 * k,
DIM_TEXT,
wf / 2.0,
hf / 2.0 + 4.0 * k,
(600.0 * k).min(wf * 0.85),
);
}
}
self.draw_chrome(canvas, wf, hf, k, fonts);
}
fn draw_background(&self, canvas: &Canvas, w: f64, h: f64) {
let t = self.t0.elapsed().as_secs_f64();
// Uniform layout: float2 u_res, float u_t (declared order, no padding needed).
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
let data = Data::new_copy(bytemuck_bytes(&uniforms));
match self.mesh.make_shader(data, &[], 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));
}
}
}
fn draw_carousel(&mut self, canvas: &Canvas, w: f64, h: f64, k: f64, fonts: &Fonts) {
let (card_w, card_h) = (POSTER_W * k, POSTER_H * k);
// The strip's vertical center: the space between the top bar and the detail
// block (the GTK vexpand'ed scroller, approximated).
let cy = h * 0.44;
let pos = self.anim_pos;
let bump = self.bump * k;
// Paint order = draw order: farthest from the (integer) cursor first, so the
// dense side stacks overlap toward the focus. Stable → the equidistant
// neighbors keep a deterministic order.
let mut order: Vec<usize> = (0..self.games.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - self.cursor).abs()));
for i in order {
let d = i as f64 - pos;
let a = d.abs();
if a > VISIBLE_RANGE {
continue;
}
let prox = a.min(1.0);
let scale = 1.0 - prox * RECEDE_SCALE;
let angle = -d.clamp(-1.0, 1.0) * ROTATE_DEG;
// Piecewise strip: a full FOCUS_GAP around the focus, then the dense side
// stacks (the classic coverflow shelf).
let offset = if a <= 1.0 {
d * FOCUS_GAP * k
} else {
d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) * k
};
let cx = w / 2.0 + offset + bump;
let m = card_matrix(cx, cy, angle, scale, card_w, card_h, PERSPECTIVE * k);
let game = &self.games[i];
canvas.save();
canvas.concat_44(&M44::row_major(&m));
let rect = Rect::from_wh(card_w as f32, card_h as f32);
let rr = RRect::new_rect_xy(rect, 16.0 * k as f32, 16.0 * k as f32);
canvas.clip_rrect(rr, None, true);
match self.art.get(&game.id) {
Some(img) => {
// Cover-fit: center-crop the source to the card's 2:3.
let (iw, ih) = (img.width() as f32, img.height() as f32);
let card_aspect = rect.width() / rect.height();
let src = if iw / ih > card_aspect {
let sw = ih * card_aspect;
Rect::from_xywh((iw - sw) / 2.0, 0.0, sw, ih)
} else {
let sh = iw / card_aspect;
Rect::from_xywh(0.0, (ih - sh) / 2.0, iw, sh)
};
canvas.draw_image_rect(
img,
Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)),
rect,
&Paint::default(),
);
}
None => {
// Solid face, not glass: the side cards OVERLAP (GTK CSS note).
canvas.draw_rect(
rect,
&Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None),
);
let mono = initials(&game.title);
let font = fonts.sans_bold(38.0 * k);
let tw = font.measure_str(&mono, None).0;
canvas.draw_str(
&mono,
Point::new(
(card_w as f32 - tw) / 2.0,
card_h as f32 / 2.0 + 13.0 * k as f32,
),
&font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.45), None),
);
}
}
// Store badge, top-left.
{
let label = store_label(&game.store);
let font = fonts.sans_bold(11.0 * k);
let tw = font.measure_str(label, None).0;
let (px, py) = (8.0 * k as f32, 8.0 * k as f32);
let (bw, bh) = (tw + 16.0 * k as f32, 20.0 * k as f32);
canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(px, py, bw, bh), bh / 2.0, bh / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
);
canvas.draw_str(
label,
Point::new(px + 8.0 * k as f32, py + 14.0 * k as f32),
&font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 1.0), None),
);
}
// The brightness recede: an opaque-black veil, never whole-card alpha.
if prox > 0.0 {
canvas.draw_rect(
rect,
&Paint::new(
Color4f::new(0.0, 0.0, 0.0, (prox * RECEDE_DIM) as f32),
None,
),
);
}
canvas.restore();
}
// Detail block: focused title + store, centered between strip and hints.
if let Some(g) = self.games.get(self.cursor as usize) {
fonts.centered_bold(
canvas,
&g.title,
27.0 * k,
WHITE,
w / 2.0,
h - 96.0 * k,
w * 0.8,
);
fonts.centered(
canvas,
&store_label(&g.store).to_uppercase(),
12.0 * k,
Color4f::new(1.0, 1.0, 1.0, 0.5),
w / 2.0,
h - 66.0 * k,
w * 0.5,
);
}
if let Some(status) = &self.status {
fonts.centered(
canvas,
status,
13.0 * k,
Color4f::new(1.0, 0.576, 0.541, 1.0), // the GTK #ff938a
w / 2.0,
h - 44.0 * k,
w * 0.85,
);
}
}
/// Top bar (host + controller chip) and the bottom hint bar — per-scene affordances.
fn draw_chrome(&self, canvas: &Canvas, w: f64, h: f64, k: f64, fonts: &Fonts) {
let font = fonts.sans_bold(18.0 * k);
canvas.draw_str(
&self.host_label,
Point::new(24.0 * k as f32, 32.0 * k as f32),
&font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None),
);
if let Some(chip) = &fonts.chip_text {
let cf = fonts.sans(12.0 * k);
let tw = cf.measure_str(chip, None).0;
let (bh, pad) = (24.0 * k as f32, 12.0 * k as f32);
let bx = w as f32 - 24.0 * k as f32 - tw - 2.0 * pad;
canvas.draw_rrect(
RRect::new_rect_xy(
Rect::from_xywh(bx, 18.0 * k as f32, tw + 2.0 * pad, bh),
bh / 2.0,
bh / 2.0,
),
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.08), None),
);
canvas.draw_str(
chip,
Point::new(bx + pad, 18.0 * k as f32 + 16.0 * k as f32),
&cf,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.7), None),
);
}
let hint = if self.connecting {
"Connecting…".to_string()
} else {
match &self.phase {
LibraryPhase::Ready => "A Play B Quit L1 / R1 Jump".to_string(),
LibraryPhase::Error {
can_retry: true, ..
} => "A Retry B Quit".to_string(),
_ => "B Quit".to_string(),
}
};
fonts.left(
canvas,
&hint,
13.0 * k,
Color4f::new(1.0, 1.0, 1.0, 0.85),
24.0 * k,
h - 20.0 * k,
);
}
/// The loading spinner: a rotating arc off the aurora clock.
fn draw_spinner(&self, canvas: &Canvas, cx: f64, cy: f64, r: f64) {
let t = self.t0.elapsed().as_secs_f64();
let start = (t * 300.0) % 360.0;
let mut paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.85), None);
paint.set_style(skia_safe::PaintStyle::Stroke);
paint.set_stroke_width(3.0);
paint.set_anti_alias(true);
canvas.draw_arc(
Rect::from_xywh(
(cx - r) as f32,
(cy - r) as f32,
2.0 * r as f32,
2.0 * r as f32,
),
start as f32,
270.0,
false,
&paint,
);
}
}
const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0);
const DIM_TEXT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.7);
fn bytemuck_bytes(v: &[f32; 3]) -> &[u8] {
unsafe { std::slice::from_raw_parts(v.as_ptr().cast::<u8>(), 12) }
}
/// The text toolkit shared by every scene: typefaces + a paragraph-based centered-text
/// helper (shaping + font fallback — poster titles can be CJK; `draw_str` can't).
pub(crate) struct Fonts {
pub sans: Typeface,
pub collection: FontCollection,
/// The controller chip's current text (fed per frame by the overlay).
pub chip_text: Option<String>,
}
impl Fonts {
fn sans(&self, size: f64) -> Font {
Font::new(self.sans.clone(), size as f32)
}
fn sans_bold(&self, size: f64) -> Font {
let mut f = Font::new(self.sans.clone(), size as f32);
f.set_embolden(true);
f
}
fn paragraph(
&self,
text: &str,
size: f64,
color: Color4f,
bold: bool,
align: TextAlign,
max_w: f64,
) -> skia_safe::textlayout::Paragraph {
let mut style = ParagraphStyle::new();
style.set_text_align(align);
let mut ts = TextStyle::new();
ts.set_font_size(size as f32);
ts.set_color(color.to_color());
ts.set_font_style(if bold {
FontStyle::bold()
} else {
FontStyle::normal()
});
style.set_text_style(&ts);
let mut b = ParagraphBuilder::new(&style, self.collection.clone());
b.add_text(text);
let mut p = b.build();
p.layout(max_w as f32);
p
}
/// Centered paragraph with `y` as its top edge.
#[allow(clippy::too_many_arguments)]
fn centered(
&self,
canvas: &Canvas,
text: &str,
size: f64,
color: Color4f,
cx: f64,
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, size, color, false, TextAlign::Center, max_w);
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
}
#[allow(clippy::too_many_arguments)]
fn centered_bold(
&self,
canvas: &Canvas,
text: &str,
size: f64,
color: Color4f,
cx: f64,
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, size, color, true, TextAlign::Center, max_w);
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
}
fn left(&self, canvas: &Canvas, text: &str, size: f64, color: Color4f, x: f64, y: f64) {
canvas.draw_str(
text,
Point::new(x as f32, y as f32),
&self.sans(size),
&Paint::new(color, None),
);
}
}
/// Resolve the first available family. Generic aliases ("sans-serif", "monospace")
/// resolve through fontconfig on Linux; Windows' DirectWrite-backed FontMgr has no
/// generic aliases, so the list falls through to concrete family names there.
pub(crate) fn match_first_family(
mgr: &skia_safe::FontMgr,
families: &[&str],
style: FontStyle,
) -> Option<Typeface> {
families
.iter()
.find_map(|f| mgr.match_family_style(f, style))
}
pub(crate) fn build_fonts() -> Result<Fonts> {
let mgr = skia_safe::FontMgr::new();
let sans = match_first_family(
&mgr,
&["sans-serif", "Segoe UI", "Arial"],
FontStyle::normal(),
)
.ok_or_else(|| anyhow!("no sans-serif typeface (fontconfig alias or system family)"))?;
let mut collection = FontCollection::new();
collection.set_default_font_manager(mgr, None);
Ok(Fonts {
sans,
collection,
chip_text: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The generated mesh-gradient SkSL must actually compile (Skia's SkSL frontend runs
/// on the CPU — no GPU needed) — the shape test in `library` can't catch type errors.
#[test]
fn mesh_sksl_compiles() {
RuntimeEffect::make_for_shader(mesh_sksl(), None)
.unwrap_or_else(|e| panic!("mesh-gradient SkSL rejected:\n{e}"));
}
/// Render the background on a CPU raster surface at a few times and dump PNGs — a visual
/// check of the mesh-gradient look (ignored; run with `--ignored` + PF_MESH_DUMP=<dir>).
#[test]
#[ignore]
fn mesh_dump_png() {
let dir = std::env::var("PF_MESH_DUMP").expect("set PF_MESH_DUMP to an output dir");
let effect = RuntimeEffect::make_for_shader(mesh_sksl(), None).unwrap();
let (w, h) = (1280i32, 800i32);
for t in [0.0f32, 20.0, 60.0, 300.0] {
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
let uniforms: [f32; 3] = [w as f32, h as f32, t];
let data = Data::new_copy(bytemuck_bytes(&uniforms));
let shader = effect.make_shader(data, &[], None).unwrap();
let mut paint = Paint::default();
paint.set_shader(shader);
surface
.canvas()
.draw_rect(Rect::from_wh(w as f32, h as f32), &paint);
let png = surface
.image_snapshot()
.encode(None, skia_safe::EncodedImageFormat::PNG, 100)
.unwrap();
std::fs::write(format!("{dir}/mesh_t{t}.png"), png.as_bytes()).unwrap();
}
}
}
+202
View File
@@ -0,0 +1,202 @@
//! The console's shared binary↔overlay state and command bus — the widened sibling of
//! [`crate::library::LibraryShared`]. The session binary's service threads (discovery,
//! probing, pairing, waking, persistence) WRITE snapshots in; the shell reads them per
//! frame by generation stamp. The overlay never blocks: anything that touches the
//! network or disk rides a [`ConsoleCmd`] to the binary instead.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
/// One row on the console home carousel — a saved host, a discovered-but-unsaved one,
/// or (client-side) the trailing Add Host tile. Fully resolved by the service thread;
/// the shell renders it verbatim.
#[derive(Clone, Debug, PartialEq)]
pub struct HostRow {
/// Stable identity across refreshes: the pinned fingerprint when known, else
/// `addr:port` — keeps the cursor on "the same host" as snapshots churn.
pub key: String,
pub name: String,
pub addr: String,
pub port: u16,
/// Pinned certificate fingerprint (lowercase hex); empty = not pinned.
pub fp_hex: String,
pub paired: bool,
/// In the known-hosts store (vs. discovered-only).
pub saved: bool,
/// Advertising on mDNS or proven reachable by the probe sweep.
pub online: bool,
/// The management API's port (mDNS TXT or store), for the library fetch.
pub mgmt_port: u16,
/// Offline + a stored MAC → activating wakes first ("Wake & Connect").
pub can_wake: bool,
/// Last successful connect (UNIX seconds) — the most-recent accent.
pub last_used: Option<u64>,
}
/// The pairing ceremony's observable state (one at a time — the ceremony is modal).
#[derive(Clone, Debug, PartialEq, Default)]
pub enum PairPhase {
#[default]
Idle,
/// The SPAKE2 exchange is running (up to ~90 s on a mistyped-then-fixed PIN).
Busy,
Failed(String),
/// Paired and persisted; `key` addresses the host's refreshed row.
Paired {
key: String,
},
}
/// A wake-and-wait in progress (one at a time). The service thread re-sends magic
/// packets and probes; the shell renders the card and acts on `online`.
#[derive(Clone, Debug, PartialEq)]
pub struct WakeStatus {
pub key: String,
pub name: String,
/// Seconds since the wake started (the card's counter).
pub seconds: u32,
pub timed_out: bool,
/// The host answered a probe — the shell launches if the wake wanted a connect.
pub online: bool,
/// Connect once awake (A on an offline host) vs. a bare wake.
pub then_connect: bool,
}
#[derive(Default)]
struct ConsoleState {
hosts: Vec<HostRow>,
hosts_gen: u64,
pair: PairPhase,
wake: Option<WakeStatus>,
}
/// The shared handle. Service threads write; the shell polls per frame (cheap locks,
/// no rendering data inside).
#[derive(Clone, Default)]
pub struct ConsoleShared(Arc<Mutex<ConsoleState>>);
impl ConsoleShared {
pub fn set_hosts(&self, hosts: Vec<HostRow>) {
let mut s = self.0.lock().unwrap();
if s.hosts != hosts {
s.hosts = hosts;
s.hosts_gen += 1;
}
}
pub(crate) fn hosts_gen(&self) -> u64 {
self.0.lock().unwrap().hosts_gen
}
pub(crate) fn hosts_snapshot(&self) -> (Vec<HostRow>, u64) {
let s = self.0.lock().unwrap();
(s.hosts.clone(), s.hosts_gen)
}
pub fn set_pair(&self, phase: PairPhase) {
self.0.lock().unwrap().pair = phase;
}
pub(crate) fn pair(&self) -> PairPhase {
self.0.lock().unwrap().pair.clone()
}
pub fn set_wake(&self, wake: Option<WakeStatus>) {
self.0.lock().unwrap().wake = wake;
}
pub(crate) fn wake(&self) -> Option<WakeStatus> {
self.0.lock().unwrap().wake.clone()
}
}
/// Work the shell asks the binary to do. Everything here blocks (network/disk), so it
/// runs on the binary's service thread, never on the render path.
#[derive(Debug, Clone, PartialEq)]
pub enum ConsoleCmd {
/// (Re)fetch a host's game library into the shared library model.
FetchLibrary {
addr: String,
mgmt: u16,
fp_hex: String,
},
/// Run the SPAKE2 PIN ceremony; on success persist the pin and refresh hosts.
Pair {
addr: String,
port: u16,
pin: String,
device_name: String,
},
/// Save a manually entered host (unpaired) and refresh the rows.
SaveHost {
name: String,
addr: String,
port: u16,
},
/// Start the wake-and-wait loop for this saved host.
Wake { key: String, then_connect: bool },
/// Stop the wake loop (B on the wake card) and clear its status.
CancelWake,
/// Sweep reachability now (the home screen refreshes its presence pips).
Probe,
}
/// The overlay→binary command queue. A plain deque under the same locking discipline as
/// the shared models — the service thread drains it on a short cadence (it's never
/// latency-critical: every command's effect arrives via a model snapshot anyway).
#[derive(Clone, Default)]
pub struct ConsoleBus(Arc<Mutex<VecDeque<ConsoleCmd>>>);
impl ConsoleBus {
/// Queue a command. Normally the shell's side; the binary may also seed one (the
/// direct-entry library fetch) — same lane, same handler.
pub fn send(&self, cmd: ConsoleCmd) {
self.0.lock().unwrap().push_back(cmd);
}
/// Binary side: drain everything queued since the last call.
pub fn drain(&self) -> Vec<ConsoleCmd> {
self.0.lock().unwrap().drain(..).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosts_generation_bumps_only_on_change() {
let shared = ConsoleShared::default();
let row = HostRow {
key: "aa".into(),
name: "Tower".into(),
addr: "10.0.0.2".into(),
port: 9777,
fp_hex: "aa".into(),
paired: true,
saved: true,
online: false,
mgmt_port: 47990,
can_wake: false,
last_used: None,
};
shared.set_hosts(vec![row.clone()]);
let g1 = shared.hosts_gen();
shared.set_hosts(vec![row.clone()]);
assert_eq!(shared.hosts_gen(), g1, "identical snapshot doesn't churn");
shared.set_hosts(vec![HostRow {
online: true,
..row
}]);
assert_eq!(shared.hosts_gen(), g1 + 1);
}
#[test]
fn bus_drains_in_order() {
let bus = ConsoleBus::default();
bus.send(ConsoleCmd::Probe);
bus.send(ConsoleCmd::CancelWake);
assert_eq!(bus.drain(), vec![ConsoleCmd::Probe, ConsoleCmd::CancelWake]);
assert!(bus.drain().is_empty());
}
}
+184
View File
@@ -0,0 +1,184 @@
//! 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 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 (home, library).
Aurora,
/// The quiet indigo form backdrop (settings, add-host, pair).
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,
}
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),
}
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),
}
}
/// 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()),
}
}
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),
}
}
/// 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),
}
}
}
@@ -0,0 +1,367 @@
//! The controller "Add Host" screen — three field rows (name / address / port) plus the
//! Add action. A on a field opens the on-screen keyboard in a bottom tray (on a Steam
//! Deck it opens SDL text input instead — Steam's own keyboard types, ours never
//! draws), B peels one layer: keyboard first, then the screen. Field edits are live;
//! hardware keyboards type straight into the focused field through SDL text input.
use crate::glyphs::{Hint, HintKey};
use crate::model::ConsoleCmd;
use crate::screens::{Ctx, Outbox};
use crate::theme::{Fonts, DIM, W};
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Field {
Name,
Address,
Port,
}
const FIELDS: [Field; 3] = [Field::Name, Field::Address, Field::Port];
pub(crate) struct AddHostScreen {
list: MenuList,
keyboard: Keyboard,
name: String,
address: String,
port: String,
editing: Option<Field>,
}
impl AddHostScreen {
pub(crate) fn new() -> AddHostScreen {
AddHostScreen {
list: MenuList::new(),
keyboard: Keyboard::new(),
name: String::new(),
address: String::new(),
port: "9777".into(),
editing: None,
}
}
pub(crate) fn editing(&self) -> bool {
self.editing.is_some()
}
fn can_add(&self) -> bool {
!self.address.trim().is_empty() && self.port.parse::<u16>().is_ok_and(|p| p > 0)
}
fn field_mut(&mut self, f: Field) -> &mut String {
match f {
Field::Name => &mut self.name,
Field::Address => &mut self.address,
Field::Port => &mut self.port,
}
}
fn charset(f: Field) -> Charset {
match f {
Field::Name => Charset::Free,
Field::Address => Charset::Hostname,
Field::Port => Charset::Digits,
}
}
/// Apply one typed character to the editing field (charset + the port's 5-digit
/// cap). Returns false when refused — the boundary thud.
fn type_char(&mut self, ch: char) -> bool {
let Some(f) = self.editing else { return false };
if !permits(Self::charset(f), ch) {
return false;
}
if f == Field::Port && self.field_mut(f).chars().count() >= 5 {
return false;
}
self.field_mut(f).push(ch);
true
}
fn backspace(&mut self) -> bool {
let Some(f) = self.editing else { return false };
self.field_mut(f).pop().is_some()
}
pub(crate) fn text_input(&mut self, text: &str) {
for ch in text.chars() {
self.type_char(ch);
}
}
pub(crate) fn edit_key(&mut self, sc: sdl3::keyboard::Scancode) -> bool {
use sdl3::keyboard::Scancode as S;
if self.editing.is_none() {
return false;
}
match sc {
S::Backspace => {
self.backspace();
true
}
S::Return | S::KpEnter | S::Escape => {
self.editing = None;
true
}
_ => false,
}
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
if let Some(_field) = self.editing {
if ctx.deck {
// Steam's keyboard owns typing; the pad only closes the field.
return match ev {
MenuEvent::Back | MenuEvent::Confirm => {
self.editing = None;
Some(MenuPulse::Confirm)
}
_ => None,
};
}
let (msg, pulse) = self.keyboard.menu(ev);
return match msg {
KeyMsg::Type(c) => {
if self.type_char(c) {
Some(MenuPulse::Move)
} else {
Some(MenuPulse::Boundary)
}
}
KeyMsg::Backspace => {
if self.backspace() {
Some(MenuPulse::Move)
} else {
Some(MenuPulse::Boundary)
}
}
KeyMsg::Done => {
self.editing = None;
Some(MenuPulse::Confirm)
}
KeyMsg::None => pulse,
};
}
if ev == MenuEvent::Back {
fx.pop();
return None;
}
let (msg, pulse) = self.list.menu(ev, FIELDS.len() + 1);
match msg {
ListMsg::Activate => {
if self.list.cursor < FIELDS.len() {
self.editing = Some(FIELDS[self.list.cursor]);
} else if self.can_add() {
fx.cmds.push(ConsoleCmd::SaveHost {
name: self.name.trim().to_string(),
addr: self.address.trim().to_string(),
port: self.port.parse().unwrap_or(9777),
});
fx.toast = Some(format!("Added {}", self.address.trim()));
fx.pop();
} else {
// Not addable yet — jump to what's missing instead of a dead press.
self.list.cursor = 1; // the address row
self.editing = Some(Field::Address);
}
pulse
}
_ => pulse,
}
}
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
if self.editing.is_some() {
if ctx.deck {
return vec![
Hint::new(HintKey::Key("STEAM + X"), "Keyboard"),
Hint::new(HintKey::Confirm, "Done"),
Hint::new(HintKey::Back, "Done"),
];
}
return vec![
Hint::new(HintKey::Confirm, "Type"),
Hint::new(HintKey::Tertiary, "Delete"),
Hint::new(HintKey::Back, "Done"),
];
}
vec![
Hint::new(HintKey::Confirm, "Select"),
Hint::new(HintKey::Back, "Cancel"),
]
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
fonts.centered(
canvas,
"Hosts on this network appear automatically — add one by address for everything else.",
W::Regular,
13.0 * k,
DIM,
cx,
f64::from(rect.top) + 2.0 * k,
f64::from(rect.width()) * 0.72,
);
// While the keyboard tray is up (never on Deck) the rows squeeze above it.
let seat = self.keyboard.seat(self.editing.is_some() && !ctx.deck, dt);
let tray_h = if seat > 0.0 {
(Keyboard::tray_height() + 12.0) * k * seat
} else {
0.0
};
let list_rect = Rect::from_ltrb(
rect.left,
rect.top + (34.0 * k) as f32,
rect.right,
rect.bottom - tray_h as f32,
);
let rows = self.rows();
self.list.render(
canvas,
list_rect,
&rows,
fonts,
k,
dt,
self.editing.is_none(),
);
if seat > 0.0 {
self.keyboard.render(
canvas,
fonts,
f64::from(rect.width()),
f64::from(rect.bottom),
seat,
k,
);
}
}
fn rows(&self) -> Vec<RowSpec> {
let field_row = |label: &str, value: &str, placeholder: &str, f: Field| {
let mut row = RowSpec::field(label, value.to_string(), placeholder);
row.caret = self.editing == Some(f);
row
};
vec![
field_row(
"Name",
&self.name,
"Optional — e.g. Living Room",
Field::Name,
),
field_row("Address", &self.address, "IP or hostname", Field::Address),
field_row("Port", &self.port, "9777", Field::Port),
RowSpec::action("Add Host", self.can_add()),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::screens::Nav;
use pf_client_core::trust::Settings;
fn ctx<'a>(
settings: &'a mut Settings,
pads: &'a [pf_client_core::gamepad::PadInfo],
library: &'a crate::library::LibraryShared,
deck: bool,
) -> Ctx<'a> {
Ctx {
hosts: &[],
library,
settings,
pads,
deck,
device_name: "t",
t: 0.0,
}
}
#[test]
fn end_to_end_add_flow() {
let mut settings = Settings::default();
let library = crate::library::LibraryShared::default();
let mut c = ctx(&mut settings, &[], &library, false);
let mut s = AddHostScreen::new();
let mut fx = Outbox::default();
// A on "Add Host" while the address is empty jumps into the address field.
s.list.cursor = 3;
s.menu(MenuEvent::Confirm, &mut c, &mut fx);
assert_eq!(s.editing, Some(Field::Address));
assert_eq!(s.list.cursor, 1);
// Hardware keys / Steam keyboard commit text; spaces are refused for addresses.
s.text_input("deck tower.local");
assert_eq!(s.address, "decktower.local");
s.edit_key(sdl3::keyboard::Scancode::Backspace);
assert_eq!(s.address, "decktower.loca");
s.text_input("l");
s.edit_key(sdl3::keyboard::Scancode::Return);
assert!(s.editing.is_none());
// Now Add saves, toasts, and pops.
s.list.cursor = 3;
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut c, &mut fx);
assert!(matches!(
fx.cmds.first(),
Some(ConsoleCmd::SaveHost { addr, port: 9777, .. }) if addr == "decktower.local"
));
assert!(matches!(fx.nav, Some(Nav::Pop)));
}
#[test]
fn port_caps_at_five_digits() {
let mut s = AddHostScreen::new();
s.editing = Some(Field::Port);
s.port.clear();
s.text_input("123456789");
assert_eq!(s.port, "12345");
assert!(!s.type_char('x'), "digits only");
}
#[test]
fn deck_mode_never_uses_the_grid() {
let mut settings = Settings::default();
let library = crate::library::LibraryShared::default();
let mut c = ctx(&mut settings, &[], &library, true);
let mut s = AddHostScreen::new();
let mut fx = Outbox::default();
s.list.cursor = 1;
s.menu(MenuEvent::Confirm, &mut c, &mut fx); // open the address field
assert!(s.editing());
// Moves don't drive a key grid on Deck; B closes the field.
assert!(s
.menu(
MenuEvent::Move(pf_client_core::gamepad::MenuDir::Right),
&mut c,
&mut fx
)
.is_none());
s.menu(MenuEvent::Back, &mut c, &mut fx);
assert!(!s.editing());
assert!(fx.nav.is_none(), "B closed the field, not the screen");
}
}
+573
View File
@@ -0,0 +1,573 @@
//! The console home: a center-snapping carousel of host tiles (saved, discovered, and
//! the trailing Add Host action) — the Swift `GamepadHomeView` re-homed onto Skia. A
//! connects (or wakes, or routes to pairing), Y opens a paired host's library, X opens
//! settings, B quits to Gaming Mode. The cursor is the authority; the sprung position
//! chases it, and the focus pop (scale/brightness/fade) reads off the LIVE sprung
//! distance so the look always matches the strip mid-motion.
use crate::anim::Spring;
use crate::glyphs::{Hint, HintKey};
use crate::library::{step_cursor, StepResult, BUMP_C, BUMP_K, BUMP_PX, SPRING_C, SPRING_K};
use crate::model::{ConsoleCmd, HostRow};
use crate::screens::{ConnectIntent, Ctx, Outbox, Screen};
use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, DIM, ONLINE_GREEN, W, WHITE};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Color4f, MaskFilter, Paint, Path, Point, RRect, Rect};
const TILE_W: f64 = 340.0;
const TILE_H: f64 = 224.0;
const TILE_GAP: f64 = 30.0;
const TILE_CORNER: f64 = 26.0;
/// The Add Host tile's synthetic key (host keys are fingerprints or `addr:port`,
/// neither starts with `\0`).
const ADD_KEY: &str = "\0add";
pub(crate) struct HomeScreen {
cursor: i32,
anim: Spring,
bump: Spring,
/// Last-seen tile keys — hosts churn under discovery; focus follows the KEY.
keys: Vec<String>,
}
impl HomeScreen {
pub(crate) fn new() -> HomeScreen {
HomeScreen {
cursor: 0,
anim: Spring::rest(0.0),
bump: Spring::rest(0.0),
keys: Vec::new(),
}
}
/// Keep the cursor on "the same tile" across host-list churn.
fn reconcile(&mut self, hosts: &[HostRow]) {
let keys: Vec<String> = hosts
.iter()
.map(|h| h.key.clone())
.chain(std::iter::once(ADD_KEY.to_string()))
.collect();
if keys != self.keys {
let followed = self
.keys
.get(self.cursor as usize)
.and_then(|old| keys.iter().position(|k| k == old));
self.cursor = followed.unwrap_or(self.cursor as usize).min(keys.len() - 1) as i32;
// A follow that moved the tile shifts the spring target; the chase animates
// the strip to its new berth instead of snapping.
self.keys = keys;
}
}
fn focused<'h>(&self, hosts: &'h [HostRow]) -> Option<&'h HostRow> {
hosts.get(self.cursor as usize)
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
self.reconcile(ctx.hosts);
let len = ctx.hosts.len() + 1;
match ev {
MenuEvent::Move(MenuDir::Left) => self.step(-1, len, false),
MenuEvent::Move(MenuDir::Right) => self.step(1, len, false),
MenuEvent::JumpBack => self.step(-5, len, true),
MenuEvent::JumpForward => self.step(5, len, true),
MenuEvent::Confirm => {
match self.focused(ctx.hosts) {
None => fx.push(Screen::AddHost(super::add_host::AddHostScreen::new())),
Some(h) if !h.paired => fx.push(Screen::Pair(super::pair::PairScreen::new(
h,
ctx.device_name,
))),
Some(h) if !h.online && h.can_wake => {
// Wake first; the wake overlay connects once it answers.
fx.cmds.push(ConsoleCmd::Wake {
key: h.key.clone(),
then_connect: true,
});
}
Some(h) => {
// Dial-first even when the presence pips say offline — a
// routed/VPN host is mDNS-blind and probe-shy but dials fine.
fx.connect = Some(ConnectIntent {
addr: h.addr.clone(),
port: h.port,
fp_hex: h.fp_hex.clone(),
launch: None,
title: h.name.clone(),
});
}
}
Some(MenuPulse::Confirm)
}
MenuEvent::Secondary => match self.focused(ctx.hosts) {
Some(h) if h.paired && h.saved => {
fx.cmds.push(ConsoleCmd::FetchLibrary {
addr: h.addr.clone(),
mgmt: h.mgmt_port,
fp_hex: h.fp_hex.clone(),
});
fx.push(Screen::Library(super::library::LibraryScreen::new(h)));
Some(MenuPulse::Confirm)
}
Some(_) => {
fx.toast = Some("Pair with this host to browse its library".into());
Some(MenuPulse::Boundary)
}
None => None,
},
MenuEvent::Tertiary => {
fx.push(Screen::Settings(super::settings::SettingsScreen::new()));
Some(MenuPulse::Confirm)
}
MenuEvent::Back => {
fx.pop(); // popping the root = quit (the shell's rule)
None
}
MenuEvent::Move(_) => None,
}
}
fn step(&mut self, delta: i32, len: usize, clamp: bool) -> Option<MenuPulse> {
match step_cursor(self.cursor, len, delta, clamp) {
StepResult::Moved(to) => {
self.cursor = to;
Some(MenuPulse::Move)
}
StepResult::Boundary => {
self.bump = Spring {
pos: -BUMP_PX * f64::from(delta.signum()),
vel: 0.0,
};
Some(MenuPulse::Boundary)
}
}
}
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
let mut hints = Vec::new();
match self.focused(ctx.hosts) {
None => hints.push(Hint::new(HintKey::Confirm, "Add Host")),
Some(h) if !h.paired => hints.push(Hint::new(HintKey::Confirm, "Pair…")),
Some(h) if !h.online && h.can_wake => {
hints.push(Hint::new(HintKey::Confirm, "Wake & Connect"))
}
Some(_) => hints.push(Hint::new(HintKey::Confirm, "Connect")),
}
if self.focused(ctx.hosts).is_some_and(|h| h.paired && h.saved) {
hints.push(Hint::new(HintKey::Secondary, "Library"));
}
hints.push(Hint::new(HintKey::Tertiary, "Settings"));
hints.push(Hint::new(HintKey::Back, "Quit"));
hints
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
self.reconcile(ctx.hosts);
self.anim
.step(f64::from(self.cursor), SPRING_K, SPRING_C, dt);
self.anim.settle(f64::from(self.cursor), 0.001, 0.01);
self.bump.step(0.0, BUMP_K, BUMP_C, dt);
self.bump.settle(0.0, 0.3, 4.0);
let w = f64::from(rect.width());
let tile_w = (TILE_W * k).min(w * 0.84);
let tile_h = (TILE_H * k)
.min(f64::from(rect.height()) - 48.0 * k)
.max(118.0 * k);
let pitch = tile_w + TILE_GAP * k;
let cx0 = f64::from(rect.left) + w / 2.0 + self.bump.pos * k;
let cy = f64::from(rect.top) + f64::from(rect.height()) / 2.0;
let len = ctx.hosts.len() + 1;
for i in 0..len {
let d = i as f64 - self.anim.pos;
if d.abs() > 2.6 {
continue;
}
let f = 1.0 - d.abs().min(1.0); // 1 at focus → 0 one slot out
let scale = 0.88 + 0.12 * f;
let alpha = 0.78 + 0.22 * f;
let cx = cx0 + d * pitch;
let tile = Rect::from_xywh(
(cx - tile_w / 2.0) as f32,
(cy - tile_h / 2.0) as f32,
tile_w as f32,
tile_h as f32,
);
canvas.save();
canvas.translate((cx as f32, cy as f32));
canvas.scale((scale as f32, scale as f32));
canvas.translate((-cx as f32, -cy as f32));
canvas.save_layer_alpha_f(None, alpha as f32);
if f > 0.4 {
crate::theme::drop_shadow(
canvas,
tile,
TILE_CORNER as f32,
k as f32,
0.45 * f as f32,
);
}
match ctx.hosts.get(i) {
Some(h) => draw_host_tile(canvas, fonts, h, tile, k, ctx.t),
None => draw_add_tile(canvas, fonts, tile, k),
}
// The brightness recede: an opaque veil, matching the coverflow's rule.
if f < 1.0 {
let veil = (1.0 - f) as f32 * 0.24;
canvas.draw_rrect(
RRect::new_rect_xy(tile, (TILE_CORNER * k) as f32, (TILE_CORNER * k) as f32),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, veil), None),
);
}
canvas.restore(); // layer
canvas.restore(); // transform
}
if ctx.hosts.is_empty() {
fonts.centered(
canvas,
"Hosts on this network appear automatically — add one by address for everything else.",
W::Regular,
13.0 * k,
DIM,
f64::from(rect.left) + w / 2.0,
cy + tile_h / 2.0 + 24.0 * k,
w * 0.7,
);
}
}
}
fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f64, _t: f64) {
crate::theme::panel(
canvas,
rect,
TILE_CORNER as f32,
h.saved.then(|| brand(0.20)),
if h.saved {
PanelStroke::Gradient
} else {
PanelStroke::GradientDashed
},
k as f32,
);
let pad = 20.0 * k;
let (l, t) = (f64::from(rect.left) + pad, f64::from(rect.top) + pad);
draw_monogram(canvas, fonts, &h.name, h.saved, l, t, k);
// Top-right status cluster: a lock for a paired identity, a glowing pip when live.
let mut sx = f64::from(rect.right) - pad;
if h.online {
let r = 4.5 * k;
let center = Point::new((sx - r) as f32, (t + 9.0 * k) as f32);
let mut glow = Paint::new(
Color4f::new(ONLINE_GREEN.r, ONLINE_GREEN.g, ONLINE_GREEN.b, 0.7),
None,
);
glow.set_mask_filter(MaskFilter::blur(
skia_safe::BlurStyle::Normal,
(5.0 * k) as f32,
None,
));
canvas.draw_circle(center, r as f32, &glow);
canvas.draw_circle(center, r as f32, &Paint::new(ONLINE_GREEN, None));
sx -= 2.0 * r + 9.0 * k;
}
if h.paired {
draw_lock(canvas, sx - 9.0 * k, t + 4.0 * k, k);
}
let max_w = f64::from(rect.width()) - 2.0 * pad;
let sub_base = f64::from(rect.bottom) - pad;
fonts.draw_clipped(
canvas,
&format!("{}:{}", h.addr, h.port),
l,
sub_base,
W::Regular,
13.0 * k,
white(0.55),
max_w,
);
fonts.draw_clipped(
canvas,
&h.name,
l,
sub_base - 22.0 * k,
W::Bold,
23.0 * k,
WHITE,
max_w,
);
}
fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
crate::theme::panel(
canvas,
rect,
TILE_CORNER as f32,
None,
PanelStroke::GradientDashed,
k as f32,
);
let pad = 20.0 * k;
let (l, t) = (f64::from(rect.left) + pad, f64::from(rect.top) + pad);
// The badge with a + instead of a monogram.
let badge = Rect::from_xywh(l as f32, t as f32, (52.0 * k) as f32, (52.0 * k) as f32);
canvas.draw_rrect(
RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32),
&Paint::new(brand(0.16), None),
);
let mut ring = Paint::new(brand(0.5), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width(1.0);
ring.set_anti_alias(true);
canvas.draw_rrect(
RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32),
&ring,
);
let (bcx, bcy) = (l + 26.0 * k, t + 26.0 * k);
let mut p = Paint::new(BRAND, None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((3.0 * k) as f32);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_anti_alias(true);
let r = 9.0 * k;
canvas.draw_line(
((bcx - r) as f32, bcy as f32),
((bcx + r) as f32, bcy as f32),
&p,
);
canvas.draw_line(
(bcx as f32, (bcy - r) as f32),
(bcx as f32, (bcy + r) as f32),
&p,
);
let max_w = f64::from(rect.width()) - 2.0 * pad;
let sub_base = f64::from(rect.bottom) - pad;
fonts.draw_clipped(
canvas,
"Register a host by address",
l,
sub_base,
W::Regular,
13.0 * k,
white(0.55),
max_w,
);
fonts.draw_clipped(
canvas,
"Add Host",
l,
sub_base - 22.0 * k,
W::Bold,
23.0 * k,
WHITE,
max_w,
);
}
fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f64, y: f64, k: f64) {
let badge = Rect::from_xywh(x as f32, y as f32, (52.0 * k) as f32, (52.0 * k) as f32);
let rr = RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32);
if filled {
let mut p = Paint::default();
p.set_shader(skia_safe::gradient_shader::linear(
(
Point::new(badge.left, badge.top),
Point::new(badge.left, badge.bottom),
),
skia_safe::gradient_shader::GradientShaderColors::Colors(&[
BRAND.to_color(),
brand(0.68).to_color(),
]),
None,
skia_safe::TileMode::Clamp,
None,
None,
));
canvas.draw_rrect(rr, &p);
} else {
canvas.draw_rrect(rr, &Paint::new(brand(0.16), None));
let mut ring = Paint::new(brand(0.5), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width(1.0);
ring.set_anti_alias(true);
canvas.draw_rrect(rr, &ring);
}
let letter: String = name
.trim()
.chars()
.next()
.map(|c| c.to_uppercase().collect())
.unwrap_or_else(|| "".to_string());
let size = 25.0 * k;
let tw = fonts.measure(&letter, W::Bold, size) as f64;
fonts.draw(
canvas,
&letter,
x + 26.0 * k - tw / 2.0,
y + 26.0 * k + size * 0.36,
W::Bold,
size,
if filled { WHITE } else { BRAND },
);
}
/// A small padlock: filled body + stroked shackle (the paired-identity mark).
fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) {
let ink = white(0.5);
let body_w = 11.0 * k;
let body_h = 8.0 * k;
let body_top = y + 5.0 * k;
canvas.draw_rrect(
RRect::new_rect_xy(
Rect::from_xywh(x as f32, body_top as f32, body_w as f32, body_h as f32),
(2.0 * k) as f32,
(2.0 * k) as f32,
),
&Paint::new(ink, None),
);
let mut p = Paint::new(ink, None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((1.6 * k) as f32);
p.set_anti_alias(true);
let mut shackle = Path::new();
let (cx, r) = (x + body_w / 2.0, 3.2 * k);
shackle.move_to(((cx - r) as f32, body_top as f32));
shackle.arc_to(
Rect::from_xywh(
(cx - r) as f32,
(body_top - r) as f32,
(2.0 * r) as f32,
(2.0 * r) as f32,
),
180.0,
180.0,
false,
);
canvas.draw_path(&shackle, &p);
}
#[cfg(test)]
mod tests {
use super::*;
fn host(key: &str, paired: bool, online: bool, can_wake: bool) -> HostRow {
HostRow {
key: key.into(),
name: key.into(),
addr: "10.0.0.9".into(),
port: 9777,
fp_hex: if paired { "ab".into() } else { String::new() },
paired,
saved: true,
online,
mgmt_port: 47990,
can_wake,
last_used: None,
}
}
fn ctx_settings() -> pf_client_core::trust::Settings {
pf_client_core::trust::Settings::default()
}
#[test]
fn cursor_follows_the_key_through_churn() {
let mut s = HomeScreen::new();
let a = host("a", true, true, false);
let b = host("b", true, true, false);
s.reconcile(&[a.clone(), b.clone()]);
s.cursor = 1; // on "b"
s.keys = vec!["a".into(), "b".into(), ADD_KEY.into()];
// A new host lands in front — focus must stay on "b".
let c = host("c", false, true, false);
s.reconcile(&[c, a, b]);
assert_eq!(s.cursor, 2);
}
#[test]
fn confirm_routes_by_host_state() {
let mut settings = ctx_settings();
let hosts = [
host("paired-online", true, true, false),
host("unpaired", false, true, false),
host("asleep", true, false, true),
];
let pads: Vec<pf_client_core::gamepad::PadInfo> = Vec::new();
// Paired+online → connect intent.
let mut s = HomeScreen::new();
let mut fx = Outbox::default();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "test",
t: 0.0,
};
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(fx.connect.is_some());
// Unpaired → the pair screen.
let mut fx = Outbox::default();
s.cursor = 1;
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(matches!(fx.nav, Some(crate::screens::Nav::Push(_))));
assert!(fx.connect.is_none());
// Asleep + MAC on file → wake-then-connect.
let mut fx = Outbox::default();
s.cursor = 2;
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(matches!(
fx.cmds.first(),
Some(ConsoleCmd::Wake {
then_connect: true,
..
})
));
}
#[test]
fn add_tile_is_always_last() {
let mut settings = ctx_settings();
let pads: Vec<pf_client_core::gamepad::PadInfo> = Vec::new();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "test",
t: 0.0,
};
let mut s = HomeScreen::new();
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(
matches!(fx.nav, Some(crate::screens::Nav::Push(b)) if matches!(*b, Screen::AddHost(_)))
);
}
}
+407
View File
@@ -0,0 +1,407 @@
//! The game-library screen: the coverflow carousel (spring-chased cursor, perspective
//! card tilt, poster art streaming in) — the original console library, now one screen
//! on the shell's stack. B pops back to the host list; A launches the focused title in
//! the same window. The shell owns the aurora, chrome, and the connecting overlay.
use crate::anim::Spring;
use crate::glyphs::{Hint, HintKey};
use crate::library::{
card_matrix, initials, step_cursor, store_label, LibraryGame, LibraryPhase, LibraryShared,
StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, JUMP, PERSPECTIVE, POSTER_H, POSTER_W,
RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K, VISIBLE_RANGE,
};
use crate::model::{ConsoleCmd, HostRow};
use crate::screens::{ConnectIntent, Ctx, Outbox};
use crate::theme::{white, Fonts, DIM, W, WHITE};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Color4f, Data, Image, Paint, Point, RRect, Rect, M44};
use std::collections::HashMap;
pub(crate) struct LibraryScreen {
host_name: String,
addr: String,
port: u16,
fp_hex: String,
mgmt: u16,
shared: Option<LibraryShared>,
// Synced snapshot of the shared model (re-pulled when the generation bumps).
generation: u64,
phase: LibraryPhase,
games: Vec<LibraryGame>,
// Navigation: the integer cursor is the authority; the eased position chases it.
cursor: i32,
anim: Spring,
bump: Spring,
/// Decoded posters by game id (decode once; Skia uploads lazily on first draw).
art: HashMap<String, Image>,
}
impl LibraryScreen {
pub(crate) fn new(host: &HostRow) -> LibraryScreen {
LibraryScreen {
host_name: host.name.clone(),
addr: host.addr.clone(),
port: host.port,
fp_hex: host.fp_hex.clone(),
mgmt: host.mgmt_port,
shared: None, // adopted from Ctx on the first render (the shell owns it)
generation: u64::MAX,
phase: LibraryPhase::Loading,
games: Vec::new(),
cursor: 0,
anim: Spring::rest(0.0),
bump: Spring::rest(0.0),
art: HashMap::new(),
}
}
pub(crate) fn host_name(&self) -> &str {
&self.host_name
}
fn fetch_cmd(&self) -> ConsoleCmd {
ConsoleCmd::FetchLibrary {
addr: self.addr.clone(),
mgmt: self.mgmt,
fp_hex: self.fp_hex.clone(),
}
}
/// Pull the shared model when it changed; decode newly arrived poster bytes.
fn sync(&mut self, library: &LibraryShared) {
if self.shared.is_none() {
self.shared = Some(library.clone());
}
let Some(shared) = &self.shared else { return };
if shared.generation() != self.generation {
let (phase, games, generation) = shared.snapshot();
let fresh = self.games.len() != games.len()
|| self.games.iter().zip(&games).any(|(a, b)| a.id != b.id);
self.phase = phase;
self.games = games;
self.generation = generation;
if fresh {
self.cursor = 0;
self.anim = Spring::rest(0.0);
self.bump = Spring::rest(0.0);
self.art.clear();
}
self.cursor = self.cursor.clamp(0, (self.games.len() as i32 - 1).max(0));
}
for (id, bytes) in shared.drain_art() {
match Image::from_encoded(Data::new_copy(&bytes)) {
Some(img) => {
self.art.insert(id, img);
}
None => tracing::debug!(%id, "undecodable poster"),
}
}
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
self.sync(ctx.library);
match &self.phase {
LibraryPhase::Ready => match ev {
MenuEvent::Move(MenuDir::Left) => self.step(-1, false),
MenuEvent::Move(MenuDir::Right) => self.step(1, false),
MenuEvent::JumpBack => self.step(-JUMP, true),
MenuEvent::JumpForward => self.step(JUMP, true),
MenuEvent::Confirm => {
let g = self.games.get(self.cursor as usize)?;
fx.connect = Some(ConnectIntent {
addr: self.addr.clone(),
port: self.port,
fp_hex: self.fp_hex.clone(),
launch: Some(g.id.clone()),
title: g.title.clone(),
});
Some(MenuPulse::Confirm)
}
MenuEvent::Back => {
fx.pop();
None
}
MenuEvent::Move(_) | MenuEvent::Secondary | MenuEvent::Tertiary => None,
},
LibraryPhase::Error { can_retry, .. } => match ev {
MenuEvent::Confirm if *can_retry => {
self.phase = LibraryPhase::Loading; // local; the fetch re-syncs it
fx.cmds.push(self.fetch_cmd());
Some(MenuPulse::Confirm)
}
MenuEvent::Back => {
fx.pop();
None
}
_ => None,
},
LibraryPhase::Loading | LibraryPhase::Empty => {
if ev == MenuEvent::Back {
fx.pop();
}
None
}
}
}
fn step(&mut self, delta: i32, clamp: bool) -> Option<MenuPulse> {
match step_cursor(self.cursor, self.games.len(), delta, clamp) {
StepResult::Moved(to) => {
self.cursor = to;
Some(MenuPulse::Move)
}
StepResult::Boundary => {
self.bump = Spring {
pos: -BUMP_PX * f64::from(delta.signum()),
vel: 0.0,
};
Some(MenuPulse::Boundary)
}
}
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
match &self.phase {
LibraryPhase::Ready => vec![
Hint::new(HintKey::Confirm, "Play"),
Hint::new(HintKey::Shoulders, "Jump"),
Hint::new(HintKey::Back, "Back"),
],
LibraryPhase::Error {
can_retry: true, ..
} => vec![
Hint::new(HintKey::Confirm, "Retry"),
Hint::new(HintKey::Back, "Back"),
],
_ => vec![Hint::new(HintKey::Back, "Back")],
}
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
self.sync(ctx.library);
let (w, cy_all) = (
f64::from(rect.width()),
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
);
let cx = f64::from(rect.left) + w / 2.0;
match self.phase.clone() {
LibraryPhase::Ready => {
self.anim
.step(f64::from(self.cursor), SPRING_K, SPRING_C, dt);
self.anim.settle(f64::from(self.cursor), 0.001, 0.01);
self.bump.step(0.0, BUMP_K, BUMP_C, dt);
self.bump.settle(0.0, 0.3, 4.0);
self.draw_carousel(canvas, rect, k, fonts);
}
LibraryPhase::Loading => {
crate::theme::spinner(canvas, cx, cy_all - 24.0 * k, 16.0 * k, ctx.t);
fonts.centered(
canvas,
"Loading library…",
W::Regular,
14.0 * k,
DIM,
cx,
cy_all + 16.0 * k,
w * 0.8,
);
}
LibraryPhase::Empty => {
fonts.centered(
canvas,
"No games found",
W::Bold,
22.0 * k,
WHITE,
cx,
cy_all - 20.0 * k,
w * 0.8,
);
fonts.centered(
canvas,
"Install Steam titles or add custom entries in the host's web console.",
W::Regular,
14.0 * k,
DIM,
cx,
cy_all + 12.0 * k,
w * 0.8,
);
}
LibraryPhase::Error { title, body, .. } => {
fonts.centered(
canvas,
&title,
W::Bold,
22.0 * k,
WHITE,
cx,
cy_all - 32.0 * k,
w * 0.8,
);
fonts.centered(
canvas,
&body,
W::Regular,
14.0 * k,
DIM,
cx,
cy_all + 4.0 * k,
(600.0 * k).min(w * 0.85),
);
}
}
}
fn draw_carousel(&mut self, canvas: &Canvas, rect: Rect, k: f64, fonts: &Fonts) {
let (card_w, card_h) = (POSTER_W * k, POSTER_H * k);
let w = f64::from(rect.width());
// The strip rides slightly above center; the detail block gets the band below.
let cy = f64::from(rect.top) + f64::from(rect.height()) * 0.44;
let pos = self.anim.pos;
let bump = self.bump.pos * k;
// Paint order = draw order: farthest from the (integer) cursor first, so the
// dense side stacks overlap toward the focus.
let mut order: Vec<usize> = (0..self.games.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - self.cursor).abs()));
for i in order {
let d = i as f64 - pos;
let a = d.abs();
if a > VISIBLE_RANGE {
continue;
}
let prox = a.min(1.0);
let scale = 1.0 - prox * RECEDE_SCALE;
let angle = -d.clamp(-1.0, 1.0) * ROTATE_DEG;
let offset = if a <= 1.0 {
d * FOCUS_GAP * k
} else {
d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) * k
};
let ccx = f64::from(rect.left) + w / 2.0 + offset + bump;
let m = card_matrix(ccx, cy, angle, scale, card_w, card_h, PERSPECTIVE * k);
let game = &self.games[i];
canvas.save();
canvas.concat_44(&M44::row_major(&m));
let crect = Rect::from_wh(card_w as f32, card_h as f32);
let rr = RRect::new_rect_xy(crect, 16.0 * k as f32, 16.0 * k as f32);
canvas.clip_rrect(rr, None, true);
match self.art.get(&game.id) {
Some(img) => {
// Cover-fit: center-crop the source to the card's 2:3.
let (iw, ih) = (img.width() as f32, img.height() as f32);
let card_aspect = crect.width() / crect.height();
let src = if iw / ih > card_aspect {
let sw = ih * card_aspect;
Rect::from_xywh((iw - sw) / 2.0, 0.0, sw, ih)
} else {
let sh = iw / card_aspect;
Rect::from_xywh(0.0, (ih - sh) / 2.0, iw, sh)
};
canvas.draw_image_rect(
img,
Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)),
crect,
&Paint::default(),
);
}
None => {
// Solid face, not glass: the side cards OVERLAP.
canvas.draw_rect(
crect,
&Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None),
);
let mono = initials(&game.title);
let font = fonts.font(W::Bold, 38.0 * k);
let tw = font.measure_str(&mono, None).0;
canvas.draw_str(
&mono,
Point::new(
(card_w as f32 - tw) / 2.0,
card_h as f32 / 2.0 + 13.0 * k as f32,
),
&font,
&Paint::new(white(0.45), None),
);
}
}
// Store badge, top-left.
{
let label = store_label(&game.store);
let size = 11.0 * k;
let tw = fonts.measure(label, W::SemiBold, size) as f64;
let (px, py) = (8.0 * k, 8.0 * k);
let (bw, bh) = (tw + 16.0 * k, 20.0 * k);
canvas.draw_rrect(
RRect::new_rect_xy(
Rect::from_xywh(px as f32, py as f32, bw as f32, bh as f32),
(bh / 2.0) as f32,
(bh / 2.0) as f32,
),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
);
fonts.draw(
canvas,
label,
px + 8.0 * k,
py + 14.0 * k,
W::SemiBold,
size,
WHITE,
);
}
// The brightness recede: an opaque-black veil, never whole-card alpha.
if prox > 0.0 {
canvas.draw_rect(
crect,
&Paint::new(
Color4f::new(0.0, 0.0, 0.0, (prox * RECEDE_DIM) as f32),
None,
),
);
}
canvas.restore();
}
// Detail block: focused title + store, in the band under the strip.
if let Some(g) = self.games.get(self.cursor as usize) {
let cx = f64::from(rect.left) + w / 2.0;
fonts.centered(
canvas,
&g.title,
W::Bold,
27.0 * k,
WHITE,
cx,
f64::from(rect.bottom) - 64.0 * k,
w * 0.8,
);
fonts.centered(
canvas,
&store_label(&g.store).to_uppercase(),
W::Regular,
12.0 * k,
white(0.5),
cx,
f64::from(rect.bottom) - 30.0 * k,
w * 0.5,
);
}
}
}
+407
View File
@@ -0,0 +1,407 @@
//! The in-console PIN pairing screen — the couch counterpart of the desktop PairSheet,
//! and the piece that makes a Steam Deck self-sufficient (pairing used to need the
//! Decky plugin or a desktop). The host shows a PIN in its web console; the user types
//! it here (on-screen keyboard, or Steam's keyboard on Deck), the SPAKE2 ceremony runs
//! on the binary's service thread, and success pins the host and pops back to Home.
use crate::glyphs::{Hint, HintKey};
use crate::model::{ConsoleCmd, HostRow, PairPhase};
use crate::screens::{Ctx, Outbox};
use crate::theme::{Fonts, DIM, ERROR, W};
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Field {
Pin,
Device,
}
pub(crate) struct PairScreen {
host_name: String,
addr: String,
port: u16,
list: MenuList,
keyboard: Keyboard,
pin: String,
device: String,
editing: Option<Field>,
/// The ceremony is in flight (mirrors the model's `PairPhase::Busy` immediately so
/// a second A can't double-submit before the service thread picks the command up).
busy: bool,
error: Option<String>,
}
impl PairScreen {
pub(crate) fn new(host: &HostRow, device_name: &str) -> PairScreen {
PairScreen {
host_name: host.name.clone(),
addr: host.addr.clone(),
port: host.port,
list: MenuList::new(),
keyboard: Keyboard::new(),
pin: String::new(),
device: device_name.to_string(),
editing: None,
busy: false,
error: None,
}
}
pub(crate) fn host_name(&self) -> &str {
&self.host_name
}
/// The shell routes the model's pairing phase here (success pops shell-side).
pub(crate) fn apply_phase(&mut self, phase: &PairPhase) {
match phase {
PairPhase::Busy => self.busy = true,
PairPhase::Failed(msg) => {
self.busy = false;
self.error = Some(msg.clone());
}
PairPhase::Idle | PairPhase::Paired { .. } => self.busy = false,
}
}
pub(crate) fn editing(&self) -> bool {
self.editing.is_some()
}
fn can_pair(&self) -> bool {
!self.pin.trim().is_empty() && !self.busy
}
fn field_mut(&mut self, f: Field) -> &mut String {
match f {
Field::Pin => &mut self.pin,
Field::Device => &mut self.device,
}
}
fn charset(f: Field) -> Charset {
match f {
Field::Pin => Charset::Digits,
Field::Device => Charset::Free,
}
}
fn type_char(&mut self, ch: char) -> bool {
let Some(f) = self.editing else { return false };
if !permits(Self::charset(f), ch) {
return false;
}
if f == Field::Pin && self.pin.chars().count() >= 8 {
return false; // PINs are 4 digits today; leave headroom, refuse novels
}
self.field_mut(f).push(ch);
true
}
pub(crate) fn text_input(&mut self, text: &str) {
for ch in text.chars() {
self.type_char(ch);
}
}
pub(crate) fn edit_key(&mut self, sc: sdl3::keyboard::Scancode) -> bool {
use sdl3::keyboard::Scancode as S;
if self.editing.is_none() {
return false;
}
match sc {
S::Backspace => {
let f = self.editing.unwrap();
self.field_mut(f).pop();
true
}
S::Return | S::KpEnter | S::Escape => {
self.editing = None;
true
}
_ => false,
}
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
if self.editing.is_some() {
if ctx.deck {
return match ev {
MenuEvent::Back | MenuEvent::Confirm => {
self.editing = None;
Some(MenuPulse::Confirm)
}
_ => None,
};
}
let (msg, pulse) = self.keyboard.menu(ev);
return match msg {
KeyMsg::Type(c) => {
if self.type_char(c) {
Some(MenuPulse::Move)
} else {
Some(MenuPulse::Boundary)
}
}
KeyMsg::Backspace => {
let f = self.editing.unwrap();
if self.field_mut(f).pop().is_some() {
Some(MenuPulse::Move)
} else {
Some(MenuPulse::Boundary)
}
}
KeyMsg::Done => {
self.editing = None;
Some(MenuPulse::Confirm)
}
KeyMsg::None => pulse,
};
}
if ev == MenuEvent::Back {
// Leaving mid-ceremony is fine — success still pins and toasts globally.
fx.pop();
return None;
}
let (msg, pulse) = self.list.menu(ev, 3);
match msg {
ListMsg::Activate => {
match self.list.cursor {
0 => self.editing = Some(Field::Pin),
1 => self.editing = Some(Field::Device),
_ if self.can_pair() => {
self.busy = true;
self.error = None;
fx.cmds.push(ConsoleCmd::Pair {
addr: self.addr.clone(),
port: self.port,
pin: self.pin.trim().to_string(),
device_name: if self.device.trim().is_empty() {
ctx.device_name.to_string()
} else {
self.device.trim().to_string()
},
});
}
_ => {
// No PIN yet — jump into the PIN field instead of a dead press.
self.list.cursor = 0;
self.editing = Some(Field::Pin);
}
}
pulse
}
_ => pulse,
}
}
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
if self.editing.is_some() {
if ctx.deck {
return vec![
Hint::new(HintKey::Key("STEAM + X"), "Keyboard"),
Hint::new(HintKey::Confirm, "Done"),
Hint::new(HintKey::Back, "Done"),
];
}
return vec![
Hint::new(HintKey::Confirm, "Type"),
Hint::new(HintKey::Tertiary, "Delete"),
Hint::new(HintKey::Back, "Done"),
];
}
vec![
Hint::new(HintKey::Confirm, "Select"),
Hint::new(HintKey::Back, "Cancel"),
]
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
fonts.centered(
canvas,
"Enter the PIN from the host's web console (Pairing page) or its log.",
W::Regular,
13.0 * k,
DIM,
cx,
f64::from(rect.top) + 2.0 * k,
f64::from(rect.width()) * 0.72,
);
let seat = self.keyboard.seat(self.editing.is_some() && !ctx.deck, dt);
let tray_h = if seat > 0.0 {
(Keyboard::tray_height() + 12.0) * k * seat
} else {
0.0
};
// A status band under the rows: the ceremony spinner or the error line.
let status_h = 34.0 * k;
let list_rect = Rect::from_ltrb(
rect.left,
rect.top + (34.0 * k) as f32,
rect.right,
rect.bottom - tray_h as f32 - status_h as f32,
);
let rows = self.rows();
self.list.render(
canvas,
list_rect,
&rows,
fonts,
k,
dt,
self.editing.is_none() && !self.busy,
);
let status_y = f64::from(rect.bottom) - tray_h - status_h + 6.0 * k;
if self.busy {
crate::theme::spinner(canvas, cx - 70.0 * k, status_y + 8.0 * k, 7.0 * k, ctx.t);
fonts.centered(
canvas,
"Pairing… confirm the PIN on the host",
W::Regular,
13.0 * k,
DIM,
cx + 10.0 * k,
status_y,
f64::from(rect.width()) * 0.6,
);
} else if let Some(err) = &self.error {
fonts.centered(
canvas,
err,
W::Regular,
13.0 * k,
ERROR,
cx,
status_y,
f64::from(rect.width()) * 0.8,
);
}
if seat > 0.0 {
self.keyboard.render(
canvas,
fonts,
f64::from(rect.width()),
f64::from(rect.bottom),
seat,
k,
);
}
}
fn rows(&self) -> Vec<RowSpec> {
// The PIN renders spaced out (● would hide typos; a PIN is not a secret worth
// masking — the host shows it on screen anyway).
let pin_display: String = self
.pin
.chars()
.flat_map(|c| [c, ' '])
.collect::<String>()
.trim_end()
.to_string();
let mut pin = RowSpec::field("PIN", pin_display, "From the host");
pin.caret = self.editing == Some(Field::Pin);
let mut device = RowSpec::field(
"Device name",
self.device.clone(),
"How the host lists this device",
);
device.caret = self.editing == Some(Field::Device);
vec![
pin,
device,
RowSpec::action(if self.busy { "Pairing…" } else { "Pair" }, self.can_pair()),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use pf_client_core::trust::Settings;
fn host() -> HostRow {
HostRow {
key: "10.0.0.7:9777".into(),
name: "Tower".into(),
addr: "10.0.0.7".into(),
port: 9777,
fp_hex: String::new(),
paired: false,
saved: true,
online: true,
mgmt_port: 47990,
can_wake: false,
last_used: None,
}
}
#[test]
fn pair_submits_with_pin_and_device_fallback() {
let mut settings = Settings::default();
let pads = Vec::new();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "living-room-deck",
t: 0.0,
};
let mut s = PairScreen::new(&host(), "living-room-deck");
s.device.clear(); // the user deleted the prefill
s.editing = Some(Field::Pin);
s.text_input("1234");
s.edit_key(sdl3::keyboard::Scancode::Return);
s.list.cursor = 2;
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(matches!(
fx.cmds.first(),
Some(ConsoleCmd::Pair { pin, device_name, .. })
if pin == "1234" && device_name == "living-room-deck"
));
assert!(s.busy);
// A second A while busy is a no-op (the action row is disabled).
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(fx.cmds.is_empty());
}
#[test]
fn pin_is_digits_only() {
let mut s = PairScreen::new(&host(), "d");
s.editing = Some(Field::Pin);
s.text_input("12ab34");
assert_eq!(s.pin, "1234");
}
#[test]
fn failure_lands_in_the_error_line() {
let mut s = PairScreen::new(&host(), "d");
s.busy = true;
s.apply_phase(&PairPhase::Failed("Wrong PIN".into()));
assert!(!s.busy);
assert_eq!(s.error.as_deref(), Some("Wrong PIN"));
}
}
@@ -0,0 +1,445 @@
//! The console settings screen — the couch-relevant subset of the Settings store,
//! restyled as glass rows and fully controller-navigable (the Swift
//! `GamepadSettingsView`, re-homed): up/down moves focus, left/right steps the focused
//! value (clamped — the boundary thud tells the thumb it's the last option), A cycles
//! forward wrapping, B closes. Every change persists immediately; the desktop shells
//! read the same file, so values round-trip freely.
use crate::glyphs::{Hint, HintKey};
use crate::screens::{Ctx, Outbox};
use crate::theme::{Fonts, DIM, W};
use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
/// index when the pad list under the "Use controller" row churns.
#[derive(Clone, Copy, PartialEq, Eq)]
enum RowId {
Resolution,
Refresh,
Bitrate,
Compositor,
Codec,
Decoder,
Hdr,
Audio,
Mic,
Pad,
PadType,
Stats,
}
const ROWS: [RowId; 12] = [
RowId::Resolution,
RowId::Refresh,
RowId::Bitrate,
RowId::Compositor,
RowId::Codec,
RowId::Decoder,
RowId::Hdr,
RowId::Audio,
RowId::Mic,
RowId::Pad,
RowId::PadType,
RowId::Stats,
];
const RESOLUTIONS: [(u32, u32); 6] = [
(0, 0), // native
(1280, 720),
(1280, 800), // the Deck's panel
(1920, 1080),
(2560, 1440),
(3840, 2160),
];
const REFRESH: [u32; 5] = [0, 30, 60, 90, 120];
const BITRATES: [u32; 7] = [0, 5_000, 10_000, 20_000, 30_000, 50_000, 80_000];
const COMPOSITORS: [(&str, &str); 5] = [
("auto", "Automatic"),
("kwin", "KWin"),
("wlroots", "wlroots"),
("mutter", "Mutter"),
("gamescope", "gamescope"),
];
const CODECS: [(&str, &str); 4] = [
("auto", "Automatic"),
("hevc", "HEVC"),
("h264", "H.264"),
("av1", "AV1"),
];
const DECODERS: [(&str, &str); 4] = [
("auto", "Automatic"),
("vulkan", "Vulkan Video"),
("vaapi", "VAAPI"),
("software", "Software"),
];
const AUDIO: [(u8, &str); 3] = [(2, "Stereo"), (6, "5.1"), (8, "7.1")];
const PAD_TYPES: [(&str, &str); 6] = [
("auto", "Automatic"),
("xbox360", "Xbox 360"),
("xboxone", "Xbox One"),
("dualsense", "DualSense"),
("dualshock4", "DualShock 4"),
("steamdeck", "Steam Deck"),
];
pub(crate) struct SettingsScreen {
list: MenuList,
}
impl SettingsScreen {
pub(crate) fn new() -> SettingsScreen {
SettingsScreen {
list: MenuList::new(),
}
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
if ev == MenuEvent::Back {
fx.pop();
return None;
}
let (msg, pulse) = self.list.menu(ev, ROWS.len());
match msg {
ListMsg::Adjust(delta) => {
let changed = adjust(ROWS[self.list.cursor], delta, false, ctx);
if changed {
ctx.settings.save();
Some(MenuPulse::Move)
} else {
Some(MenuPulse::Boundary)
}
}
ListMsg::Activate => {
// A cycles forward WRAPPING, so every option is reachable one-handed.
if adjust(ROWS[self.list.cursor], 1, true, ctx) {
ctx.settings.save();
}
pulse
}
ListMsg::None => pulse,
}
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
vec![
Hint::new(HintKey::Adjust, "Adjust"),
Hint::new(HintKey::Confirm, "Change"),
Hint::new(HintKey::Back, "Done"),
]
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
// The focused row's explainer sits in a reserved band under the list.
let detail_h = 34.0 * k;
let list_rect = Rect::from_ltrb(
rect.left,
rect.top,
rect.right,
rect.bottom - detail_h as f32,
);
let rows: Vec<RowSpec> = ROWS.iter().map(|id| row_spec(*id, ctx)).collect();
self.list
.render(canvas, list_rect, &rows, fonts, k, dt, true);
let detail = detail(ROWS[self.list.cursor]);
fonts.centered(
canvas,
detail,
W::Regular,
13.0 * k,
DIM,
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
f64::from(rect.bottom) - detail_h + 6.0 * k,
f64::from(rect.width()) * 0.8,
);
}
}
fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
let s = &ctx.settings;
let (header, label, value): (Option<&'static str>, &str, String) = match id {
RowId::Resolution => (
Some("Stream"),
"Resolution",
if s.width == 0 {
"Native".into()
} else {
format!("{} × {}", s.width, s.height)
},
),
RowId::Refresh => (
None,
"Refresh rate",
if s.refresh_hz == 0 {
"Native".into()
} else {
format!("{} Hz", s.refresh_hz)
},
),
RowId::Bitrate => (
None,
"Bitrate",
if s.bitrate_kbps == 0 {
"Automatic".into()
} else {
format!("{} Mbps", s.bitrate_kbps / 1000)
},
),
RowId::Compositor => (
None,
"Compositor",
label_for(&COMPOSITORS, &s.compositor).into(),
),
RowId::Codec => (
Some("Video"),
"Video codec",
label_for(&CODECS, &s.codec).into(),
),
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
RowId::Audio => (
Some("Audio"),
"Audio channels",
AUDIO
.iter()
.find(|(v, _)| *v == s.audio_channels)
.map_or("Stereo", |(_, l)| l)
.into(),
),
RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()),
RowId::Pad => (
Some("Controller"),
"Use controller",
if s.forward_pad.is_empty() {
"Automatic".into()
} else {
ctx.pads
.iter()
.find(|p| p.key == s.forward_pad)
.map_or_else(|| "Saved (disconnected)".to_string(), |p| p.name.clone())
},
),
RowId::PadType => (
None,
"Controller type",
label_for(&PAD_TYPES, &s.gamepad).into(),
),
RowId::Stats => (
Some("Interface"),
"Statistics overlay",
on_off(s.show_stats).into(),
),
};
RowSpec {
header,
label: label.into(),
value: Some(value),
value_dim: false,
caret: false,
adjustable: true,
enabled: true,
}
}
fn detail(id: RowId) -> &'static str {
match id {
RowId::Resolution => {
"The host creates a virtual display at exactly this size — no scaling."
}
RowId::Refresh => "Native follows the display this window is on.",
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
RowId::Compositor => {
"Which compositor drives the virtual output — honored only if available on the host."
}
RowId::Codec => "A preference — the host falls back if it can't encode this one.",
RowId::Decoder => "Automatic prefers Vulkan Video, then VAAPI, then software.",
RowId::Hdr => {
"HDR10 — engages when the host sends HDR content and this display supports it."
}
RowId::Audio => "The speaker layout requested from the host.",
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
RowId::Stats => "Resolution, frame rate, throughput and latency while streaming.",
}
}
fn on_off(v: bool) -> &'static str {
if v {
"On"
} else {
"Off"
}
}
fn label_for<'a>(options: &'a [(&str, &'a str)], value: &str) -> &'a str {
options
.iter()
.find(|(v, _)| *v == value)
.map_or("", |(_, l)| l)
}
/// Step (`wrap=false`, clamped — false = boundary) or cycle (`wrap=true`) a row's
/// value. Toggles read left = off, right = on; a no-op is a boundary.
fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
let s = &mut *ctx.settings;
match id {
RowId::Resolution => {
let cur = RESOLUTIONS
.iter()
.position(|(w, h)| (*w, *h) == (s.width, s.height));
step_option(cur, RESOLUTIONS.len(), delta, wrap).map(|i| {
(s.width, s.height) = RESOLUTIONS[i];
})
}
RowId::Refresh => {
let cur = REFRESH.iter().position(|r| *r == s.refresh_hz);
step_option(cur, REFRESH.len(), delta, wrap).map(|i| s.refresh_hz = REFRESH[i])
}
RowId::Bitrate => {
let cur = BITRATES.iter().position(|b| *b == s.bitrate_kbps);
step_option(cur, BITRATES.len(), delta, wrap).map(|i| s.bitrate_kbps = BITRATES[i])
}
RowId::Compositor => step_str(&COMPOSITORS, &mut s.compositor, delta, wrap),
RowId::Codec => step_str(&CODECS, &mut s.codec, delta, wrap),
RowId::Decoder => step_str(&DECODERS, &mut s.decoder, delta, wrap),
RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap),
RowId::Audio => {
let cur = AUDIO.iter().position(|(v, _)| *v == s.audio_channels);
step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0)
}
RowId::Mic => toggle(&mut s.mic_enabled, delta, wrap),
RowId::Pad => {
// Automatic first, then every connected pad by stable key.
let keys: Vec<String> = std::iter::once(String::new())
.chain(ctx.pads.iter().map(|p| p.key.clone()))
.collect();
let cur = keys.iter().position(|c| *c == s.forward_pad);
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
}
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
RowId::Stats => toggle(&mut s.show_stats, delta, wrap),
}
.is_some()
}
/// The shared stepping rule: clamp when adjusting, wrap when cycling; an unknown
/// current value snaps to the first option on any step.
fn step_option(current: Option<usize>, len: usize, delta: i32, wrap: bool) -> Option<usize> {
if len == 0 {
return None;
}
let Some(cur) = current else { return Some(0) };
let target = cur as i32 + delta;
if wrap {
Some(target.rem_euclid(len as i32) as usize)
} else if target < 0 || target >= len as i32 {
None
} else {
Some(target as usize)
}
}
fn step_str(options: &[(&str, &str)], value: &mut String, delta: i32, wrap: bool) -> Option<()> {
let cur = options.iter().position(|(v, _)| v == value);
step_option(cur, options.len(), delta, wrap).map(|i| *value = options[i].0.to_string())
}
fn toggle(value: &mut bool, delta: i32, wrap: bool) -> Option<()> {
let target = if wrap { !*value } else { delta > 0 };
if *value == target {
None
} else {
*value = target;
Some(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use pf_client_core::trust::Settings;
fn ctx_parts() -> (Settings, Vec<pf_client_core::gamepad::PadInfo>) {
(Settings::default(), Vec::new())
}
#[test]
fn adjust_clamps_and_activate_wraps() {
let (mut settings, pads) = ctx_parts();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
// Resolution starts at Native (index 0): left refuses, right steps.
assert!(!adjust(RowId::Resolution, -1, false, &mut ctx));
assert!(adjust(RowId::Resolution, 1, false, &mut ctx));
assert_eq!((ctx.settings.width, ctx.settings.height), (1280, 720));
// Cycle from the last option wraps to the first.
(ctx.settings.width, ctx.settings.height) = (3840, 2160);
assert!(adjust(RowId::Resolution, 1, true, &mut ctx));
assert_eq!(ctx.settings.width, 0, "wrapped to Native");
}
#[test]
fn toggles_read_left_off_right_on() {
let (mut settings, pads) = ctx_parts();
settings.mic_enabled = false;
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
assert!(
!adjust(RowId::Mic, -1, false, &mut ctx),
"already off = thud"
);
assert!(adjust(RowId::Mic, 1, false, &mut ctx));
assert!(ctx.settings.mic_enabled);
assert!(adjust(RowId::Mic, 1, true, &mut ctx), "A always flips");
assert!(!ctx.settings.mic_enabled);
}
#[test]
fn unknown_value_snaps_to_first() {
let (mut settings, pads) = ctx_parts();
settings.bitrate_kbps = 12_345; // set via a desktop shell's free-form field
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic");
}
}
File diff suppressed because it is too large Load Diff
+168 -56
View File
@@ -2,9 +2,15 @@
//! two offscreen render-target surfaces (the presenter runs one frame in flight and may
//! still be sampling the previous image while we render the next), damage-driven
//! redraws (content/size change only — an unchanged OSD costs zero GPU work per frame).
//!
//! Two personas over one `Overlay`: the CONSOLE (the full shell — home, library,
//! settings, pairing — always dirty, the aurora animates) and the stream chrome (stats
//! OSD, capture hint, the auto-fading start banner).
use crate::library::LibraryShared;
use crate::library_ui::{build_fonts, Fonts, LibraryUi};
use crate::model::{ConsoleBus, ConsoleShared, HostRow};
use crate::screens::Screen;
use crate::shell::{ConsoleOptions, Shell};
use crate::theme::{match_first_family, Fonts};
use anyhow::{anyhow, Context as _, Result};
use ash::vk as avk;
use ash::vk::Handle as _;
@@ -15,11 +21,16 @@ use pf_presenter::overlay::{
use skia_safe::gpu::vk as skvk;
use skia_safe::gpu::{self, DirectContext, SurfaceOrigin};
use skia_safe::{Canvas, Color4f, Font, FontMgr, Paint, Point, RRect, Rect, Surface};
use std::time::Instant;
/// Skia's GPU resource budget — the OSD/HUD need a few MB; the console library will
/// revisit (the plan budgets 64 MB on Deck-class shared memory).
/// Skia's GPU resource budget — poster art plus a few screen layers; 64 MB fits
/// Deck-class shared memory.
const RESOURCE_CACHE_BYTES: usize = 64 << 20;
/// How long the start-of-stream banner lingers (fading through the tail).
const BANNER_S: f64 = 6.0;
const BANNER_FADE_S: f64 = 0.6;
/// One offscreen target: the Skia surface + the raw Vulkan handles the presenter
/// samples. The image is Skia-owned (freed with the surface); the view is ours.
struct Slot {
@@ -37,6 +48,24 @@ struct Drawn {
height: u32,
stats: Option<String>,
hint: Option<String>,
/// The start banner's alpha, quantized — a fade step is a redraw, steady is not.
banner_step: u8,
}
/// Where the console starts (the session binary's `--browse` forms).
pub enum ConsoleEntry {
/// The host list (bare `--browse`).
Home,
/// Home with this host's library already pushed (`--browse host` — the Decky
/// per-host launch; B backs out to Home).
Library(HostRow),
}
/// The binary's ends of the console: models to write, commands to serve.
pub struct ConsoleHandles {
pub console: ConsoleShared,
pub library: crate::library::LibraryShared,
pub bus: ConsoleBus,
}
pub struct SkiaOverlay {
@@ -47,10 +76,15 @@ pub struct SkiaOverlay {
/// Which slot the LAST returned frame lives in — the next render takes the other.
current: usize,
drawn: Drawn,
/// The stats OSD's monospace face (system-resolved; the console uses Geist).
font: Option<Font>,
/// The console library (`--browse`) — `None` for a plain `--connect` session.
library: Option<LibraryUi>,
fonts: Option<Fonts>,
/// The console shell (`--browse`) — `None` for a plain `--connect` session.
shell: Option<Shell>,
/// When the current stream started presenting — drives the start banner.
streaming_since: Option<Instant>,
/// The banner's words (set per stream from the active-pad state).
banner_text: Option<String>,
}
struct Gpu {
@@ -72,22 +106,44 @@ impl SkiaOverlay {
current: 0,
drawn: Drawn::default(),
font: None,
library: None,
fonts: None,
shell: None,
streaming_since: None,
banner_text: None,
}
}
/// The `--browse` overlay: the console library between streams, stream chrome
/// during them. Returns the shared model the binary's fetch threads write into.
pub fn with_library(host_label: String) -> Result<(SkiaOverlay, LibraryShared)> {
let shared = LibraryShared::default();
/// The `--browse` overlay: the full console shell between streams, stream chrome
/// during them. Returns the binary's handles (models + command bus).
pub fn console(
opts: ConsoleOptions,
entry: ConsoleEntry,
) -> Result<(SkiaOverlay, ConsoleHandles)> {
let console = ConsoleShared::default();
let library = crate::library::LibraryShared::default();
let bus = ConsoleBus::default();
let stack = match entry {
ConsoleEntry::Home => vec![Screen::Home(crate::screens::home::HomeScreen::new())],
ConsoleEntry::Library(host) => vec![
Screen::Home(crate::screens::home::HomeScreen::new()),
Screen::Library(crate::screens::library::LibraryScreen::new(&host)),
],
};
let shell = Shell::new(console.clone(), library.clone(), bus.clone(), opts, stack)?;
let mut o = SkiaOverlay::new();
o.library = Some(LibraryUi::new(shared.clone(), host_label)?);
Ok((o, shared))
o.shell = Some(shell);
Ok((
o,
ConsoleHandles {
console,
library,
bus,
},
))
}
fn library_visible(&self) -> bool {
self.library.as_ref().is_some_and(|l| !l.in_stream)
fn console_visible(&self) -> bool {
self.shell.as_ref().is_some_and(|s| !s.in_stream)
}
}
@@ -146,14 +202,14 @@ impl Overlay for SkiaOverlay {
.ok_or_else(|| anyhow!("Skia DirectContext over the shared device"))?;
context.set_resource_cache_limit(RESOURCE_CACHE_BYTES);
let typeface = crate::library_ui::match_first_family(
let typeface = match_first_family(
&FontMgr::new(),
&["monospace", "Consolas", "Cascadia Mono", "Courier New"],
skia_safe::FontStyle::normal(),
)
.context("no monospace typeface (fontconfig alias or system family)")?;
self.font = Some(Font::new(typeface, 14.0));
self.fonts = Some(build_fonts()?);
self.fonts = Some(crate::theme::build_fonts()?);
self.gpu = Some(Gpu {
device: shared.device.clone(),
@@ -167,77 +223,93 @@ impl Overlay for SkiaOverlay {
}
fn handle_event(&mut self, event: &sdl3::event::Event) -> bool {
// The library's keyboard fallback (arrows/Enter/Esc) — only while it's on
// screen, and never for chord-modified keys (those stay the run loop's).
if self.library_visible() {
if let sdl3::event::Event::KeyDown {
// Keyboard + text into the console while it's on screen — never for
// chord-modified keys (those stay the run loop's), never during a stream.
if !self.console_visible() {
return false;
}
let Some(shell) = &mut self.shell else {
return false;
};
match event {
sdl3::event::Event::KeyDown {
scancode: Some(sc),
keymod,
repeat,
..
} = event
{
} => {
use sdl3::keyboard::Mod;
if !keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD | Mod::LALTMOD | Mod::RALTMOD) {
return self.library.as_mut().is_some_and(|l| l.key(*sc, *repeat));
if keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD | Mod::LALTMOD | Mod::RALTMOD) {
return false;
}
shell.key(*sc, *repeat)
}
sdl3::event::Event::TextInput { text, .. } => {
shell.text_input(text);
true
}
_ => false,
}
false
}
fn handle_menu(&mut self, event: MenuEvent) -> Option<MenuPulse> {
if self.library_visible() {
self.library.as_mut().and_then(|l| l.menu(event))
if self.console_visible() {
self.shell.as_mut().and_then(|s| s.handle_menu(event))
} else {
None
}
}
fn take_action(&mut self) -> Option<OverlayAction> {
self.library.as_mut().and_then(|l| l.take_action())
self.shell.as_mut().and_then(|s| s.take_action())
}
fn text_input_active(&self) -> bool {
self.console_visible() && self.shell.as_ref().is_some_and(Shell::editing)
}
fn session_phase(&mut self, phase: SessionPhase) {
let Some(lib) = &mut self.library else { return };
let Some(shell) = &mut self.shell else { return };
match phase {
SessionPhase::Connecting => lib.set_connecting(true),
SessionPhase::Connecting => {} // the shell raised the Launch; already showing
SessionPhase::Streaming => {
lib.in_stream = true;
lib.set_connecting(false);
shell.session_streaming();
self.streaming_since = Some(Instant::now());
}
SessionPhase::Failed(msg) => lib.session_error(msg),
SessionPhase::Ended(None) => {
lib.in_stream = false;
lib.set_connecting(false);
SessionPhase::Failed(msg) => shell.session_failed(msg),
SessionPhase::Ended(reason) => {
shell.session_ended(reason);
self.streaming_since = None;
}
SessionPhase::Ended(Some(reason)) => lib.session_error(reason),
}
}
fn frame(&mut self, ctx: &FrameCtx) -> Result<Option<OverlayFrame>> {
// The console library: full-screen, opaque, and always dirty (the aurora
// animates every frame — the GPU port's whole point).
if self.library_visible() {
// The console: full-screen, opaque, and always dirty (the aurora animates
// every frame — the GPU port's whole point).
if self.console_visible() {
let next = 1 - self.current;
self.ensure_slot(next, ctx.width, ctx.height)?;
let Self {
gpu,
slots,
library,
shell,
fonts,
..
} = self;
let gpu = gpu.as_mut().expect("init ran");
let slot = slots[next].as_mut().expect("just ensured");
let lib = library.as_mut().expect("library_visible");
let fonts = fonts.as_mut().expect("init ran");
fonts.chip_text = Some(ctx.pad.map_or(
"No controller — keyboard works too".to_string(),
str::to_owned,
));
lib.sync();
lib.render(slot.surface.canvas(), ctx.width, ctx.height, fonts);
let shell = shell.as_mut().expect("console_visible");
let fonts = fonts.as_ref().expect("init ran");
shell.render(
slot.surface.canvas(),
ctx.width,
ctx.height,
fonts,
ctx.pad,
ctx.pad_pref,
ctx.pads,
);
gpu.context.flush_surface_with_texture_state(
&mut slot.surface,
&gpu::FlushInfo::default(),
@@ -258,7 +330,10 @@ impl Overlay for SkiaOverlay {
}));
}
if ctx.stats.is_none() && ctx.hint.is_none() {
// --- Stream chrome: stats OSD + capture hint + the start banner ---------------
let banner_alpha = self.banner_alpha(ctx);
let banner_step = (banner_alpha * 32.0).round() as u8;
if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 {
self.drawn = Drawn::default(); // forget content so re-show re-renders
return Ok(None);
}
@@ -267,6 +342,7 @@ impl Overlay for SkiaOverlay {
height: ctx.height,
stats: ctx.stats.map(str::to_owned),
hint: ctx.hint.map(str::to_owned),
banner_step,
};
if want == self.drawn {
// Unchanged — hand the presenter the already-rendered image.
@@ -292,7 +368,20 @@ impl Overlay for SkiaOverlay {
draw_osd_panel(canvas, font, stats, 12.0, 12.0);
}
if let Some(hint) = &want.hint {
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height);
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0);
} else if banner_step > 0 {
// The start banner: the leave/stats shortcuts, fading out on its own —
// discoverable without the stats overlay, gone before it annoys.
if let Some(text) = &self.banner_text {
draw_hint_pill(
canvas,
font,
text,
ctx.width,
ctx.height,
banner_alpha as f32,
);
}
}
// Flush on the shared queue, ending in SHADER_READ_ONLY on our family — the
@@ -309,6 +398,7 @@ impl Overlay for SkiaOverlay {
self.current = next;
self.drawn = want;
let slot = self.slots[next].as_ref().expect("just rendered");
Ok(Some(OverlayFrame {
image: slot.image,
view: slot.view,
@@ -319,6 +409,28 @@ impl Overlay for SkiaOverlay {
}
impl SkiaOverlay {
/// The start banner's current alpha (1 → 0 across the fade tail), refreshing its
/// words while fully visible so a pad hot-plug updates the leave hint.
fn banner_alpha(&mut self, ctx: &FrameCtx) -> f64 {
let Some(since) = self.streaming_since else {
self.banner_text = None;
return 0.0;
};
let age = since.elapsed().as_secs_f64();
if age >= BANNER_S {
self.streaming_since = None;
self.banner_text = None;
return 0.0;
}
self.banner_text = Some(if ctx.pad.is_some() {
"Hold L1 + R1 + Start + Select to leave · Ctrl+Alt+Shift+S stats".to_string()
} else {
"Ctrl+Alt+Shift+Q releases input · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats"
.to_string()
});
((BANNER_S - age) / BANNER_FADE_S).min(1.0)
}
/// Make `slots[i]` a render target of exactly `width`×`height` (rebuilt on resize).
fn ensure_slot(&mut self, i: usize, width: u32, height: u32) -> Result<()> {
if self.slots[i]
@@ -414,8 +526,8 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
}
}
/// The capture hint: a centered pill near the bottom edge (the GTK hint's position).
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32) {
/// The capture hint / start banner: a centered pill near the bottom edge.
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) {
let (_, metrics) = font.metrics();
let line_h = metrics.descent - metrics.ascent;
let text_w = font.measure_str(text, None).0;
@@ -426,12 +538,12 @@ fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height:
let y = height as f32 - h - 24.0;
canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None),
);
canvas.draw_str(
text,
Point::new(x + pad_x, y + pad_y - metrics.ascent),
font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None),
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92 * alpha), None),
);
}
+435
View File
@@ -0,0 +1,435 @@
//! The console shell's shared look: the brand palette, the embedded Geist typography
//! (the same OFL faces the Apple client bundles — one brand voice on every platform),
//! the dark-glass panel primitive standing in for Liquid Glass (translucent fill +
//! hairline stroke; the backdrops here are soft gradients, so a real backdrop blur
//! would be indistinguishable and costs Deck GPU), and the quiet form backdrop the
//! settings/add-host/pair screens sit on.
use anyhow::{anyhow, Result};
use skia_safe::textlayout::{
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle, TypefaceFontProvider,
};
use skia_safe::{
gradient_shader, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect,
Point, RRect, Rect, TileMode, Typeface,
};
// --- Palette -----------------------------------------------------------------------------
/// The punktfunk brand violet — the DARK-appearance value (#8678F5); the console UI is
/// always dark. (Light surfaces use #6656F2; nothing here is light.)
pub(crate) const BRAND: Color4f = Color4f::new(0.525, 0.471, 0.961, 1.0);
pub(crate) const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0);
pub(crate) const DIM: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.55);
pub(crate) const FAINT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.35);
/// The error/status red (the GTK client's #ff938a).
pub(crate) const ERROR: Color4f = Color4f::new(1.0, 0.576, 0.541, 1.0);
pub(crate) const ONLINE_GREEN: Color4f = Color4f::new(0.20, 0.84, 0.29, 1.0);
pub(crate) fn white(alpha: f32) -> Color4f {
Color4f::new(1.0, 1.0, 1.0, alpha)
}
pub(crate) fn brand(alpha: f32) -> Color4f {
Color4f::new(BRAND.r, BRAND.g, BRAND.b, alpha)
}
/// The dark-glass base fill every panel starts from.
const GLASS_BASE: Color4f = Color4f::new(0.086, 0.086, 0.125, 0.62);
// --- Panels (the Liquid Glass stand-in) --------------------------------------------------
pub(crate) enum PanelStroke {
/// Hairline white at this alpha (rows, pills).
Plain(f32),
/// White .22 → .04 top→bottom (the host tiles' gradient edge).
Gradient,
/// The gradient edge dashed `[6,5]` (discovered / Add-Host tiles).
GradientDashed,
/// Brand-colored hairline (the actively edited field).
Brand(f32),
}
/// One glass panel: base fill, optional tint wash, hairline stroke. `corner` and the
/// dash pattern are DESIGN units — the caller's `k` scales them.
pub(crate) fn panel(
canvas: &Canvas,
rect: Rect,
corner: f32,
tint: Option<Color4f>,
stroke: PanelStroke,
k: f32,
) {
let rr = RRect::new_rect_xy(rect, corner * k, corner * k);
canvas.draw_rrect(rr, &Paint::new(GLASS_BASE, None));
if let Some(tint) = tint {
canvas.draw_rrect(rr, &Paint::new(tint, None));
}
let mut sp = Paint::default();
sp.set_style(skia_safe::PaintStyle::Stroke);
sp.set_stroke_width(1.0);
sp.set_anti_alias(true);
match stroke {
PanelStroke::Plain(alpha) => {
sp.set_color4f(white(alpha), None);
}
PanelStroke::Brand(alpha) => {
sp.set_color4f(brand(alpha), None);
}
PanelStroke::Gradient | PanelStroke::GradientDashed => {
sp.set_shader(gradient_shader::linear(
(
Point::new(rect.left, rect.top),
Point::new(rect.left, rect.bottom),
),
gradient_shader::GradientShaderColors::Colors(&[
white(0.22).to_color(),
white(0.04).to_color(),
]),
None,
TileMode::Clamp,
None,
None,
));
if matches!(stroke, PanelStroke::GradientDashed) {
sp.set_path_effect(PathEffect::dash(&[6.0 * k, 5.0 * k], 0.0));
}
}
}
canvas.draw_rrect(rr, &sp);
}
/// The soft drop shadow under a focused tile — a blurred black round-rect behind it.
pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alpha: f32) {
let mut p = Paint::new(Color4f::new(0.0, 0.0, 0.0, alpha), None);
p.set_mask_filter(MaskFilter::blur(
skia_safe::BlurStyle::Normal,
10.0 * k,
None,
));
let shifted = rect.with_offset((0.0, 10.0 * k));
canvas.draw_rrect(RRect::new_rect_xy(shifted, corner * k, corner * k), &p);
}
// --- The form backdrop (settings / add-host / pair) --------------------------------------
/// The calm backdrop for the form screens — NOT the launcher's aurora (this stays still
/// and quiet), and deliberately not near-black: a deep indigo base plus two soft static
/// glows give the glass rows real color to sit on. A light top/bottom scrim grounds the
/// pinned title and hint bar (the Swift build blurs a tray instead; same job).
pub(crate) fn draw_form_background(canvas: &Canvas, w: f64, h: f64) {
let (wf, hf) = (w as f32, h as f32);
canvas.draw_rect(
Rect::from_wh(wf, hf),
&Paint::new(Color4f::new(0.075, 0.062, 0.150, 1.0), None),
);
// Violet lift top-leading, cooler indigo bottom-trailing — elliptical (window
// aspect) via a unit-radius radial gradient under a scale.
for (cx, cy, color, alpha) in [
(0.26, 0.14, Color4f::new(0.40, 0.31, 0.68, 1.0), 0.9f32),
(0.82, 0.90, Color4f::new(0.20, 0.24, 0.58, 1.0), 0.75),
] {
let mut paint = Paint::default();
let c = Color4f::new(color.r, color.g, color.b, alpha);
paint.set_shader(gradient_shader::radial(
Point::new(0.0, 0.0),
0.78,
gradient_shader::GradientShaderColors::Colors(&[
c.to_color(),
Color4f::new(color.r, color.g, color.b, 0.0).to_color(),
]),
None,
TileMode::Clamp,
None,
None,
));
canvas.save();
canvas.translate((wf * cx, hf * cy));
canvas.scale((wf, hf));
canvas.draw_rect(Rect::from_ltrb(-1.0, -1.0, 1.0, 1.0), &paint);
canvas.restore();
}
let mut scrim = Paint::default();
scrim.set_shader(gradient_shader::linear(
(Point::new(0.0, 0.0), Point::new(0.0, hf)),
gradient_shader::GradientShaderColors::Colors(&[
Color4f::new(0.0, 0.0, 0.0, 0.30).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.32).to_color(),
]),
Some(&[0.0, 0.22, 0.74, 1.0][..]),
TileMode::Clamp,
None,
None,
));
canvas.draw_rect(Rect::from_wh(wf, hf), &scrim);
}
/// The loading/connecting spinner: a rotating 270° arc driven by the shell clock.
pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) {
let start = (t * 300.0) % 360.0;
let mut paint = Paint::new(white(0.85), None);
paint.set_style(skia_safe::PaintStyle::Stroke);
paint.set_stroke_width((r / 5.0) as f32);
paint.set_stroke_cap(skia_safe::PaintCap::Round);
paint.set_anti_alias(true);
canvas.draw_arc(
Rect::from_xywh(
(cx - r) as f32,
(cy - r) as f32,
2.0 * r as f32,
2.0 * r as f32,
),
start as f32,
270.0,
false,
&paint,
);
}
// --- Typography ---------------------------------------------------------------------------
/// Geist weights the console uses (matching the Apple client's `.geist(size, weight)`).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum W {
Regular,
Medium,
SemiBold,
Bold,
}
/// The text toolkit shared by every screen: the four embedded Geist faces for direct
/// `draw_str` work, plus a paragraph collection ("Geist" with system fallback — game
/// titles can be CJK; `draw_str` can't shape those).
pub(crate) struct Fonts {
regular: Typeface,
medium: Typeface,
semibold: Typeface,
bold: Typeface,
collection: FontCollection,
}
/// The Geist faces ride in the binary — the console must look right on a bare gamescope
/// session with no font packages to lean on (fontconfig still serves CJK fallback where
/// it exists).
const GEIST_REGULAR: &[u8] = include_bytes!("../assets/fonts/Geist-Regular.otf");
const GEIST_MEDIUM: &[u8] = include_bytes!("../assets/fonts/Geist-Medium.otf");
const GEIST_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Geist-SemiBold.otf");
const GEIST_BOLD: &[u8] = include_bytes!("../assets/fonts/Geist-Bold.otf");
pub(crate) fn build_fonts() -> Result<Fonts> {
let mgr = FontMgr::new();
let load = |bytes: &[u8], which: &str| {
mgr.new_from_data(bytes, None)
.ok_or_else(|| anyhow!("embedded Geist face rejected: {which}"))
};
let regular = load(GEIST_REGULAR, "Regular")?;
let medium = load(GEIST_MEDIUM, "Medium")?;
let semibold = load(GEIST_SEMIBOLD, "SemiBold")?;
let bold = load(GEIST_BOLD, "Bold")?;
// Paragraphs resolve "Geist" from the asset provider first (all four weights under
// one alias — style matching picks the face), then fall back to the system manager.
let mut provider = TypefaceFontProvider::new();
for tf in [&regular, &medium, &semibold, &bold] {
provider.register_typeface(tf.clone(), Some("Geist"));
}
let mut collection = FontCollection::new();
collection.set_asset_font_manager(Some(provider.into()));
collection.set_default_font_manager(FontMgr::new(), None);
Ok(Fonts {
regular,
medium,
semibold,
bold,
collection,
})
}
impl Fonts {
pub(crate) fn font(&self, w: W, size: f64) -> Font {
let tf = match w {
W::Regular => &self.regular,
W::Medium => &self.medium,
W::SemiBold => &self.semibold,
W::Bold => &self.bold,
};
let mut f = Font::new(tf.clone(), size as f32);
f.set_subpixel(true);
f
}
pub(crate) fn measure(&self, text: &str, w: W, size: f64) -> f32 {
self.font(w, size).measure_str(text, None).0
}
/// `draw_str` at a BASELINE. Returns the advance so callers can chain runs.
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw(
&self,
canvas: &Canvas,
text: &str,
x: f64,
baseline: f64,
w: W,
size: f64,
color: Color4f,
) -> f32 {
let font = self.font(w, size);
canvas.draw_str(
text,
Point::new(x as f32, baseline as f32),
&font,
&Paint::new(color, None),
);
font.measure_str(text, None).0
}
/// Letter-spaced small caps (the section headers' `tracking(1.4)` look) — Skia's
/// `draw_str` has no tracking, so the run is placed char by char.
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw_tracked(
&self,
canvas: &Canvas,
text: &str,
x: f64,
baseline: f64,
w: W,
size: f64,
tracking: f64,
color: Color4f,
) {
let font = self.font(w, size);
let paint = Paint::new(color, None);
let mut pen = x as f32;
let mut buf = [0u8; 4];
for ch in text.chars() {
let s = ch.encode_utf8(&mut buf);
canvas.draw_str(&*s, Point::new(pen, baseline as f32), &font, &paint);
pen += font.measure_str(&*s, None).0 + tracking as f32;
}
}
fn paragraph(
&self,
text: &str,
w: W,
size: f64,
color: Color4f,
align: TextAlign,
max_w: f64,
) -> skia_safe::textlayout::Paragraph {
let mut style = ParagraphStyle::new();
style.set_text_align(align);
let mut ts = TextStyle::new();
ts.set_font_families(&["Geist"]);
ts.set_font_size(size as f32);
ts.set_color(color.to_color());
ts.set_font_style(match w {
W::Regular => FontStyle::normal(),
W::Medium => FontStyle::new(
skia_safe::font_style::Weight::MEDIUM,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::SemiBold => FontStyle::new(
skia_safe::font_style::Weight::SEMI_BOLD,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::Bold => FontStyle::bold(),
});
style.set_text_style(&ts);
let mut b = ParagraphBuilder::new(&style, self.collection.clone());
b.add_text(text);
let mut p = b.build();
p.layout(max_w as f32);
p
}
/// Centered, wrapping paragraph with `y` as its TOP edge (shaping + CJK fallback).
#[allow(clippy::too_many_arguments)]
pub(crate) fn centered(
&self,
canvas: &Canvas,
text: &str,
w: W,
size: f64,
color: Color4f,
cx: f64,
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, w, size, color, TextAlign::Center, max_w);
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
}
/// A single shaped line, middle-ellipsized to `max_w`, drawn at a baseline. For
/// host/game titles that may exceed their tile.
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw_clipped(
&self,
canvas: &Canvas,
text: &str,
x: f64,
baseline: f64,
w: W,
size: f64,
color: Color4f,
max_w: f64,
) {
let font = self.font(w, size);
if font.measure_str(text, None).0 <= max_w as f32 {
self.draw(canvas, text, x, baseline, w, size, color);
return;
}
let ell = "";
let ell_w = font.measure_str(ell, None).0;
let mut fitted = String::new();
let mut used = 0.0f32;
for ch in text.chars() {
let cw = font.measure_str(ch.to_string().as_str(), None).0;
if used + cw + ell_w > max_w as f32 {
break;
}
fitted.push(ch);
used += cw;
}
fitted.push_str(ell);
self.draw(canvas, &fitted, x, baseline, w, size, color);
}
}
/// Resolve the first available family. Generic aliases ("sans-serif", "monospace")
/// resolve through fontconfig on Linux; Windows' DirectWrite-backed FontMgr has no
/// generic aliases, so the list falls through to concrete family names there.
pub(crate) fn match_first_family(
mgr: &FontMgr,
families: &[&str],
style: FontStyle,
) -> Option<Typeface> {
families
.iter()
.find_map(|f| mgr.match_family_style(f, style))
}
#[cfg(test)]
mod tests {
use super::*;
/// The embedded Geist faces must all parse — a bad asset would take out every
/// console screen at init.
#[test]
fn embedded_fonts_load() {
let fonts = build_fonts().expect("Geist faces load");
for w in [W::Regular, W::Medium, W::SemiBold, W::Bold] {
assert!(fonts.measure("Punktfunk", w, 16.0) > 0.0);
}
// Heavier faces render wider at the same size — proves four distinct faces.
assert!(
fonts.measure("Punktfunk", W::Bold, 16.0)
> fonts.measure("Punktfunk", W::Regular, 16.0)
);
}
}
+687
View File
@@ -0,0 +1,687 @@
//! The console's focusable widgets: the vertical menu list (settings / add-host / pair
//! screens) and the controller-driven on-screen keyboard — ports of the Swift
//! `GamepadMenuList` / `GamepadKeyboard`, immediate-mode: the WIDGET owns cursor,
//! springs and scroll, the SCREEN owns the domain (row content and what an activation
//! means), and every frame the screen hands the widget fresh row specs.
use crate::anim::{approach, Spring, TRAY_C, TRAY_K};
use crate::library::{BUMP_C, BUMP_K};
use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, FAINT, W, WHITE};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Paint, Path, RRect, Rect};
// --- Menu list -----------------------------------------------------------------------------
/// What a menu-list consumed event means for the owning screen.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ListMsg {
None,
/// Left/right on the focused row (the screen returns whether the value changed).
Adjust(i32),
/// A on the focused row.
Activate,
}
/// One row's look, rebuilt by the screen each frame (never stale).
pub(crate) struct RowSpec {
/// Section header drawn above this row (the first row of each group carries it).
pub header: Option<&'static str>,
pub label: String,
/// `None` = an action row (its label draws centered, brand-tinted).
pub value: Option<String>,
/// Placeholder styling for an empty field's value.
pub value_dim: bool,
/// The live-edit caret after the value (this row is what the keyboard types into).
pub caret: bool,
/// Show chevrons while focused (left/right steps the value).
pub adjustable: bool,
/// Action rows render dimmed when not yet actionable.
pub enabled: bool,
}
impl RowSpec {
pub(crate) fn field(label: impl Into<String>, value: String, placeholder: &str) -> RowSpec {
let empty = value.is_empty();
RowSpec {
header: None,
label: label.into(),
value: Some(if empty {
placeholder.to_string()
} else {
value
}),
value_dim: empty,
caret: false,
adjustable: false,
enabled: true,
}
}
pub(crate) fn action(label: impl Into<String>, enabled: bool) -> RowSpec {
RowSpec {
header: None,
label: label.into(),
value: None,
value_dim: false,
caret: false,
adjustable: false,
enabled,
}
}
}
pub(crate) const ROW_H: f64 = 50.0;
const ROW_GAP: f64 = 6.0;
const HEADER_H: f64 = 34.0;
pub(crate) const ROW_MAX_W: f64 = 620.0;
/// The focus list: authoritative cursor, spring recoil at the ends, a scroll offset
/// that chases the focused row, and a per-row focus amount for the scale/tint ease.
pub(crate) struct MenuList {
pub cursor: usize,
bump: Spring,
scroll: f64,
focus: Vec<f64>,
}
impl MenuList {
pub(crate) fn new() -> MenuList {
MenuList {
cursor: 0,
bump: Spring::rest(0.0),
scroll: 0.0,
focus: Vec::new(),
}
}
/// Route a menu event. Up/down move focus (Boundary = recoil), left/right become
/// [`ListMsg::Adjust`], A becomes [`ListMsg::Activate`]. B is the SCREEN's.
pub(crate) fn menu(&mut self, ev: MenuEvent, len: usize) -> (ListMsg, Option<MenuPulse>) {
match ev {
MenuEvent::Move(MenuDir::Up) => (ListMsg::None, self.step(-1, len)),
MenuEvent::Move(MenuDir::Down) => (ListMsg::None, self.step(1, len)),
MenuEvent::Move(MenuDir::Left) => (ListMsg::Adjust(-1), None),
MenuEvent::Move(MenuDir::Right) => (ListMsg::Adjust(1), None),
MenuEvent::Confirm => (ListMsg::Activate, Some(MenuPulse::Confirm)),
_ => (ListMsg::None, None),
}
}
fn step(&mut self, delta: i32, len: usize) -> Option<MenuPulse> {
let target = self.cursor as i32 + delta;
if len == 0 || target < 0 || target >= len as i32 {
// Refused at an end: the dull thud plus a short vertical recoil.
self.bump = Spring {
pos: -14.0 * f64::from(delta.signum()),
vel: 0.0,
};
return Some(MenuPulse::Boundary);
}
self.cursor = target as usize;
Some(MenuPulse::Move)
}
/// Render the rows into `rect`. `active` = this list owns focus (a keyboard tray on
/// top parks it — rows keep their look but the focus ring rests).
#[allow(clippy::too_many_arguments)]
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
rows: &[RowSpec],
fonts: &Fonts,
k: f64,
dt: f64,
active: bool,
) {
self.focus.resize(rows.len(), 0.0);
for (i, f) in self.focus.iter_mut().enumerate() {
let target = if active && i == self.cursor { 1.0 } else { 0.0 };
*f = approach(*f, target, dt, 0.06);
}
self.bump.step(0.0, BUMP_K, BUMP_C, dt);
self.bump.settle(0.0, 0.3, 4.0);
// Row tops (design units) incl. headers, so scroll math and drawing agree.
let mut tops = Vec::with_capacity(rows.len());
let mut y = 0.0;
for row in rows {
if row.header.is_some() {
y += HEADER_H;
}
tops.push(y);
y += ROW_H + ROW_GAP;
}
let content_h = (y - ROW_GAP).max(0.0) * k;
let view_h = f64::from(rect.height());
// The scroll chases the focused row into the middle band, clamped to content.
let focused_center = tops.get(self.cursor).map_or(0.0, |t| (t + ROW_H / 2.0) * k);
let target = (focused_center - view_h / 2.0).clamp(0.0, (content_h - view_h).max(0.0));
self.scroll = approach(self.scroll, target, dt, 0.08);
let row_w = (ROW_MAX_W * k).min(f64::from(rect.width()) - 48.0 * k);
let x0 = f64::from(rect.left) + (f64::from(rect.width()) - row_w) / 2.0;
canvas.save();
canvas.clip_rect(rect, None, true);
for (i, row) in rows.iter().enumerate() {
let f = self.focus[i];
let top = f64::from(rect.top) + tops[i] * k - self.scroll + self.bump.pos * k;
if top + ROW_H * k < f64::from(rect.top) - 8.0 * k
|| top > f64::from(rect.bottom) + 8.0 * k
{
continue;
}
if let Some(header) = row.header {
fonts.draw_tracked(
canvas,
&header.to_uppercase(),
x0 + 16.0 * k,
top - 12.0 * k,
W::SemiBold,
12.0 * k,
1.4 * k,
white(0.45),
);
}
// Focus scale eases 0.98 → 1.0 about the row center.
let scale = 0.98 + 0.02 * f;
let (cx, cy) = (x0 + row_w / 2.0, top + ROW_H * k / 2.0);
canvas.save();
canvas.translate((cx as f32, cy as f32));
canvas.scale((scale as f32, scale as f32));
canvas.translate((-cx as f32, -cy as f32));
let r = Rect::from_xywh(x0 as f32, top as f32, row_w as f32, (ROW_H * k) as f32);
let stroke = if row.caret {
PanelStroke::Brand(0.7)
} else {
PanelStroke::Plain(0.06 + 0.22 * f as f32)
};
let tint = if row.caret {
Some(brand(0.30))
} else if f > 0.01 {
Some(brand(0.30 * f as f32))
} else {
None
};
crate::theme::panel(canvas, r, 14.0, tint, stroke, k as f32);
let baseline = cy + 16.0 * k * 0.36;
if row.value.is_none() {
// Action row: centered label, brand when actionable.
let color = if row.enabled { BRAND } else { FAINT };
let tw = fonts.measure(&row.label, W::SemiBold, 16.0 * k) as f64;
fonts.draw(
canvas,
&row.label,
cx - tw / 2.0,
baseline,
W::SemiBold,
16.0 * k,
color,
);
} else {
fonts.draw(
canvas,
&row.label,
x0 + 16.0 * k,
baseline,
W::SemiBold,
16.0 * k,
WHITE,
);
let value = row.value.as_deref().unwrap_or_default();
let vcolor = if row.value_dim {
FAINT
} else if f > 0.5 {
WHITE
} else {
white(0.6 + 0.4 * f as f32)
};
let chevron_w = if row.adjustable { 18.0 * k } else { 0.0 };
let caret_w = if row.caret { 8.0 * k } else { 0.0 };
let vmax = row_w * 0.55;
let vw = (fonts.measure(value, W::Medium, 15.0 * k) as f64).min(vmax);
let vx = x0 + row_w - 16.0 * k - chevron_w - caret_w - vw;
// Head-truncate: keep the END of a long address visible while typing.
let shown = truncate_head(fonts, value, W::Medium, 15.0 * k, vmax);
fonts.draw(canvas, &shown, vx, baseline, W::Medium, 15.0 * k, vcolor);
if row.caret {
canvas.draw_rect(
Rect::from_xywh(
(vx + vw + 3.0 * k) as f32,
(cy - 9.0 * k) as f32,
(2.0 * k) as f32,
(18.0 * k) as f32,
),
&Paint::new(BRAND, None),
);
}
if row.adjustable && f > 0.01 {
let alpha = 0.6 * f as f32;
chevron(canvas, vx - 11.0 * k, cy, 4.0 * k, true, alpha);
chevron(canvas, x0 + row_w - 16.0 * k, cy, 4.0 * k, false, alpha);
}
}
canvas.restore();
}
canvas.restore();
}
}
/// Middle-of-nowhere helper: drop chars from the FRONT until the tail fits.
fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> String {
if f64::from(fonts.measure(text, w, size)) <= max_w {
return text.to_string();
}
let mut s: Vec<char> = text.chars().collect();
while s.len() > 1 {
s.remove(0);
let candidate: String = std::iter::once('…').chain(s.iter().copied()).collect();
if f64::from(fonts.measure(&candidate, w, size)) <= max_w {
return candidate;
}
}
"".into()
}
fn chevron(canvas: &Canvas, x: f64, cy: f64, r: f64, left: bool, alpha: f32) {
let dir = if left { -1.0 } else { 1.0 };
let mut p = Paint::new(white(alpha), None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((1.8 * r / 4.0) as f32);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_anti_alias(true);
let mut path = Path::new();
path.move_to(((x - dir * r / 2.0) as f32, (cy - r) as f32));
path.line_to(((x + dir * r / 2.0) as f32, cy as f32));
path.line_to(((x - dir * r / 2.0) as f32, (cy + r) as f32));
canvas.draw_path(&path, &p);
}
// --- On-screen keyboard ----------------------------------------------------------------------
/// What the keyboard may type per field (backspace always works).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Charset {
Free,
/// Addresses/hostnames — everything but whitespace.
Hostname,
Digits,
}
pub(crate) fn permits(charset: Charset, ch: char) -> bool {
match charset {
Charset::Free => true,
Charset::Hostname => !ch.is_whitespace(),
Charset::Digits => ch.is_ascii_digit(),
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum KeyMsg {
None,
Type(char),
Backspace,
Done,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Key {
Char(char),
Space,
Backspace,
Done,
}
/// Digits first (addresses/ports), then letters; the last char column carries the
/// hostname/address punctuation — the Swift grid, verbatim.
fn key_rows() -> &'static [Vec<Key>] {
use std::sync::OnceLock;
static ROWS: OnceLock<Vec<Vec<Key>>> = OnceLock::new();
ROWS.get_or_init(|| {
let chars = |s: &str| s.chars().map(Key::Char).collect::<Vec<_>>();
vec![
chars("1234567890"),
chars("qwertyuiop"),
chars("asdfghjkl-"),
chars("zxcvbnm._:"),
vec![Key::Space, Key::Backspace, Key::Done],
]
})
}
/// The controller keyboard: a fixed key grid in a bottom tray. Dpad/stick moves the key
/// cursor, A types, X backspaces, B/Y/Done confirms. Edits apply live — closing IS done.
pub(crate) struct Keyboard {
row: usize,
col: usize,
/// Tray slide-in (0 hidden → 1 seated), the Swift `.spring(0.32, 0.86)`.
tray: Spring,
key_flash: f64,
}
impl Keyboard {
pub(crate) fn new() -> Keyboard {
Keyboard {
row: 1, // opens on "q"
col: 0,
tray: Spring::rest(0.0),
key_flash: 0.0,
}
}
/// Route a menu event; the SCREEN applies `Type`/`Backspace` to its field (charset
/// checks included — a refusal comes back as a boundary pulse from the screen).
pub(crate) fn menu(&mut self, ev: MenuEvent) -> (KeyMsg, Option<MenuPulse>) {
let rows = key_rows();
match ev {
MenuEvent::Move(dir) => {
let (mut row, mut col) = (self.row as i32, self.col as i32);
match dir {
MenuDir::Left => col -= 1,
MenuDir::Right => col += 1,
MenuDir::Up | MenuDir::Down => {
let next = row + if dir == MenuDir::Down { 1 } else { -1 };
if next < 0 || next >= rows.len() as i32 {
return (KeyMsg::None, Some(MenuPulse::Boundary));
}
// Map the column proportionally between rows of different
// widths (Done goes up to the rightmost letters, not "e").
let from = (rows[row as usize].len() - 1).max(1) as f64;
let to = (rows[next as usize].len() - 1) as f64;
col = (col as f64 * to / from).round() as i32;
row = next;
}
}
if row < 0
|| row >= rows.len() as i32
|| col < 0
|| col >= rows[row as usize].len() as i32
{
return (KeyMsg::None, Some(MenuPulse::Boundary));
}
self.row = row as usize;
self.col = col as usize;
(KeyMsg::None, Some(MenuPulse::Move))
}
MenuEvent::Confirm => {
self.key_flash = 1.0;
match rows[self.row][self.col] {
Key::Char(c) => (KeyMsg::Type(c), None),
Key::Space => (KeyMsg::Type(' '), None),
Key::Backspace => (KeyMsg::Backspace, None),
Key::Done => (KeyMsg::Done, Some(MenuPulse::Confirm)),
}
}
MenuEvent::Tertiary => (KeyMsg::Backspace, None),
MenuEvent::Secondary | MenuEvent::Back => (KeyMsg::Done, Some(MenuPulse::Confirm)),
_ => (KeyMsg::None, None),
}
}
/// Advance the tray spring toward shown/hidden. Returns the current seat 0..1 —
/// at exactly 0 while hidden, so the caller can skip rendering.
pub(crate) fn seat(&mut self, shown: bool, dt: f64) -> f64 {
self.tray
.step(if shown { 1.0 } else { 0.0 }, TRAY_K, TRAY_C, dt);
self.tray.settle(if shown { 1.0 } else { 0.0 }, 0.001, 0.01);
self.key_flash = approach(self.key_flash, 0.0, dt, 0.10);
self.tray.pos.clamp(0.0, 1.2)
}
/// The tray's design height (pre-`k`), for layout above it.
pub(crate) fn tray_height() -> f64 {
5.0 * 42.0 + 4.0 * 7.0 + 2.0 * 14.0
}
/// Render the tray with its bottom edge at `bottom`, horizontally centered, slid by
/// `seat` (0..1). The caller clips nothing — the tray rises from below the screen.
#[allow(clippy::too_many_arguments)]
pub(crate) fn render(
&self,
canvas: &Canvas,
fonts: &Fonts,
w: f64,
bottom: f64,
seat: f64,
k: f64,
) {
let rows = key_rows();
let tray_w = (560.0 * k).min(w - 32.0 * k);
let tray_h = Self::tray_height() * k;
let x0 = (w - tray_w) / 2.0;
let y0 = bottom - tray_h * seat;
let rect = Rect::from_xywh(x0 as f32, y0 as f32, tray_w as f32, tray_h as f32);
crate::theme::panel(
canvas,
rect,
22.0,
Some(skia_safe::Color4f::new(0.05, 0.045, 0.09, 0.55)),
PanelStroke::Plain(0.12),
k as f32,
);
let pad = 14.0 * k;
let gap = 7.0 * k;
let key_h = 42.0 * k;
for (r, row) in rows.iter().enumerate() {
let n = row.len() as f64;
let key_w = (tray_w - 2.0 * pad - (n - 1.0) * gap) / n;
let y = y0 + pad + r as f64 * (key_h + gap);
for (c, key) in row.iter().enumerate() {
let x = x0 + pad + c as f64 * (key_w + gap);
let focused = r == self.row && c == self.col;
let kr = Rect::from_xywh(x as f32, y as f32, key_w as f32, key_h as f32);
let fill = if focused {
let mut b = BRAND;
if self.key_flash > 0.02 {
// A just-typed key flashes brighter, then eases back.
let f = self.key_flash as f32;
b = skia_safe::Color4f::new(
b.r + (1.0 - b.r) * 0.5 * f,
b.g + (1.0 - b.g) * 0.5 * f,
b.b,
1.0,
);
}
b
} else {
white(0.08)
};
canvas.draw_rrect(
RRect::new_rect_xy(kr, (9.0 * k) as f32, (9.0 * k) as f32),
&Paint::new(fill, None),
);
let ink = if focused {
skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0)
} else {
WHITE
};
let (cx, cy) = (x + key_w / 2.0, y + key_h / 2.0);
match key {
Key::Char(ch) => {
let s = ch.to_string();
let size = 18.0 * k;
let tw = fonts.measure(&s, W::Medium, size) as f64;
fonts.draw(
canvas,
&s,
cx - tw / 2.0,
cy + size * 0.36,
W::Medium,
size,
ink,
);
}
Key::Space => draw_space_icon(canvas, cx, cy, k, ink),
Key::Backspace => draw_backspace_icon(canvas, cx, cy, k, ink),
Key::Done => {
let size = 15.0 * k;
let label = "Done";
let tw = fonts.measure(label, W::SemiBold, size) as f64;
let check_w = 14.0 * k;
let total = check_w + 6.0 * k + tw;
draw_check(canvas, cx - total / 2.0 + check_w / 2.0, cy, k, ink);
fonts.draw(
canvas,
label,
cx - total / 2.0 + check_w + 6.0 * k,
cy + size * 0.36,
W::SemiBold,
size,
ink,
);
}
}
}
}
}
}
fn stroke_paint(ink: skia_safe::Color4f, width: f32) -> Paint {
let mut p = Paint::new(ink, None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width(width);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_stroke_join(skia_safe::PaintJoin::Round);
p.set_anti_alias(true);
p
}
fn draw_space_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
// ⎵ — an underline bracket.
let (w, h) = (16.0 * k, 5.0 * k);
let p = stroke_paint(ink, (1.6 * k) as f32);
let mut path = Path::new();
path.move_to(((cx - w / 2.0) as f32, (cy - h / 2.0) as f32));
path.line_to(((cx - w / 2.0) as f32, (cy + h / 2.0) as f32));
path.line_to(((cx + w / 2.0) as f32, (cy + h / 2.0) as f32));
path.line_to(((cx + w / 2.0) as f32, (cy - h / 2.0) as f32));
canvas.draw_path(&path, &p);
}
fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
// ⌫ — the left-pointing cap with an ✕ inside.
let (w, h) = (18.0 * k, 12.0 * k);
let nose = 6.0 * k;
let p = stroke_paint(ink, (1.6 * k) as f32);
let (l, r, t, b) = (cx - w / 2.0, cx + w / 2.0, cy - h / 2.0, cy + h / 2.0);
let mut path = Path::new();
path.move_to(((l + nose) as f32, t as f32));
path.line_to((r as f32, t as f32));
path.line_to((r as f32, b as f32));
path.line_to(((l + nose) as f32, b as f32));
path.line_to((l as f32, cy as f32));
path.close();
canvas.draw_path(&path, &p);
let (xc, xr) = (cx + nose / 2.0, 2.6 * k);
canvas.draw_line(
((xc - xr) as f32, (cy - xr) as f32),
((xc + xr) as f32, (cy + xr) as f32),
&p,
);
canvas.draw_line(
((xc - xr) as f32, (cy + xr) as f32),
((xc + xr) as f32, (cy - xr) as f32),
&p,
);
}
fn draw_check(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
let p = stroke_paint(ink, (1.8 * k) as f32);
let r = 5.0 * k;
let mut path = Path::new();
path.move_to(((cx - r) as f32, cy as f32));
path.line_to(((cx - r * 0.25) as f32, (cy + r * 0.7) as f32));
path.line_to(((cx + r) as f32, (cy - r * 0.7) as f32));
canvas.draw_path(&path, &p);
}
#[cfg(test)]
mod tests {
use super::*;
fn kb() -> Keyboard {
Keyboard::new()
}
#[test]
fn keyboard_opens_on_q_and_types() {
let mut k = kb();
let (msg, _) = k.menu(MenuEvent::Confirm);
assert_eq!(msg, KeyMsg::Type('q'));
}
#[test]
fn keyboard_proportional_column_mapping() {
let mut k = kb();
// Walk to the digits row's far right ("0", col 9), then down to the bottom row:
// col must land on Done (rightmost of 3), not clamp to some middle key.
for _ in 0..9 {
k.menu(MenuEvent::Move(MenuDir::Right));
}
k.menu(MenuEvent::Move(MenuDir::Up)); // digits row
assert_eq!((k.row, k.col), (0, 9));
for _ in 0..4 {
k.menu(MenuEvent::Move(MenuDir::Down));
}
assert_eq!(k.row, 4);
assert_eq!(k.col, 2, "rightmost column maps onto Done");
let (msg, _) = k.menu(MenuEvent::Confirm);
assert_eq!(msg, KeyMsg::Done);
}
#[test]
fn keyboard_edges_refuse() {
let mut k = kb();
let (_, pulse) = k.menu(MenuEvent::Move(MenuDir::Left));
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
// X backspaces, B/Y are done — from anywhere.
assert_eq!(k.menu(MenuEvent::Tertiary).0, KeyMsg::Backspace);
assert_eq!(k.menu(MenuEvent::Secondary).0, KeyMsg::Done);
assert_eq!(k.menu(MenuEvent::Back).0, KeyMsg::Done);
}
#[test]
fn charsets() {
assert!(permits(Charset::Digits, '7'));
assert!(!permits(Charset::Digits, 'a'));
assert!(permits(Charset::Hostname, '-'));
assert!(!permits(Charset::Hostname, ' '));
assert!(permits(Charset::Free, ' '));
}
#[test]
fn menu_list_moves_and_recoils() {
let mut l = MenuList::new();
assert!(matches!(
l.menu(MenuEvent::Move(MenuDir::Down), 3).1,
Some(MenuPulse::Move)
));
assert_eq!(l.cursor, 1);
assert!(matches!(
l.menu(MenuEvent::Move(MenuDir::Up), 3).1,
Some(MenuPulse::Move)
));
assert!(matches!(
l.menu(MenuEvent::Move(MenuDir::Up), 3).1,
Some(MenuPulse::Boundary)
));
assert!(l.bump.pos.abs() > 1.0, "recoil engaged");
assert_eq!(
l.menu(MenuEvent::Move(MenuDir::Right), 3).0,
ListMsg::Adjust(1)
);
assert_eq!(l.menu(MenuEvent::Confirm, 3).0, ListMsg::Activate);
}
#[test]
fn head_truncation_keeps_the_tail() {
let fonts = crate::theme::build_fonts().unwrap();
let t = truncate_head(&fonts, "verylonghostname.local", W::Medium, 15.0, 60.0);
assert!(t.starts_with('…'));
assert!(t.ends_with("local"));
}
}