From 67608944f08d93356abae7318feaaa26eb8229f3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 17 Jun 2026 09:16:47 +0000 Subject: [PATCH] feat(client-linux): controller + keyboard shortcuts to exit fullscreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/punktfunk-client-linux/src/app.rs | 1 + crates/punktfunk-client-linux/src/gamepad.rs | 50 ++++++++++++++++++- .../punktfunk-client-linux/src/ui_stream.rs | 44 +++++++++++++++- 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/crates/punktfunk-client-linux/src/app.rs b/crates/punktfunk-client-linux/src/app.rs index 177f748..fc2bb21 100644 --- a/crates/punktfunk-client-linux/src/app.rs +++ b/crates/punktfunk-client-linux/src/app.rs @@ -469,6 +469,7 @@ fn start_session(app: Rc, req: ConnectRequest, pin: Option<[u8; 32]>) { &app.window, connector, frames.take().expect("Connected delivered once"), + app.gamepad.escape_events(), handle.stop.clone(), inhibit, &title, diff --git a/crates/punktfunk-client-linux/src/gamepad.rs b/crates/punktfunk-client-linux/src/gamepad.rs index b38f543..d184aa9 100644 --- a/crates/punktfunk-client-linux/src/gamepad.rs +++ b/crates/punktfunk-client-linux/src/gamepad.rs @@ -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 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)] pub struct PadInfo { pub id: u32, @@ -46,6 +54,9 @@ pub struct GamepadService { active: Arc>>, pinned: Arc>>, ctl: Sender, + /// 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 { @@ -54,11 +65,12 @@ impl GamepadService { let active = Arc::new(Mutex::new(None)); let pinned = Arc::new(Mutex::new(None)); 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()); if let Err(e) = std::thread::Builder::new() .name("punktfunk-gamepad".into()) .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"); } }) @@ -70,9 +82,16 @@ impl GamepadService { active, pinned, 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 { self.pads.lock().unwrap().clone() } @@ -210,6 +229,10 @@ struct Worker { last_axis: [i32; 6], held_buttons: Vec, 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 { @@ -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). fn set_sensors(&mut self, enabled: bool) { let Some(id) = self.active_id() else { return }; @@ -270,6 +313,7 @@ fn run( active_out: &Mutex>, pinned_out: &Mutex>, ctl: &Receiver, + escape_tx: &async_channel::Sender<()>, ) -> Result<(), String> { // Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its // own thread. @@ -288,6 +332,8 @@ fn run( last_axis: [i32::MIN; 6], held_buttons: Vec::new(), last_accel: [0; 3], + escape_tx: escape_tx.clone(), + chord_armed: false, }; let publish = |w: &Worker| { @@ -372,6 +418,7 @@ fn run( bit, 1, ); + w.maybe_fire_escape(); } } Event::ControllerButtonUp { which, button, .. } @@ -385,6 +432,7 @@ fn run( bit, 0, ); + w.rearm_escape(); } } Event::ControllerAxisMotion { diff --git a/crates/punktfunk-client-linux/src/ui_stream.rs b/crates/punktfunk-client-linux/src/ui_stream.rs index 05694c9..31a7785 100644 --- a/crates/punktfunk-client-linux/src/ui_stream.rs +++ b/crates/punktfunk-client-linux/src/ui_stream.rs @@ -129,6 +129,7 @@ pub fn new( window: &adw::ApplicationWindow, connector: Arc, frames: async_channel::Receiver, + escape_rx: async_channel::Receiver<()>, stop: Arc, inhibit_shortcuts: bool, title: &str, @@ -159,10 +160,21 @@ pub fn new( hint.set_margin_bottom(24); 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(); overlay.set_child(Some(&offload)); overlay.add_overlay(&stats_label); overlay.add_overlay(&hint); + overlay.add_overlay(&fs_hint); overlay.set_focusable(true); let capture = Rc::new(Capture { @@ -198,8 +210,17 @@ pub fn new( // the page dies — the window outlives every session.) let fs_handler = { let toolbar = toolbar.clone(); + let fs_hint = fs_hint.clone(); 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(); 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 // session end) — NOT on the transient unmap/map cycle a NavigationView push performs. { let window = window.clone(); let stop_h = stop.clone(); let handlers = RefCell::new(Some((fs_handler, active_handler))); + let escape_future = RefCell::new(Some(escape_future)); page.connect_hidden(move |_| { tracing::debug!("stream page hidden — ending session"); if let Some((fs, active)) = handlers.borrow_mut().take() { window.disconnect(fs); window.disconnect(active); } + if let Some(f) = escape_future.borrow_mut().take() { + f.abort(); + } if window.is_fullscreen() { window.unfullscreen(); }