From 87b49d639f0520343c9087268a890a717921b105 Mon Sep 17 00:00:00 2001 From: andreescocard Date: Wed, 24 Jun 2026 18:09:34 -0300 Subject: [PATCH 1/3] fix(ui): preserve taskbar monitor selection across display changes Store the selected taskbar's monitor device name in settings and prefer it when reattaching the widget. This keeps the widget anchored to the same physical monitor when taskbar ordering changes after monitor connect, disconnect, or reconfiguration, while still falling back to the saved index for existing settings and compatibility. --- src/native_interop.rs | 31 +++++++++++++++++++++++++++++-- src/window.rs | 30 ++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/native_interop.rs b/src/native_interop.rs index c745d08..638a9dc 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, MonitorFromWindow, 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,33 @@ 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, + /// Stable monitor identity (e.g. "\\\\.\\DISPLAY1"). Used to keep the widget + /// anchored to the same physical monitor across topology changes, where the + /// geometric ordering (and therefore the index) of taskbars can shift. + pub device: String, +} + +/// Stable identifier of the monitor a window currently lives on. +pub fn monitor_device_name(hwnd: HWND) -> String { + unsafe { + let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFOEXW::default(); + info.monitorInfo.cbSize = std::mem::size_of::() as u32; + if GetMonitorInfoW(monitor, &mut info as *mut _ as *mut _).as_bool() { + let len = info + .szDevice + .iter() + .position(|&c| c == 0) + .unwrap_or(info.szDevice.len()); + String::from_utf16_lossy(&info.szDevice[..len]) + } else { + String::new() + } + } } pub fn find_taskbars() -> Vec { @@ -39,7 +65,8 @@ 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 device = monitor_device_name(hwnd); + taskbars.push(TaskbarWindow { hwnd, rect, device }); } } } diff --git a/src/window.rs b/src/window.rs index f6d261e..fe73d66 100644 --- a/src/window.rs +++ b/src/window.rs @@ -84,6 +84,7 @@ struct AppState { last_update_check_unix: Option, taskbar_index: usize, + taskbar_device: Option, tray_offset: i32, dragging: bool, drag_start_mouse_x: i32, @@ -302,6 +303,8 @@ struct SettingsFile { tray_offset: i32, #[serde(default)] taskbar_index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + taskbar_device: Option, #[serde(default = "default_poll_interval")] poll_interval_ms: u32, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -323,6 +326,7 @@ impl Default for SettingsFile { Self { tray_offset: 0, taskbar_index: 0, + taskbar_device: None, poll_interval_ms: default_poll_interval(), language: None, last_update_check_unix: None, @@ -382,6 +386,7 @@ fn save_state_settings() { save_settings(&SettingsFile { tray_offset: s.tray_offset, taskbar_index: s.taskbar_index, + taskbar_device: s.taskbar_device.clone(), poll_interval_ms: s.poll_interval_ms, language: s .language_override @@ -494,17 +499,28 @@ fn toggle_widget_visibility(hwnd: HWND) { } } -fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { +fn attach_to_taskbar( + hwnd: HWND, + requested_index: usize, + requested_device: Option<&str>, +) -> 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]; + // Prefer the taskbar on the same physical monitor we were last anchored to. + // The geometric index is unstable across monitor connect/disconnect/reorder, + // so matching by device name keeps the widget on the user's chosen monitor. + let index = requested_device + .filter(|d| !d.is_empty()) + .and_then(|device| taskbars.iter().position(|t| t.device == device)) + .unwrap_or_else(|| requested_index.min(taskbars.len().saturating_sub(1))); + let taskbar = taskbars[index].clone(); diagnose::log(format!( - "taskbar selected index={index} count={} hwnd={:?} rect=({}, {}, {}, {})", + "taskbar selected index={index} device={} count={} hwnd={:?} rect=({}, {}, {}, {})", + taskbar.device, taskbars.len(), taskbar.hwnd, taskbar.rect.left, @@ -546,6 +562,7 @@ 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; + s.taskbar_device = Some(taskbar.device.clone()); s.embedded = true; } true @@ -1321,6 +1338,7 @@ pub fn run() { update_status: UpdateStatus::Idle, last_update_check_unix: settings.last_update_check_unix, taskbar_index: settings.taskbar_index, + taskbar_device: settings.taskbar_device.clone(), tray_offset: settings.tray_offset, dragging: false, drag_start_mouse_x: 0, @@ -1331,7 +1349,7 @@ pub fn run() { } // Try to embed in taskbar - if attach_to_taskbar(hwnd, settings.taskbar_index) { + if attach_to_taskbar(hwnd, settings.taskbar_index, settings.taskbar_device.as_deref()) { embedded = true; } @@ -2487,7 +2505,7 @@ unsafe extern "system" fn wnd_proc( s.tray_offset = new_offset; } } - if attach_to_taskbar(hwnd, target_index) { + if attach_to_taskbar(hwnd, target_index, Some(&target_taskbar.device)) { position_at_taskbar(); render_layered(); } From b85b9318f1fcaf238144ef95fbe3806e1d2a71db Mon Sep 17 00:00:00 2001 From: andreescocard Date: Fri, 26 Jun 2026 19:44:58 -0300 Subject: [PATCH 2/3] fix(taskbar): stop widget vanishing when the Start menu opens The widget embeds as a child of the taskbar, which keeps it painted above the shell's Start/Search acrylic backdrop. The actual bug was the watchdog: opening the Start menu makes the taskbar briefly drop out of EnumWindows (~5s) even though its HWND is still valid, and the watchdog read that as an explorer restart and relaunched the whole process, wiping the widget. Guard the watchdog with IsWindow on the stored taskbar handle: if the handle still exists it is a transient Start-menu enumeration blip, so skip. Only relaunch when the handle is truly destroyed (a real explorer restart) and only after TASKBAR_MISSING_TICKS consecutive confirmations. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017m74C6QgGGawTbeTb8NkBG --- src/native_interop.rs | 4 ++++ src/window.rs | 42 +++++++++++++++++++++++++++++------------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/native_interop.rs b/src/native_interop.rs index 638a9dc..c35ec77 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -196,6 +196,10 @@ pub fn get_window_thread_id(hwnd: HWND) -> u32 { unsafe { GetWindowThreadProcessId(hwnd, None) } } +pub fn window_exists(hwnd: HWND) -> bool { + unsafe { IsWindow(hwnd).as_bool() } +} + /// Unhook a WinEvent hook pub fn unhook_win_event(hook: HWINEVENTHOOK) { unsafe { diff --git a/src/window.rs b/src/window.rs index fe73d66..82ca80c 100644 --- a/src/window.rs +++ b/src/window.rs @@ -141,6 +141,8 @@ const TRAY_ICON_UPDATE_REPOSITION_SUPPRESS_MS: u64 = 750; /// recreates the taskbar and wipes our tray-icon registration). const TASKBAR_WATCH_INTERVAL_SECS: u64 = 2; +const TASKBAR_MISSING_TICKS: u32 = 2; + static SUPPRESS_TRAY_REPOSITION_UNTIL: Mutex> = Mutex::new(None); /// Current system DPI (96 = 100% scaling, 144 = 150%, 192 = 200%, etc.) @@ -233,21 +235,35 @@ fn relaunch_self() { /// dedicated thread (independent of the dead message loop) polls the taskbar /// handle and, when it changes, relaunches the widget as a fresh process. fn spawn_taskbar_watchdog() { - std::thread::spawn(move || loop { - std::thread::sleep(Duration::from_secs(TASKBAR_WATCH_INTERVAL_SECS)); - let stored = { - let state = lock_state(); - state.as_ref().and_then(|s| s.taskbar_hwnd) - }; - // Only relevant once we have embedded into a taskbar at least once. - let Some(old) = stored else { - continue; - }; - let taskbars = native_interop::find_taskbars(); - if !taskbars.is_empty() && !taskbars.iter().any(|taskbar| taskbar.hwnd == old) { + std::thread::spawn(move || { + let mut missing_streak = 0u32; + loop { + std::thread::sleep(Duration::from_secs(TASKBAR_WATCH_INTERVAL_SECS)); + let stored = { + let state = lock_state(); + state.as_ref().and_then(|s| s.taskbar_hwnd) + }; + let Some(old) = stored else { + missing_streak = 0; + continue; + }; + if native_interop::window_exists(old) { + missing_streak = 0; + continue; + } + let taskbars = native_interop::find_taskbars(); + if taskbars.is_empty() || taskbars.iter().any(|taskbar| taskbar.hwnd == old) { + missing_streak = 0; + continue; + } + missing_streak += 1; + if missing_streak < TASKBAR_MISSING_TICKS { + continue; + } + missing_streak = 0; let new = taskbars[0].hwnd; diagnose::log(format!( - "watchdog: taskbar changed old={:?} new={:?} -> relaunching", + "watchdog: taskbar destroyed old={:?} new={:?} -> relaunching", old.0, new.0 )); relaunch_self(); From 934e40fe70c92fca745a8e62b0ecdd368e16ccd5 Mon Sep 17 00:00:00 2001 From: andreescocard Date: Wed, 1 Jul 2026 09:43:49 -0300 Subject: [PATCH 3/3] feat(ui): add monitor selection for taskbar attachment Add a localized Monitor submenu with friendly per-display labels so the widget can be moved to a specific taskbar from the UI. Improve monitor attachment reliability by matching taskbars by device, briefly waiting for the remembered monitor to appear at startup, and falling back to the primary monitor when the saved display is gone. --- src/localization/dutch.rs | 1 + src/localization/english.rs | 1 + src/localization/french.rs | 1 + src/localization/german.rs | 1 + src/localization/japanese.rs | 1 + src/localization/korean.rs | 1 + src/localization/mod.rs | 1 + src/localization/portuguese_brazil.rs | 1 + src/localization/russian.rs | 1 + src/localization/spanish.rs | 1 + src/localization/traditional_chinese.rs | 1 + src/native_interop.rs | 27 ++++-- src/window.rs | 111 ++++++++++++++++++++++-- 13 files changed, 138 insertions(+), 11 deletions(-) diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index ed815bf..f0dc463 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -17,6 +17,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "Instellingen", start_with_windows: "Opstarten met Windows", reset_position: "Positie herstellen", + monitor: "Beeldscherm", language: "Taal", system_default: "Systeemstandaard", check_for_updates: "Controleren op updates", diff --git a/src/localization/english.rs b/src/localization/english.rs index 0249730..32ff2e6 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -17,6 +17,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "Settings", start_with_windows: "Start with Windows", reset_position: "Reset Position", + monitor: "Monitor", language: "Language", system_default: "System Default", check_for_updates: "Check for Updates", diff --git a/src/localization/french.rs b/src/localization/french.rs index 1850f41..96dd156 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -17,6 +17,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "Paramètres", start_with_windows: "Démarrer avec Windows", reset_position: "Réinitialiser la position", + monitor: "Écran", language: "Langue", system_default: "Par défaut du système", check_for_updates: "Vérifier les mises à jour", diff --git a/src/localization/german.rs b/src/localization/german.rs index 2b91a81..f8e577a 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -17,6 +17,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "Einstellungen", start_with_windows: "Mit Windows starten", reset_position: "Position zurücksetzen", + monitor: "Monitor", language: "Sprache", system_default: "Systemstandard", check_for_updates: "Nach Updates suchen", diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 2eec041..58cdb45 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -17,6 +17,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "設定", start_with_windows: "Windows と同時に開始", reset_position: "位置をリセット", + monitor: "モニター", language: "言語", system_default: "システム既定", check_for_updates: "更新を確認", diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 965687d..7b6d954 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -17,6 +17,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "설정", start_with_windows: "Windows 시작 시 자동 실행", reset_position: "위치 초기화", + monitor: "모니터", language: "언어", system_default: "시스템 기본값", check_for_updates: "업데이트 확인", diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 2a06b04..7521207 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -151,6 +151,7 @@ pub struct Strings { pub settings: &'static str, pub start_with_windows: &'static str, pub reset_position: &'static str, + pub monitor: &'static str, pub language: &'static str, pub system_default: &'static str, pub check_for_updates: &'static str, diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 56cf3bf..42d1c5d 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -17,6 +17,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "Configurações", start_with_windows: "Iniciar com o Windows", reset_position: "Redefinir Posição", + monitor: "Monitor", language: "Idioma", system_default: "Padrão do Sistema", check_for_updates: "Busca atualizações", diff --git a/src/localization/russian.rs b/src/localization/russian.rs index fc7e372..0c4a83d 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -17,6 +17,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "Настройки", start_with_windows: "Запускать вместе с Windows", reset_position: "Сбросить позицию", + monitor: "Монитор", language: "Язык", system_default: "Системный по умолчанию", check_for_updates: "Проверить обновления", diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index e635771..a363206 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -17,6 +17,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "Configuración", start_with_windows: "Iniciar con Windows", reset_position: "Restablecer posición", + monitor: "Monitor", language: "Idioma", system_default: "Predeterminado del sistema", check_for_updates: "Buscar actualizaciones", diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 3eb3514..8a67375 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -17,6 +17,7 @@ pub(super) const STRINGS: Strings = Strings { settings: "設定", start_with_windows: "開機時啟動", reset_position: "重置位置", + monitor: "螢幕", language: "語言", system_default: "系統預設", check_for_updates: "檢查更新", diff --git a/src/native_interop.rs b/src/native_interop.rs index c35ec77..1efb766 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -3,6 +3,9 @@ use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; use windows::Win32::Graphics::Gdi::{ GetMonitorInfoW, MonitorFromWindow, MONITORINFOEXW, MONITOR_DEFAULTTONEAREST, }; + +/// `MONITORINFOF_PRIMARY` — not re-exported by this `windows` crate version. +const MONITORINFOF_PRIMARY: u32 = 0x0000_0001; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::UI::WindowsAndMessaging::*; @@ -35,10 +38,15 @@ pub struct TaskbarWindow { /// anchored to the same physical monitor across topology changes, where the /// geometric ordering (and therefore the index) of taskbars can shift. pub device: String, + /// True if this taskbar lives on the primary monitor. Used as the fallback + /// anchor when the remembered device is gone, so we never re-trigger the + /// "widget jumps to the topmost (often secondary) taskbar" bug (#43). + pub is_primary: bool, } -/// Stable identifier of the monitor a window currently lives on. -pub fn monitor_device_name(hwnd: HWND) -> String { +/// Stable identity of the monitor a window currently lives on: its device name +/// and whether it is the primary monitor. +pub fn monitor_identity(hwnd: HWND) -> (String, bool) { unsafe { let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); let mut info = MONITORINFOEXW::default(); @@ -49,9 +57,11 @@ pub fn monitor_device_name(hwnd: HWND) -> String { .iter() .position(|&c| c == 0) .unwrap_or(info.szDevice.len()); - String::from_utf16_lossy(&info.szDevice[..len]) + let device = String::from_utf16_lossy(&info.szDevice[..len]); + let is_primary = info.monitorInfo.dwFlags & MONITORINFOF_PRIMARY != 0; + (device, is_primary) } else { - String::new() + (String::new(), false) } } } @@ -65,8 +75,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)) { - let device = monitor_device_name(hwnd); - taskbars.push(TaskbarWindow { hwnd, rect, device }); + let (device, is_primary) = monitor_identity(hwnd); + taskbars.push(TaskbarWindow { + hwnd, + rect, + device, + is_primary, + }); } } } diff --git a/src/window.rs b/src/window.rs index 82ca80c..e84c461 100644 --- a/src/window.rs +++ b/src/window.rs @@ -132,6 +132,10 @@ const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; const IDM_MODEL_ANTIGRAVITY: u16 = 62; +// Base ID for the dynamically-built Monitor submenu. Item `n` selects the +// n-th taskbar returned by `find_taskbars()`. Reserve a generous range. +const IDM_MONITOR_BASE: u16 = 70; +const IDM_MONITOR_MAX: u16 = 99; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -515,12 +519,41 @@ fn toggle_widget_visibility(hwnd: HWND) { } } +/// Friendly menu label for a monitor device, e.g. "\\.\DISPLAY2" -> "Monitor 2". +/// Falls back to a 1-based index when the device name has no trailing number. +fn monitor_display_name(device: &str, index: usize, monitor_word: &str) -> String { + let number: String = device.chars().rev().take_while(|c| c.is_ascii_digit()).collect(); + let number: String = number.chars().rev().collect(); + if number.is_empty() { + format!("{} {}", monitor_word, index + 1) + } else { + format!("{} {}", monitor_word, number) + } +} + fn attach_to_taskbar( hwnd: HWND, requested_index: usize, requested_device: Option<&str>, + allow_wait: bool, ) -> bool { - let taskbars = native_interop::find_taskbars(); + let wanted = requested_device.filter(|d| !d.is_empty()); + + // On startup the secondary taskbars can appear a beat after our window, so + // the remembered monitor may not be enumerable yet. Briefly retry rather + // than falling back and snapping the widget to the wrong monitor. + let mut taskbars = native_interop::find_taskbars(); + if allow_wait { + if let Some(device) = wanted { + let mut waited = 0; + while waited < 2000 && !taskbars.iter().any(|t| t.device == device) { + std::thread::sleep(std::time::Duration::from_millis(200)); + waited += 200; + taskbars = native_interop::find_taskbars(); + } + } + } + if taskbars.is_empty() { diagnose::log("taskbar not found; using fallback popup window"); return false; @@ -529,9 +562,12 @@ fn attach_to_taskbar( // Prefer the taskbar on the same physical monitor we were last anchored to. // The geometric index is unstable across monitor connect/disconnect/reorder, // so matching by device name keeps the widget on the user's chosen monitor. - let index = requested_device - .filter(|d| !d.is_empty()) + // When the remembered device is gone, anchor to the primary monitor before + // the geometric index: index 0 is the topmost taskbar, which on setups with + // a vertical secondary monitor is often the secondary screen (the #43 bug). + let index = wanted .and_then(|device| taskbars.iter().position(|t| t.device == device)) + .or_else(|| taskbars.iter().position(|t| t.is_primary)) .unwrap_or_else(|| requested_index.min(taskbars.len().saturating_sub(1))); let taskbar = taskbars[index].clone(); diagnose::log(format!( @@ -1365,7 +1401,12 @@ pub fn run() { } // Try to embed in taskbar - if attach_to_taskbar(hwnd, settings.taskbar_index, settings.taskbar_device.as_deref()) { + if attach_to_taskbar( + hwnd, + settings.taskbar_index, + settings.taskbar_device.as_deref(), + true, + ) { embedded = true; } @@ -2521,7 +2562,8 @@ unsafe extern "system" fn wnd_proc( s.tray_offset = new_offset; } } - if attach_to_taskbar(hwnd, target_index, Some(&target_taskbar.device)) { + if attach_to_taskbar(hwnd, target_index, Some(&target_taskbar.device), false) + { position_at_taskbar(); render_layered(); } @@ -2602,14 +2644,33 @@ unsafe extern "system" fn wnd_proc( let mut state = lock_state(); if let Some(s) = state.as_mut() { s.tray_offset = 0; + // Forget the remembered monitor so we fall back to the + // primary taskbar — an escape hatch if the widget ever + // gets stuck on a monitor that is gone or wrong. + s.taskbar_device = None; } } + // Re-anchor to the primary monitor's taskbar (device is now None). + attach_to_taskbar(hwnd, 0, None, false); save_state_settings(); position_at_taskbar(); + render_layered(); } IDM_START_WITH_WINDOWS => { set_startup_enabled(!is_startup_enabled()); } + _ if id >= IDM_MONITOR_BASE && id <= IDM_MONITOR_MAX => { + let idx = (id - IDM_MONITOR_BASE) as usize; + let taskbars = native_interop::find_taskbars(); + if let Some(target) = taskbars.get(idx) { + let device = target.device.clone(); + if attach_to_taskbar(hwnd, idx, Some(&device), false) { + save_state_settings(); + position_at_taskbar(); + render_layered(); + } + } + } IDM_FREQ_1MIN | IDM_FREQ_5MIN | IDM_FREQ_15MIN | IDM_FREQ_1HOUR => { let new_interval = match id { IDM_FREQ_1MIN => POLL_1_MIN, @@ -2893,6 +2954,46 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(reset_pos_str.as_ptr()), ); + // Monitor submenu — one entry per detected taskbar, so the user can move + // the widget between monitors without dragging. Only shown when there is + // more than one (single-monitor setups have nothing to choose). + let taskbars = native_interop::find_taskbars(); + if taskbars.len() > 1 { + let current_device = { + let state = lock_state(); + state.as_ref().and_then(|s| s.taskbar_device.clone()) + }; + let monitor_menu = CreatePopupMenu().unwrap(); + for (i, taskbar) in taskbars.iter().enumerate() { + if i > (IDM_MONITOR_MAX - IDM_MONITOR_BASE) as usize { + break; + } + let mut label = monitor_display_name(&taskbar.device, i, strings.monitor); + if taskbar.is_primary { + label.push_str(" \u{2605}"); // ★ marks the primary monitor + } + let flags = if current_device.as_deref() == Some(taskbar.device.as_str()) { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let label_w = native_interop::wide_str(&label); + let _ = AppendMenuW( + monitor_menu, + flags, + (IDM_MONITOR_BASE as usize) + i, + PCWSTR::from_raw(label_w.as_ptr()), + ); + } + let monitor_label = native_interop::wide_str(strings.monitor); + let _ = AppendMenuW( + settings_menu, + MF_POPUP, + monitor_menu.0 as usize, + PCWSTR::from_raw(monitor_label.as_ptr()), + ); + } + let language_menu = CreatePopupMenu().unwrap(); let system_label = native_interop::wide_str(strings.system_default); let system_flags = if language_override.is_none() {