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..76ff880 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,5 +1,8 @@ use windows::core::PCWSTR; use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; +use windows::Win32::Graphics::Gdi::{ + 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::*; @@ -24,10 +27,47 @@ 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, +} + +/// 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, String::new()); + } + let mut info = MONITORINFOEXW { + monitorInfo: MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }, + ..Default::default() + }; + 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, String::new()) + } + } } pub fn find_taskbars() -> Vec { @@ -39,7 +79,13 @@ 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 }); + let (is_primary, monitor_device) = monitor_info_for_rect(&rect); + taskbars.push(TaskbarWindow { + hwnd, + rect, + is_primary, + monitor_device, + }); } } } diff --git a/src/window.rs b/src/window.rs index bf30f88..ec99214 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, @@ -132,6 +141,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; @@ -303,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")] @@ -324,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, @@ -383,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 @@ -495,18 +511,96 @@ 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. +/// +/// 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, 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, true); + } + } + } + (fallback_index.min(taskbars.len().saturating_sub(1)), false) +} + +/// 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 = requested_index.min(taskbars.len().saturating_sub(1)); - let taskbar = taskbars[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={} hwnd={:?} rect=({}, {}, {}, {})", + "taskbar selected index={index} count={} device={} hwnd={:?} rect=({}, {}, {}, {})", taskbars.len(), + taskbar.monitor_device, taskbar.hwnd, taskbar.rect.left, taskbar.rect.top, @@ -547,11 +641,61 @@ 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`. + // 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; } 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, false) { + position_at_taskbar(); + render_layered(); + } +} + fn taskbar_at_point(pt: POINT) -> Option<(usize, native_interop::TaskbarWindow)> { native_interop::find_taskbars() .into_iter() @@ -1322,6 +1466,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, @@ -1332,7 +1478,12 @@ 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, + false, + ) { embedded = true; } @@ -2235,6 +2386,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(); @@ -2488,7 +2646,15 @@ 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. 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(); } @@ -2673,6 +2839,42 @@ 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; + // 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_device.as_deref(), target, true) { + 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 +2907,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 +3050,56 @@ 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_device = { + let state = lock_state(); + 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() { + 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); + // 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) + }; + let _ = AppendMenuW( + monitor_menu, + flags, + 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( + menu, + MF_POPUP, + monitor_menu.0 as usize, + 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(); @@ -3294,3 +3556,129 @@ 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)"); + } + + 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, true) + ); + } + + #[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, 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 and + // report that we did NOT match the preferred monitor. + assert_eq!( + resolve_taskbar_index(&taskbars, Some(r"\\.\DISPLAY9"), 5), + (1, false) + ); + // 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, 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())); + } +}