diff --git a/electron-app/magnifier/rust-sampler/Cargo.toml b/electron-app/magnifier/rust-sampler/Cargo.toml index f7b7d2502..447963327 100644 --- a/electron-app/magnifier/rust-sampler/Cargo.toml +++ b/electron-app/magnifier/rust-sampler/Cargo.toml @@ -33,6 +33,7 @@ windows = { version = "0.58", features = [ "Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", + "Win32_UI_HiDpi", ] } [target.'cfg(target_os = "linux")'.dependencies] diff --git a/electron-app/magnifier/rust-sampler/src/main.rs b/electron-app/magnifier/rust-sampler/src/main.rs index 8885135d5..b2945080f 100644 --- a/electron-app/magnifier/rust-sampler/src/main.rs +++ b/electron-app/magnifier/rust-sampler/src/main.rs @@ -67,36 +67,12 @@ fn run() -> Result<(), String> { } }); - // Get DPI scale - Windows needs it, others use 1.0 - let dpi_scale = get_dpi_scale(); - - fn get_dpi_scale() -> f64 { - #[cfg(target_os = "windows")] - { - // On Windows, get DPI scale directly from system - use windows::Win32::Graphics::Gdi::{GetDC, GetDeviceCaps, LOGPIXELSX, ReleaseDC}; - unsafe { - let hdc = GetDC(None); - if !hdc.is_invalid() { - let dpi = GetDeviceCaps(hdc, LOGPIXELSX); - let _ = ReleaseDC(None, hdc); - return dpi as f64 / 96.0; - } - } - 1.0 // Fallback - } - #[cfg(not(target_os = "windows"))] - { - 1.0 - } - } - // Main loop - wait for commands from channel loop { match cmd_rx.recv() { Ok(Command::Start { grid_size, sample_rate }) => { eprintln!("Starting sampling: grid_size={}, sample_rate={}", grid_size, sample_rate); - if let Err(e) = run_sampling_loop(&mut *sampler, grid_size, sample_rate, dpi_scale, &cmd_rx) { + if let Err(e) = run_sampling_loop(&mut *sampler, grid_size, sample_rate, &cmd_rx) { eprintln!("Sampling loop error: {}", e); send_error(&e); } @@ -123,7 +99,6 @@ fn run_sampling_loop( sampler: &mut dyn PixelSampler, initial_grid_size: usize, sample_rate: u64, - dpi_scale: f64, cmd_rx: &std::sync::mpsc::Receiver, ) -> Result<(), String> { use std::sync::mpsc::TryRecvError; @@ -159,8 +134,8 @@ fn run_sampling_loop( let loop_start = std::time::Instant::now(); - // Get cursor position (returns physical coordinates for Electron window positioning) - let physical_cursor = match sampler.get_cursor_position() { + // Get cursor position (returns logical coordinates, DPI-aware) + let cursor_pos = match sampler.get_cursor_position() { Ok(pos) => pos, Err(_e) => { // On Wayland/some platforms, we can't get cursor position directly @@ -171,24 +146,18 @@ fn run_sampling_loop( // Sample every frame regardless of cursor movement for smooth updates // This ensures the UI is responsive even if cursor position can't be tracked - last_cursor = physical_cursor.clone(); - - // Convert physical coordinates back to virtual for sampling - // We know dpi_scale is available here since it's declared at function scope - let virtual_cursor = Point { - x: (physical_cursor.x as f64 / dpi_scale) as i32, - y: (physical_cursor.y as f64 / dpi_scale) as i32, - }; + last_cursor = cursor_pos.clone(); + // Samplers handle DPI internally (like macOS), so pass coordinates directly // Sample center pixel - let center_color = sampler.sample_pixel(virtual_cursor.x, virtual_cursor.y) + let center_color = sampler.sample_pixel(cursor_pos.x, cursor_pos.y) .unwrap_or_else(|e| { eprintln!("Failed to sample center pixel: {}", e); Color::new(128, 128, 128) }); // Sample grid - let grid = sampler.sample_grid(virtual_cursor.x, virtual_cursor.y, current_grid_size, 1.0) + let grid = sampler.sample_grid(cursor_pos.x, cursor_pos.y, current_grid_size, 1.0) .unwrap_or_else(|e| { eprintln!("Failed to sample grid: {}", e); vec![vec![Color::new(128, 128, 128); current_grid_size]; current_grid_size] @@ -201,7 +170,7 @@ fn run_sampling_loop( .collect(); let pixel_data = PixelData { - cursor: physical_cursor.clone(), + cursor: cursor_pos.clone(), center: center_color.into(), grid: grid_data, timestamp: SystemTime::now() diff --git a/electron-app/magnifier/rust-sampler/src/sampler/windows.rs b/electron-app/magnifier/rust-sampler/src/sampler/windows.rs index e8bdf2925..576cef768 100644 --- a/electron-app/magnifier/rust-sampler/src/sampler/windows.rs +++ b/electron-app/magnifier/rust-sampler/src/sampler/windows.rs @@ -6,6 +6,7 @@ use windows::Win32::Graphics::Gdi::{ GetDeviceCaps, GetDIBits, GetPixel, LOGPIXELSX, ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, CLR_INVALID, DIB_RGB_COLORS, HDC, SRCCOPY, }; +use windows::Win32::UI::HiDpi::{SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2}; use windows::Win32::UI::WindowsAndMessaging::GetCursorPos; pub struct WindowsSampler { @@ -16,6 +17,11 @@ pub struct WindowsSampler { impl WindowsSampler { pub fn new() -> Result { unsafe { + // Set DPI awareness to per-monitor v2 so we can access physical pixels + // This must be done before any GDI calls + // Ignore errors - if it fails, we'll fall back to system DPI awareness + let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + let hdc = GetDC(None); if hdc.is_invalid() { @@ -27,6 +33,8 @@ impl WindowsSampler { // Standard DPI is 96, so scale = actual_dpi / 96 let dpi = GetDeviceCaps(hdc, LOGPIXELSX); let dpi_scale = dpi as f64 / 96.0; + + eprintln!("[WindowsSampler] DPI scale factor: {}", dpi_scale); Ok(WindowsSampler { hdc, @@ -47,12 +55,13 @@ impl Drop for WindowsSampler { impl PixelSampler for WindowsSampler { fn sample_pixel(&mut self, x: i32, y: i32) -> Result { unsafe { - // On Windows, for a DPI-unaware process (which this Rust subprocess is): - // - GetCursorPos returns VIRTUALIZED coordinates (e.g., 0-2559 at 200% on 5120 wide screen) - // - GetDC(None) returns a VIRTUALIZED DC that also uses virtual coordinates - // - GetPixel on that DC expects the SAME virtualized coordinates - // NO conversion needed - both APIs work in the same virtualized space - let color_ref = GetPixel(self.hdc, x, y); + // With DPI awareness enabled, follow the macOS pattern: + // - x, y are logical coordinates (like CGWindowListCreateImage on macOS) + // - Convert to physical coordinates internally for GDI + let physical_x = (x as f64 * self.dpi_scale) as i32; + let physical_y = (y as f64 * self.dpi_scale) as i32; + + let color_ref = GetPixel(self.hdc, physical_x, physical_y); // Check for error (CLR_INVALID is returned on error) // COLORREF is a newtype wrapper around u32 @@ -79,14 +88,16 @@ impl PixelSampler for WindowsSampler { GetCursorPos(&mut point) .map_err(|e| format!("Failed to get cursor position: {}", e))?; - // Convert from virtual coordinates (returned by GetCursorPos) to physical coordinates - // Electron (per-monitor DPI aware) expects physical coordinates for window positioning - let physical_x = (point.x as f64 * self.dpi_scale) as i32; - let physical_y = (point.y as f64 * self.dpi_scale) as i32; - + // With DPI awareness enabled, follow the macOS pattern: + // - GetCursorPos returns physical coordinates + // - Convert to logical coordinates (like macOS CGEventGetLocation) + // - This matches Electron's coordinate system and main.rs expectations + let logical_x = (point.x as f64 / self.dpi_scale) as i32; + let logical_y = (point.y as f64 / self.dpi_scale) as i32; + Ok(Point { - x: physical_x, - y: physical_y, + x: logical_x, + y: logical_y, }) } } @@ -97,10 +108,14 @@ impl PixelSampler for WindowsSampler { unsafe { let half_size = (grid_size / 2) as i32; - // For a DPI-unaware process, all GDI operations use virtualized coordinates - // No conversion needed - let x_start = center_x - half_size; - let y_start = center_y - half_size; + // With DPI awareness enabled, follow the macOS pattern: + // - center_x, center_y are logical coordinates + // - Convert to physical coordinates for GDI operations + let physical_center_x = (center_x as f64 * self.dpi_scale) as i32; + let physical_center_y = (center_y as f64 * self.dpi_scale) as i32; + + let x_start = physical_center_x - half_size; + let y_start = physical_center_y - half_size; let width = grid_size as i32; let height = grid_size as i32; @@ -220,13 +235,16 @@ impl WindowsSampler { let half_size = (grid_size / 2) as i32; let mut grid = Vec::with_capacity(grid_size); - // For DPI-unaware process, use coordinates directly + // center_x, center_y are logical coordinates - convert to physical + let physical_center_x = (center_x as f64 * self.dpi_scale) as i32; + let physical_center_y = (center_y as f64 * self.dpi_scale) as i32; + for row in 0..grid_size { let mut row_pixels = Vec::with_capacity(grid_size); for col in 0..grid_size { - // Calculate pixel coordinates (no conversion needed) - let x = center_x + (col as i32 - half_size); - let y = center_y + (row as i32 - half_size); + // Calculate physical pixel coordinates + let x = physical_center_x + (col as i32 - half_size); + let y = physical_center_y + (row as i32 - half_size); let color_ref = GetPixel(self.hdc, x, y); diff --git a/electron-app/magnifier/rust-sampler/tests/windows_sampler_tests.rs b/electron-app/magnifier/rust-sampler/tests/windows_sampler_tests.rs index e79051916..80bc051ac 100644 --- a/electron-app/magnifier/rust-sampler/tests/windows_sampler_tests.rs +++ b/electron-app/magnifier/rust-sampler/tests/windows_sampler_tests.rs @@ -32,8 +32,9 @@ impl MockWindowsSampler { impl PixelSampler for MockWindowsSampler { fn sample_pixel(&mut self, x: i32, y: i32) -> Result { - // Simulate DPI coordinate conversion (virtual -> physical) - // In DPI-aware Electron: GetCursorPos gives virtual, GetPixel expects physical + // With DPI awareness enabled, follow the macOS pattern: + // - x, y are logical coordinates + // - Convert to physical coordinates internally for sampling let physical_x = (x as f64 * self.dpi_scale) as i32; let physical_y = (y as f64 * self.dpi_scale) as i32; @@ -52,32 +53,32 @@ impl PixelSampler for MockWindowsSampler { } fn get_cursor_position(&self) -> Result { - // Simulate Windows sampler behavior: return physical coordinates - // (virtual coordinates converted to physical for Electron compatibility) - let virtual_x = 100; - let virtual_y = 100; - let physical_x = (virtual_x as f64 * self.dpi_scale) as i32; - let physical_y = (virtual_y as f64 * self.dpi_scale) as i32; - Ok(Point { x: physical_x, y: physical_y }) + // With DPI awareness, GetCursorPos returns physical coordinates + // But we return logical coordinates (physical / dpi_scale) for Electron + let physical_x = 200; // Simulated physical cursor position + let physical_y = 200; + let logical_x = (physical_x as f64 / self.dpi_scale) as i32; + let logical_y = (physical_y as f64 / self.dpi_scale) as i32; + Ok(Point { x: logical_x, y: logical_y }) } - // Override sample_grid to simulate production behavior (virtual coordinates) + // Override sample_grid to simulate production behavior (logical coordinates) fn sample_grid(&mut self, center_x: i32, center_y: i32, grid_size: usize, _scale_factor: f64) -> Result>, String> { let half_size = (grid_size / 2) as i32; let mut grid = Vec::with_capacity(grid_size); - // Production sample_grid operates in virtual coordinates (no DPI scaling) + // Production sample_grid operates in logical coordinates like macOS for row in 0..grid_size { let mut row_pixels = Vec::with_capacity(grid_size); for col in 0..grid_size { - // Calculate virtual pixel coordinates (matches production behavior) - let virtual_x = center_x + (col as i32 - half_size); - let virtual_y = center_y + (row as i32 - half_size); + // Calculate logical pixel coordinates (matches production behavior) + let logical_x = center_x + (col as i32 - half_size); + let logical_y = center_y + (row as i32 - half_size); - // Convert virtual to physical for bounds checking and color calculation + // Convert logical to physical for bounds checking and color calculation // (since screen_width/screen_height are physical dimensions) - let physical_x = (virtual_x as f64 * self.dpi_scale) as i32; - let physical_y = (virtual_y as f64 * self.dpi_scale) as i32; + let physical_x = (logical_x as f64 * self.dpi_scale) as i32; + let physical_y = (logical_y as f64 * self.dpi_scale) as i32; // Sample in physical space if physical_x < 0 || physical_y < 0 || physical_x >= self.screen_width || physical_y >= self.screen_height { @@ -128,8 +129,9 @@ fn test_windows_sampler_cursor_position() { let sampler = MockWindowsSampler::new(1920, 1080); let cursor = sampler.get_cursor_position().unwrap(); - assert_eq!(cursor.x, 100); - assert_eq!(cursor.y, 100); + // With DPI awareness, physical 200 / scale 1.0 = logical 200 + assert_eq!(cursor.x, 200); + assert_eq!(cursor.y, 200); } #[test] @@ -496,10 +498,10 @@ fn test_windows_sampler_dpi_150_percent() { #[test] fn test_windows_sampler_dpi_200_percent() { // Test 200% DPI scaling (2x) - the reported issue - // Physical screen: 5120x2880, Virtual screen: 2560x1440 + // Physical screen: 5120x2880, Logical screen: 2560x1440 let mut sampler = MockWindowsSampler::new_with_dpi(5120, 2880, 2.0); - // Virtual coordinate 1000 should map to physical 2000 (1000 * 2.0) + // Logical coordinate 1000 should map to physical 2000 (1000 * 2.0) internally let color = sampler.sample_pixel(1000, 500).unwrap(); // Color should be based on physical coordinates (2000, 1000)