927a571414
Extend the gamepad/console shell (pf-console-ui) to parity with the other clients: - Settings gain a Touchscreen → Touch mode row (Trackpad / Direct pointer / Touch passthrough), the one couch-relevant Settings field the screen lacked. - The pair screen adds the no-PIN delegated-approval path: a "Request access" action (only when the host advertises a fingerprint to pin) opens a connect the host PARKS until the operator approves this device, then persists it as paired. A role-based row model keeps the cursor off stale indices; manual hosts stay PIN-only, matching the desktop shells. - Threads request_access through OverlayAction::Launch and ConnectIntent; the shell shows a "Waiting for approval…" takeover, and the session binary parks on a 185 s budget (PendingApproval → persist-as-paired via on_connected). Auto-wake (WoL) was already implemented end-to-end and is left as-is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
528 lines
18 KiB
Rust
528 lines
18 KiB
Rust
//! 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 pf_client_core::trust::{StatsVerbosity, TouchMode};
|
||
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,
|
||
Touch,
|
||
Stats,
|
||
}
|
||
|
||
const ROWS: [RowId; 13] = [
|
||
RowId::Resolution,
|
||
RowId::Refresh,
|
||
RowId::Bitrate,
|
||
RowId::Compositor,
|
||
RowId::Codec,
|
||
RowId::Decoder,
|
||
RowId::Hdr,
|
||
RowId::Audio,
|
||
RowId::Mic,
|
||
RowId::Pad,
|
||
RowId::PadType,
|
||
RowId::Touch,
|
||
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.match_window {
|
||
"Match window".into()
|
||
} else 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::Touch => (
|
||
Some("Touchscreen"),
|
||
"Touch mode",
|
||
s.touch_mode().label().into(),
|
||
),
|
||
RowId::Stats => (
|
||
Some("Interface"),
|
||
"Statistics overlay",
|
||
s.stats_verbosity().label().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. \
|
||
Match window follows this window, including mid-stream resizes."
|
||
}
|
||
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::Touch => {
|
||
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
||
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
||
}
|
||
RowId::Stats => {
|
||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||
Ctrl+Alt+Shift+S cycles it live 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 => {
|
||
// The D1 tri-state as one picker: Native, Match window, then the explicit
|
||
// sizes (RESOLUTIONS keeps its (0,0) = Native head; Match window is the
|
||
// virtual index 1, stored as the `match_window` flag with w/h cleared).
|
||
let cur = if s.match_window {
|
||
Some(1)
|
||
} else {
|
||
RESOLUTIONS
|
||
.iter()
|
||
.position(|(w, h)| (*w, *h) == (s.width, s.height))
|
||
.map(|i| if i == 0 { 0 } else { i + 1 })
|
||
};
|
||
step_option(cur, RESOLUTIONS.len() + 1, delta, wrap).map(|i| {
|
||
s.match_window = i == 1;
|
||
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
||
})
|
||
}
|
||
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::Touch => {
|
||
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
|
||
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||
.map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string())
|
||
}
|
||
RowId::Stats => {
|
||
let cur = StatsVerbosity::ALL
|
||
.iter()
|
||
.position(|v| *v == s.stats_verbosity());
|
||
step_option(cur, StatsVerbosity::ALL.len(), delta, wrap)
|
||
.map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i]))
|
||
}
|
||
}
|
||
.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 — first onto
|
||
// Match window (the D1 tri-state's middle option), then the explicit sizes.
|
||
assert!(!adjust(RowId::Resolution, -1, false, &mut ctx));
|
||
assert!(adjust(RowId::Resolution, 1, false, &mut ctx));
|
||
assert!(ctx.settings.match_window, "Native → Match window");
|
||
assert_eq!((ctx.settings.width, ctx.settings.height), (0, 0));
|
||
assert!(adjust(RowId::Resolution, 1, false, &mut ctx));
|
||
assert!(
|
||
!ctx.settings.match_window,
|
||
"explicit size clears the policy"
|
||
);
|
||
assert_eq!((ctx.settings.width, ctx.settings.height), (1280, 720));
|
||
// Stepping back from an explicit size returns to Match window, then Native.
|
||
assert!(adjust(RowId::Resolution, -1, false, &mut ctx));
|
||
assert!(ctx.settings.match_window);
|
||
assert!(adjust(RowId::Resolution, -1, false, &mut ctx));
|
||
assert!(!ctx.settings.match_window);
|
||
assert_eq!(ctx.settings.width, 0, "back to Native");
|
||
// 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");
|
||
assert!(!ctx.settings.match_window);
|
||
}
|
||
|
||
#[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 touch_mode_steps_and_wraps() {
|
||
let (mut settings, pads) = ctx_parts();
|
||
assert_eq!(settings.touch_mode, "trackpad");
|
||
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,
|
||
};
|
||
// Trackpad → Pointer → Touch, then a step past the end is a boundary.
|
||
assert!(
|
||
!adjust(RowId::Touch, -1, false, &mut ctx),
|
||
"already first = thud"
|
||
);
|
||
assert!(adjust(RowId::Touch, 1, false, &mut ctx));
|
||
assert_eq!(ctx.settings.touch_mode, "pointer");
|
||
assert!(adjust(RowId::Touch, 1, false, &mut ctx));
|
||
assert_eq!(ctx.settings.touch_mode, "touch");
|
||
assert!(!adjust(RowId::Touch, 1, false, &mut ctx), "last = thud");
|
||
// A wraps back to the first.
|
||
assert!(adjust(RowId::Touch, 1, true, &mut ctx));
|
||
assert_eq!(ctx.settings.touch_mode, "trackpad");
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|