From c2e9f5fbb3bb87ac7f0bca2dc0da295e68ce9187 Mon Sep 17 00:00:00 2001 From: Sven Bledt Date: Tue, 21 Jul 2026 01:59:19 +0200 Subject: [PATCH 1/3] Add taskbar monitor picker to the context menu On multi-monitor setups the widget could only be moved between taskbars by dragging it, which is hard to discover. This adds a "Display on" submenu to the right-click context menu (shared by the widget and the tray icon) that lists each detected taskbar as "Monitor N", tags the primary display, and checkmarks the current one. Selecting an entry re-embeds the widget on that monitor's taskbar and persists the choice. The submenu is omitted on single-monitor systems. Selection reuses the existing positional taskbar_index and attach_to_taskbar path, so there is no settings-schema change; if the widget was hidden, picking a monitor shows it there. New menu command IDs use the 90..=105 range to avoid colliding with the existing tray toggle ID. Strings are localized across all 11 supported languages. --- src/localization/dutch.rs | 3 + src/localization/english.rs | 3 + src/localization/french.rs | 3 + src/localization/german.rs | 3 + src/localization/japanese.rs | 3 + src/localization/korean.rs | 3 + src/localization/mod.rs | 3 + src/localization/portuguese_brazil.rs | 3 + src/localization/russian.rs | 3 + src/localization/simplified_chinese.rs | 3 + src/localization/spanish.rs | 3 + src/localization/traditional_chinese.rs | 3 + src/native_interop.rs | 29 +++++++- src/window.rs | 96 +++++++++++++++++++++++++ 14 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index ed815bf..3331c2e 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -46,5 +46,8 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Open Antigravity en meld je opnieuw aan. Ververs of herstart de app daarna.", codex_window_title: "Codex-gebruiksmonitor", antigravity_window_title: "Antigravity-gebruiksmonitor", + display_on: "Weergeven op", + monitor: "Monitor", + primary: "Primair", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 0249730..0c7c02a 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -46,5 +46,8 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Open Antigravity and sign in again. After that, refresh or restart this app.", codex_window_title: "Codex Usage Monitor", antigravity_window_title: "Antigravity Usage Monitor", + display_on: "Display on", + monitor: "Monitor", + primary: "Primary", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index 1850f41..40d4787 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -46,5 +46,8 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Ouvrez Antigravity et reconnectez-vous. Ensuite, actualisez ou redemarrez cette application.", codex_window_title: "Moniteur d'utilisation Codex", antigravity_window_title: "Moniteur d'utilisation Antigravity", + display_on: "Afficher sur", + monitor: "Moniteur", + primary: "Principal", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 2b91a81..b51d427 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -46,5 +46,8 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Offnen Sie Antigravity und melden Sie sich erneut an. Aktualisieren oder starten Sie diese App anschliessend neu.", codex_window_title: "Codex-Nutzungsmonitor", antigravity_window_title: "Antigravity-Nutzungsmonitor", + display_on: "Anzeigen auf", + monitor: "Monitor", + primary: "Primär", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 2eec041..a4aa7ea 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -46,5 +46,8 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Antigravity を開いて再度サインインしてください。その後、このアプリを更新するか再起動してください。", codex_window_title: "Codex 使用量モニター", antigravity_window_title: "Antigravity 使用量モニター", + display_on: "表示するモニター", + monitor: "モニター", + primary: "プライマリ", second_suffix: "秒", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 965687d..9971468 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -46,5 +46,8 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Antigravity를 열고 다시 로그인하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", codex_window_title: "Codex 사용량 모니터", antigravity_window_title: "Antigravity 사용량 모니터", + display_on: "표시할 모니터", + monitor: "모니터", + primary: "기본", second_suffix: "초", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index a11ba02..e8f1b9a 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -189,6 +189,9 @@ pub struct Strings { pub antigravity_token_expired_body: &'static str, pub codex_window_title: &'static str, pub antigravity_window_title: &'static str, + pub display_on: &'static str, + pub monitor: &'static str, + pub primary: &'static str, } pub fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 56cf3bf..f06eb4d 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -47,4 +47,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Abra o Antigravity e entre novamente. Depois disso, atualize ou reinicie este aplicativo.", codex_window_title: "Monitor de uso do Codex", antigravity_window_title: "Monitor de uso do Antigravity", + display_on: "Exibir em", + monitor: "Monitor", + primary: "Principal", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index fc7e372..2b178d1 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -47,4 +47,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Откройте Antigravity и войдите снова. После этого обновите или перезапустите приложение.", codex_window_title: "Монитор использования Codex", antigravity_window_title: "Монитор использования Antigravity", + display_on: "Показывать на", + monitor: "Монитор", + primary: "Основной", }; diff --git a/src/localization/simplified_chinese.rs b/src/localization/simplified_chinese.rs index 8a62588..be373a3 100644 --- a/src/localization/simplified_chinese.rs +++ b/src/localization/simplified_chinese.rs @@ -46,5 +46,8 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "请打开 Antigravity 并重新登录。完成后,请刷新或重新启动此应用程序。", codex_window_title: "Codex 使用量监控", antigravity_window_title: "Antigravity 使用量监控", + display_on: "显示于", + monitor: "显示器", + primary: "主要", second_suffix: "秒", }; diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index e635771..c481d5e 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -46,5 +46,8 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Abre Antigravity e inicia sesion otra vez. Despues, actualiza o reinicia esta aplicacion.", codex_window_title: "Monitor de uso de Codex", antigravity_window_title: "Monitor de uso de Antigravity", + display_on: "Mostrar en", + monitor: "Monitor", + primary: "Principal", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 3eb3514..d78efed 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -46,5 +46,8 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "請開啟 Antigravity 並重新登入。完成後,請重新整理或重新啟動此應用程式。", codex_window_title: "Codex 使用量監控", antigravity_window_title: "Antigravity 使用量監控", + display_on: "顯示於", + monitor: "螢幕", + primary: "主要", second_suffix: "秒", }; diff --git a/src/native_interop.rs b/src/native_interop.rs index c745d08..3232528 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -2,6 +2,9 @@ use windows::core::PCWSTR; use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; +use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromRect, MONITORINFO, MONITOR_DEFAULTTONEAREST, +}; use windows::Win32::UI::WindowsAndMessaging::*; // Window style constants @@ -28,6 +31,26 @@ pub const WM_APP_TRAY: u32 = WM_APP + 3; pub struct TaskbarWindow { pub hwnd: HWND, pub rect: RECT, + pub is_primary: bool, +} + +/// Return true when the monitor containing `rect` is the primary display. +fn rect_on_primary_monitor(rect: &RECT) -> bool { + unsafe { + let hmon = MonitorFromRect(rect as *const RECT, MONITOR_DEFAULTTONEAREST); + if hmon.is_invalid() { + return false; + } + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if GetMonitorInfoW(hmon, &mut info).as_bool() { + (info.dwFlags & MONITORINFOF_PRIMARY) != 0 + } else { + false + } + } } pub fn find_taskbars() -> Vec { @@ -39,7 +62,11 @@ pub fn find_taskbars() -> Vec { let class_name = String::from_utf16_lossy(&class_name[..len as usize]); if class_name == "Shell_TrayWnd" || class_name == "Shell_SecondaryTrayWnd" { if let Some(rect) = get_taskbar_rect(hwnd).or_else(|| get_window_rect_safe(hwnd)) { - taskbars.push(TaskbarWindow { hwnd, rect }); + taskbars.push(TaskbarWindow { + hwnd, + rect, + is_primary: rect_on_primary_monitor(&rect), + }); } } } diff --git a/src/window.rs b/src/window.rs index bf30f88..467745a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -132,6 +132,9 @@ const IDM_LANG_SIMPLIFIED_CHINESE: u16 = 51; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; const IDM_MODEL_ANTIGRAVITY: u16 = 62; +// Note: 70 is taken by tray_icon::IDM_TOGGLE_WIDGET; use 90..=105 to avoid collision. +const IDM_MONITOR_BASE: u16 = 90; +const IDM_MONITOR_MAX: u16 = 105; // supports up to 16 monitors (90..=105) const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -2673,6 +2676,32 @@ unsafe extern "system" fn wnd_proc( id if id == tray_icon::IDM_TOGGLE_WIDGET => { toggle_widget_visibility(hwnd); } + id if (IDM_MONITOR_BASE..=IDM_MONITOR_MAX).contains(&id) => { + let target = (id - IDM_MONITOR_BASE) as usize; + // Only mutate/persist visibility once we know the re-attach + // succeeded, so a failed attach can't leave the saved state + // marked visible while the window stays hidden. + if attach_to_taskbar(hwnd, target) { + let was_hidden = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.tray_offset = 0; + let was_hidden = !s.widget_visible; + s.widget_visible = true; + was_hidden + } else { + false + } + }; + position_at_taskbar(); + if was_hidden { + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + render_layered(); + sync_tray_icons(hwnd); + save_state_settings(); + } + } _ => {} } LRESULT(0) @@ -2705,6 +2734,16 @@ unsafe extern "system" fn wnd_proc( } } +/// Build the menu label for a monitor entry, e.g. "Monitor 1" or "Monitor 1 (Primary)". +fn monitor_menu_label(index: usize, is_primary: bool, strings: &Strings) -> String { + let base = format!("{} {}", strings.monitor, index + 1); + if is_primary { + format!("{} ({})", base, strings.primary) + } else { + base + } +} + fn show_context_menu(hwnd: HWND) { unsafe { let ( @@ -2838,6 +2877,41 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(models_label.as_ptr()), ); + // "Display on" (monitor) submenu — only when more than one taskbar exists + let current_taskbar_index = { + let state = lock_state(); + state.as_ref().map(|s| s.taskbar_index).unwrap_or(0) + }; + let taskbars = native_interop::find_taskbars(); + if taskbars.len() > 1 { + let monitor_menu = CreatePopupMenu().unwrap(); + for (i, taskbar) in taskbars.iter().enumerate() { + if i > (IDM_MONITOR_MAX - IDM_MONITOR_BASE) as usize { + break; + } + let label = monitor_menu_label(i, taskbar.is_primary, &strings); + let label_str = native_interop::wide_str(&label); + let flags = if i == current_taskbar_index { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + monitor_menu, + flags, + IDM_MONITOR_BASE as usize + i, + PCWSTR::from_raw(label_str.as_ptr()), + ); + } + let display_on_label = native_interop::wide_str(strings.display_on); + let _ = AppendMenuW( + menu, + MF_POPUP, + monitor_menu.0 as usize, + PCWSTR::from_raw(display_on_label.as_ptr()), + ); + } + // Settings submenu let settings_menu = CreatePopupMenu().unwrap(); @@ -3294,3 +3368,25 @@ fn draw_rounded_rect(hdc: HDC, rect: &RECT, color: &Color, radius: i32) { let _ = DeleteObject(brush); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_strings() -> Strings { + localization::LanguageId::English.strings() + } + + #[test] + fn monitor_label_numbers_from_one() { + let s = test_strings(); + assert_eq!(monitor_menu_label(0, false, &s), "Monitor 1"); + assert_eq!(monitor_menu_label(2, false, &s), "Monitor 3"); + } + + #[test] + fn monitor_label_tags_primary() { + let s = test_strings(); + assert_eq!(monitor_menu_label(0, true, &s), "Monitor 1 (Primary)"); + } +} From b6f1e63f904ac6ff193f870fb873f154c9825011 Mon Sep 17 00:00:00 2001 From: Sven Bledt Date: Tue, 21 Jul 2026 14:09:14 +0200 Subject: [PATCH 2/3] Fix widget jumping monitors when the taskbar set changes The monitor picker persisted only a positional taskbar_index. Taskbars are ordered by screen position, but the *set* is not stable: when a fullscreen app hides or recreates a monitor's taskbar, that taskbar drops out of (and later returns to) the enumeration, so a given index maps to a different physical monitor. Any re-attach that resolved the saved index against the changed set therefore moved the widget onto the wrong taskbar. A diagnostic capture showed exactly this: index=1 resolving to the right monitor with count=2, then to a middle monitor once count grew to 3. Identify the target monitor by its stable device name (szDevice, e.g. "\.\DISPLAY2") instead of list position: - find_taskbars() now records each taskbar's monitor device name. - attach_to_taskbar() prefers the saved device, falling back to the clamped index only when that monitor's taskbar is absent, and persists the resolved device. - The new taskbar_monitor setting carries the choice (index kept for back-compat / migration; existing settings upgrade on first attach). - The picker resolves the clicked entry to the device captured when the menu was built, and checkmarks by device rather than index. - On WM_DISPLAYCHANGE the widget re-homes to its saved monitor once that monitor's taskbar returns (e.g. after leaving fullscreen), only ever pulling toward the user's choice. Adds unit tests for the resolver covering the changed-set case. --- src/native_interop.rs | 45 +++++++--- src/window.rs | 194 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 216 insertions(+), 23 deletions(-) diff --git a/src/native_interop.rs b/src/native_interop.rs index 3232528..76ff880 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,10 +1,10 @@ use windows::core::PCWSTR; use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; -use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; -use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::Graphics::Gdi::{ - GetMonitorInfoW, MonitorFromRect, MONITORINFO, MONITOR_DEFAULTTONEAREST, + GetMonitorInfoW, MonitorFromRect, MONITORINFO, MONITORINFOEXW, MONITOR_DEFAULTTONEAREST, }; +use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; +use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::UI::WindowsAndMessaging::*; // Window style constants @@ -27,28 +27,45 @@ pub const WM_APP: u32 = 0x8000; pub const WM_APP_USAGE_UPDATED: u32 = WM_APP + 1; pub const WM_APP_TRAY: u32 = WM_APP + 3; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct TaskbarWindow { pub hwnd: HWND, pub rect: RECT, pub is_primary: bool, + /// Stable identity of the monitor this taskbar sits on (e.g. "\\.\DISPLAY2"). + /// Unlike the taskbar's position in `find_taskbars()`, this does not change + /// when another monitor's taskbar is hidden/recreated (e.g. by fullscreen + /// video), so it is what we persist and re-attach against. + pub monitor_device: String, } -/// Return true when the monitor containing `rect` is the primary display. -fn rect_on_primary_monitor(rect: &RECT) -> bool { +/// Query the monitor containing `rect`: whether it is the primary display and +/// its stable device name (`szDevice`, e.g. "\\.\DISPLAY1"). The device name is +/// empty only if the monitor cannot be resolved. +fn monitor_info_for_rect(rect: &RECT) -> (bool, String) { unsafe { let hmon = MonitorFromRect(rect as *const RECT, MONITOR_DEFAULTTONEAREST); if hmon.is_invalid() { - return false; + return (false, String::new()); } - let mut info = MONITORINFO { - cbSize: std::mem::size_of::() as u32, + let mut info = MONITORINFOEXW { + monitorInfo: MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }, ..Default::default() }; - if GetMonitorInfoW(hmon, &mut info).as_bool() { - (info.dwFlags & MONITORINFOF_PRIMARY) != 0 + if GetMonitorInfoW(hmon, &mut info.monitorInfo as *mut MONITORINFO).as_bool() { + let is_primary = (info.monitorInfo.dwFlags & MONITORINFOF_PRIMARY) != 0; + let len = info + .szDevice + .iter() + .position(|&c| c == 0) + .unwrap_or(info.szDevice.len()); + let device = String::from_utf16_lossy(&info.szDevice[..len]); + (is_primary, device) } else { - false + (false, String::new()) } } } @@ -62,10 +79,12 @@ pub fn find_taskbars() -> Vec { let class_name = String::from_utf16_lossy(&class_name[..len as usize]); if class_name == "Shell_TrayWnd" || class_name == "Shell_SecondaryTrayWnd" { if let Some(rect) = get_taskbar_rect(hwnd).or_else(|| get_window_rect_safe(hwnd)) { + let (is_primary, monitor_device) = monitor_info_for_rect(&rect); taskbars.push(TaskbarWindow { hwnd, rect, - is_primary: rect_on_primary_monitor(&rect), + is_primary, + monitor_device, }); } } diff --git a/src/window.rs b/src/window.rs index 467745a..ead2b78 100644 --- a/src/window.rs +++ b/src/window.rs @@ -84,6 +84,15 @@ struct AppState { last_update_check_unix: Option, taskbar_index: usize, + /// Stable device name of the monitor the widget is attached to (e.g. + /// "\\.\DISPLAY2"). Preferred over `taskbar_index` when re-attaching, so a + /// changing taskbar set (fullscreen hides/recreates a taskbar) cannot move + /// the widget onto a different physical monitor. + taskbar_monitor: Option, + /// Device names captured when the "Display on" submenu was last built, in + /// the same order as its entries. Lets the click handler resolve a menu + /// index to a stable monitor even if the taskbar set changed since. + menu_taskbar_monitors: Vec, tray_offset: i32, dragging: bool, drag_start_mouse_x: i32, @@ -306,6 +315,8 @@ struct SettingsFile { tray_offset: i32, #[serde(default)] taskbar_index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + taskbar_monitor: Option, #[serde(default = "default_poll_interval")] poll_interval_ms: u32, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -327,6 +338,7 @@ impl Default for SettingsFile { Self { tray_offset: 0, taskbar_index: 0, + taskbar_monitor: None, poll_interval_ms: default_poll_interval(), language: None, last_update_check_unix: None, @@ -386,6 +398,7 @@ fn save_state_settings() { save_settings(&SettingsFile { tray_offset: s.tray_offset, taskbar_index: s.taskbar_index, + taskbar_monitor: s.taskbar_monitor.clone(), poll_interval_ms: s.poll_interval_ms, language: s .language_override @@ -498,18 +511,41 @@ fn toggle_widget_visibility(hwnd: HWND) { } } -fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { +/// Choose which taskbar in `taskbars` to attach to. +/// +/// Prefer the taskbar on the saved monitor (`preferred_device`); the positional +/// `fallback_index` is only used when that monitor's taskbar is not currently +/// present (e.g. it is temporarily gone because a fullscreen app hid it). This +/// keeps the widget on the user's chosen physical monitor even as the number +/// and ordering of taskbars changes underneath us. +fn resolve_taskbar_index( + taskbars: &[native_interop::TaskbarWindow], + preferred_device: Option<&str>, + fallback_index: usize, +) -> usize { + if let Some(device) = preferred_device { + if !device.is_empty() { + if let Some(pos) = taskbars.iter().position(|t| t.monitor_device == device) { + return pos; + } + } + } + fallback_index.min(taskbars.len().saturating_sub(1)) +} + +fn attach_to_taskbar(hwnd: HWND, preferred_device: Option<&str>, fallback_index: usize) -> bool { let taskbars = native_interop::find_taskbars(); if taskbars.is_empty() { diagnose::log("taskbar not found; using fallback popup window"); return false; } - let index = requested_index.min(taskbars.len().saturating_sub(1)); - let taskbar = taskbars[index]; + let index = resolve_taskbar_index(&taskbars, preferred_device, fallback_index); + let taskbar = taskbars[index].clone(); diagnose::log(format!( - "taskbar selected index={index} count={} hwnd={:?} rect=({}, {}, {}, {})", + "taskbar selected index={index} count={} device={} hwnd={:?} rect=({}, {}, {}, {})", taskbars.len(), + taskbar.monitor_device, taskbar.hwnd, taskbar.rect.left, taskbar.rect.top, @@ -550,11 +586,53 @@ fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { s.tray_notify_hwnd = tray_notify; s.win_event_hook = hook; s.taskbar_index = index; + // Persist the monitor identity so later re-attaches target the same + // physical display rather than whatever now happens to sit at `index`. + // Only overwrite the saved preference when we actually resolved a + // monitor, so a transient empty device name can't wipe it. + if !taskbar.monitor_device.is_empty() { + s.taskbar_monitor = Some(taskbar.monitor_device.clone()); + } s.embedded = true; } true } +/// Move the widget back onto its saved monitor if that monitor's taskbar is +/// present but the widget is currently attached elsewhere. Does nothing when no +/// monitor is saved, when the saved monitor's taskbar is absent, or when the +/// widget is already there — so it only ever pulls the widget toward the user's +/// choice, never away from it. +fn reattach_to_saved_monitor_if_needed(hwnd: HWND) { + let (saved_device, current_hwnd, fallback_index) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => (s.taskbar_monitor.clone(), s.taskbar_hwnd, s.taskbar_index), + None => return, + } + }; + let Some(device) = saved_device else { + return; + }; + if device.is_empty() { + return; + } + let taskbars = native_interop::find_taskbars(); + let Some(target) = taskbars.iter().find(|t| t.monitor_device == device) else { + return; // saved monitor's taskbar not present right now; leave as-is + }; + if current_hwnd == Some(target.hwnd) { + return; // already on the saved monitor + } + diagnose::log(format!( + "display change: re-homing widget to saved monitor {device}" + )); + if attach_to_taskbar(hwnd, Some(device.as_str()), fallback_index) { + position_at_taskbar(); + render_layered(); + } +} + fn taskbar_at_point(pt: POINT) -> Option<(usize, native_interop::TaskbarWindow)> { native_interop::find_taskbars() .into_iter() @@ -1325,6 +1403,8 @@ pub fn run() { update_status: UpdateStatus::Idle, last_update_check_unix: settings.last_update_check_unix, taskbar_index: settings.taskbar_index, + taskbar_monitor: settings.taskbar_monitor.clone(), + menu_taskbar_monitors: Vec::new(), tray_offset: settings.tray_offset, dragging: false, drag_start_mouse_x: 0, @@ -1335,7 +1415,11 @@ pub fn run() { } // Try to embed in taskbar - if attach_to_taskbar(hwnd, settings.taskbar_index) { + if attach_to_taskbar( + hwnd, + settings.taskbar_monitor.as_deref(), + settings.taskbar_index, + ) { embedded = true; } @@ -2238,6 +2322,13 @@ unsafe extern "system" fn wnd_proc( check_theme_change(); check_language_change(); } + // A monitor topology change (e.g. a fullscreen app exiting and its + // taskbar coming back) can make the saved monitor's taskbar + // available again. Re-home to it so the widget returns to the + // display the user chose instead of staying on a fallback. + if msg == WM_DISPLAYCHANGE { + reattach_to_saved_monitor_if_needed(hwnd); + } refresh_dpi(); position_at_taskbar(); render_layered(); @@ -2491,7 +2582,13 @@ unsafe extern "system" fn wnd_proc( s.tray_offset = new_offset; } } - if attach_to_taskbar(hwnd, target_index) { + // Anchor to the dropped monitor by its stable device + // name so the choice survives later taskbar churn. + if attach_to_taskbar( + hwnd, + Some(target_taskbar.monitor_device.as_str()), + target_index, + ) { position_at_taskbar(); render_layered(); } @@ -2678,10 +2775,20 @@ unsafe extern "system" fn wnd_proc( } id if (IDM_MONITOR_BASE..=IDM_MONITOR_MAX).contains(&id) => { let target = (id - IDM_MONITOR_BASE) as usize; + // Resolve the clicked entry to the monitor device captured + // when the menu was built, so the attach lands on the + // display the user actually clicked even if the taskbar set + // changed between opening the menu and choosing an entry. + let target_device = { + let state = lock_state(); + state + .as_ref() + .and_then(|s| s.menu_taskbar_monitors.get(target).cloned()) + }; // Only mutate/persist visibility once we know the re-attach // succeeded, so a failed attach can't leave the saved state // marked visible while the window stays hidden. - if attach_to_taskbar(hwnd, target) { + if attach_to_taskbar(hwnd, target_device.as_deref(), target) { let was_hidden = { let mut state = lock_state(); if let Some(s) = state.as_mut() { @@ -2878,11 +2985,12 @@ fn show_context_menu(hwnd: HWND) { ); // "Display on" (monitor) submenu — only when more than one taskbar exists - let current_taskbar_index = { + let current_device = { let state = lock_state(); - state.as_ref().map(|s| s.taskbar_index).unwrap_or(0) + state.as_ref().and_then(|s| s.taskbar_monitor.clone()) }; let taskbars = native_interop::find_taskbars(); + let mut menu_devices: Vec = Vec::new(); if taskbars.len() > 1 { let monitor_menu = CreatePopupMenu().unwrap(); for (i, taskbar) in taskbars.iter().enumerate() { @@ -2891,7 +2999,12 @@ fn show_context_menu(hwnd: HWND) { } let label = monitor_menu_label(i, taskbar.is_primary, &strings); let label_str = native_interop::wide_str(&label); - let flags = if i == current_taskbar_index { + // Check the entry for the monitor the widget is on, matched by + // stable device name rather than list position. + let flags = if current_device + .as_deref() + .is_some_and(|d| !d.is_empty() && d == taskbar.monitor_device) + { MF_CHECKED } else { MENU_ITEM_FLAGS(0) @@ -2902,6 +3015,7 @@ fn show_context_menu(hwnd: HWND) { IDM_MONITOR_BASE as usize + i, PCWSTR::from_raw(label_str.as_ptr()), ); + menu_devices.push(taskbar.monitor_device.clone()); } let display_on_label = native_interop::wide_str(strings.display_on); let _ = AppendMenuW( @@ -2911,6 +3025,14 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(display_on_label.as_ptr()), ); } + // Remember the enumeration order so the click handler can map a chosen + // entry back to its monitor even if the taskbar set changes meanwhile. + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.menu_taskbar_monitors = menu_devices; + } + } // Settings submenu let settings_menu = CreatePopupMenu().unwrap(); @@ -3389,4 +3511,56 @@ mod tests { let s = test_strings(); assert_eq!(monitor_menu_label(0, true, &s), "Monitor 1 (Primary)"); } + + fn taskbar(device: &str) -> native_interop::TaskbarWindow { + native_interop::TaskbarWindow { + hwnd: HWND::default(), + rect: RECT::default(), + is_primary: false, + monitor_device: device.to_string(), + } + } + + #[test] + fn resolve_prefers_saved_monitor_over_index() { + let taskbars = [taskbar(r"\\.\DISPLAY1"), taskbar(r"\\.\DISPLAY2")]; + // Saved monitor is at position 1; the stale fallback index points at 0. + assert_eq!( + resolve_taskbar_index(&taskbars, Some(r"\\.\DISPLAY2"), 0), + 1 + ); + } + + #[test] + fn resolve_survives_a_changed_taskbar_set() { + // Reproduces the reported bug: the widget was on DISPLAY3, then a + // hidden taskbar reappeared and shifted every list position. Matching by + // device name keeps it on DISPLAY3 instead of following the old index. + let before = [taskbar(r"\\.\DISPLAY1"), taskbar(r"\\.\DISPLAY3")]; + let chosen = resolve_taskbar_index(&before, Some(r"\\.\DISPLAY3"), 1); + let saved = before[chosen].monitor_device.clone(); + + let after = [ + taskbar(r"\\.\DISPLAY1"), + taskbar(r"\\.\DISPLAY2"), + taskbar(r"\\.\DISPLAY3"), + ]; + // Same stale index (1) would now be DISPLAY2; the device match must win. + let resolved = resolve_taskbar_index(&after, Some(&saved), 1); + assert_eq!(after[resolved].monitor_device, r"\\.\DISPLAY3"); + } + + #[test] + fn resolve_falls_back_to_clamped_index_when_monitor_absent() { + let taskbars = [taskbar(r"\\.\DISPLAY1"), taskbar(r"\\.\DISPLAY2")]; + // Saved monitor is gone (fullscreen) -> clamp the index into range. + assert_eq!( + resolve_taskbar_index(&taskbars, Some(r"\\.\DISPLAY9"), 5), + 1 + ); + // No saved monitor at all -> plain clamped index. + assert_eq!(resolve_taskbar_index(&taskbars, None, 0), 0); + // Empty device string is ignored like "no preference". + assert_eq!(resolve_taskbar_index(&taskbars, Some(""), 1), 1); + } } From bebe9bd40e3eddc6d88f4fb0bba3a450a030c7dd Mon Sep 17 00:00:00 2001 From: Sven Bledt Date: Tue, 21 Jul 2026 16:48:10 +0200 Subject: [PATCH 3/3] Stop a fallback attach from overwriting the saved monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix identified the target monitor by stable device name so a changing taskbar set could not move the widget. But attach_to_taskbar still persisted whatever monitor it *resolved to* into taskbar_monitor on every call — including automatic attaches (startup, WM_DISPLAYCHANGE re-home) that fell back to a different monitor because the chosen one's taskbar was temporarily absent (fullscreen app, monitor churn). That rewrote the user's saved choice with the fallback monitor. Two visible symptoms followed: the widget appeared to switch taskbars, and the picker's own checkmark / persisted setting silently moved to the wrong monitor, from which reattach_to_saved_monitor_if_needed could never bring it home because the saved intent itself was now gone. Treat the saved preference as the user's intent and only adopt a resolved monitor when: - the user explicitly picked it (picker click / drag), or - the resolve actually matched the saved preference, or - no preference was saved yet (legacy index-only migration). - resolve_taskbar_index() now also reports whether it matched the preferred device or fell back to the positional index. - New pure monitor_pref_after_attach() encodes the persist decision. - attach_to_taskbar() gains a persist_choice flag: picker and drag pass true, startup and display-change re-home pass false so a transient fallback can never clobber the saved monitor. Adds unit tests covering the fallback (must not overwrite), matched-keep, legacy migration, and user-choice-on-fallback cases. --- src/window.rs | 158 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 138 insertions(+), 20 deletions(-) diff --git a/src/window.rs b/src/window.rs index ead2b78..ec99214 100644 --- a/src/window.rs +++ b/src/window.rs @@ -518,29 +518,84 @@ fn toggle_widget_visibility(hwnd: HWND) { /// present (e.g. it is temporarily gone because a fullscreen app hid it). This /// keeps the widget on the user's chosen physical monitor even as the number /// and ordering of taskbars changes underneath us. +/// +/// Returns the chosen index together with whether it was resolved by matching +/// the preferred monitor's device name (`true`) rather than by falling back to +/// the positional index (`false`). Callers use that flag to avoid persisting a +/// temporary fallback over the user's saved choice. fn resolve_taskbar_index( taskbars: &[native_interop::TaskbarWindow], preferred_device: Option<&str>, fallback_index: usize, -) -> usize { +) -> (usize, bool) { if let Some(device) = preferred_device { if !device.is_empty() { if let Some(pos) = taskbars.iter().position(|t| t.monitor_device == device) { - return pos; + return (pos, true); } } } - fallback_index.min(taskbars.len().saturating_sub(1)) + (fallback_index.min(taskbars.len().saturating_sub(1)), false) } -fn attach_to_taskbar(hwnd: HWND, preferred_device: Option<&str>, fallback_index: usize) -> bool { +/// Decide what the saved monitor preference (`taskbar_monitor`) should become +/// after an attach, given how that attach resolved. Returns `Some(device)` to +/// write, or `None` to leave the existing preference untouched. +/// +/// The saved preference is the user's *intent*, and it must survive a temporary +/// fallback: when the chosen monitor's taskbar is briefly gone (e.g. a +/// fullscreen app hid it) an automatic re-attach lands on a different monitor, +/// but that must NOT rewrite the intent — otherwise the widget's home is lost +/// permanently and it can never return. So only adopt the resolved monitor as +/// the new saved preference when: +/// - the user explicitly picked it (`persist_choice`), or +/// - the resolve actually matched the saved preference (`matched_preferred`), +/// or +/// - there was no saved preference yet (`!has_saved_pref`) — migration from +/// the legacy index-only setting. +fn monitor_pref_after_attach( + persist_choice: bool, + requested_device: Option<&str>, + resolved_device: &str, + matched_preferred: bool, + has_saved_pref: bool, +) -> Option { + if persist_choice { + // Honor the user's explicit intent — the monitor they asked for — even + // if we had to fall back to another one at this instant. Its taskbar + // will return and reattach_to_saved_monitor_if_needed will home to it. + let dev = requested_device + .filter(|d| !d.is_empty()) + .unwrap_or(resolved_device); + (!dev.is_empty()).then(|| dev.to_string()) + } else if !resolved_device.is_empty() && (matched_preferred || !has_saved_pref) { + Some(resolved_device.to_string()) + } else { + None + } +} + +/// Attach the widget to a taskbar. +/// +/// `preferred_device` names the monitor to prefer; `fallback_index` is used only +/// when that monitor's taskbar is absent. `persist_choice` marks this as an +/// explicit user selection (picker/drag) whose monitor should be saved as the +/// new intent; automatic attaches (startup, display-change re-home) pass `false` +/// so a temporary fallback cannot overwrite the user's saved monitor. +fn attach_to_taskbar( + hwnd: HWND, + preferred_device: Option<&str>, + fallback_index: usize, + persist_choice: bool, +) -> bool { let taskbars = native_interop::find_taskbars(); if taskbars.is_empty() { diagnose::log("taskbar not found; using fallback popup window"); return false; } - let index = resolve_taskbar_index(&taskbars, preferred_device, fallback_index); + let (index, matched_preferred) = + resolve_taskbar_index(&taskbars, preferred_device, fallback_index); let taskbar = taskbars[index].clone(); diagnose::log(format!( "taskbar selected index={index} count={} device={} hwnd={:?} rect=({}, {}, {}, {})", @@ -588,10 +643,18 @@ fn attach_to_taskbar(hwnd: HWND, preferred_device: Option<&str>, fallback_index: s.taskbar_index = index; // Persist the monitor identity so later re-attaches target the same // physical display rather than whatever now happens to sit at `index`. - // Only overwrite the saved preference when we actually resolved a - // monitor, so a transient empty device name can't wipe it. - if !taskbar.monitor_device.is_empty() { - s.taskbar_monitor = Some(taskbar.monitor_device.clone()); + // Crucially, a temporary fallback (the chosen monitor's taskbar is gone + // right now) must NOT overwrite the user's saved choice — see + // monitor_pref_after_attach — otherwise the widget's home is lost and it + // can never return. + if let Some(device) = monitor_pref_after_attach( + persist_choice, + preferred_device, + &taskbar.monitor_device, + matched_preferred, + s.taskbar_monitor.is_some(), + ) { + s.taskbar_monitor = Some(device); } s.embedded = true; } @@ -627,7 +690,7 @@ fn reattach_to_saved_monitor_if_needed(hwnd: HWND) { diagnose::log(format!( "display change: re-homing widget to saved monitor {device}" )); - if attach_to_taskbar(hwnd, Some(device.as_str()), fallback_index) { + if attach_to_taskbar(hwnd, Some(device.as_str()), fallback_index, false) { position_at_taskbar(); render_layered(); } @@ -1419,6 +1482,7 @@ pub fn run() { hwnd, settings.taskbar_monitor.as_deref(), settings.taskbar_index, + false, ) { embedded = true; } @@ -2583,11 +2647,13 @@ unsafe extern "system" fn wnd_proc( } } // Anchor to the dropped monitor by its stable device - // name so the choice survives later taskbar churn. + // name so the choice survives later taskbar churn. This + // is an explicit user choice, so persist it. if attach_to_taskbar( hwnd, Some(target_taskbar.monitor_device.as_str()), target_index, + true, ) { position_at_taskbar(); render_layered(); @@ -2788,7 +2854,7 @@ unsafe extern "system" fn wnd_proc( // Only mutate/persist visibility once we know the re-attach // succeeded, so a failed attach can't leave the saved state // marked visible while the window stays hidden. - if attach_to_taskbar(hwnd, target_device.as_deref(), target) { + if attach_to_taskbar(hwnd, target_device.as_deref(), target, true) { let was_hidden = { let mut state = lock_state(); if let Some(s) = state.as_mut() { @@ -3527,7 +3593,7 @@ mod tests { // Saved monitor is at position 1; the stale fallback index points at 0. assert_eq!( resolve_taskbar_index(&taskbars, Some(r"\\.\DISPLAY2"), 0), - 1 + (1, true) ); } @@ -3537,7 +3603,7 @@ mod tests { // hidden taskbar reappeared and shifted every list position. Matching by // device name keeps it on DISPLAY3 instead of following the old index. let before = [taskbar(r"\\.\DISPLAY1"), taskbar(r"\\.\DISPLAY3")]; - let chosen = resolve_taskbar_index(&before, Some(r"\\.\DISPLAY3"), 1); + let (chosen, _) = resolve_taskbar_index(&before, Some(r"\\.\DISPLAY3"), 1); let saved = before[chosen].monitor_device.clone(); let after = [ @@ -3546,21 +3612,73 @@ mod tests { taskbar(r"\\.\DISPLAY3"), ]; // Same stale index (1) would now be DISPLAY2; the device match must win. - let resolved = resolve_taskbar_index(&after, Some(&saved), 1); + let (resolved, matched) = resolve_taskbar_index(&after, Some(&saved), 1); + assert!(matched); assert_eq!(after[resolved].monitor_device, r"\\.\DISPLAY3"); } #[test] fn resolve_falls_back_to_clamped_index_when_monitor_absent() { let taskbars = [taskbar(r"\\.\DISPLAY1"), taskbar(r"\\.\DISPLAY2")]; - // Saved monitor is gone (fullscreen) -> clamp the index into range. + // Saved monitor is gone (fullscreen) -> clamp the index into range and + // report that we did NOT match the preferred monitor. assert_eq!( resolve_taskbar_index(&taskbars, Some(r"\\.\DISPLAY9"), 5), - 1 + (1, false) ); - // No saved monitor at all -> plain clamped index. - assert_eq!(resolve_taskbar_index(&taskbars, None, 0), 0); + // No saved monitor at all -> plain clamped index, no match. + assert_eq!(resolve_taskbar_index(&taskbars, None, 0), (0, false)); // Empty device string is ignored like "no preference". - assert_eq!(resolve_taskbar_index(&taskbars, Some(""), 1), 1); + assert_eq!(resolve_taskbar_index(&taskbars, Some(""), 1), (1, false)); + } + + #[test] + fn auto_attach_fallback_never_overwrites_saved_choice() { + // THE reported regression: the user's monitor (DISPLAY3) taskbar is + // temporarily gone, so an automatic attach falls back to DISPLAY1. The + // saved preference must stay DISPLAY3 so the widget can home back to it. + let next = monitor_pref_after_attach( + false, // automatic re-attach, not a user choice + Some(r"\\.\DISPLAY3"), // still asking for the saved monitor + r"\\.\DISPLAY1", // but we fell back to DISPLAY1 + false, // did not match the preferred monitor + true, // a preference is already saved + ); + assert_eq!(next, None, "a fallback must not rewrite the saved monitor"); + } + + #[test] + fn auto_attach_keeps_preference_when_matched() { + let next = monitor_pref_after_attach( + false, + Some(r"\\.\DISPLAY3"), + r"\\.\DISPLAY3", + true, + true, + ); + assert_eq!(next, Some(r"\\.\DISPLAY3".to_string())); + } + + #[test] + fn auto_attach_migrates_legacy_index_only_settings() { + // Upgrading from the index-only setting (nothing saved yet): adopt + // whatever we resolved so the choice becomes sticky going forward. + let next = monitor_pref_after_attach(false, None, r"\\.\DISPLAY2", false, false); + assert_eq!(next, Some(r"\\.\DISPLAY2".to_string())); + } + + #[test] + fn user_choice_persists_requested_monitor_even_on_fallback() { + // The user explicitly picks DISPLAY3 but its taskbar vanished this + // instant; we still save DISPLAY3 as the intent so we home to it when + // it returns, rather than saving the fallback we landed on. + let next = monitor_pref_after_attach( + true, + Some(r"\\.\DISPLAY3"), + r"\\.\DISPLAY1", + false, + true, + ); + assert_eq!(next, Some(r"\\.\DISPLAY3".to_string())); } }