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
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:
@@ -360,8 +360,9 @@ impl Default for Settings {
|
||||
impl Settings {
|
||||
fn path() -> Result<PathBuf> {
|
||||
// The shell's settings file on each OS: the GTK shell's on Linux, the WinUI
|
||||
// shell's on Windows. The shells own (and write) these files through this one
|
||||
// struct; the session binary only reads them and must never call `save`.
|
||||
// shell's on Windows. The desktop shells AND the session binary's console
|
||||
// settings screen write it (load-modify-save per change — Gaming Mode has no
|
||||
// other editor); a plain `--connect` stream only ever reads.
|
||||
#[cfg(windows)]
|
||||
return Ok(config_dir()?.join("client-windows-settings.json"));
|
||||
#[cfg(not(windows))]
|
||||
|
||||
@@ -23,6 +23,8 @@ ash = { version = "0.38", features = ["loaded"] }
|
||||
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
# `config::GamepadPref` keys the button-glyph style (PlayStation shapes vs ABXY).
|
||||
punktfunk-core = { path = "../punktfunk-core" }
|
||||
|
||||
# Linux links the system SDL3; Windows builds it from source (same choice as the rest
|
||||
# of the workspace's Windows SDL consumers).
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(_)))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 [®ular, &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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
use ash::vk;
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
|
||||
/// The presenter's device, shared with the overlay so its renderer (Skia's
|
||||
/// `DirectContext`) creates resources on the same VkDevice/queue. Handles stay valid for
|
||||
@@ -34,6 +35,11 @@ pub struct FrameCtx<'a> {
|
||||
pub hint: Option<&'a str>,
|
||||
/// The active gamepad's name (the console library's controller chip).
|
||||
pub pad: Option<&'a str>,
|
||||
/// The active pad's resolved kind — drives the console UI's button glyphs
|
||||
/// (PlayStation shapes for DualSense/DualShock, ABXY letters otherwise).
|
||||
pub pad_pref: Option<GamepadPref>,
|
||||
/// Every connected pad (the console settings' "Use controller" row).
|
||||
pub pads: &'a [pf_client_core::gamepad::PadInfo],
|
||||
}
|
||||
|
||||
/// One overlay image ready to composite: RGBA, PREMULTIPLIED alpha, already in
|
||||
@@ -46,13 +52,22 @@ pub struct OverlayFrame {
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// An action the overlay raises out of its input handling (browse mode: the console
|
||||
/// library's A/B/retry). The run loop hands each to the session binary's callback.
|
||||
/// An action the overlay raises out of its input handling (browse mode). Only actions
|
||||
/// the RUN LOOP must act on live here — starting/canceling sessions and quitting; data
|
||||
/// work (pairing, discovery, library fetches…) rides the console command bus instead.
|
||||
pub enum OverlayAction {
|
||||
/// Launch this library title as a session (`id` rides the Hello).
|
||||
Launch { id: String, title: String },
|
||||
/// Retry whatever failed (the library fetch).
|
||||
Retry,
|
||||
/// Start a session on this host. `launch` carries a library title id on the Hello
|
||||
/// (`None` streams the desktop); `title` is display-only (window title).
|
||||
Launch {
|
||||
addr: String,
|
||||
port: u16,
|
||||
fp_hex: String,
|
||||
launch: Option<String>,
|
||||
title: String,
|
||||
},
|
||||
/// Abort an in-flight connect (B while Connecting) — the console keeps browsing.
|
||||
/// The run loop stops the pump; a dial that already won the race is quit-closed.
|
||||
CancelConnect,
|
||||
/// Quit the launcher (B at the root) — ends the process, Gaming Mode returns.
|
||||
Quit,
|
||||
}
|
||||
@@ -95,6 +110,13 @@ pub trait Overlay {
|
||||
/// A session lifecycle edge (browse mode scene driving).
|
||||
fn session_phase(&mut self, _phase: SessionPhase) {}
|
||||
|
||||
/// True while a text field is being edited — the run loop starts/stops SDL text
|
||||
/// input to match (IME + `Event::TextInput` delivery on desktop; under gamescope
|
||||
/// this is also what lets Steam's on-screen keyboard type into the app).
|
||||
fn text_input_active(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Once per presenter iteration. Damage-driven: re-render (flush + transition to
|
||||
/// SHADER_READ_ONLY) only when the content or size changed, else return the previous
|
||||
/// image. `None` = nothing to composite. The returned image must stay untouched
|
||||
|
||||
@@ -145,6 +145,9 @@ struct StreamState {
|
||||
connector: Option<Arc<NativeClient>>,
|
||||
capture: Option<Capture>,
|
||||
force_software: Arc<AtomicBool>,
|
||||
/// The user canceled this connect from the console — never engage the stream
|
||||
/// (skip capture/attach on a late `Connected`) and route its end back silently.
|
||||
canceled: bool,
|
||||
ready_announced: bool,
|
||||
mode_line: String,
|
||||
clock_offset_ns: i64,
|
||||
@@ -192,6 +195,7 @@ impl StreamState {
|
||||
connector: None,
|
||||
capture: None,
|
||||
force_software,
|
||||
canceled: false,
|
||||
ready_announced: false,
|
||||
mode_line: String::new(),
|
||||
clock_offset_ns: 0,
|
||||
@@ -337,6 +341,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
let mut fullscreen = opts.fullscreen;
|
||||
let mut print_stats = opts.print_stats;
|
||||
let mut overlay_frame: Option<OverlayFrame> = None;
|
||||
// SDL text input tracks the overlay's editing state (started = IME/`TextInput`
|
||||
// events on desktop, and the door Steam's on-screen keyboard types through under
|
||||
// gamescope). Toggled edge-wise — start/stop are not free on Wayland.
|
||||
let mut text_input_on = false;
|
||||
|
||||
let outcome = 'main: loop {
|
||||
// --- SDL events (input, window, gamepads) ---------------------------------------
|
||||
@@ -499,6 +507,18 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
cap.flush_motion();
|
||||
}
|
||||
|
||||
// Text input follows the overlay's editing state (edge-triggered).
|
||||
let want_text = overlay.as_ref().is_some_and(|o| o.text_input_active());
|
||||
if want_text != text_input_on {
|
||||
text_input_on = want_text;
|
||||
let ti = video.text_input();
|
||||
if want_text {
|
||||
ti.start(&window);
|
||||
} else {
|
||||
ti.stop(&window);
|
||||
}
|
||||
}
|
||||
|
||||
// Controller escape chord: release capture (+ leave fullscreen on desktop — under
|
||||
// a `--fullscreen` gamescope launch there is nothing to release into). Only
|
||||
// emitted while a session is attached.
|
||||
@@ -522,9 +542,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
}
|
||||
|
||||
// --- Browse: menu navigation + overlay actions (library visible only) ------------
|
||||
// --- Browse: menu navigation + overlay actions (console visible only) ------------
|
||||
if let ModeCtl::Browse(on_action) = &mut mode {
|
||||
if stream.is_none() {
|
||||
// Menu events flow while no stream is engaged — including a connect in
|
||||
// flight (connector still None), so B can cancel the dial. Once attached,
|
||||
// the gamepad worker forwards raw input instead of translating.
|
||||
if stream.as_ref().is_none_or(|s| s.connector.is_none()) {
|
||||
while let Ok(ev) = menu_rx.try_recv() {
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
if let Some(pulse) = o.handle_menu(ev) {
|
||||
@@ -534,26 +557,39 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
}
|
||||
if let Some(action) = overlay.as_mut().and_then(|o| o.take_action()) {
|
||||
let force_software = Arc::new(AtomicBool::new(false));
|
||||
match on_action(
|
||||
action,
|
||||
&gamepad,
|
||||
native,
|
||||
force_software.clone(),
|
||||
presenter.vulkan_decode(),
|
||||
) {
|
||||
ActionOutcome::Handled => {}
|
||||
ActionOutcome::Start(params) => {
|
||||
stream = Some(StreamState::new(
|
||||
*params,
|
||||
force_software,
|
||||
events.event_sender(),
|
||||
));
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Connecting);
|
||||
match action {
|
||||
OverlayAction::CancelConnect => {
|
||||
if let Some(st) = &mut stream {
|
||||
if st.connector.is_none() && !st.canceled {
|
||||
tracing::info!("connect canceled from the console");
|
||||
st.canceled = true;
|
||||
st.handle.stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
action => {
|
||||
let force_software = Arc::new(AtomicBool::new(false));
|
||||
match on_action(
|
||||
action,
|
||||
&gamepad,
|
||||
native,
|
||||
force_software.clone(),
|
||||
presenter.vulkan_decode(),
|
||||
) {
|
||||
ActionOutcome::Handled => {}
|
||||
ActionOutcome::Start(params) => {
|
||||
stream = Some(StreamState::new(
|
||||
*params,
|
||||
force_software,
|
||||
events.event_sender(),
|
||||
));
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Connecting);
|
||||
}
|
||||
}
|
||||
ActionOutcome::Quit => break Some(Outcome::Ended(None)),
|
||||
}
|
||||
}
|
||||
ActionOutcome::Quit => break Some(Outcome::Ended(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -571,6 +607,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
mode: m,
|
||||
fingerprint,
|
||||
} => {
|
||||
if st.canceled {
|
||||
// The dial won the race against the cancel: quit-close the host
|
||||
// side now; the stop flag (already set) ends the pump and the
|
||||
// Ended path routes back to the console without ever engaging.
|
||||
c.disconnect_quit();
|
||||
continue;
|
||||
}
|
||||
st.mode_line = format!("{}×{}@{}", m.width, m.height, m.refresh_hz);
|
||||
tracing::info!(mode = %st.mode_line, "connected");
|
||||
window
|
||||
@@ -613,13 +656,19 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
})
|
||||
}
|
||||
ModeCtl::Browse(_) => {
|
||||
tracing::warn!(%msg, "connect failed — back to the library");
|
||||
tracing::warn!(%msg, "connect failed — back to the console");
|
||||
let canceled = st.canceled;
|
||||
if let Some(st) = stream.take() {
|
||||
st.shutdown();
|
||||
}
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Failed(&msg));
|
||||
// A user-canceled dial ends silently — no error scene.
|
||||
if canceled {
|
||||
o.session_phase(SessionPhase::Ended(None));
|
||||
} else {
|
||||
o.session_phase(SessionPhase::Failed(&msg));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -634,11 +683,17 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)),
|
||||
ModeCtl::Browse(_) => {
|
||||
window.set_title(&opts.window_title).ok();
|
||||
let canceled = st.canceled;
|
||||
if let Some(st) = stream.take() {
|
||||
st.shutdown();
|
||||
}
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
o.session_phase(SessionPhase::Ended(reason.as_deref()));
|
||||
// A canceled connect's end carries no reason strip.
|
||||
o.session_phase(SessionPhase::Ended(if canceled {
|
||||
None
|
||||
} else {
|
||||
reason.as_deref()
|
||||
}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -667,13 +722,16 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
let pad_name = gamepad.active().map(|p| p.name);
|
||||
let pad = gamepad.active();
|
||||
let pads = gamepad.pads();
|
||||
let ctx = FrameCtx {
|
||||
width: pw,
|
||||
height: ph,
|
||||
stats,
|
||||
hint,
|
||||
pad: pad_name.as_deref(),
|
||||
pad: pad.as_ref().map(|p| p.name.as_str()),
|
||||
pad_pref: pad.as_ref().map(|p| p.pref),
|
||||
pads: &pads,
|
||||
};
|
||||
match o.frame(&ctx) {
|
||||
Ok(f) => overlay_frame = f,
|
||||
|
||||
Reference in New Issue
Block a user