diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.cs index b16525a45d9..5bdf0c058f6 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -1335,15 +1335,9 @@ public virtual ContextMenuStrip? ContextMenuStrip return; } - if (oldValue is not null) - { - oldValue.Disposed -= DetachContextMenuStrip; - } + oldValue?.Disposed -= DetachContextMenuStrip; - if (value is not null) - { - value.Disposed += DetachContextMenuStrip; - } + value?.Disposed += DetachContextMenuStrip; OnContextMenuStripChanged(EventArgs.Empty); } @@ -11278,6 +11272,38 @@ internal void WmContextMenu(ref Message m, Control sourceControl) } } + /// + /// Handles the WM_MENUSELECT message. + /// + private void WmMenuSelect(ref Message m) + { +#pragma warning disable WFDEV006 // Type or member is obsolete + if (Properties.TryGetValue(s_contextMenuProperty, out ContextMenu? contextMenu)) + { + contextMenu.ProcessMenuSelect(ref m); + } + + DefWndProc(ref m); +#pragma warning restore WFDEV006 + } + + /// + /// Handles the WM_EXITMENULOOP message. If this control has a context menu, its + /// Collapse event is raised. + /// + private void WmExitMenuLoop(ref Message m) + { +#pragma warning disable WFDEV006 // Type or member is obsolete + if (m.WParamInternal != 0u && + Properties.TryGetValue(s_contextMenuProperty, out ContextMenu? contextMenu)) + { + contextMenu.OnCollapse(EventArgs.Empty); + } + + DefWndProc(ref m); +#pragma warning restore WFDEV006 + } + /// /// Handles the WM_INITMENUPOPUP message. Dispatches to the legacy so /// that events fire for submenus. Without this, controls that own @@ -12765,8 +12791,12 @@ protected virtual void WndProc(ref Message m) WmInitMenuPopup(ref m); break; - case PInvokeCore.WM_EXITMENULOOP: case PInvokeCore.WM_MENUSELECT: + WmMenuSelect(ref m); + break; + case PInvokeCore.WM_EXITMENULOOP: + WmExitMenuLoop(ref m); + break; default: // If we received a thread execute message, then execute it. @@ -13078,15 +13108,9 @@ public virtual ContextMenu ContextMenu return; } - if (oldValue is not null) - { - oldValue.Disposed -= DetachContextMenu; - } + oldValue?.Disposed -= DetachContextMenu; - if (value is not null) - { - value.Disposed += DetachContextMenu; - } + value?.Disposed += DetachContextMenu; OnContextMenuChanged(EventArgs.Empty); } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/LegacyMenuInteropCompat.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/LegacyMenuInteropCompat.cs index cc5e9fb3d3e..9903a3f8943 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/LegacyMenuInteropCompat.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/LegacyMenuInteropCompat.cs @@ -112,6 +112,12 @@ internal static class LegacyMenuUnsafeNativeMethods [DllImport(Libraries.User32, ExactSpelling = true)] public static extern bool SetMenuDefaultItem(HandleRef hMenu, int uItem, bool fByPos); + [DllImport(Libraries.User32, ExactSpelling = true)] + public static extern int GetMenuItemID(IntPtr hMenu, int nPos); + + [DllImport(Libraries.User32, ExactSpelling = true)] + public static extern IntPtr GetSubMenu(IntPtr hMenu, int nPos); + public static bool GetMenuItemInfo(HandleRef hMenu, int uItem, bool fByPosition, LegacyMenuNativeMethods.MENUITEMINFO_T lpmii) { bool result = GetMenuItemInfo(hMenu.Handle, uItem, fByPosition, lpmii); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/Menu.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/Menu.cs index 32bff340c60..be078620bfa 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/Menu.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/Menu.cs @@ -624,6 +624,82 @@ internal virtual bool ProcessInitMenuPopup(IntPtr handle) return false; } + internal bool ProcessMenuSelect(ref Message m) + { + const int MF_POPUP = 0x0010; + const int MF_SYSMENU = 0x2000; + + int item = PARAM.SignedLOWORD((nint)m.WParamInternal); + int flags = PARAM.SignedHIWORD((nint)m.WParamInternal); + MenuItem? menuItem = null; + + if ((flags & MF_SYSMENU) == 0) + { + if ((flags & MF_POPUP) == 0) + { + Command? command = Command.GetCommandFromID(item); + if (command?.Target is MenuItem.MenuItemData data) + { + menuItem = data.baseItem; + } + } + else + { + // Use native Win32 APIs to find the correct MenuItem. We cannot use + // managed MenuItems[index] because hidden items (Visible = false) are + // skipped in the native menu, causing the native index to differ from + // the managed collection index. + menuItem = GetMenuItemFromNativeIndex((IntPtr)(nint)m.LParamInternal, item); + } + } + + if (menuItem is not null) + { + menuItem.PerformSelect(); + return true; + } + + return false; + } + + /// + /// Resolves a popup menu item from its native menu position using Win32 APIs. + /// For a popup item (one with a submenu), this retrieves the submenu handle via + /// GetSubMenu, then recursively searches the submenu's children to find + /// a managed whose is the + /// popup item we need. This approach is immune to index mismatches caused by + /// hidden (Visible = false) menu items. + /// + private static MenuItem? GetMenuItemFromNativeIndex(IntPtr hmenu, int index) + { + int id = UnsafeNativeMethods.GetMenuItemID(hmenu, index); + if (id == unchecked((int)0xFFFFFFFF)) + { + // The item is a popup — get its submenu handle and search children. + IntPtr childMenu = UnsafeNativeMethods.GetSubMenu(hmenu, index); + int count = UnsafeNativeMethods.GetMenuItemCount(new HandleRef(null, childMenu)); + for (int i = 0; i < count; i++) + { + MenuItem? child = GetMenuItemFromNativeIndex(childMenu, i); + if (child?.Parent is MenuItem parentItem) + { + return parentItem; + } + } + } + else + { + // Non-popup item — look up by command ID. + Command? command = Command.GetCommandFromID(id); + if (command?.Target is MenuItem.MenuItemData data) + { + return data.baseItem; + } + } + + return null; + } + protected internal virtual bool ProcessCmdKey(ref Message msg, Keys keyData) { MenuItem item = FindMenuItem(FindShortcut, (IntPtr)(int)keyData); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.cs index 43074b0f95f..c5c1571a8aa 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.cs @@ -7004,6 +7004,21 @@ private void WmInitMenuPopup(ref Message m) base.WndProc(ref m); } + /// + /// Handles the WM_MENUSELECT message. + /// + private void WmMenuSelect(ref Message m) + { +#pragma warning disable WFDEV006 // Type or member is obsolete + if (Properties.TryGetValue(s_propCurMenu, out MainMenu? curMenu)) + { + curMenu.ProcessMenuSelect(ref m); + } +#pragma warning restore WFDEV006 + + base.WndProc(ref m); + } + /// /// Handles the WM_MENUCHAR message. /// @@ -7406,6 +7421,9 @@ protected override void WndProc(ref Message m) case PInvokeCore.WM_INITMENUPOPUP: WmInitMenuPopup(ref m); break; + case PInvokeCore.WM_MENUSELECT: + WmMenuSelect(ref m); + break; case PInvokeCore.WM_UNINITMENUPOPUP: WmUnInitMenuPopup(ref m); break; diff --git a/src/System.Windows.Forms.Legacy/System.Windows.Forms.Legacy.Tests/ContextMenu/ContextMenuSubMenuPopupTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/ContextMenuSubMenuPopupTests.cs similarity index 90% rename from src/System.Windows.Forms.Legacy/System.Windows.Forms.Legacy.Tests/ContextMenu/ContextMenuSubMenuPopupTests.cs rename to src/test/unit/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/ContextMenuSubMenuPopupTests.cs index 23189e4b774..15872c375c9 100644 --- a/src/System.Windows.Forms.Legacy/System.Windows.Forms.Legacy.Tests/ContextMenu/ContextMenuSubMenuPopupTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/ContextMenuSubMenuPopupTests.cs @@ -2,9 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Runtime.InteropServices; -using System.Windows.Forms; -namespace System.Windows.Forms.Legacy.Tests; +namespace System.Windows.Forms.Tests; /// /// Regression tests for WM_INITMENUPOPUP dispatch on a plain that @@ -20,8 +19,8 @@ namespace System.Windows.Forms.Legacy.Tests; /// /// /// Without the fix in , WM_INITMENUPOPUP falls through to -/// DefWndProc (grouped with WM_EXITMENULOOP / default), so -/// never fires and placeholder items are never replaced. +/// DefWndProc, so is never reached, +/// never fires, and placeholder items are never replaced. /// /// public class ContextMenuSubMenuPopupTests @@ -69,9 +68,9 @@ public void Control_WmInitMenuPopup_DirectSubMenu_FiresPopupEvent() // Act: simulate Windows delivering WM_INITMENUPOPUP to the control's HWND for the submenu. // - // Before the fix, Control.WndProc groups WM_INITMENUPOPUP with WM_EXITMENULOOP and calls - // DefWndProc — ContextMenu.ProcessInitMenuPopup is never reached, Popup never fires, and - // the placeholder is never replaced. + // Before the fix, Control.WndProc fell through to DefWndProc — + // ContextMenu.ProcessInitMenuPopup is never reached, Popup never fires, and the + // placeholder is never replaced. SendMessage(controlHandle, WM_INITMENUPOPUP, subMenuHandle, IntPtr.Zero); // Assert @@ -126,7 +125,7 @@ public void Control_WmInitMenuPopup_NestedSubMenu_FiresPopupEvent() nestedPopupFired, "MenuItem.Popup must fire for a nested submenu when WM_INITMENUPOPUP targets its HMENU."); - Assert.Equal(1, nestedItem.MenuItems.Count); + Assert.Single(nestedItem.MenuItems); Assert.Equal("NestedDynamicItem", nestedItem.MenuItems[0].Text); } } diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/LazyMenuPopulationRegressionTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/LazyMenuPopulationRegressionTests.cs new file mode 100644 index 00000000000..08fbe19e988 --- /dev/null +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/LazyMenuPopulationRegressionTests.cs @@ -0,0 +1,240 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; + +namespace System.Windows.Forms.Tests; + +/// +/// Regression tests proving that the top-level Universal Copy and Grid Colors +/// context-menu entries are actually populated when the user hovers them — not just that the +/// internal event wiring exists. +/// +/// +/// +/// The screenshot that drove this test shows the Universal Copy submenu open with four +/// entries: "New Copy Template", "Edit Copy Template", "New Copy Template From", and +/// "Copy Schedules". Without the WM_MENUSELECT fix in the +/// Grid Colors menu's handler never fires and the submenu +/// stays on stale placeholder items; without the WM_INITMENUPOPUP fix the Universal Copy +/// handler never fires and the placeholder is never replaced. +/// +/// +/// These tests simulate the real message sequence Windows delivers when the user hovers a +/// top-level context-menu entry and assert the resulting menu item count and text, mirroring +/// the production patterns in GridColourSchemeManager (Select-based) and +/// ZFilterGridModule.AddUniversalCopyMenuItems (Popup-based). +/// +/// +public class LazyMenuPopulationRegressionTests +{ + private const uint WM_MENUSELECT = 0x011F; + private const uint WM_INITMENUPOPUP = 0x0117; + private const int MF_POPUP = 0x0010; + + [DllImport("user32.dll")] + private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + + /// + /// Grid Colors pattern (GridColourSchemeManager.ToggleAndPopulateGridColourMenuItems): + /// the top-level "Grid Colors" menu item subscribes to and + /// repopulates its children from the colour store each time the user hovers it. + /// + /// + /// + /// Before the WM_MENUSELECT fix the message was dropped in , + /// so never fired and the submenu stayed on the original + /// placeholder items ("Select Color Scheme", "Create New Scheme", "Manage Color Schemes"). + /// This test verifies the submenu is replaced with live entries after the message lands. + /// + /// + [StaFact] + public void GridColors_SelectHandler_PopulatesSubMenuItems_WhenWmMenuSelectReceived() + { + // Arrange: replicate the GridColourSchemeManager setup — parent item with three static + // placeholders, replaced on Select by a dynamically sourced set of colour schemes. + using Control control = new() { Visible = true }; + + MenuItem gridColorsItem = new("Grid Colors"); + gridColorsItem.MenuItems.Add(new MenuItem("Select Color Scheme")); + gridColorsItem.MenuItems.Add(new MenuItem("Create New Scheme")); + gridColorsItem.MenuItems.Add(new MenuItem("Manage Color Schemes")); + + gridColorsItem.Select += (_, _) => + { + // Mirror ToggleAndPopulateGridColourMenuItems(): clear placeholders, add live entries. + gridColorsItem.MenuItems.Clear(); + gridColorsItem.MenuItems.Add(new MenuItem("Standard*")); + gridColorsItem.MenuItems.Add(new MenuItem("Dark Theme")); + gridColorsItem.MenuItems.Add(new MenuItem("High Contrast")); + }; + + ContextMenu contextMenu = new(new[] { gridColorsItem }); + control.ContextMenu = contextMenu; + + // Force native handles so ProcessMenuSelect can match by HMENU. + IntPtr controlHandle = control.Handle; + IntPtr contextMenuHandle = contextMenu.Handle; + + Assert.NotEqual(IntPtr.Zero, controlHandle); + Assert.NotEqual(IntPtr.Zero, contextMenuHandle); + + // "Grid Colors" is at native index 0 in the context menu; it has children so Windows sets MF_POPUP. + IntPtr wParam = unchecked((IntPtr)(0 | (MF_POPUP << 16))); + + // Act: simulate Windows delivering WM_MENUSELECT when the user hovers "Grid Colors". + SendMessage(controlHandle, WM_MENUSELECT, wParam, contextMenuHandle); + + // Assert: the submenu must contain the live entries, not the original placeholders. + Assert.Equal(3, gridColorsItem.MenuItems.Count); + Assert.Equal("Standard*", gridColorsItem.MenuItems[0].Text); + Assert.Equal("Dark Theme", gridColorsItem.MenuItems[1].Text); + Assert.Equal("High Contrast", gridColorsItem.MenuItems[2].Text); + } + + /// + /// Regression test for the hidden-items index mismatch: when context menu items have + /// Visible = false, the native menu skips them, so the native index differs from + /// the managed index. The original ProcessMenuSelect used + /// MenuItems[nativeIndex] which returned the wrong item. The fix uses native + /// Win32 APIs (GetMenuItemID, GetSubMenu) to navigate the actual menu structure. + /// + [StaFact] + public void GridColors_SelectHandler_WorksWithHiddenItems_WhenNativeIndexDiffersFromManaged() + { + using Control control = new() { Visible = true }; + + // Build a context menu with hidden items before Grid Colors to create index mismatch. + MenuItem viewItem = new("View"); + MenuItem deactivateItem = new("Deactivate") { Visible = false }; + MenuItem activateItem = new("Activate") { Visible = false }; + MenuItem gridColorsItem = new("Grid Colors"); + gridColorsItem.MenuItems.Add(new MenuItem("Placeholder")); + + bool selectFired = false; + gridColorsItem.Select += (_, _) => + { + selectFired = true; + gridColorsItem.MenuItems.Clear(); + gridColorsItem.MenuItems.Add(new MenuItem("Scheme A")); + gridColorsItem.MenuItems.Add(new MenuItem("Scheme B")); + }; + + // Managed indices: View=0, Deactivate=1(hidden), Activate=2(hidden), GridColors=3 + // Native indices: View=0, GridColors=1 (hidden items skipped) + ContextMenu contextMenu = new(new[] { viewItem, deactivateItem, activateItem, gridColorsItem }); + control.ContextMenu = contextMenu; + + IntPtr controlHandle = control.Handle; + IntPtr contextMenuHandle = contextMenu.Handle; + + // Grid Colors is at NATIVE index 1 (after View), not managed index 3. + IntPtr wParam = unchecked((IntPtr)(1 | (MF_POPUP << 16))); + + SendMessage(controlHandle, WM_MENUSELECT, wParam, contextMenuHandle); + + Assert.True(selectFired, "Select event should fire on Grid Colors despite hidden items shifting the native index."); + Assert.Equal(2, gridColorsItem.MenuItems.Count); + Assert.Equal("Scheme A", gridColorsItem.MenuItems[0].Text); + Assert.Equal("Scheme B", gridColorsItem.MenuItems[1].Text); + } + + /// + /// Universal Copy pattern (ZFilterGridModule.AddUniversalCopyMenuItems via + /// ): the "Universal Copy" submenu starts with a single + /// placeholder and switches to four real entries the first time it is opened. + /// + /// + /// + /// The screenshot captures exactly these four entries: + /// "New Copy Template", "Edit Copy Template", "New Copy Template From", "Copy Schedules". + /// This test asserts all four are present after WM_INITMENUPOPUP is delivered to the owning + /// control — proving the user-visible submenu content, not merely that Popup fired. + /// + /// + [StaFact] + public void UniversalCopy_PopupHandler_PopulatesExactSubMenuItems_WhenWmInitMenuPopupReceived() + { + // Arrange: replicate the Universal Copy entry — starts with a loading placeholder, + // real items added on first Popup (AddUniversalCopyMenuItems pattern from PR #53445). + using Control control = new() { Visible = true }; + + MenuItem universalCopyItem = new("Universal Copy"); + universalCopyItem.MenuItems.Add(new MenuItem("")); // placeholder + + bool alreadyPopulated = false; + universalCopyItem.Popup += (_, _) => + { + if (alreadyPopulated) return; + alreadyPopulated = true; + + // Mirror AddUniversalCopyMenuItems(): replace placeholder with real entries. + universalCopyItem.MenuItems.Clear(); + universalCopyItem.MenuItems.Add(new MenuItem("New Copy Template")); + universalCopyItem.MenuItems.Add(new MenuItem("Edit Copy Template")); + universalCopyItem.MenuItems.Add(new MenuItem("New Copy Template From")); + universalCopyItem.MenuItems.Add(new MenuItem("Copy Schedules")); + }; + + ContextMenu contextMenu = new(new[] { universalCopyItem }); + control.ContextMenu = contextMenu; + + IntPtr controlHandle = control.Handle; + IntPtr universalCopyHandle = universalCopyItem.Handle; + + Assert.NotEqual(IntPtr.Zero, controlHandle); + Assert.NotEqual(IntPtr.Zero, universalCopyHandle); + + // Act: simulate Windows delivering WM_INITMENUPOPUP when Universal Copy is hovered. + // Before the WM_INITMENUPOPUP fix in Control.WndProc, ProcessInitMenuPopup was never + // reached, Popup never fired, and the "" placeholder was never replaced. + SendMessage(controlHandle, WM_INITMENUPOPUP, universalCopyHandle, IntPtr.Zero); + + // Assert: exact submenu items visible in the screenshot. + Assert.Equal(4, universalCopyItem.MenuItems.Count); + Assert.Equal("New Copy Template", universalCopyItem.MenuItems[0].Text); + Assert.Equal("Edit Copy Template", universalCopyItem.MenuItems[1].Text); + Assert.Equal("New Copy Template From", universalCopyItem.MenuItems[2].Text); + Assert.Equal("Copy Schedules", universalCopyItem.MenuItems[3].Text); + } + + /// + /// Regression guard: sending WM_MENUSELECT a second time must not duplicate entries. + /// The Grid Colors handler clears and rebuilds on every Select; verifies idempotent + /// population when the user repeatedly moves the mouse away and back. + /// + [StaFact] + public void GridColors_SelectHandler_ReplacesItemsOnRepeatHover_NoDuplicates() + { + using Control control = new() { Visible = true }; + + MenuItem gridColorsItem = new("Grid Colors"); + gridColorsItem.MenuItems.Add(new MenuItem("Select Color Scheme")); + + int populateCount = 0; + gridColorsItem.Select += (_, _) => + { + populateCount++; + gridColorsItem.MenuItems.Clear(); + gridColorsItem.MenuItems.Add(new MenuItem($"Scheme A (call {populateCount})")); + gridColorsItem.MenuItems.Add(new MenuItem($"Scheme B (call {populateCount})")); + }; + + ContextMenu contextMenu = new(new[] { gridColorsItem }); + control.ContextMenu = contextMenu; + + IntPtr controlHandle = control.Handle; + IntPtr contextMenuHandle = contextMenu.Handle; + IntPtr wParam = unchecked((IntPtr)(0 | (MF_POPUP << 16))); + + // First hover + SendMessage(controlHandle, WM_MENUSELECT, wParam, contextMenuHandle); + Assert.Equal(2, gridColorsItem.MenuItems.Count); + Assert.Equal("Scheme A (call 1)", gridColorsItem.MenuItems[0].Text); + + // Second hover — must replace, not append + SendMessage(controlHandle, WM_MENUSELECT, wParam, contextMenuHandle); + Assert.Equal(2, gridColorsItem.MenuItems.Count); + Assert.Equal("Scheme A (call 2)", gridColorsItem.MenuItems[0].Text); + } +} diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/MenuSelectTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/MenuSelectTests.cs new file mode 100644 index 00000000000..ce5f6ef7acd --- /dev/null +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/Controls/Unsupported/ContextMenu/MenuSelectTests.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Runtime.InteropServices; + +namespace System.Windows.Forms.Tests; + +public class MenuSelectTests +{ + private const uint WM_MENUSELECT = 0x011F; + private const int MF_POPUP = 0x0010; + + [DllImport("user32.dll")] + private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + + [StaFact] + public void Control_WmMenuSelect_CommandItem_FiresSelectEvent() + { + using Control control = new() { Visible = true }; + + MenuItem menuItem = new("Command Item"); + bool selectFired = false; + menuItem.Select += (_, _) => selectFired = true; + + ContextMenu contextMenu = new(new[] { menuItem }); + control.ContextMenu = contextMenu; + + IntPtr controlHandle = control.Handle; + IntPtr contextMenuHandle = contextMenu.Handle; + int commandId = GetCommandId(menuItem); + + SendMessage(controlHandle, WM_MENUSELECT, unchecked((IntPtr)commandId), contextMenuHandle); + + Assert.True(selectFired); + } + + [StaFact] + public void Form_WmMenuSelect_PopupMenuItem_FiresSelectEvent() + { + using Form form = new(); + + MainMenu mainMenu = new(); + MenuItem fileMenuItem = new("File"); + bool selectFired = false; + fileMenuItem.Select += (_, _) => selectFired = true; + + mainMenu.MenuItems.Add(fileMenuItem); + form.Menu = mainMenu; + + IntPtr formHandle = form.Handle; + IntPtr mainMenuHandle = mainMenu.Handle; + IntPtr wParam = unchecked((IntPtr)(MF_POPUP << 16)); + + SendMessage(formHandle, WM_MENUSELECT, wParam, mainMenuHandle); + + Assert.True(selectFired); + } + + private static int GetCommandId(MenuItem menuItem) + { + _ = menuItem.Handle; + + FieldInfo dataField = typeof(MenuItem).GetField("_data", BindingFlags.Instance | BindingFlags.NonPublic)!; + object data = dataField.GetValue(menuItem)!; + + MethodInfo getMenuId = data.GetType().GetMethod("GetMenuID", BindingFlags.Instance | BindingFlags.NonPublic)!; + return (int)getMenuId.Invoke(data, null)!; + } +}