diff --git a/src/app_settings.rs b/src/app_settings.rs index c7b833fb..4e325595 100644 --- a/src/app_settings.rs +++ b/src/app_settings.rs @@ -89,6 +89,10 @@ pub struct PlacementOverride { pub nest: String, #[serde(default)] pub monitor_index: usize, + /// GDI device name (e.g. `\\.\DISPLAY2`) of the saved monitor. Enumeration + /// order can change between boots, so this wins over `monitor_index`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub monitor_device: Option, #[serde(default)] pub screen_x: i32, #[serde(default)] diff --git a/src/native_interop.rs b/src/native_interop.rs index 4d66c051..9032b7d9 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -4,7 +4,7 @@ use windows::core::BOOL; use windows::core::PCWSTR; use windows::Win32::Foundation::{HWND, LPARAM, RECT, WPARAM}; use windows::Win32::Graphics::Gdi::{ - EnumDisplayMonitors, GetMonitorInfoW, HDC, HMONITOR, MONITORINFO, + EnumDisplayMonitors, GetMonitorInfoW, HDC, HMONITOR, MONITORINFO, MONITORINFOEXW, }; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::Shell::ShellExecuteW; @@ -128,6 +128,22 @@ pub fn find_monitors() -> Vec { result } +/// GDI device name of a monitor (e.g. `\\.\DISPLAY2`), stable across boots +/// unlike its position in `find_monitors`. +pub fn monitor_device_name(monitor: HMONITOR) -> Option { + let mut info = MONITORINFOEXW::default(); + info.monitorInfo.cbSize = std::mem::size_of::() as u32; + if !unsafe { GetMonitorInfoW(monitor, &mut info.monitorInfo).as_bool() } { + return None; + } + let len = info + .szDevice + .iter() + .position(|&c| c == 0) + .unwrap_or(info.szDevice.len()); + (len > 0).then(|| String::from_utf16_lossy(&info.szDevice[..len])) +} + pub fn find_taskbars() -> Vec { unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { let taskbars = &mut *(lparam.0 as *mut Vec); diff --git a/src/window.rs b/src/window.rs index 4abd7ec0..b66c0965 100644 --- a/src/window.rs +++ b/src/window.rs @@ -644,7 +644,7 @@ fn theme_with_placement(state: &AppState, auto_ejected: bool) -> Option Option Option 0 || p.screen_y > 0 { let offset = if horizontal { p.screen_x } else { p.screen_y }; diff --git a/src/window/message_loop.rs b/src/window/message_loop.rs index 960c40d7..fcab905b 100644 --- a/src/window/message_loop.rs +++ b/src/window/message_loop.rs @@ -512,6 +512,7 @@ pub(super) unsafe extern "system" fn wnd_proc( s.placement_override = Some(PlacementOverride { nest: "taskbar".into(), monitor_index, + monitor_device: native_interop::monitor_device_name(handle), screen_x, screen_y, tray_offset, @@ -590,6 +591,7 @@ pub(super) unsafe extern "system" fn wnd_proc( s.placement_override = Some(PlacementOverride { nest: "floating".into(), monitor_index: monitor_idx, + monitor_device: native_interop::monitor_device_name(display.handle), screen_x: clamped_x, screen_y: clamped_y, tray_offset: 0, diff --git a/src/window/placement_regression_tests.rs b/src/window/placement_regression_tests.rs index 18487f74..0e6c8b7e 100644 --- a/src/window/placement_regression_tests.rs +++ b/src/window/placement_regression_tests.rs @@ -60,6 +60,7 @@ fn placement(nest: &str) -> PlacementOverride { PlacementOverride { nest: nest.into(), monitor_index: 0, + monitor_device: None, screen_x: 0, screen_y: 0, tray_offset: 0, diff --git a/src/window/placement_tests.rs b/src/window/placement_tests.rs index d227e6ed..83f37017 100644 --- a/src/window/placement_tests.rs +++ b/src/window/placement_tests.rs @@ -569,6 +569,7 @@ fn test_placement_override_serialization_and_normalization() { let ov = app_settings::PlacementOverride { nest: "floating".into(), monitor_index: 1, + monitor_device: None, screen_x: 250, screen_y: 120, tray_offset: 0, diff --git a/src/window/positioning.rs b/src/window/positioning.rs index 56d5cac6..ba5b0f6a 100644 --- a/src/window/positioning.rs +++ b/src/window/positioning.rs @@ -733,6 +733,34 @@ pub(super) fn monitor_index_for_handle( displays.iter().position(|display| display.handle == handle) } +/// Resolve a saved monitor: the device name wins, then the saved index. A +/// named monitor that is absent (e.g. not enumerated yet during boot) yields +/// None so callers fall back without the index silently picking another one. +pub(super) fn resolve_saved_monitor( + displays: &[native_interop::DisplayMonitor], + index: usize, + device: Option<&str>, +) -> Option { + let names: Vec> = displays + .iter() + .map(|display| native_interop::monitor_device_name(display.handle)) + .collect(); + resolve_saved_monitor_in(&names, index, device) +} + +fn resolve_saved_monitor_in( + names: &[Option], + index: usize, + device: Option<&str>, +) -> Option { + match device { + Some(device) => names + .iter() + .position(|name| name.as_deref() == Some(device)), + None => (index < names.len()).then_some(index), + } +} + pub(super) fn override_primary_placement( theme: &mut ThemeDocument, placement: theme_engine::Placement, @@ -1064,3 +1092,38 @@ pub(super) fn taskbar_free_dock_slot( .total_cmp(&calculate_rect_overlap_ratio(widget, *b)) }) } + +#[cfg(test)] +mod resolve_saved_monitor_tests { + use super::resolve_saved_monitor_in; + + fn names(list: &[&str]) -> Vec> { + list.iter().map(|name| Some((*name).to_string())).collect() + } + + #[test] + fn device_name_follows_the_monitor_when_enumeration_order_changes() { + // Saved on DISPLAY2 at index 1; a third monitor now sorts before it. + let displays = names(&[r"\\.\DISPLAY1", r"\\.\DISPLAY3", r"\\.\DISPLAY2"]); + assert_eq!( + resolve_saved_monitor_in(&displays, 1, Some(r"\\.\DISPLAY2")), + Some(2) + ); + } + + #[test] + fn absent_named_monitor_does_not_fall_back_to_the_stale_index() { + let displays = names(&[r"\\.\DISPLAY1", r"\\.\DISPLAY3"]); + assert_eq!( + resolve_saved_monitor_in(&displays, 1, Some(r"\\.\DISPLAY2")), + None + ); + } + + #[test] + fn placements_saved_without_a_device_keep_using_the_index() { + let displays = names(&[r"\\.\DISPLAY1", r"\\.\DISPLAY2"]); + assert_eq!(resolve_saved_monitor_in(&displays, 1, None), Some(1)); + assert_eq!(resolve_saved_monitor_in(&displays, 5, None), None); + } +}