From 59d7eb9bfde7966cab045cb9c30f012d07e1d851 Mon Sep 17 00:00:00 2001 From: Labeat Zekaj Date: Tue, 13 Oct 2026 08:34:01 +0200 Subject: [PATCH] fix(window): pin saved monitor by device name Placement overrides persisted only monitor_index, a position in find_monitors()'s primary-first, left-to-right order. Adding, removing or rearranging a display (or changing the primary) shifts that order, and the saved index then silently selects a different monitor. Store the GDI device name (e.g. \\.\DISPLAY2) alongside the index and resolve by name first. If the named monitor is absent, taskbar placement falls back as before and floating placement uses the saved point, without overwriting the saved choice, so it returns once the monitor reappears. Placements saved without a device name keep using the index. Co-Authored-By: Claude Opus 5.5 --- src/app_settings.rs | 4 ++ src/native_interop.rs | 18 ++++++- src/window.rs | 16 +++--- src/window/message_loop.rs | 2 + src/window/placement_regression_tests.rs | 1 + src/window/placement_tests.rs | 1 + src/window/positioning.rs | 63 ++++++++++++++++++++++++ 7 files changed, 98 insertions(+), 7 deletions(-) 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); + } +}