From 94f49025acc6815fa5525111df2b8cbbd047b388 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Sun, 1 Nov 2026 20:51:03 -0500 Subject: [PATCH] Capture the cursor at the user's enlarged pointer size on Windows Cursor handles keep their nominal 32 px bitmaps; the system enlarges the pointer only when drawing it, so screenshots and recordings showed the standard-size cursor. Scale the snapshot by the accessibility pointer size (SPI_GETCURSORBASESIZE), preferring the cursor file's larger image, and size the standalone cursor image from the icon instead of a fixed 64 px. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/jiffshot-capture/src/win/screen.rs | 188 +++++++++++++++++++--- 1 file changed, 164 insertions(+), 24 deletions(-) diff --git a/crates/jiffshot-capture/src/win/screen.rs b/crates/jiffshot-capture/src/win/screen.rs index 3b64899..0f796d6 100644 --- a/crates/jiffshot-capture/src/win/screen.rs +++ b/crates/jiffshot-capture/src/win/screen.rs @@ -9,17 +9,19 @@ use crate::{from_wide, Frame, Rect}; use std::io; use windows_sys::Win32::Foundation::{BOOL, LPARAM, POINT, RECT, TRUE}; use windows_sys::Win32::Graphics::Gdi::{ - BitBlt, EnumDisplayMonitors, GetDC, GetMonitorInfoW, MonitorFromPoint, ReleaseDC, CAPTUREBLT, - HDC, HMONITOR, MONITORINFO, MONITORINFOEXW, MONITOR_DEFAULTTONEAREST, SRCCOPY, + BitBlt, DeleteObject, EnumDisplayMonitors, GetDC, GetMonitorInfoW, GetObjectW, + MonitorFromPoint, ReleaseDC, BITMAP, CAPTUREBLT, HDC, HMONITOR, MONITORINFO, MONITORINFOEXW, + MONITOR_DEFAULTTONEAREST, SRCCOPY, }; use windows_sys::Win32::UI::HiDpi::{ GetDpiForMonitor, SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, MDT_EFFECTIVE_DPI, }; use windows_sys::Win32::UI::WindowsAndMessaging::{ - CopyIcon, DestroyIcon, DrawIconEx, GetCursorInfo, GetIconInfo, GetSystemMetrics, CURSORINFO, - CURSOR_SHOWING, DI_NORMAL, HICON, ICONINFO, MONITORINFOF_PRIMARY, SM_CXVIRTUALSCREEN, - SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, + CopyIcon, CopyImage, DestroyIcon, DrawIconEx, GetCursorInfo, GetIconInfo, GetSystemMetrics, + SystemParametersInfoW, CURSORINFO, CURSOR_SHOWING, DI_NORMAL, HICON, ICONINFO, IMAGE_CURSOR, + LR_COPYFROMRESOURCE, MONITORINFOF_PRIMARY, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, + SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, }; /// Opts the process into per-monitor-v2 DPI awareness so every coordinate is @@ -196,6 +198,96 @@ pub struct CursorSnapshot { /// Top-left of the cursor image in screen coordinates. pub x: i32, pub y: i32, + width: i32, + height: i32, +} + +/// The default cursor size Windows designs cursor images for. +const CURSOR_DEFAULT_SIZE: i32 = 32; +/// Undocumented `SPI_GETCURSORBASESIZE`: the pointer size chosen in +/// Settings > Accessibility > Mouse pointer (32 is the default). +const SPI_GETCURSORBASESIZE: u32 = 0x2028; + +/// The user's pointer size. Cursor handles keep their nominal (32 px) +/// bitmaps and the system enlarges them only when drawing the pointer, so a +/// capture must scale them by the same factor. +fn cursor_base_size() -> i32 { + let mut size: u32 = 0; + // SAFETY: the out-parameter is a plain u32. + let ok = unsafe { + SystemParametersInfoW( + SPI_GETCURSORBASESIZE, + 0, + &mut size as *mut u32 as *mut core::ffi::c_void, + 0, + ) + }; + if ok == 0 || !(CURSOR_DEFAULT_SIZE as u32..=1024).contains(&size) { + CURSOR_DEFAULT_SIZE + } else { + size as i32 + } +} + +/// Hotspot and drawn size of an icon or cursor. +/// +/// # Safety +/// `icon` must be a valid icon or cursor handle. +unsafe fn icon_metrics(icon: HICON) -> Option<(i32, i32, i32, i32)> { + let mut icon_info: ICONINFO = std::mem::zeroed(); + if GetIconInfo(icon, &mut icon_info) == 0 { + return None; + } + let mut bitmap: BITMAP = std::mem::zeroed(); + let has_size = !icon_info.hbmMask.is_null() + && GetObjectW( + icon_info.hbmMask, + std::mem::size_of::() as i32, + &mut bitmap as *mut BITMAP as *mut core::ffi::c_void, + ) != 0; + // Monochrome cursors stack the AND and XOR masks in one bitmap. + let monochrome = icon_info.hbmColor.is_null(); + if !icon_info.hbmMask.is_null() { + DeleteObject(icon_info.hbmMask); + } + if !icon_info.hbmColor.is_null() { + DeleteObject(icon_info.hbmColor); + } + let (width, height) = if has_size { + let height = if monochrome { + bitmap.bmHeight / 2 + } else { + bitmap.bmHeight + }; + (bitmap.bmWidth, height) + } else { + (CURSOR_DEFAULT_SIZE, CURSOR_DEFAULT_SIZE) + }; + Some(( + icon_info.xHotspot as i32, + icon_info.yHotspot as i32, + width, + height, + )) +} + +/// A copy of `cursor` enlarged for the user's pointer size, preferring the +/// cursor file's own larger image over stretching the nominal one. `None` +/// at the default size or when no copy could be made. +/// +/// # Safety +/// `cursor` must be a valid cursor handle. +unsafe fn scaled_copy(cursor: HICON, base_size: i32) -> Option { + if base_size <= CURSOR_DEFAULT_SIZE { + return None; + } + let (_, _, width, height) = icon_metrics(cursor)?; + let scale = |v: i32| (v * base_size + CURSOR_DEFAULT_SIZE / 2) / CURSOR_DEFAULT_SIZE; + let (width, height) = (scale(width), scale(height)); + [LR_COPYFROMRESOURCE, 0].into_iter().find_map(|flags| { + let copy = CopyImage(cursor, IMAGE_CURSOR, width, height, flags); + (!copy.is_null()).then_some(copy as HICON) + }) } // SAFETY: the icon is an owned copy, only drawn and destroyed. @@ -215,26 +307,18 @@ impl CursorSnapshot { { return None; } - let icon = CopyIcon(info.hCursor); + let icon = scaled_copy(info.hCursor, cursor_base_size()) + .unwrap_or_else(|| CopyIcon(info.hCursor)); if icon.is_null() { return None; } - let mut icon_info: ICONINFO = std::mem::zeroed(); - let (mut hot_x, mut hot_y) = (0, 0); - if GetIconInfo(icon, &mut icon_info) != 0 { - hot_x = icon_info.xHotspot as i32; - hot_y = icon_info.yHotspot as i32; - if !icon_info.hbmMask.is_null() { - windows_sys::Win32::Graphics::Gdi::DeleteObject(icon_info.hbmMask); - } - if !icon_info.hbmColor.is_null() { - windows_sys::Win32::Graphics::Gdi::DeleteObject(icon_info.hbmColor); - } - } + let (hot_x, hot_y, width, height) = icon_metrics(icon).unwrap_or((0, 0, 32, 32)); Some(CursorSnapshot { icon, x: info.ptScreenPos.x - hot_x, y: info.ptScreenPos.y - hot_y, + width, + height, }) } } @@ -242,23 +326,25 @@ impl CursorSnapshot { /// The cursor alone, with transparency (drawn once on black and once on /// white; the difference gives the alpha). pub fn image(&self) -> io::Result { - let size = 64; + let (width, height) = (self.width.max(1), self.height.max(1)); let on = |v: u8| -> io::Result { - let mut f = Frame::new(size, size); + let mut f = Frame::new(width as u32, height as u32); f.pixels.iter_mut().for_each(|b| *b = v); let probe = CursorSnapshot { icon: self.icon, x: 0, y: 0, + width, + height, }; let r = probe.draw_onto(&mut f, (0, 0)); std::mem::forget(probe); r.map(|_| f) }; let (black, white) = (on(0)?, on(255)?); - let mut out = Frame::new(size, size); + let mut out = Frame::new(width as u32, height as u32); out.alpha = true; - for i in 0..(size * size) as usize { + for i in 0..(width * height) as usize { let a = 255 - (white.pixels[i * 4 + 1] as i32 - black.pixels[i * 4 + 1] as i32).clamp(0, 255); let un = |c: u8| { @@ -289,8 +375,8 @@ impl CursorSnapshot { self.x - origin.0, self.y - origin.1, self.icon, - 0, - 0, + self.width, + self.height, 0, std::ptr::null_mut(), DI_NORMAL, @@ -310,6 +396,60 @@ impl Drop for CursorSnapshot { } } +#[cfg(test)] +mod cursor_scale { + use super::*; + use windows_sys::Win32::UI::WindowsAndMessaging::{LoadCursorW, IDC_ARROW, IDC_IBEAM}; + + #[test] + fn default_size_keeps_the_nominal_cursor() { + // SAFETY: a shared system cursor. + let arrow = unsafe { LoadCursorW(std::ptr::null_mut(), IDC_ARROW) }; + assert!(unsafe { scaled_copy(arrow, CURSOR_DEFAULT_SIZE) }.is_none()); + } + + #[test] + fn enlarged_pointer_scales_image_and_hotspot() { + // SAFETY: a shared system cursor; the copies are destroyed. + unsafe { + let ibeam = LoadCursorW(std::ptr::null_mut(), IDC_IBEAM); + let (hot_x, hot_y, w, h) = icon_metrics(ibeam).unwrap(); + let copy = scaled_copy(ibeam, CURSOR_DEFAULT_SIZE * 3).unwrap(); + let (big_hot_x, big_hot_y, big_w, big_h) = icon_metrics(copy).unwrap(); + DestroyIcon(copy); + assert_eq!((big_w, big_h), (w * 3, h * 3)); + assert!((big_hot_x - hot_x * 3).abs() <= 3, "{big_hot_x} vs {hot_x}"); + assert!((big_hot_y - hot_y * 3).abs() <= 3, "{big_hot_y} vs {hot_y}"); + } + } + + #[test] + fn image_covers_the_whole_cursor() { + // SAFETY: a shared system cursor, copied and owned by the snapshot. + let snapshot = unsafe { + let arrow = LoadCursorW(std::ptr::null_mut(), IDC_ARROW); + let icon = scaled_copy(arrow, CURSOR_DEFAULT_SIZE * 4).unwrap(); + let (_, _, width, height) = icon_metrics(icon).unwrap(); + CursorSnapshot { + icon, + x: 0, + y: 0, + width, + height, + } + }; + let image = snapshot.image().unwrap(); + assert_eq!((image.width, image.height), (128, 128)); + let opaque_right = (0..image.height) + .flat_map(|y| (0..image.width).map(move |x| (x, y))) + .filter(|&(x, y)| image.pixels[((y * image.width + x) * 4 + 3) as usize] > 0) + .map(|(x, _)| x) + .max() + .unwrap(); + assert!(opaque_right > 32, "arrow drawn at nominal size"); + } +} + #[cfg(test)] mod grab_timing { use super::*;