Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 41 additions & 10 deletions src/MenYou/Platform/Windows/StartButtonLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
/// 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 (<c>Shell_SecondaryTrayWnd</c>) 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
Expand All @@ -35,37 +39,61 @@
// 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 <paramref name="results"/> 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<RECT> results)

Check notice

Code scanning / CodeQL

Missing a summary in documentation comment Note

Documentation should have a summary.
{
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)

Check notice

Code scanning / CodeQL

Calls to unmanaged code Note

Replace this call with a call to managed code if possible.
{
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
// hook coordinates this rect is compared against) are physical pixels,
// 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
Expand Down Expand Up @@ -128,6 +156,9 @@
[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);

Check notice

Code scanning / CodeQL

Unmanaged code Note

Minimise the use of unmanaged code.

[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
Expand Down
34 changes: 22 additions & 12 deletions src/MenYou/Platform/Windows/StartClickHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
/// 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
{
Expand All @@ -30,7 +31,11 @@
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<StartButtonLocator.RECT> _startRects = new();
private List<StartButtonLocator.RECT> _scratchRects = new();
private long _lastRectRefreshTicks;
private bool _consumeUp;

Expand Down Expand Up @@ -64,8 +69,8 @@
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))
{
Expand All @@ -76,14 +81,16 @@
_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)
Expand All @@ -101,10 +108,13 @@
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);

Check notice

Code scanning / CodeQL

Calls to unmanaged code Note

Replace this call with a call to managed code if possible.

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)
{
Expand Down