feat(windows): surface shortcuts on a Help screen; exit fullscreen when the stream stops
apple / swift (push) Successful in 1m10s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m13s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 44s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 41s
android / android (push) Successful in 4m11s
ci / rust (push) Failing after 1m21s
ci / web (push) Successful in 55s
apple / screenshots (push) Successful in 5m52s
ci / docs-site (push) Successful in 1m2s
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 11:17:19 +02:00
parent d490397908
commit 8a4b0c7bae
5 changed files with 193 additions and 117 deletions
+90
View File
@@ -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<Element> = 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<Screen>) -> 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(),
])
}
+4
View File
@@ -277,6 +277,10 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let sa = set_show_add.clone(); let sa = set_show_add.clone();
move || sa.call(true) 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({ button("Settings").icon(Symbol::Setting).on_click({
let ss = set_screen.clone(); let ss = set_screen.clone();
move || ss.call(Screen::Settings) move || ss.call(Screen::Settings)
+9 -3
View File
@@ -8,7 +8,8 @@
//! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows) //! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows)
//! * [`pair`] — the SPAKE2 PIN pairing ceremony //! * [`pair`] — the SPAKE2 PIN pairing ceremony
//! * [`speed`] — the per-host network speed test (probe burst over the real data plane) //! * [`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 //! * [`stream`] — the live stream: `SwapChainPanel` + D3D11 presenter + HUD overlay
//! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor //! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor
//! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`) //! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`)
@@ -23,6 +24,7 @@
//! present must not go through state/rerender. //! present must not go through state/rerender.
mod connect; mod connect;
mod help;
mod hosts; mod hosts;
mod licenses; mod licenses;
mod pair; mod pair;
@@ -57,6 +59,9 @@ pub(crate) enum Screen {
Settings, Settings,
/// Open-source / third-party license notices (reached from Settings). /// Open-source / third-party license notices (reached from Settings).
Licenses, Licenses,
/// In-stream keyboard-shortcuts reference + capture help (reached from the host list's Help
/// button).
Help,
Pair, Pair,
/// Per-host network speed test (probe burst + recommended bitrate). /// Per-host network speed test (probe burst + recommended bitrate).
SpeedTest, SpeedTest,
@@ -382,8 +387,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
set_hover, set_hover,
}, },
), ),
// connecting_page / request_access_page / waking_page / settings_page / licenses_page use // connecting_page / request_access_page / waking_page / settings_page / licenses_page /
// no hooks (they never touch `cx`), so calling them inline is sound. // help_page use no hooks (they never touch `cx`), so calling them inline is sound.
Screen::Connecting => connect::connecting_page(ctx, &status), Screen::Connecting => connect::connecting_page(ctx, &status),
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen), Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
Screen::Waking => connect::waking_page(ctx, &set_screen), Screen::Waking => connect::waking_page(ctx, &set_screen),
@@ -395,6 +400,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
nav_progress, nav_progress,
), ),
Screen::Licenses => licenses::licenses_page(&set_screen), Screen::Licenses => licenses::licenses_page(&set_screen),
Screen::Help => help::help_page(&set_screen),
Screen::Pair => component(pair::pair_page, svc), Screen::Pair => component(pair::pair_page, svc),
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }), Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }), Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }),
+5 -63
View File
@@ -109,59 +109,6 @@ fn settings_card(controls: Vec<Element>) -> Element {
card(vstack(controls).spacing(10.0)).into() 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<Element> = 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) — /// 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 /// 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 /// 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" => (
"Input", "Input",
vstack(( settings_card(vec![
settings_card(vec![ forward_combo.into(),
forward_combo.into(), pad_combo.into(),
pad_combo.into(), shortcuts_toggle.into(),
shortcuts_toggle.into(), ]),
]),
shortcuts_reference(),
))
.spacing(14.0)
.into(),
), ),
"audio" => ( "audio" => (
"Audio", "Audio",
+85 -51
View File
@@ -180,6 +180,9 @@ pub fn uninstall() {
} }
if let Some(mut st) = STATE.lock().unwrap().take() { if let Some(mut st) = STATE.lock().unwrap().take() {
set_captured(&mut st, false); // hand the cursor back + flush held state 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; st.locked = on;
} }
/// Toggle borderless fullscreen for our top-level window (F11). The classic Win32 dance: entering, /// The pre-fullscreen window placement, saved on entering fullscreen and restored on leaving it.
/// save the window placement and strip `WS_OVERLAPPEDWINDOW`, then size the window to the whole /// Module-level (not a `toggle_fullscreen`-local static) so the F11 toggle and the stream-stop exit
/// monitor; exiting, restore the style and the saved placement. The window's own style bit doubles /// ([`uninstall`]) share the one saved placement, and its presence is also the "are we fullscreen?"
/// as the fullscreen flag, so no extra state beyond the saved placement is needed. windows-reactor /// flag for [`exit_fullscreen`]. Only ever touched on the UI thread (the hook proc / the stream
/// owns the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with /// page's unmount), but a Mutex keeps the static sound + `Sync`.
/// the GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize. static SAVED_PLACEMENT: Mutex<Option<windows::Win32::UI::WindowsAndMessaging::WINDOWPLACEMENT>> =
fn toggle_fullscreen(hwnd: isize) { 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::{ use windows::Win32::Graphics::Gdi::{
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY, GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY,
}; };
use windows::Win32::UI::WindowsAndMessaging::{ use windows::Win32::UI::WindowsAndMessaging::{
GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPos, GWL_STYLE,
GWL_STYLE, SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER, SWP_FRAMECHANGED, SWP_NOOWNERZORDER, SWP_NOZORDER, WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW,
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<Option<WINDOWPLACEMENT>> = Mutex::new(None);
let hwnd = HWND(hwnd as *mut _);
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize; let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
unsafe { unsafe {
let style = GetWindowLongPtrW(hwnd, GWL_STYLE); let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
if style & overlapped != 0 { let mut wp = WINDOWPLACEMENT {
// Windowed → fullscreen: remember where we were, drop the frame, cover the monitor. length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
let mut wp = WINDOWPLACEMENT { ..Default::default()
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32, };
..Default::default() let mut mi = MONITORINFO {
}; cbSize: std::mem::size_of::<MONITORINFO>() as u32,
let mut mi = MONITORINFO { ..Default::default()
cbSize: std::mem::size_of::<MONITORINFO>() as u32, };
..Default::default() let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
}; if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool() {
let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY); *SAVED_PLACEMENT.lock().unwrap() = Some(wp);
if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool() SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped);
{ let r = mi.rcMonitor;
*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 _ = SetWindowPos( let _ = SetWindowPos(
hwnd, hwnd,
None, None,
0, r.left,
0, r.top,
0, r.right - r.left,
0, r.bottom - r.top,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED, 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) { fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
let _ = c.send_input(&InputEvent { let _ = c.send_input(&InputEvent {
kind, kind,