WPF context menu disappearing with display scaling above 100%
Friday, February 27, 2026While developing a Vault Explorer extension, I ran into a problem: in a WPF window, the context menu partially disappears as soon as you hover the mouse over it. Some items reappear when hovered, others remain invisible. The menu is unusable.
The strangest part is that it doesn't happen on every workstation.
Dead ends
First hypothesis: the graphics card. The user has an NVIDIA RTX 2000 Ada Generation with a slightly outdated driver. Driver update: no change.
Second hypothesis: hardware rendering. WPF uses hardware acceleration by default for rendering. You can force software rendering with:
RenderOptions.ProcessRenderMode = RenderMode.SoftwareOnly;
No luck either.
Third hypothesis: the context menu drop shadow, which uses layered transparent windows. Disabling it with HasDropShadow="False" on the ContextMenu. Still nothing.
The real cause
When comparing working and non-working workstations, the common factor is obvious: display scaling. Workstations at 100% work fine. Those at 125% or 150% have the problem.
After digging around, you find this issue in Microsoft's WPF repository. It's a known .NET Framework 4.8 bug.
Technical explanation
In .NET 4.8, Microsoft added DPI scaling logic to the Popup class (which is used internally by ContextMenu). When the system detects a high DPI mode, Popup.CreateWindow calls DestroyWindow() to destroy the underlying HWND and recreate it with the correct DPI affinity.
The problem is that DestroyWindow() does much more than just destroy the HWND: it releases mouse capture, fires the OnClosed event and resets IsOpen to false. The result: the context menu ends up in an inconsistent state where it is partially displayed but thinks it is closed.
This bug was fixed in .NET Core 3.1, but the fix was never backported to .NET Framework 4.8.
The solution
Our Vault add-in is an extension loaded into the Vault Explorer, which is a WinForms application. We don't control the host process, so there's no way to modify its manifest or app.config.
First approach: global DPI awareness change
The first idea is to change the DPI awareness context at the thread level using the Win32 API SetThreadDpiAwarenessContext. We switch the thread to System DPI Aware mode (DPI_AWARENESS_CONTEXT_SYSTEM_AWARE) just before opening our WPF window, and restore the previous context when it closes:
[DllImport("user32.dll")]
static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext);
static readonly IntPtr DPI_AWARENESS_CONTEXT_SYSTEM_AWARE = new(-2);
void ExecuteCommand(IVaultExplorerCommand cmd)
{
IntPtr previousContext =
SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE);
try
{
cmd.Execute();
}
finally
{
SetThreadDpiAwarenessContext(previousContext);
}
}
In System DPI Aware mode, the problematic Popup code that destroys and recreates the HWND is not executed, because this logic only triggers in Per-Monitor DPI Aware mode. The context menu works normally.
But this approach has a side effect: changing the DPI awareness for the entire duration of the command (which includes creating and displaying the WPF window) disrupts WPF's layout system. Margins, sizes and element positioning become incorrect. The window looks broken.
The right approach: targeted change only for the context menu
The DPI awareness change must be restricted to the strict minimum: only while the ContextMenu is opening. To do this, we intercept the routed events ContextMenuOpeningEvent and ContextMenuClosingEvent at the window level.
But that alone is not enough. Changing the DPI context mid-flight breaks the popup positioning. Here's why:
- Before the switch,
GetCursorPosreturns virtualized coordinates (e.g.(283, 338)at 150% scaling) - After the switch to SYSTEM_AWARE,
GetCursorPosreturns the actual physical pixels ((424, 507)) - WPF uses its internal mouse position (captured before the switch) to position the popup, but interprets it in the physical coordinate space of SYSTEM_AWARE mode
The result: the menu consistently appears offset from the cursor.
Fixing the position with WM_WINDOWPOSCHANGING
The solution is to intercept the Win32 WM_WINDOWPOSCHANGING message on the popup's HWND to replace the position calculated by WPF with the actual physical cursor position.
The tricky part is the timing of the hook. The ContextMenu.Opened event fires after the popup is already positioned — too late. And calling SetWindowPos after the fact has no effect because WPF intercepts WM_WINDOWPOSCHANGING internally and overrides external repositioning attempts.
The solution is to use PresentationSource.AddSourceChangedHandler on the ContextMenu. This event fires when the HwndSource is created and associated with the ContextMenu, before the initial popup positioning. We can then install a WndProc hook that intercepts the first WM_WINDOWPOSCHANGING with an actual move (without the SWP_NOMOVE flag):
sealed class DpiAwarenessContextMenuFixer
{
static readonly IntPtr DPI_AWARENESS_CONTEXT_SYSTEM_AWARE = new(-2);
const int WM_WINDOWPOSCHANGING = 0x0046;
const uint SWP_NOMOVE = 0x0002;
[DllImport("user32.dll")]
static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext);
[DllImport("user32.dll")]
static extern bool GetCursorPos(out POINT point);
[StructLayout(LayoutKind.Sequential)]
struct POINT { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)]
struct WINDOWPOS
{
public IntPtr hwnd, hwndInsertAfter;
public int x, y, cx, cy;
public uint flags;
}
IntPtr _previousDpiAwarenessContext;
POINT _cursorPhysicalPosition;
bool _needsRepositioning;
ContextMenu _currentContextMenu;
public static void Attach(Window window)
{
window.Loaded += (s, e) =>
{
var fixer = new DpiAwarenessContextMenuFixer();
var w = (Window)s;
w.AddHandler(ContextMenuService.ContextMenuOpeningEvent,
new ContextMenuEventHandler(fixer.OnContextMenuOpening),
handledEventsToo: true);
w.AddHandler(ContextMenuService.ContextMenuClosingEvent,
new ContextMenuEventHandler(fixer.OnContextMenuClosing),
handledEventsToo: true);
};
}
void OnContextMenuOpening(object sender, ContextMenuEventArgs e)
{
_previousDpiAwarenessContext =
SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE);
// In SYSTEM_AWARE mode, GetCursorPos returns actual physical pixels.
GetCursorPos(out _cursorPhysicalPosition);
_needsRepositioning = true;
// SourceChanged fires when the HwndSource is created for the
// ContextMenu, BEFORE the initial popup positioning.
_currentContextMenu = FindContextMenu(e.OriginalSource);
if (_currentContextMenu != null)
PresentationSource.AddSourceChangedHandler(
_currentContextMenu, OnSourceChanged);
}
void OnSourceChanged(object sender, SourceChangedEventArgs e)
{
if (e.NewSource is HwndSource hwndSource)
hwndSource.AddHook(PopupWndProc);
if (sender is ContextMenu cm)
PresentationSource.RemoveSourceChangedHandler(cm, OnSourceChanged);
}
IntPtr PopupWndProc(IntPtr hwnd, int msg, IntPtr wParam,
IntPtr lParam, ref bool handled)
{
if (msg == WM_WINDOWPOSCHANGING && _needsRepositioning)
{
var pos = Marshal.PtrToStructure<WINDOWPOS>(lParam);
if ((pos.flags & SWP_NOMOVE) == 0)
{
_needsRepositioning = false;
pos.x = _cursorPhysicalPosition.X;
pos.y = _cursorPhysicalPosition.Y;
Marshal.StructureToPtr(pos, lParam, false);
}
}
return IntPtr.Zero;
}
void OnContextMenuClosing(object sender, ContextMenuEventArgs e)
{
SetThreadDpiAwarenessContext(_previousDpiAwarenessContext);
if (_currentContextMenu != null)
{
PresentationSource.RemoveSourceChangedHandler(
_currentContextMenu, OnSourceChanged);
_currentContextMenu = null;
}
}
static ContextMenu FindContextMenu(DependencyObject element)
{
while (element != null)
{
if (element is FrameworkElement fe && fe.ContextMenu != null)
return fe.ContextMenu;
element = VisualTreeHelper.GetParent(element);
}
return null;
}
}
Call DpiAwarenessContextMenuFixer.Attach(window) before ShowDialog() on each WPF window that contains context menus.
Summary
| Step | Event | Action |
|---|---|---|
| 1 | ContextMenuOpening |
Switch to SYSTEM_AWARE, capture physical cursor position, subscribe to SourceChanged |
| 2 | SourceChanged |
Popup HwndSource is created, install WndProc hook |
| 3 | WM_WINDOWPOSCHANGING |
Replace WPF's calculated position with the physical cursor position |
| 4 | ContextMenuClosing |
Restore previous DPI awareness |
Besoin d'un développement Vault ? Contactez-moi pour un devis gratuit.