fix(clients/windows): hide the local cursor while captured
apple / swift (push) Successful in 1m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m42s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 58s
android / android (push) Successful in 4m21s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m4s
arch / build-publish (push) Successful in 5m57s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m19s
ci / rust (push) Successful in 5m4s
ci / bench (push) Successful in 5m4s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
deb / build-publish (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
apple / screenshots (push) Successful in 6m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 11m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m32s
docker / deploy-docs (push) Failing after 18s
apple / swift (push) Successful in 1m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m42s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 58s
android / android (push) Successful in 4m21s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m4s
arch / build-publish (push) Successful in 5m57s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m19s
ci / rust (push) Successful in 5m4s
ci / bench (push) Successful in 5m4s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
deb / build-publish (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
apple / screenshots (push) Successful in 6m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 11m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 11m32s
docker / deploy-docs (push) Failing after 18s
set_locked() hid the OS cursor with a one-shot ShowCursor(false), but the WinUI content island answers WM_SETCURSOR and re-asserts the arrow on every pointer move, so the cursor reappeared the instant the mouse moved while the pointer lock was engaged. A low-level mouse hook never sees WM_SETCURSOR, so it couldn't suppress it. Subclass the WinUI window and all its descendants (the video is a composition SwapChainPanel, so the pointer actually sits over WinUI's internal input-site child, which is the window that receives WM_SETCURSOR) and, while the lock wants the cursor hidden, answer WM_SETCURSOR ourselves with SetCursor(None) and return TRUE — halting WinUI's arrow re-assertion. ShowCursor(false) stays as the coarse first-frame hide. The subclass is removed on teardown so the reused app window (host list) is left pristine. Adds the Win32_UI_Shell feature for the subclassing APIs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,11 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
|||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
"Win32_UI_HiDpi",
|
"Win32_UI_HiDpi",
|
||||||
"Win32_UI_Input_KeyboardAndMouse",
|
"Win32_UI_Input_KeyboardAndMouse",
|
||||||
|
# Win32 window subclassing (SetWindowSubclass/DefSubclassProc) — the stream input hooks
|
||||||
|
# subclass the WinUI window + its content-island children to swallow WM_SETCURSOR so the
|
||||||
|
# local cursor stays hidden while the pointer is locked (WinUI otherwise re-asserts the arrow
|
||||||
|
# on every pointer move, defeating a one-shot ShowCursor(false)).
|
||||||
|
"Win32_UI_Shell",
|
||||||
"Win32_UI_WindowsAndMessaging",
|
"Win32_UI_WindowsAndMessaging",
|
||||||
] }
|
] }
|
||||||
|
|
||||||
|
|||||||
@@ -35,16 +35,19 @@ use punktfunk_core::input::{InputEvent, InputKind};
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use windows::core::BOOL;
|
||||||
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
|
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
|
||||||
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
||||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
use windows::Win32::UI::Input::KeyboardAndMouse::{VK_D, VK_F11, VK_Q, VK_S};
|
use windows::Win32::UI::Input::KeyboardAndMouse::{VK_D, VK_F11, VK_Q, VK_S};
|
||||||
|
use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass};
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos,
|
CallNextHookEx, ClipCursor, EnumChildWindows, GetClientRect, GetForegroundWindow, SetCursor,
|
||||||
SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT,
|
SetCursorPos, SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK,
|
||||||
LLKHF_EXTENDED, LLMHF_INJECTED, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_KEYUP,
|
KBDLLHOOKSTRUCT, LLKHF_EXTENDED, LLMHF_INJECTED, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
|
||||||
WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE,
|
WM_KEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL,
|
||||||
WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP,
|
WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SETCURSOR, WM_SYSKEYUP,
|
||||||
|
WM_XBUTTONDOWN, WM_XBUTTONUP,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct State {
|
struct State {
|
||||||
@@ -91,6 +94,14 @@ static CAPTURED: AtomicBool = AtomicBool::new(false);
|
|||||||
/// Ctrl+Alt+Shift+S for the session (parity with the GTK client's live `s` toggle); the HUD poll
|
/// Ctrl+Alt+Shift+S for the session (parity with the GTK client's live `s` toggle); the HUD poll
|
||||||
/// reads it lock-free to drive the overlay.
|
/// reads it lock-free to drive the overlay.
|
||||||
static HUD_VISIBLE: AtomicBool = AtomicBool::new(false);
|
static HUD_VISIBLE: AtomicBool = AtomicBool::new(false);
|
||||||
|
/// Whether the pointer lock currently wants the OS cursor hidden. Read lock-free by
|
||||||
|
/// [`cursor_subclass_proc`] (which runs on the UI thread inside `WM_SETCURSOR`) so it can override
|
||||||
|
/// WinUI's per-move arrow re-assertion — a one-shot `ShowCursor(false)` alone loses that race
|
||||||
|
/// because the content island re-sets the arrow every time the pointer moves.
|
||||||
|
static CURSOR_HIDDEN: AtomicBool = AtomicBool::new(false);
|
||||||
|
/// Our `SetWindowSubclass` id on the WinUI window + its content-island children (any stable value;
|
||||||
|
/// scopes the subclass so install/remove target exactly our proc).
|
||||||
|
const CURSOR_SUBCLASS_ID: usize = 0x7066_6375; // 'pfcu'
|
||||||
|
|
||||||
/// Whether stream input is currently captured (drives the HUD's release/capture hint).
|
/// Whether stream input is currently captured (drives the HUD's release/capture hint).
|
||||||
pub fn is_captured() -> bool {
|
pub fn is_captured() -> bool {
|
||||||
@@ -181,6 +192,9 @@ pub fn uninstall() {
|
|||||||
if let Some(mut st) = STATE.lock().unwrap().take() {
|
if let Some(mut st) = STATE.lock().unwrap().take() {
|
||||||
// Hand the cursor back + flush held state.
|
// Hand the cursor back + flush held state.
|
||||||
set_captured(&mut st, false);
|
set_captured(&mut st, false);
|
||||||
|
// Drop the WM_SETCURSOR subclass so the long-lived app window (reused for the host list
|
||||||
|
// once the stream ends) is left pristine — set_captured already cleared CURSOR_HIDDEN.
|
||||||
|
remove_cursor_subclass(HWND(st.hwnd as *mut _));
|
||||||
// Fullscreen is a streaming-only mode: if F11 put us there, drop back to a normal window
|
// 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.
|
// so the GUI (the host list) is never left borderless-fullscreen after the stream ends.
|
||||||
exit_fullscreen(HWND(st.hwnd as *mut _));
|
exit_fullscreen(HWND(st.hwnd as *mut _));
|
||||||
@@ -199,6 +213,64 @@ fn flush_held(st: &mut State) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subclass proc on the WinUI window + its content-island children: while the pointer lock wants
|
||||||
|
/// the cursor hidden ([`CURSOR_HIDDEN`]), answer `WM_SETCURSOR` ourselves with `SetCursor(None)`
|
||||||
|
/// and return TRUE — halting WinUI's default handling before it re-asserts the arrow. This is what
|
||||||
|
/// actually keeps the cursor hidden while captured; the sibling `ShowCursor(false)` cannot, because
|
||||||
|
/// WinUI re-sets the arrow on every pointer move (the content island answers `WM_SETCURSOR` itself,
|
||||||
|
/// which a low-level mouse hook never sees). When not hidden, we defer to the chain untouched.
|
||||||
|
unsafe extern "system" fn cursor_subclass_proc(
|
||||||
|
hwnd: HWND,
|
||||||
|
msg: u32,
|
||||||
|
wparam: WPARAM,
|
||||||
|
lparam: LPARAM,
|
||||||
|
_id: usize,
|
||||||
|
_ref: usize,
|
||||||
|
) -> LRESULT {
|
||||||
|
if msg == WM_SETCURSOR && CURSOR_HIDDEN.load(Ordering::Relaxed) {
|
||||||
|
unsafe {
|
||||||
|
let _ = SetCursor(None);
|
||||||
|
}
|
||||||
|
return LRESULT(1); // handled — suppress the framework's arrow re-assertion
|
||||||
|
}
|
||||||
|
unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "system" fn subclass_install_cb(child: HWND, _l: LPARAM) -> BOOL {
|
||||||
|
unsafe {
|
||||||
|
let _ = SetWindowSubclass(child, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID, 0);
|
||||||
|
}
|
||||||
|
BOOL(1) // keep enumerating
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "system" fn subclass_remove_cb(child: HWND, _l: LPARAM) -> BOOL {
|
||||||
|
unsafe {
|
||||||
|
let _ = RemoveWindowSubclass(child, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID);
|
||||||
|
}
|
||||||
|
BOOL(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install [`cursor_subclass_proc`] on the top-level WinUI window and every descendant — the video
|
||||||
|
/// is a composition SwapChainPanel, so the pointer actually sits over WinUI's internal content-
|
||||||
|
/// island child window, which is the window that receives `WM_SETCURSOR`. `EnumChildWindows`
|
||||||
|
/// recurses into all descendants, so one pass covers it. Idempotent (re-installing the same
|
||||||
|
/// id+proc just refreshes it), so it's safe to call on every lock engage.
|
||||||
|
fn install_cursor_subclass(top: HWND) {
|
||||||
|
unsafe {
|
||||||
|
let _ = SetWindowSubclass(top, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID, 0);
|
||||||
|
let _ = EnumChildWindows(Some(top), Some(subclass_install_cb), LPARAM(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove our subclass from the top-level window and every descendant. Called on teardown so the
|
||||||
|
/// long-lived app window (reused for the host list after the stream ends) is left pristine.
|
||||||
|
fn remove_cursor_subclass(top: HWND) {
|
||||||
|
unsafe {
|
||||||
|
let _ = RemoveWindowSubclass(top, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID);
|
||||||
|
let _ = EnumChildWindows(Some(top), Some(subclass_remove_cb), LPARAM(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Engage or release the pointer lock: confine + hide + recentre on, free + show on off.
|
/// Engage or release the pointer lock: confine + hide + recentre on, free + show on off.
|
||||||
/// Guarded so the `ClipCursor`/`ShowCursor` calls stay balanced (one each per transition).
|
/// Guarded so the `ClipCursor`/`ShowCursor` calls stay balanced (one each per transition).
|
||||||
/// Engaging captures the lock geometry (rect, centre, screen→host scale) — see `State::clip`.
|
/// Engaging captures the lock geometry (rect, centre, screen→host scale) — see `State::clip`.
|
||||||
@@ -237,10 +309,16 @@ fn set_locked(st: &mut State, on: bool) {
|
|||||||
st.scale = (ww / vw).min(wh / vh).max(0.01);
|
st.scale = (ww / vw).min(wh / vh).max(0.01);
|
||||||
let _ = SetCursorPos(st.center_x, st.center_y);
|
let _ = SetCursorPos(st.center_x, st.center_y);
|
||||||
}
|
}
|
||||||
|
// Hide the OS cursor. ShowCursor(false) is the coarse gate; the subclass is what
|
||||||
|
// actually holds it hidden against WinUI's per-move arrow re-assertion — see
|
||||||
|
// cursor_subclass_proc / install_cursor_subclass.
|
||||||
let _ = ShowCursor(false);
|
let _ = ShowCursor(false);
|
||||||
|
CURSOR_HIDDEN.store(true, Ordering::Relaxed);
|
||||||
|
install_cursor_subclass(hwnd);
|
||||||
st.acc_x = 0.0;
|
st.acc_x = 0.0;
|
||||||
st.acc_y = 0.0;
|
st.acc_y = 0.0;
|
||||||
} else {
|
} else {
|
||||||
|
CURSOR_HIDDEN.store(false, Ordering::Relaxed);
|
||||||
let _ = ClipCursor(None);
|
let _ = ClipCursor(None);
|
||||||
let _ = ShowCursor(true);
|
let _ = ShowCursor(true);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user