From 5cbd249d091a7d3ae11dffd4652fc52e32d9492f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 17 Jun 2026 00:17:42 +0200 Subject: [PATCH] =?UTF-8?q?fix(client/windows):=20first=20on-glass=20pass?= =?UTF-8?q?=20=E2=80=94=20component=20routing,=20pointer=20lock,=20stats?= =?UTF-8?q?=20HUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real run on a display surfaced three issues the headless/dev-VM build never hit: - Route each hook-using screen (hosts/pair/stream) as its own component() instead of calling it with the shared cx. Calling hooks on the parent cx changed the hook order when the screen flipped, tripping reactor's Rules-of-Hooks guard and aborting the moment you navigated to the stream page. - Mouse: replace the absolute path (which swallowed WM_MOUSEMOVE and so froze the OS cursor, snapping the host pointer back to one point) with proper pointer lock — hide + ClipCursor + recentre, shipping relative MouseMove scaled by the Contain-fit factor. Ctrl+Alt+Shift+Q now actually toggles capture: track modifier state from the hook's own event stream (GetAsyncKeyState doesn't see keys we suppress in our own LL hook), and flush held keys/buttons on release so nothing sticks on the host. - Add the stats HUD overlay (mode · fps · Mb/s · capture→client/decode latency), mirroring the Apple client. Stats live in root state and reach the stream page as a prop (a child's own async-state update is pruned when props are unchanged), fed by a small poll thread. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-client-windows/src/app.rs | 215 +++++++++++++---- crates/punktfunk-client-windows/src/input.rs | 232 ++++++++++++++----- 2 files changed, 338 insertions(+), 109 deletions(-) diff --git a/crates/punktfunk-client-windows/src/app.rs b/crates/punktfunk-client-windows/src/app.rs index faff309..b3ab5b1 100644 --- a/crates/punktfunk-client-windows/src/app.rs +++ b/crates/punktfunk-client-windows/src/app.rs @@ -14,7 +14,7 @@ use crate::discovery::{self, DiscoveredHost}; use crate::gamepad::GamepadService; use crate::present::Presenter; -use crate::session::{self, SessionEvent, SessionParams}; +use crate::session::{self, SessionEvent, SessionParams, Stats}; use crate::trust::{self, KnownHost, KnownHosts, Settings}; use crate::video::DecodedFrame; use punktfunk_core::client::NativeClient; @@ -51,6 +51,56 @@ struct Target { pair_optional: bool, } +/// Stable app services handed to the page components as props. Each routed screen that uses +/// hooks (`hosts_page`/`pair_page`/`stream_page`) is mounted as its own `component(...)`, so +/// its hooks live in an isolated slot list — calling them on the shared parent `cx` would +/// change the hook order whenever the screen changes (reactor's Rules-of-Hooks guard aborts). +/// +/// `Svc` compares equal by `ctx` identity (it never meaningfully changes across renders), so a +/// page whose props are just `Svc` re-renders only via its own state hooks, never spuriously +/// from the parent. +#[derive(Clone)] +struct Svc { + ctx: Arc, + set_screen: AsyncSetState, + set_status: AsyncSetState, +} + +impl PartialEq for Svc { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.ctx, &other.ctx) + } +} + +/// Props for the hosts page: the services plus the changing discovery/status data that must +/// drive its re-render (compared by value, so a new host list or error refreshes the page). +#[derive(Clone)] +struct HostsProps { + svc: Svc, + hosts: Vec, + status: String, +} + +impl PartialEq for HostsProps { + fn eq(&self, other: &Self) -> bool { + self.svc == other.svc && self.hosts == other.hosts && self.status == other.status + } +} + +/// Props for the stream page: the services plus the live stats that drive the HUD overlay +/// (compared by value, so each new sample re-renders the overlay). +#[derive(Clone)] +struct StreamProps { + svc: Svc, + stats: Stats, +} + +impl PartialEq for StreamProps { + fn eq(&self, other: &Self) -> bool { + self.svc == other.svc && self.stats == other.stats + } +} + /// UI-thread-only present context: the D3D11 presenter plus the decoded-frame receiver. struct PresentCtx { presenter: Presenter, @@ -68,6 +118,9 @@ thread_local! { struct Shared { handoff: Mutex, async_channel::Receiver)>>, target: Mutex, + /// Latest stream stats, written by the session's event loop and mirrored into reactor state + /// by the stream page's HUD poll thread to drive the overlay. + stats: Mutex, } pub struct AppCtx { @@ -173,6 +226,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { let (screen, set_screen) = cx.use_async_state(Screen::Hosts); let (hosts, set_hosts) = cx.use_async_state(Vec::::new()); let (status, set_status) = cx.use_async_state(String::new()); + let (stats, set_stats) = cx.use_async_state(Stats::default()); // Continuous LAN discovery (spawned once). cx.use_effect((), { @@ -193,8 +247,40 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { } }); + // HUD stats: the session event loop writes `shared.stats`; this poll thread mirrors it into + // root state so the stream page gets it as a *prop*. (A child component's own async-state + // update is pruned when its props are unchanged — only a prop change re-renders it, exactly + // like discovery → hosts above.) + cx.use_effect((), { + let shared = ctx.shared.clone(); + let set_stats = set_stats.clone(); + move || { + std::thread::Builder::new() + .name("pf-hud".into()) + .spawn(move || { + let mut last = Stats::default(); + loop { + std::thread::sleep(std::time::Duration::from_millis(400)); + let s = *shared.stats.lock().unwrap(); + if s != last { + last = s; + set_stats.call(s); + } + } + }) + .ok(); + } + }); + + // Each hook-using screen is mounted as its own component so its hooks are isolated from + // root's (root's own hooks above stay a stable prefix regardless of which screen renders). + let svc = Svc { + ctx: ctx.clone(), + set_screen: set_screen.clone(), + set_status: set_status.clone(), + }; match screen { - Screen::Hosts => hosts_page(cx, ctx, &hosts, &status, &set_screen, &set_status), + Screen::Hosts => component(hosts_page, HostsProps { svc, hosts, status }), Screen::Connecting => vstack(( ProgressRing::indeterminate() .width(48.0) @@ -211,20 +297,19 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { .horizontal_alignment(HorizontalAlignment::Center) .vertical_alignment(VerticalAlignment::Center) .into(), + // settings_page uses no hooks (it never touches `cx`), so calling it inline is sound. Screen::Settings => settings_page(ctx, &set_screen), - Screen::Pair => pair_page(cx, ctx, &set_screen, &set_status), - Screen::Stream => stream_page(cx, ctx), + Screen::Pair => component(pair_page, svc), + Screen::Stream => component(stream_page, StreamProps { svc, stats }), } } -fn hosts_page( - cx: &mut RenderCx, - ctx: &Arc, - hosts: &[DiscoveredHost], - status: &str, - set_screen: &AsyncSetState, - set_status: &AsyncSetState, -) -> Element { +fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { + let ctx = &props.svc.ctx; + let hosts = props.hosts.as_slice(); + let status = props.status.as_str(); + let set_screen = &props.svc.set_screen; + let set_status = &props.svc.set_status; let (manual, set_manual) = cx.use_state(String::new()); let known = KnownHosts::load(); @@ -459,6 +544,7 @@ fn connect( let _ = k.save(); } gamepad.attach(connector.clone()); + *shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers *shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone())); ss.call(Screen::Stream); } @@ -483,7 +569,7 @@ fn connect( ss.call(Screen::Hosts); break; } - Ok(SessionEvent::Stats(_)) => {} + Ok(SessionEvent::Stats(s)) => *shared.stats.lock().unwrap() = s, Err(_) => { gamepad.detach(); ss.call(Screen::Hosts); @@ -493,12 +579,10 @@ fn connect( }); } -fn pair_page( - cx: &mut RenderCx, - ctx: &Arc, - set_screen: &AsyncSetState, - set_status: &AsyncSetState, -) -> Element { +fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { + let ctx = &props.ctx; + let set_screen = &props.set_screen; + let set_status = &props.set_status; let (code, set_code) = cx.use_state(String::new()); let target = ctx.shared.target.lock().unwrap().clone(); @@ -688,7 +772,8 @@ fn present_newest(ctx: &mut PresentCtx) { ctx.presenter.present(cpu); } -fn stream_page(cx: &mut RenderCx, ctx: &Arc) -> Element { +fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { + let ctx = &props.svc.ctx; // Take the connector + frames handoff once on mount; keep the connector alive (and for input) // in a use_ref, stash frames for `on_ready`, install the input hooks (and remove on unmount). let connector_ref = cx.use_ref::>>(None); @@ -710,7 +795,7 @@ fn stream_page(cx: &mut RenderCx, ctx: &Arc) -> Element { cx.use_effect((), { let rendering = rendering.clone(); move || { - if let Ok(r) = on_rendering(|| { + if let Ok(r) = on_rendering(move || { PRESENT.with(|cell| { if let Some(ctx) = cell.borrow_mut().as_mut() { present_newest(ctx); @@ -722,30 +807,70 @@ fn stream_page(cx: &mut RenderCx, ctx: &Arc) -> Element { } }); - swap_chain_panel() - .on_ready(|panel| match Presenter::new(1280, 720) { - Ok(p) => { - if let Err(e) = panel.set_swap_chain(p.swap_chain()) { - tracing::error!(error = %e, "set_swap_chain"); - } - if let Some(frames) = PENDING_FRAMES.with(|c| c.borrow_mut().take()) { - PRESENT.with(|cell| { - *cell.borrow_mut() = Some(PresentCtx { - presenter: p, - frames, + let mode = connector_ref.borrow().as_ref().map(|c| c.mode()); + grid(( + swap_chain_panel() + .on_ready(|panel| match Presenter::new(1280, 720) { + Ok(p) => { + if let Err(e) = panel.set_swap_chain(p.swap_chain()) { + tracing::error!(error = %e, "set_swap_chain"); + } + if let Some(frames) = PENDING_FRAMES.with(|c| c.borrow_mut().take()) { + PRESENT.with(|cell| { + *cell.borrow_mut() = Some(PresentCtx { + presenter: p, + frames, + }); }); - }); - tracing::info!("stream presenter bound to SwapChainPanel"); + tracing::info!("stream presenter bound to SwapChainPanel"); + } } - } - Err(e) => tracing::error!(error = %e, "create presenter"), - }) - .on_resize(|w, h| { - PRESENT.with(|cell| { - if let Some(ctx) = cell.borrow_mut().as_mut() { - ctx.presenter.resize(w as u32, h as u32); - } - }); - }) - .into() + Err(e) => tracing::error!(error = %e, "create presenter"), + }) + .on_resize(|w, h| { + PRESENT.with(|cell| { + if let Some(ctx) = cell.borrow_mut().as_mut() { + ctx.presenter.resize(w as u32, h as u32); + } + }); + }), + hud_overlay(&props.stats, mode), + )) + .into() +} + +/// The streaming HUD overlay (top-right), mirroring the Apple client: mode + fps/throughput, the +/// capture→client latency + decode time, and the release-cursor hint. Layered over the +/// `SwapChainPanel` in the same grid cell. +fn hud_overlay(stats: &Stats, mode: Option) -> Element { + let res = mode + .map(|m| format!("{}\u{00D7}{}@{}", m.width, m.height, m.refresh_hz)) + .unwrap_or_else(|| "\u{2014}".into()); + let line1 = format!("{res} {:.0} fps {:.1} Mb/s", stats.fps, stats.mbps); + let line2 = format!( + "capture\u{2192}client {:.1} ms p50 \u{00B7} decode {:.1} ms", + stats.latency_ms, stats.decode_ms + ); + border( + vstack(( + text_block(line1) + .font_size(12.0) + .foreground(Color::rgb(255, 255, 255)), + text_block(line2) + .font_size(11.0) + .foreground(Color::rgb(200, 200, 200)), + text_block("Ctrl+Alt+Shift+Q releases the mouse") + .font_size(11.0) + .foreground(Color::rgb(160, 160, 160)), + )) + .spacing(2.0), + ) + .background(Color::rgb(0, 0, 0)) + .corner_radius(8.0) + .padding(uniform(10.0)) + .opacity(0.82) + .horizontal_alignment(HorizontalAlignment::Right) + .vertical_alignment(VerticalAlignment::Top) + .margin(uniform(12.0)) + .into() } diff --git a/crates/punktfunk-client-windows/src/input.rs b/crates/punktfunk-client-windows/src/input.rs index 4378673..fccc635 100644 --- a/crates/punktfunk-client-windows/src/input.rs +++ b/crates/punktfunk-client-windows/src/input.rs @@ -1,14 +1,23 @@ //! Stream input: Win32 low-level keyboard + mouse hooks forwarding to the host while the WinUI -//! window is focused and capture is engaged. +//! window is focused and the pointer is captured. //! //! windows-reactor exposes no raw key-down/up or pointer-position/wheel events (only keyboard //! *accelerators* and pointer button-state), which is insufficient for a game stream. So this //! drops below XAML to `WH_KEYBOARD_LL` / `WH_MOUSE_LL`, installed on the UI thread when the -//! stream page mounts and removed when it unmounts. The `SwapChainPanel` fills the window, so the -//! pointer maps through the window's client rect (Contain-fit into the negotiated mode), and -//! keys carry the native Windows VK directly (the wire contract). While captured, events inside -//! the video area are swallowed (so Alt+Tab / Win etc. reach the host); Ctrl+Alt+Shift+Q toggles -//! capture; clicks outside the client area (the title bar) pass through so the window stays usable. +//! stream page mounts and removed when it unmounts. +//! +//! **Pointer lock.** While captured the cursor is *locked* the way a game-streaming client locks +//! it (Moonlight/Parsec): the OS cursor is hidden + confined to the window (`ClipCursor`), and +//! every physical move is turned into a **relative** delta (`InputKind::MouseMove`) — we read the +//! offset from the window centre, ship it (scaled screen→host through the Contain-fit factor, with +//! sub-pixel remainder carried so slow drags aren't lost), then warp the cursor back to centre so +//! it never reaches a screen edge. This is why the old absolute path froze: swallowing +//! `WM_MOUSEMOVE` pinned the OS cursor, so `pt` never travelled and the absolute coordinate +//! snapped to one point. Keys carry the native Windows VK directly (the wire contract). +//! +//! **Ctrl+Alt+Shift+Q** toggles capture — releasing the lock hands the cursor back to the local +//! desktop (and re-grabs on the next toggle). Losing foreground also releases the lock so the +//! cursor is never stranded. use punktfunk_core::client::NativeClient; use punktfunk_core::config::Mode; @@ -17,16 +26,15 @@ use std::collections::HashSet; use std::sync::atomic::{AtomicIsize, Ordering}; use std::sync::{Arc, Mutex}; use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM}; -use windows::Win32::Graphics::Gdi::ScreenToClient; +use windows::Win32::Graphics::Gdi::ClientToScreen; use windows::Win32::System::LibraryLoader::GetModuleHandleW; -use windows::Win32::UI::Input::KeyboardAndMouse::{ - GetAsyncKeyState, VIRTUAL_KEY, VK_CONTROL, VK_MENU, VK_Q, VK_SHIFT, -}; +use windows::Win32::UI::Input::KeyboardAndMouse::VK_Q; use windows::Win32::UI::WindowsAndMessaging::{ - CallNextHookEx, GetClientRect, GetForegroundWindow, SetWindowsHookExW, UnhookWindowsHookEx, - HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_KEYUP, - WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE, - WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, + CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos, + SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, + LLMHF_INJECTED, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_KEYUP, WM_LBUTTONDOWN, + WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, + WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, }; struct State { @@ -34,7 +42,21 @@ struct State { mode: Mode, /// Our window handle, stored as the raw `isize` so `State` is `Send` (`HWND` is not). hwnd: isize, + /// User intent: forward input to the host (toggled by Ctrl+Alt+Shift+Q). captured: bool, + /// The OS pointer is currently locked (hidden + confined + recentering). Tracks the real + /// `ClipCursor`/`ShowCursor` state so we engage/disengage exactly once per transition. + locked: bool, + /// Lock centre in screen coordinates (the cursor is warped here after every move). + center_x: i32, + center_y: i32, + /// Sub-pixel remainder of the screen→host scale, carried so slow drags aren't truncated away. + acc_x: f32, + acc_y: f32, + /// Modifier state, tracked from the hook's own event stream (see `kbd_proc`). + ctrl: bool, + alt: bool, + shift: bool, held_keys: HashSet, held_buttons: HashSet, } @@ -48,14 +70,25 @@ static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0); /// Install the hooks for a streaming session. Call from the UI thread once the window is shown. pub fn install(connector: Arc, mode: Mode) { let hwnd = unsafe { GetForegroundWindow() }; - *STATE.lock().unwrap() = Some(State { + let mut st = State { connector, mode, hwnd: hwnd.0 as isize, captured: true, + locked: false, + center_x: 0, + center_y: 0, + acc_x: 0.0, + acc_y: 0.0, + ctrl: false, + alt: false, + shift: false, held_keys: HashSet::new(), held_buttons: HashSet::new(), - }); + }; + // Lock immediately (the window is foreground at mount, like Moonlight grabbing on stream start). + set_locked(&mut st, true); + *STATE.lock().unwrap() = Some(st); unsafe { let hinst = GetModuleHandleW(None).ok(); if let Ok(h) = SetWindowsHookExW(WH_KEYBOARD_LL, Some(kbd_proc), hinst.map(Into::into), 0) { @@ -65,10 +98,13 @@ pub fn install(connector: Arc, mode: Mode) { MOUSE_HOOK.store(h.0 as isize, Ordering::SeqCst); } } - tracing::info!("stream input hooks installed (Ctrl+Alt+Shift+Q toggles capture)"); + tracing::info!( + "stream input hooks installed — pointer locked (Ctrl+Alt+Shift+Q toggles capture)" + ); } -/// Remove the hooks and flush any held keys/buttons (so nothing sticks down on the host). +/// Remove the hooks, release the pointer lock, and flush any held keys/buttons (so nothing +/// sticks down on the host). pub fn uninstall() { unsafe { let k = KBD_HOOK.swap(0, Ordering::SeqCst); @@ -80,14 +116,65 @@ pub fn uninstall() { let _ = UnhookWindowsHookEx(HHOOK(m as *mut _)); } } - if let Some(st) = STATE.lock().unwrap().take() { - for vk in &st.held_keys { - send(&st.connector, InputKind::KeyUp, *vk as u32, 0, 0, 0); - } - for b in &st.held_buttons { - send(&st.connector, InputKind::MouseButtonUp, *b, 0, 0, 0); + if let Some(mut st) = STATE.lock().unwrap().take() { + set_locked(&mut st, false); // hand the cursor back to the desktop + flush_held(&mut st); + } +} + +/// Release every held key/button on the host, so nothing sticks down when capture is dropped +/// (toggled off) or the session ends. +fn flush_held(st: &mut State) { + let c = st.connector.clone(); + for vk in st.held_keys.drain() { + send(&c, InputKind::KeyUp, vk as u32, 0, 0, 0); + } + for b in st.held_buttons.drain() { + send(&c, InputKind::MouseButtonUp, b, 0, 0, 0); + } +} + +/// 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). +fn set_locked(st: &mut State, on: bool) { + if on == st.locked { + return; + } + let hwnd = HWND(st.hwnd as *mut _); + unsafe { + if on { + let mut rc = RECT::default(); + if GetClientRect(hwnd, &mut rc).is_ok() { + let mut tl = POINT { + x: rc.left, + y: rc.top, + }; + let mut br = POINT { + x: rc.right, + y: rc.bottom, + }; + let _ = ClientToScreen(hwnd, &mut tl); + let _ = ClientToScreen(hwnd, &mut br); + let clip = RECT { + left: tl.x, + top: tl.y, + right: br.x, + bottom: br.y, + }; + let _ = ClipCursor(Some(&clip as *const RECT)); + st.center_x = (tl.x + br.x) / 2; + st.center_y = (tl.y + br.y) / 2; + let _ = SetCursorPos(st.center_x, st.center_y); + } + let _ = ShowCursor(false); + st.acc_x = 0.0; + st.acc_y = 0.0; + } else { + let _ = ClipCursor(None); + let _ = ShowCursor(true); } } + st.locked = on; } fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) { @@ -101,10 +188,6 @@ fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32 }); } -fn key_down(vk: VIRTUAL_KEY) -> bool { - (unsafe { GetAsyncKeyState(vk.0 as i32) } as u16 & 0x8000) != 0 -} - unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { if code == HC_ACTION as i32 { let kb = unsafe { &*(lparam.0 as *const KBDLLHOOKSTRUCT) }; @@ -113,16 +196,26 @@ unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> let vk = kb.vkCode as u16; let mut guard = STATE.lock().unwrap(); if let Some(st) = guard.as_mut() { + // Track modifier state from the hook's own event stream — reliable even while we + // swallow these keys (GetAsyncKeyState doesn't reflect keys suppressed by our own LL + // hook, which is why the shortcut never fired). Handles the generic + L/R vk codes. + match kb.vkCode { + 0x11 | 0xA2 | 0xA3 => st.ctrl = !up, // (L/R)CONTROL + 0x12 | 0xA4 | 0xA5 => st.alt = !up, // (L/R)MENU (Alt) + 0x10 | 0xA0 | 0xA1 => st.shift = !up, // (L/R)SHIFT + _ => {} + } let foreground = unsafe { GetForegroundWindow() }.0 as isize == st.hwnd; if foreground { // Capture toggle: Ctrl+Alt+Shift+Q (consumed locally, never forwarded). - if !up - && vk == VK_Q.0 - && key_down(VK_CONTROL) - && key_down(VK_MENU) - && key_down(VK_SHIFT) - { - st.captured = !st.captured; + if !up && vk == VK_Q.0 && st.ctrl && st.alt && st.shift { + let on = !st.captured; + st.captured = on; + set_locked(st, on); // grab/release the cursor immediately + if !on { + flush_held(st); // release held keys/buttons so nothing sticks on the host + } + tracing::info!(captured = on, "capture toggled (Ctrl+Alt+Shift+Q)"); return LRESULT(1); } if st.captured { @@ -143,48 +236,59 @@ unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> unsafe { CallNextHookEx(None, code, wparam, lparam) } } -/// Map a screen point to video pixels through the client-rect Contain-fit letterbox. Returns -/// `None` when the point is outside the video area (so the title bar / borders stay interactive). -fn map_abs(st: &State, screen: POINT) -> Option<(i32, i32, u32)> { - let hwnd = HWND(st.hwnd as *mut _); - let mut p = screen; - unsafe { - let _ = ScreenToClient(hwnd, &mut p); - } +/// Client-area size in pixels (for the screen→host relative-motion scale). +fn client_size(hwnd: isize) -> (f32, f32) { let mut rc = RECT::default(); - if unsafe { GetClientRect(hwnd, &mut rc) }.is_err() { - return None; + if unsafe { GetClientRect(HWND(hwnd as *mut _), &mut rc) }.is_ok() { + ( + (rc.right - rc.left).max(1) as f32, + (rc.bottom - rc.top).max(1) as f32, + ) + } else { + (1.0, 1.0) } - let (ww, wh) = ( - (rc.right - rc.left).max(1) as f64, - (rc.bottom - rc.top).max(1) as f64, - ); - if (p.x as f64) < 0.0 || (p.y as f64) < 0.0 || p.x as f64 > ww || p.y as f64 > wh { - return None; - } - let (vw, vh) = (st.mode.width.max(1) as f64, st.mode.height.max(1) as f64); - let scale = (ww / vw).min(wh / vh); - let (ox, oy) = ((ww - vw * scale) / 2.0, (wh - vh * scale) / 2.0); - let px = (((p.x as f64 - ox) / scale).round()).clamp(0.0, vw - 1.0) as i32; - let py = (((p.y as f64 - oy) / scale).round()).clamp(0.0, vh - 1.0) as i32; - let flags = (st.mode.width << 16) | (st.mode.height & 0xffff); - Some((px, py, flags)) } unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { if code == HC_ACTION as i32 { let ms = unsafe { &*(lparam.0 as *const MSLLHOOKSTRUCT) }; let msg = wparam.0 as u32; + let injected = (ms.flags & LLMHF_INJECTED) != 0; let mut guard = STATE.lock().unwrap(); if let Some(st) = guard.as_mut() { let foreground = unsafe { GetForegroundWindow() }.0 as isize == st.hwnd; - if st.captured && foreground { - let Some((px, py, flags)) = map_abs(st, ms.pt) else { + let want_lock = st.captured && foreground; + if want_lock != st.locked { + set_locked(st, want_lock); // sync to focus changes (e.g. lost foreground) + } + if st.locked { + // Skip the synthetic move our own SetCursorPos recentre generates. + if injected { return unsafe { CallNextHookEx(None, code, wparam, lparam) }; - }; + } let c = st.connector.clone(); match msg { - WM_MOUSEMOVE => send(&c, InputKind::MouseMoveAbs, 0, px, py, flags), + WM_MOUSEMOVE => { + let dx = (ms.pt.x - st.center_x) as f32; + let dy = (ms.pt.y - st.center_y) as f32; + if dx != 0.0 || dy != 0.0 { + // screen px → host px: the Contain-fit display scale's inverse, so the + // host cursor tracks the physical mouse 1:1 on screen at any window size. + let (ww, wh) = client_size(st.hwnd); + let (vw, vh) = + (st.mode.width.max(1) as f32, st.mode.height.max(1) as f32); + let s = (ww / vw).min(wh / vh).max(0.01); + st.acc_x += dx / s; + st.acc_y += dy / s; + let (hx, hy) = (st.acc_x.trunc() as i32, st.acc_y.trunc() as i32); + st.acc_x -= hx as f32; + st.acc_y -= hy as f32; + if hx != 0 || hy != 0 { + send(&c, InputKind::MouseMove, 0, hx, hy, 0); + } + } + let _ = unsafe { SetCursorPos(st.center_x, st.center_y) }; + } WM_LBUTTONDOWN => button(st, 1, true), WM_LBUTTONUP => button(st, 1, false), WM_RBUTTONDOWN => button(st, 3, true), @@ -211,7 +315,7 @@ unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) ), _ => {} } - return LRESULT(1); // swallow inside the video area + return LRESULT(1); // swallow inside the locked window } } }