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:
2026-06-17 09:16:47 +00:00
parent 25c8dd58c7
commit 67608944f0
3 changed files with 93 additions and 2 deletions
+1
View File
@@ -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,
+49 -1
View File
@@ -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 {
+43 -1
View File
@@ -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();
} }