diff --git a/src/MenYou/Platform/Windows/StartButtonLocator.cs b/src/MenYou/Platform/Windows/StartButtonLocator.cs
index 945a0b0..d329a30 100644
--- a/src/MenYou/Platform/Windows/StartButtonLocator.cs
+++ b/src/MenYou/Platform/Windows/StartButtonLocator.cs
@@ -18,7 +18,11 @@ namespace MenYou.Platform.Windows;
/// for the click hook a slightly oversized band is fine, and false
/// positives are rare in this region). Same DPI scaling as above.
///
-/// Refresh the rect whenever the taskbar moves, the alignment toggles, or
+/// With "show my taskbar on all displays" on, each extra monitor gets its own
+/// taskbar (Shell_SecondaryTrayWnd) with its own Start button, so there
+/// is one rect per taskbar, each scaled by its own tray's monitor DPI.
+///
+/// Refresh the rects whenever the taskbar moves, the alignment toggles, or
/// the display scale changes.
[SupportedOSPlatform("windows")]
internal static class StartButtonLocator
@@ -35,24 +39,49 @@ internal static class StartButtonLocator
// Start menu shows up" bug. RECT.Contains is half-open (x < Right), so 55
// covers the whole button yet still excludes File Explorer's first pixel at
// x=55: no false positives. (Do NOT widen to 64 — that swallows ~9 px of
- // File Explorer.) The button grows with DPI, so Get() scales this by the
- // tray's monitor DPI, rounding UP (69 px at 125%, 83 at 150%): at a
+ // File Explorer.) The button grows with DPI, so GetStartRect scales this by
+ // the tray's monitor DPI, rounding UP (69 px at 125%, 83 at 150%): at a
// fractional boundary the shared pixel column goes to Start — the safe
// side, since the half-open Contains keeps the column at Right excluded
// either way. A UI-Automation lookup of the real StartButton rect would be
// more durable still — but it must run OFF the hook thread (a cross-process
// UIA call can take tens of ms; overrunning the LL-hook timeout gets
// WH_MOUSE_LL silently removed), never inside StartClickHook.HookProc /
- // EnsureRectFresh.
+ // EnsureRectsFresh.
private const int LeftAlignedStartWidth = 55;
private const int CenteredSlop = 40; // a bit wider than the visible button so we don't miss
- /// Returns the screen-coordinate rectangle covering the Start button.
- /// Returns an empty rect if Shell_TrayWnd isn't found (Explorer not up).
- public static RECT Get()
+ /// Fills with one screen-coordinate rect per
+ /// visible Start button: the primary taskbar (Shell_TrayWnd) plus one per
+ /// secondary-monitor taskbar (Shell_SecondaryTrayWnd). Clears the list
+ /// first; leaves it empty if no taskbar is found (Explorer not up). A tray
+ /// window that dies mid-query is skipped, not treated as fatal. Takes the
+ /// caller's list so the click hook can refresh without allocating.
+ public static void GetAll(List results)
{
+ results.Clear();
+ var align = GetTaskbarAlignment();
+
var tray = FindWindowW("Shell_TrayWnd", null);
- if (tray == IntPtr.Zero) return default;
+ if (tray != IntPtr.Zero)
+ {
+ var rect = GetStartRect(tray, align);
+ if (!rect.IsEmpty) results.Add(rect);
+ }
+
+ // Secondary taskbars don't center their icon group on Win 11: Start is
+ // the leftmost element there even when TaskbarAl says centered.
+ var secondary = IntPtr.Zero;
+ while ((secondary = FindWindowExW(IntPtr.Zero, secondary, "Shell_SecondaryTrayWnd", null)) != IntPtr.Zero)
+ {
+ var rect = GetStartRect(secondary, align: 0);
+ if (!rect.IsEmpty) results.Add(rect);
+ }
+ }
+
+ /// Start rect for one taskbar window; empty if the window died mid-query.
+ private static RECT GetStartRect(IntPtr tray, int align)
+ {
if (!GetWindowRect(tray, out var trayRect)) return default;
// The constants above are 96-DPI measurements while trayRect (and the
@@ -60,12 +89,11 @@ public static RECT Get()
// so both branches scale by the tray's monitor DPI (Explorer is
// per-monitor DPI aware). 0 means the tray hwnd died since the
// GetWindowRect above — return empty like the sibling failure paths so
- // EnsureRectFresh keeps the last good rect instead of adopting an
+ // EnsureRectsFresh keeps the last good rects instead of adopting an
// unscaled one.
var dpi = (int)GetDpiForWindow(tray);
if (dpi == 0) return default;
- var align = GetTaskbarAlignment();
if (align == 0)
{
// Left-aligned: Start is the leftmost icon. (+95)/96 is integer
@@ -128,6 +156,9 @@ public struct RECT
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr FindWindowW(string? lpClassName, string? lpWindowName);
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern IntPtr FindWindowExW(IntPtr hWndParent, IntPtr hWndChildAfter, string? lpszClass, string? lpszWindow);
+
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
diff --git a/src/MenYou/Platform/Windows/StartClickHook.cs b/src/MenYou/Platform/Windows/StartClickHook.cs
index 8aa6460..ff56744 100644
--- a/src/MenYou/Platform/Windows/StartClickHook.cs
+++ b/src/MenYou/Platform/Windows/StartClickHook.cs
@@ -10,9 +10,10 @@ namespace MenYou.Platform.Windows;
/// menu from opening — no flash.
///
/// Lives on its own STA thread with a message pump (required for
-/// out-of-context hooks). Re-queries the Start button rect every time it
-/// sees an event so taskbar moves don't desync us; the query is cheap
-/// (Shell_TrayWnd GetWindowRect + a DPI query + a registry read).
+/// out-of-context hooks). Re-queries the Start button rects (one per taskbar
+/// — secondary monitors get their own) every time it sees an event so taskbar
+/// moves don't desync us; the query is cheap (a GetWindowRect + DPI query per
+/// taskbar + a registry read).
[SupportedOSPlatform("windows")]
internal sealed class StartClickHook : IDisposable
{
@@ -30,7 +31,11 @@ internal sealed class StartClickHook : IDisposable
private IntPtr _hHook;
private uint _threadId;
private bool _disposed;
- private StartButtonLocator.RECT _startRect;
+ // Double-buffered so a refresh never allocates on the hook thread: the
+ // scratch list is filled, then the two are swapped. Only the hook thread
+ // touches either list.
+ private List _startRects = new();
+ private List _scratchRects = new();
private long _lastRectRefreshTicks;
private bool _consumeUp;
@@ -64,8 +69,8 @@ private void MessageLoop()
HookTrace.Log($"StartClickHook: SetWindowsHookEx failed err={Marshal.GetLastWin32Error()}");
return;
}
- _startRect = StartButtonLocator.Get();
- HookTrace.Log($"StartClickHook: initial Start rect {_startRect}");
+ StartButtonLocator.GetAll(_startRects);
+ HookTrace.Log($"StartClickHook: initial Start rects [{string.Join(", ", _startRects)}]");
while (GetMessage(out var msg, IntPtr.Zero, 0, 0))
{
@@ -76,14 +81,16 @@ private void MessageLoop()
_hHook = IntPtr.Zero;
}
- private void EnsureRectFresh()
+ private void EnsureRectsFresh()
{
// Refresh at most every 250 ms; the user can't move the taskbar that fast.
var now = Environment.TickCount64;
if (now - _lastRectRefreshTicks < 250) return;
_lastRectRefreshTicks = now;
- var fresh = StartButtonLocator.Get();
- if (!fresh.IsEmpty) _startRect = fresh;
+ StartButtonLocator.GetAll(_scratchRects);
+ // Keep the stale rects if the query came up empty (Explorer restarting).
+ if (_scratchRects.Count > 0)
+ (_startRects, _scratchRects) = (_scratchRects, _startRects);
}
private IntPtr HookProc(int code, IntPtr wParam, IntPtr lParam)
@@ -101,10 +108,13 @@ private IntPtr HookProc(int code, IntPtr wParam, IntPtr lParam)
return CallNextHookEx(IntPtr.Zero, code, wParam, lParam);
HookTrace.Log($"StartClickHook: saw msg=0x{msg:X} at ({data.pt.X},{data.pt.Y}) flags=0x{data.flags:X}");
- EnsureRectFresh();
- if (_startRect.IsEmpty) return CallNextHookEx(IntPtr.Zero, code, wParam, lParam);
+ EnsureRectsFresh();
+ var rects = _startRects;
+ if (rects.Count == 0) return CallNextHookEx(IntPtr.Zero, code, wParam, lParam);
- var inside = _startRect.Contains(data.pt.X, data.pt.Y);
+ var inside = false;
+ for (var i = 0; i < rects.Count && !inside; i++)
+ inside = rects[i].Contains(data.pt.X, data.pt.Y);
if (msg == WM_LBUTTONDOWN && inside)
{