Menu contextuel WPF qui disparait avec un scaling supérieur à 100%
vendredi 27 février 2026En développant une extension de l'explorateur Vault, j'ai rencontré un problème : dans une fenêtre WPF, le menu contextuel disparait partiellement dès qu'on passe le curseur de la souris dessus. Certains éléments réapparaissent quand on les survole, d'autres restent invisibles. Le menu est inutilisable.
Le plus étrange, c'est que ça ne se produit pas sur tous les postes.
Les fausses pistes
Première hypothèse : la carte graphique. L'utilisateur a une NVIDIA RTX 2000 Ada Generation avec un pilote un peu ancien. Mise à jour du pilote : rien.
Deuxième hypothèse : le rendu matériel. WPF utilise par défaut l'accélération matérielle pour le rendu. On peut forcer le rendu logiciel avec :
RenderOptions.ProcessRenderMode = RenderMode.SoftwareOnly;
Rien non plus.
Troisième hypothèse : l'ombre portée du menu contextuel, qui utilise des fenêtres transparentes en couche. On désactive avec HasDropShadow="False" sur le ContextMenu. Toujours rien.
La vraie cause
En comparant les postes qui fonctionnent et ceux qui ne fonctionnent pas, le point commun saute aux yeux : la mise à l'échelle de l'affichage. Les postes à 100% fonctionnent. Ceux à 125% ou 150% ont le problème.
En fouillant, on tombe sur ce ticket dans le dépôt WPF de Microsoft. C'est un bug connu de .NET Framework 4.8.
L'explication technique
Dans .NET 4.8, Microsoft a ajouté une logique de scaling DPI dans la classe Popup (qui est utilisée en interne par ContextMenu). Quand le système détecte un mode DPI élevé, Popup.CreateWindow appelle DestroyWindow() pour détruire le HWND sous-jacent et le recréer avec la bonne affinité DPI.
Le problème, c'est que DestroyWindow() fait beaucoup plus que détruire le HWND : il relâche la capture de la souris, déclenche l'événement OnClosed et réinitialise IsOpen à false. Résultat : le menu contextuel se retrouve dans un état incohérent où il est partiellement affiché, mais pense être fermé.
Ce bug a été corrigé dans .NET Core 3.1, mais la correction n'a jamais été rétroportée dans .NET Framework 4.8.
La solution
Notre complément Vault est une extension chargée dans l'explorateur Vault, qui est une application WinForms. On ne contrôle pas le processus hôte, donc pas moyen de modifier son manifeste ou son app.config.
Première approche : changement global du DPI awareness
La première idée est de modifier le contexte de DPI awareness au niveau du fil d'exécution avec l'API Win32 SetThreadDpiAwarenessContext. On passe le fil d'exécution en mode System DPI Aware (DPI_AWARENESS_CONTEXT_SYSTEM_AWARE) juste avant d'ouvrir notre fenêtre WPF, et on restaure le contexte précédent à la fermeture :
[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);
}
}
En mode System DPI Aware, le code problématique de Popup qui détruit et recrée le HWND n'est pas exécuté, car cette logique ne se déclenche qu'en mode Per-Monitor DPI Aware. Le menu contextuel fonctionne normalement.
Mais cette approche a un effet de bord : changer le DPI awareness pour toute la durée de la commande (qui inclut la création et l'affichage de la fenêtre WPF) perturbe le système de layout de WPF. Les marges, les tailles et le positionnement des éléments sont incorrects. La fenêtre a une apparence étrange.
La bonne approche : changement ciblé uniquement pour le menu contextuel
Il faut restreindre le changement de DPI awareness au strict minimum : uniquement pendant l'ouverture du ContextMenu. Pour ça, on intercepte les événements routés ContextMenuOpeningEvent et ContextMenuClosingEvent au niveau de la fenêtre.
Mais ce n'est pas suffisant. Le changement de DPI context en cours de route fausse le positionnement du popup. En effet :
- Avant le switch,
GetCursorPosrenvoie des coordonnées virtualisées (par exemple(283, 338)à 150%) - Après le switch en SYSTEM_AWARE,
GetCursorPosrenvoie les pixels physiques réels ((424, 507)) - WPF utilise sa position interne de la souris (capturée avant le switch) pour positionner le popup, mais l'interprète dans le référentiel physique du mode SYSTEM_AWARE
Résultat : le menu apparait systématiquement décalé par rapport au curseur.
Corriger le positionnement avec WM_WINDOWPOSCHANGING
La solution est d'intercepter le message Win32 WM_WINDOWPOSCHANGING sur le HWND du popup pour remplacer la position calculée par WPF par la position physique réelle du curseur.
Le point délicat est le timing du hook. L'événement ContextMenu.Opened se déclenche après que le popup est déjà positionné — trop tard. Et SetWindowPos appelé après coup n'a aucun effet car WPF intercepte WM_WINDOWPOSCHANGING en interne et annule les repositionnements externes.
La solution est d'utiliser PresentationSource.AddSourceChangedHandler sur le ContextMenu. Cet événement se déclenche quand le HwndSource est créé et associé au ContextMenu, avant le positionnement initial du popup. On peut alors installer un hook WndProc qui interceptera le premier WM_WINDOWPOSCHANGING avec un vrai déplacement (sans le flag SWP_NOMOVE) :
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);
// En SYSTEM_AWARE, GetCursorPos renvoie les pixels physiques réels.
GetCursorPos(out _cursorPhysicalPosition);
_needsRepositioning = true;
// SourceChanged se déclenche quand le HwndSource est créé pour le
// ContextMenu, AVANT le positionnement initial du popup.
_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;
}
}
On appelle DpiAwarenessContextMenuFixer.Attach(window) avant ShowDialog() sur chaque fenêtre WPF qui contient des menus contextuels.
Résumé
| Étape | Événement | Action |
|---|---|---|
| 1 | ContextMenuOpening |
Switch vers SYSTEM_AWARE, capture de la position physique du curseur, inscription à SourceChanged |
| 2 | SourceChanged |
Le HwndSource du popup est créé, installation du hook WndProc |
| 3 | WM_WINDOWPOSCHANGING |
Remplacement de la position calculée par WPF par la position physique du curseur |
| 4 | ContextMenuClosing |
Restauration du DPI awareness précédent |
Besoin d'un développement Vault ? Contactez-moi pour un devis gratuit.