From 8a4b0c7bae79531d5d4d1262cba6171b22e331ba Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 8 Jul 2026 11:17:19 +0200 Subject: [PATCH] feat(windows): surface shortcuts on a Help screen; exit fullscreen when the stream stops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-stream keyboard shortcuts were only reachable deep in Settings > Input. Move them to a dedicated Help screen reached from a Help button on the host list (mirrors the Licenses screen — a hook-free page routed from root), and drop the reference card out of Settings so there's one home for it. Fullscreen (F11) was a window-style toggle that stream stop never undid, so disconnecting while fullscreen left the GUI borderless-fullscreen. Split toggle_fullscreen into is_fullscreen/enter_fullscreen/exit_fullscreen sharing a module-level SAVED_PLACEMENT, and have input::uninstall() call exit_fullscreen() on teardown. exit_fullscreen is a no-op when nothing was saved, so it's safe on every stream stop and idempotent with F11 — fullscreen is now streaming-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/windows/src/app/help.rs | 90 ++++++++++++++++++ clients/windows/src/app/hosts.rs | 4 + clients/windows/src/app/mod.rs | 12 ++- clients/windows/src/app/settings.rs | 68 +------------- clients/windows/src/input.rs | 136 +++++++++++++++++----------- 5 files changed, 193 insertions(+), 117 deletions(-) create mode 100644 clients/windows/src/app/help.rs diff --git a/clients/windows/src/app/help.rs b/clients/windows/src/app/help.rs new file mode 100644 index 00000000..7c64f6b5 --- /dev/null +++ b/clients/windows/src/app/help.rs @@ -0,0 +1,90 @@ +//! The Help screen: a short note on the in-stream capture model plus a reference of the keyboard +//! shortcuts — reached from the Help button on the host list. The Windows counterpart of the GTK +//! client's Keyboard Shortcuts window; the bindings themselves live in [`crate::input`], so both +//! clients document the same set. + +use super::style::*; +use super::Screen; +use windows_reactor::*; + +/// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it +/// does. Read-only — the bindings themselves live in the input hook ([`crate::input`]). +const STREAM_SHORTCUTS: &[(&str, &str)] = &[ + ("F11", "Toggle fullscreen"), + ( + "Ctrl+Alt+Shift+Q", + "Release captured input (click the stream to recapture)", + ), + ("Ctrl+Alt+Shift+D", "Disconnect"), + ("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"), +]; + +/// A subtle key-cap chip for the shortcuts reference — the chord on a filled, bordered pill. +fn key_chip(keys: &str) -> Element { + border(text_block(keys).font_size(12.0).semibold()) + .background(ThemeRef::SubtleFill) + .border_brush(ThemeRef::CardStroke) + .border_thickness(uniform(1.0)) + .corner_radius(6.0) + .padding(edges(8.0, 3.0, 8.0, 3.0)) + .horizontal_alignment(HorizontalAlignment::Left) + .into() +} + +/// A read-only reference card listing the in-stream keyboard shortcuts. One grid, chord chip then +/// action, so the actions line up across rows. +fn shortcuts_reference() -> Element { + let mut children: Vec = Vec::new(); + for (i, (keys, action)) in STREAM_SHORTCUTS.iter().enumerate() { + let row = i as i32; + children.push(key_chip(keys).grid_row(row).grid_column(0)); + let action_cell: Element = text_block(*action) + .foreground(ThemeRef::SecondaryText) + .vertical_alignment(VerticalAlignment::Center) + .into(); + children.push(action_cell.grid_row(row).grid_column(1)); + } + let table = grid(children) + .columns([GridLength::Auto, GridLength::Star(1.0)]) + .rows(vec![GridLength::Auto; STREAM_SHORTCUTS.len()]) + .column_spacing(12.0) + .row_spacing(6.0); + card(vstack(( + text_block("In-stream keyboard shortcuts") + .semibold() + .margin(edges(0.0, 0.0, 0.0, 8.0)), + table, + ))) + .into() +} + +/// The Help screen: a `page`-column with a Back button to the host list, an intro card on the +/// capture model, and the shortcuts reference. Hook-free — called inline from `root` like the +/// other static screens. +pub(crate) fn help_page(set_screen: &AsyncSetState) -> Element { + let back_btn = button("Back").accent().icon(Symbol::Back).on_click({ + let ss = set_screen.clone(); + move || ss.call(Screen::Hosts) + }); + + let intro = card( + vstack(( + text_block("During a stream").font_size(15.0).semibold(), + text_block( + "Click the stream to capture your mouse and keyboard \u{2014} the shortcuts below \ + then work while you play. Release capture to hand the cursor back to this \ + computer, and click the stream again to retake it.", + ) + .font_size(12.0) + .wrap() + .foreground(ThemeRef::SecondaryText), + )) + .spacing(8.0), + ); + + page(vec![ + page_header("Help", back_btn), + intro.into(), + shortcuts_reference(), + ]) +} diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index d7c81a24..ed8d87ed 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -277,6 +277,10 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { let sa = set_show_add.clone(); move || sa.call(true) }), + button("Help").icon(Symbol::Help).on_click({ + let ss = set_screen.clone(); + move || ss.call(Screen::Help) + }), button("Settings").icon(Symbol::Setting).on_click({ let ss = set_screen.clone(); move || ss.call(Screen::Settings) diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index c2179295..4c2fe254 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -8,7 +8,8 @@ //! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows) //! * [`pair`] — the SPAKE2 PIN pairing ceremony //! * [`speed`] — the per-host network speed test (probe burst over the real data plane) -//! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen +//! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen · +//! [`help`] — the in-stream keyboard-shortcuts reference (reached from the host list) //! * [`stream`] — the live stream: `SwapChainPanel` + D3D11 presenter + HUD overlay //! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor //! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`) @@ -23,6 +24,7 @@ //! present must not go through state/rerender. mod connect; +mod help; mod hosts; mod licenses; mod pair; @@ -57,6 +59,9 @@ pub(crate) enum Screen { Settings, /// Open-source / third-party license notices (reached from Settings). Licenses, + /// In-stream keyboard-shortcuts reference + capture help (reached from the host list's Help + /// button). + Help, Pair, /// Per-host network speed test (probe burst + recommended bitrate). SpeedTest, @@ -382,8 +387,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { set_hover, }, ), - // connecting_page / request_access_page / waking_page / settings_page / licenses_page use - // no hooks (they never touch `cx`), so calling them inline is sound. + // connecting_page / request_access_page / waking_page / settings_page / licenses_page / + // help_page use no hooks (they never touch `cx`), so calling them inline is sound. Screen::Connecting => connect::connecting_page(ctx, &status), Screen::RequestAccess => connect::request_access_page(ctx, &set_screen), Screen::Waking => connect::waking_page(ctx, &set_screen), @@ -395,6 +400,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { nav_progress, ), Screen::Licenses => licenses::licenses_page(&set_screen), + Screen::Help => help::help_page(&set_screen), Screen::Pair => component(pair::pair_page, svc), Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }), Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }), diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index f36ccf70..68188a4a 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -109,59 +109,6 @@ fn settings_card(controls: Vec) -> Element { card(vstack(controls).spacing(10.0)).into() } -/// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it -/// does. Read-only — the bindings themselves live in the input hook (`crate::input`); this is the -/// Windows analogue of that window, so both clients document the same set. -const STREAM_SHORTCUTS: &[(&str, &str)] = &[ - ("F11", "Toggle fullscreen"), - ( - "Ctrl+Alt+Shift+Q", - "Release captured input (click the stream to recapture)", - ), - ("Ctrl+Alt+Shift+D", "Disconnect"), - ("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"), -]; - -/// A subtle key-cap chip for the shortcuts reference — the chord on a filled, bordered pill. -fn key_chip(keys: &str) -> Element { - border(text_block(keys).font_size(12.0).semibold()) - .background(ThemeRef::SubtleFill) - .border_brush(ThemeRef::CardStroke) - .border_thickness(uniform(1.0)) - .corner_radius(6.0) - .padding(edges(8.0, 3.0, 8.0, 3.0)) - .horizontal_alignment(HorizontalAlignment::Left) - .into() -} - -/// A read-only reference card listing the in-stream keyboard shortcuts — the Windows counterpart of -/// the GTK client's Keyboard Shortcuts window. One grid, chord chip then action, so the actions -/// line up across rows. -fn shortcuts_reference() -> Element { - let mut children: Vec = Vec::new(); - for (i, (keys, action)) in STREAM_SHORTCUTS.iter().enumerate() { - let row = i as i32; - children.push(key_chip(keys).grid_row(row).grid_column(0)); - let action_cell: Element = text_block(*action) - .foreground(ThemeRef::SecondaryText) - .vertical_alignment(VerticalAlignment::Center) - .into(); - children.push(action_cell.grid_row(row).grid_column(1)); - } - let table = grid(children) - .columns([GridLength::Auto, GridLength::Star(1.0)]) - .rows(vec![GridLength::Auto; STREAM_SHORTCUTS.len()]) - .column_spacing(12.0) - .row_spacing(6.0); - card(vstack(( - text_block("In-stream keyboard shortcuts") - .semibold() - .margin(edges(0.0, 0.0, 0.0, 8.0)), - table, - ))) - .into() -} - /// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) — /// one pane item per section, the section's card as the content, the built-in back arrow /// returning to the host list. `section`/`set_section` are the selected pane tag, held in ROOT @@ -399,16 +346,11 @@ pub(crate) fn settings_page( ), "input" => ( "Input", - vstack(( - settings_card(vec![ - forward_combo.into(), - pad_combo.into(), - shortcuts_toggle.into(), - ]), - shortcuts_reference(), - )) - .spacing(14.0) - .into(), + settings_card(vec![ + forward_combo.into(), + pad_combo.into(), + shortcuts_toggle.into(), + ]), ), "audio" => ( "Audio", diff --git a/clients/windows/src/input.rs b/clients/windows/src/input.rs index d3df291a..8b5c644c 100644 --- a/clients/windows/src/input.rs +++ b/clients/windows/src/input.rs @@ -180,6 +180,9 @@ pub fn uninstall() { } if let Some(mut st) = STATE.lock().unwrap().take() { set_captured(&mut st, false); // hand the cursor back + flush held state + // Fullscreen is a streaming-only mode: if F11 put us there, drop back to a normal window + // so the GUI (the host list) is never left borderless-fullscreen after the stream ends. + exit_fullscreen(HWND(st.hwnd as *mut _)); } } @@ -244,73 +247,104 @@ fn set_locked(st: &mut State, on: bool) { st.locked = on; } -/// Toggle borderless fullscreen for our top-level window (F11). The classic Win32 dance: entering, -/// save the window placement and strip `WS_OVERLAPPEDWINDOW`, then size the window to the whole -/// monitor; exiting, restore the style and the saved placement. The window's own style bit doubles -/// as the fullscreen flag, so no extra state beyond the saved placement is needed. windows-reactor -/// owns the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with -/// the GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize. -fn toggle_fullscreen(hwnd: isize) { +/// The pre-fullscreen window placement, saved on entering fullscreen and restored on leaving it. +/// Module-level (not a `toggle_fullscreen`-local static) so the F11 toggle and the stream-stop exit +/// ([`uninstall`]) share the one saved placement, and its presence is also the "are we fullscreen?" +/// flag for [`exit_fullscreen`]. Only ever touched on the UI thread (the hook proc / the stream +/// page's unmount), but a Mutex keeps the static sound + `Sync`. +static SAVED_PLACEMENT: Mutex> = + Mutex::new(None); + +/// Whether our top-level window is currently borderless-fullscreen. Entering strips +/// `WS_OVERLAPPEDWINDOW`, so its absence is the flag — no extra state beyond [`SAVED_PLACEMENT`]. +fn is_fullscreen(hwnd: HWND) -> bool { + use windows::Win32::UI::WindowsAndMessaging::{ + GetWindowLongPtrW, GWL_STYLE, WS_OVERLAPPEDWINDOW, + }; + let overlapped = WS_OVERLAPPEDWINDOW.0 as isize; + unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) & overlapped == 0 } +} + +/// Enter borderless fullscreen: remember the window placement, drop the frame +/// (`WS_OVERLAPPEDWINDOW`), and size the window to cover the whole monitor. windows-reactor owns +/// the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with the +/// GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize. +fn enter_fullscreen(hwnd: HWND) { use windows::Win32::Graphics::Gdi::{ GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY, }; use windows::Win32::UI::WindowsAndMessaging::{ - GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, - GWL_STYLE, SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER, - WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW, + GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPos, GWL_STYLE, + SWP_FRAMECHANGED, SWP_NOOWNERZORDER, SWP_NOZORDER, WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW, }; - // The pre-fullscreen placement, so exiting restores the exact windowed size + position. Only - // ever touched on the UI thread (the hook proc), but a Mutex keeps the static sound + `Sync`. - static SAVED: Mutex> = Mutex::new(None); - let hwnd = HWND(hwnd as *mut _); let overlapped = WS_OVERLAPPEDWINDOW.0 as isize; unsafe { let style = GetWindowLongPtrW(hwnd, GWL_STYLE); - if style & overlapped != 0 { - // Windowed → fullscreen: remember where we were, drop the frame, cover the monitor. - let mut wp = WINDOWPLACEMENT { - length: std::mem::size_of::() as u32, - ..Default::default() - }; - let mut mi = MONITORINFO { - cbSize: std::mem::size_of::() as u32, - ..Default::default() - }; - let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY); - if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool() - { - *SAVED.lock().unwrap() = Some(wp); - SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped); - let r = mi.rcMonitor; - let _ = SetWindowPos( - hwnd, - None, - r.left, - r.top, - r.right - r.left, - r.bottom - r.top, - SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED, - ); - } - } else { - // Fullscreen → windowed: restore the frame, then the saved placement. - SetWindowLongPtrW(hwnd, GWL_STYLE, style | overlapped); - if let Some(wp) = SAVED.lock().unwrap().take() { - let _ = SetWindowPlacement(hwnd, &wp); - } + let mut wp = WINDOWPLACEMENT { + length: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut mi = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY); + if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool() { + *SAVED_PLACEMENT.lock().unwrap() = Some(wp); + SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped); + let r = mi.rcMonitor; let _ = SetWindowPos( hwnd, None, - 0, - 0, - 0, - 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED, + r.left, + r.top, + r.right - r.left, + r.bottom - r.top, + SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED, ); } } } +/// Leave borderless fullscreen: restore the frame style and the saved placement. A no-op when we +/// aren't fullscreen (nothing saved), so it's safe to call unconditionally on stream stop. +fn exit_fullscreen(hwnd: HWND) { + use windows::Win32::UI::WindowsAndMessaging::{ + GetWindowLongPtrW, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, GWL_STYLE, + SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER, + WS_OVERLAPPEDWINDOW, + }; + let Some(wp) = SAVED_PLACEMENT.lock().unwrap().take() else { + return; // never went fullscreen — nothing to restore + }; + let overlapped = WS_OVERLAPPEDWINDOW.0 as isize; + unsafe { + let style = GetWindowLongPtrW(hwnd, GWL_STYLE); + SetWindowLongPtrW(hwnd, GWL_STYLE, style | overlapped); + let _ = SetWindowPlacement(hwnd, &wp); + let _ = SetWindowPos( + hwnd, + None, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED, + ); + } +} + +/// Toggle borderless fullscreen for our top-level window (F11), the classic Win32 dance split into +/// [`enter_fullscreen`] / [`exit_fullscreen`] so the stream-stop path can force windowed too. +fn toggle_fullscreen(hwnd: isize) { + let hwnd = HWND(hwnd as *mut _); + if is_fullscreen(hwnd) { + exit_fullscreen(hwnd); + } else { + enter_fullscreen(hwnd); + } +} + fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) { let _ = c.send_input(&InputEvent { kind,