feat(client-linux): controller + keyboard shortcuts to exit fullscreen
On the Steam Deck there was no way out of fullscreen — no F11 key, and the header bar (with the fullscreen button) is hidden while fullscreen. - Controller: a Moonlight-style escape chord (L1+R1+Start+Select) held together leaves fullscreen and releases input capture. The gamepad service latches the chord (fires once per press) and signals the stream page over an async channel; four simultaneous buttons no game uses as a deliberate combo, so it can't trigger during play. - Keyboard: F11 already toggled fullscreen (checked before input forwarding, so it works while captured) — now surfaced. - Discoverability: entering fullscreen flashes a 4s hint listing both exits (F11 · L1+R1+Start+Select). The escape future is aborted on page-hidden so a stale session can't act on the shared window. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -469,6 +469,7 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
|||||||
&app.window,
|
&app.window,
|
||||||
connector,
|
connector,
|
||||||
frames.take().expect("Connected delivered once"),
|
frames.take().expect("Connected delivered once"),
|
||||||
|
app.gamepad.escape_events(),
|
||||||
handle.stop.clone(),
|
handle.stop.clone(),
|
||||||
inhibit,
|
inhibit,
|
||||||
&title,
|
&title,
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
|
|||||||
const ACCEL_LSB_PER_G: f32 = 10_000.0;
|
const ACCEL_LSB_PER_G: f32 = 10_000.0;
|
||||||
const G: f32 = 9.80665;
|
const G: f32 = 9.80665;
|
||||||
|
|
||||||
|
/// The controller "escape" chord (Moonlight convention): L1 + R1 + Start + Select held
|
||||||
|
/// together. Intercepted by the client to leave fullscreen + release input capture — the
|
||||||
|
/// Deck has no F11 key and fullscreen hides the window chrome, so with a controller this
|
||||||
|
/// is the only way out. Four simultaneous buttons that no game uses as a deliberate
|
||||||
|
/// combo, so it can't be triggered by normal play. Still forwarded to the host (the user
|
||||||
|
/// is leaving anyway); we only also raise the escape signal.
|
||||||
|
const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wire::BTN_BACK];
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PadInfo {
|
pub struct PadInfo {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
@@ -46,6 +54,9 @@ pub struct GamepadService {
|
|||||||
active: Arc<Mutex<Option<PadInfo>>>,
|
active: Arc<Mutex<Option<PadInfo>>>,
|
||||||
pinned: Arc<Mutex<Option<u32>>>,
|
pinned: Arc<Mutex<Option<u32>>>,
|
||||||
ctl: Sender<Ctl>,
|
ctl: Sender<Ctl>,
|
||||||
|
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
|
||||||
|
/// fullscreen + release capture.
|
||||||
|
escape_rx: async_channel::Receiver<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GamepadService {
|
impl GamepadService {
|
||||||
@@ -54,11 +65,12 @@ impl GamepadService {
|
|||||||
let active = Arc::new(Mutex::new(None));
|
let active = Arc::new(Mutex::new(None));
|
||||||
let pinned = Arc::new(Mutex::new(None));
|
let pinned = Arc::new(Mutex::new(None));
|
||||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||||
|
let (escape_tx, escape_rx) = async_channel::unbounded();
|
||||||
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
||||||
if let Err(e) = std::thread::Builder::new()
|
if let Err(e) = std::thread::Builder::new()
|
||||||
.name("punktfunk-gamepad".into())
|
.name("punktfunk-gamepad".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = run(&p, &a, &pin, &ctl_rx) {
|
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx) {
|
||||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -70,9 +82,16 @@ impl GamepadService {
|
|||||||
active,
|
active,
|
||||||
pinned,
|
pinned,
|
||||||
ctl,
|
ctl,
|
||||||
|
escape_rx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A receiver that yields one `()` each time the controller escape chord is pressed.
|
||||||
|
/// A fresh clone per call (shared mpmc channel); the stream page spawns a future on it.
|
||||||
|
pub fn escape_events(&self) -> async_channel::Receiver<()> {
|
||||||
|
self.escape_rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pads(&self) -> Vec<PadInfo> {
|
pub fn pads(&self) -> Vec<PadInfo> {
|
||||||
self.pads.lock().unwrap().clone()
|
self.pads.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
@@ -210,6 +229,10 @@ struct Worker {
|
|||||||
last_axis: [i32; 6],
|
last_axis: [i32; 6],
|
||||||
held_buttons: Vec<u32>,
|
held_buttons: Vec<u32>,
|
||||||
last_accel: [i16; 3],
|
last_accel: [i16; 3],
|
||||||
|
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||||
|
escape_tx: async_channel::Sender<()>,
|
||||||
|
/// The escape chord is fully held — latched so it fires once, not every poll.
|
||||||
|
chord_armed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Worker {
|
impl Worker {
|
||||||
@@ -250,6 +273,26 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it
|
||||||
|
/// fires once per press). Called after each button-down updates `held_buttons`.
|
||||||
|
fn maybe_fire_escape(&mut self) {
|
||||||
|
if self.chord_armed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||||
|
self.chord_armed = true;
|
||||||
|
let _ = self.escape_tx.try_send(());
|
||||||
|
tracing::info!("gamepad escape chord (L1+R1+Start+Select) — leaving fullscreen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-arm once the chord is broken (any of its buttons released).
|
||||||
|
fn rearm_escape(&mut self) {
|
||||||
|
if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) {
|
||||||
|
self.chord_armed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||||||
fn set_sensors(&mut self, enabled: bool) {
|
fn set_sensors(&mut self, enabled: bool) {
|
||||||
let Some(id) = self.active_id() else { return };
|
let Some(id) = self.active_id() else { return };
|
||||||
@@ -270,6 +313,7 @@ fn run(
|
|||||||
active_out: &Mutex<Option<PadInfo>>,
|
active_out: &Mutex<Option<PadInfo>>,
|
||||||
pinned_out: &Mutex<Option<u32>>,
|
pinned_out: &Mutex<Option<u32>>,
|
||||||
ctl: &Receiver<Ctl>,
|
ctl: &Receiver<Ctl>,
|
||||||
|
escape_tx: &async_channel::Sender<()>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
||||||
// own thread.
|
// own thread.
|
||||||
@@ -288,6 +332,8 @@ fn run(
|
|||||||
last_axis: [i32::MIN; 6],
|
last_axis: [i32::MIN; 6],
|
||||||
held_buttons: Vec::new(),
|
held_buttons: Vec::new(),
|
||||||
last_accel: [0; 3],
|
last_accel: [0; 3],
|
||||||
|
escape_tx: escape_tx.clone(),
|
||||||
|
chord_armed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let publish = |w: &Worker| {
|
let publish = |w: &Worker| {
|
||||||
@@ -372,6 +418,7 @@ fn run(
|
|||||||
bit,
|
bit,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
w.maybe_fire_escape();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::ControllerButtonUp { which, button, .. }
|
Event::ControllerButtonUp { which, button, .. }
|
||||||
@@ -385,6 +432,7 @@ fn run(
|
|||||||
bit,
|
bit,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
w.rearm_escape();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::ControllerAxisMotion {
|
Event::ControllerAxisMotion {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ pub fn new(
|
|||||||
window: &adw::ApplicationWindow,
|
window: &adw::ApplicationWindow,
|
||||||
connector: Arc<NativeClient>,
|
connector: Arc<NativeClient>,
|
||||||
frames: async_channel::Receiver<DecodedFrame>,
|
frames: async_channel::Receiver<DecodedFrame>,
|
||||||
|
escape_rx: async_channel::Receiver<()>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
inhibit_shortcuts: bool,
|
inhibit_shortcuts: bool,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -159,10 +160,21 @@ pub fn new(
|
|||||||
hint.set_margin_bottom(24);
|
hint.set_margin_bottom(24);
|
||||||
hint.set_visible(false);
|
hint.set_visible(false);
|
||||||
|
|
||||||
|
// Flashed when entering fullscreen — the only exit affordances once the header bar is
|
||||||
|
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the
|
||||||
|
// only way out on a Steam Deck).
|
||||||
|
let fs_hint = gtk::Label::new(Some("F11 · L1 + R1 + Start + Select — exit fullscreen"));
|
||||||
|
fs_hint.add_css_class("osd");
|
||||||
|
fs_hint.set_halign(gtk::Align::Center);
|
||||||
|
fs_hint.set_valign(gtk::Align::Start);
|
||||||
|
fs_hint.set_margin_top(12);
|
||||||
|
fs_hint.set_visible(false);
|
||||||
|
|
||||||
let overlay = gtk::Overlay::new();
|
let overlay = gtk::Overlay::new();
|
||||||
overlay.set_child(Some(&offload));
|
overlay.set_child(Some(&offload));
|
||||||
overlay.add_overlay(&stats_label);
|
overlay.add_overlay(&stats_label);
|
||||||
overlay.add_overlay(&hint);
|
overlay.add_overlay(&hint);
|
||||||
|
overlay.add_overlay(&fs_hint);
|
||||||
overlay.set_focusable(true);
|
overlay.set_focusable(true);
|
||||||
|
|
||||||
let capture = Rc::new(Capture {
|
let capture = Rc::new(Capture {
|
||||||
@@ -198,8 +210,17 @@ pub fn new(
|
|||||||
// the page dies — the window outlives every session.)
|
// the page dies — the window outlives every session.)
|
||||||
let fs_handler = {
|
let fs_handler = {
|
||||||
let toolbar = toolbar.clone();
|
let toolbar = toolbar.clone();
|
||||||
|
let fs_hint = fs_hint.clone();
|
||||||
window.connect_fullscreened_notify(move |w| {
|
window.connect_fullscreened_notify(move |w| {
|
||||||
toolbar.set_reveal_top_bars(!w.is_fullscreen());
|
let fs = w.is_fullscreen();
|
||||||
|
toolbar.set_reveal_top_bars(!fs);
|
||||||
|
if fs {
|
||||||
|
fs_hint.set_visible(true);
|
||||||
|
let fs_hint = fs_hint.clone();
|
||||||
|
glib::timeout_add_seconds_local_once(4, move || fs_hint.set_visible(false));
|
||||||
|
} else {
|
||||||
|
fs_hint.set_visible(false);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -404,18 +425,39 @@ pub fn new(
|
|||||||
let cap = capture.clone();
|
let cap = capture.clone();
|
||||||
overlay.connect_unmap(move |_| cap.release());
|
overlay.connect_unmap(move |_| cap.release());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Controller escape chord (gamepad service) → leave fullscreen + release capture. The
|
||||||
|
// chord is the only fullscreen exit a controller has (no F11 key; fullscreen hides the
|
||||||
|
// chrome). Aborted on page-hidden so a stale future can't act on the shared window.
|
||||||
|
let escape_future = {
|
||||||
|
let window = window.clone();
|
||||||
|
let cap = capture.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
while escape_rx.recv().await.is_ok() {
|
||||||
|
if window.is_fullscreen() {
|
||||||
|
window.unfullscreen();
|
||||||
|
}
|
||||||
|
cap.release();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// The page's `hidden` fires once navigation away completes (back button, pop on
|
// The page's `hidden` fires once navigation away completes (back button, pop on
|
||||||
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
|
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
|
||||||
{
|
{
|
||||||
let window = window.clone();
|
let window = window.clone();
|
||||||
let stop_h = stop.clone();
|
let stop_h = stop.clone();
|
||||||
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
||||||
|
let escape_future = RefCell::new(Some(escape_future));
|
||||||
page.connect_hidden(move |_| {
|
page.connect_hidden(move |_| {
|
||||||
tracing::debug!("stream page hidden — ending session");
|
tracing::debug!("stream page hidden — ending session");
|
||||||
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
||||||
window.disconnect(fs);
|
window.disconnect(fs);
|
||||||
window.disconnect(active);
|
window.disconnect(active);
|
||||||
}
|
}
|
||||||
|
if let Some(f) = escape_future.borrow_mut().take() {
|
||||||
|
f.abort();
|
||||||
|
}
|
||||||
if window.is_fullscreen() {
|
if window.is_fullscreen() {
|
||||||
window.unfullscreen();
|
window.unfullscreen();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user