Today, we’ll explore how to add a native context menu to a tray icon.
Last time, we learned how to add an icon to the system tray and handle mouse clicks. Today, we’ll add a native context menu using only Windows API. The advantage of this approach is that the menu will be standard and system-native. This way, we’ll contribute to the fight against Windows UI inconsistency. On the other hand, this system menu hasn’t changed since the early versions of Windows and, to put it mildly, doesn’t look great in modern Windows 11. There are two options here: either hope that future updates will improve the menu (and our menu will automatically look better); or create a custom menu (which we’ll tackle next time).
We’ll start by making significant changes to the previous code. Remember the NativeWindow
class that was responsible for intercepting window messages? Well, we’ll replace it with the application’s main window. Experiments show that the fewest issues arise when we display the context menu from the main window rather than a specially created hidden one. To avoid spreading functionality across different places, we’ll fully tie it to the main window.
To achieve this, we’ll create a WindowsHelper
class that can intercept window messages for a given window and retrieve its handle:
internal class WindowHelper : IDisposable
{
private readonly Window window; // Reference to the main window
private readonly WNDPROC windowProc, nativeWindowProc; // Delegates for custom and original window procedures
public WindowHelper(Window window)
{
ArgumentNullException.ThrowIfNull(window); // Ensure the window is not null
this.window = window;
// Create a delegate for the custom window procedure
windowProc = new WNDPROC(WindowProc);
// Replace the window's default procedure with our custom one
var proc = PInvoke.SetWindowLongPtr(
hWnd: new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this.window)), // Get the window handle
nIndex: WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, // Specify that we're replacing the window procedure
dwNewLong: Marshal.GetFunctionPointerForDelegate(windowProc)); // Convert the delegate to a function pointer
// Store the original window procedure for later use
nativeWindowProc = Marshal.GetDelegateForFunctionPointer<WNDPROC>(proc);
}
// Destructor to ensure resources are cleaned up
~WindowHelper()
{
Dispose(disposing: false);
}
// Property to get the window handle
public HWND Handle => (HWND)WinRT.Interop.WindowNative.GetWindowHandle(window);
// Flag to track if the object has been disposed
public bool IsDisposed { get; private set; } = false;
// Event to notify subscribers about window messages
public event Action<uint, uint, int>? Message;
// Dispose method to clean up resources
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // Prevent the destructor from being called
}
// Helper method to handle disposal
private void Dispose(bool disposing)
{
if (!IsDisposed)
{
// Restore the original window procedure
PInvoke.SetWindowLongPtr(
hWnd: new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this.window)),
nIndex: WINDOW_LONG_PTR_INDEX.GWL_WNDPROC,
dwNewLong: Marshal.GetFunctionPointerForDelegate(nativeWindowProc));
IsDisposed = true; // Mark the object as disposed
}
}
// Custom window procedure to handle messages
private LRESULT WindowProc(HWND hWnd, uint msg, WPARAM wParam, LPARAM lParam)
{
// Notify subscribers about the received message
Message?.Invoke(msg, (uint)wParam.Value, (int)lParam.Value);
// Call the original window procedure to ensure default behavior
return PInvoke.CallWindowProc(nativeWindowProc, hWnd, msg, wParam, lParam);
}
}
We will pass this class to NotifyIcon
:
public NotifyIcon(WindowHelper windowHelper, bool keepIconAlive = false)
{
// Ensure the provided WindowHelper instance is not null
ArgumentNullException.ThrowIfNull(windowHelper);
// Store the WindowHelper instance for later use
this.windowHelper = windowHelper;
// .. skipped .. (other initialization code)
// Subscribe to the Message event of WindowHelper to handle window messages
this.windowHelper.Message += ProcessMessage;
}
In all places where a window handle is needed, we will use windowHelper.Handle
.
Now, let’s move on to creating the context menu itself. Right away, I’ll say that the code is heavily simplified, and, of course, context menus have many more capabilities. Here, it’s important for us to understand the essence. So, for the context menu, we’ll add two classes: NotifyContextMenu
and NotifyContextMenuItem
. The first is responsible for creating and managing the context menu, while the second represents a menu item. For simplicity, we’ll implement only the text and an enabled/disabled flag.
NotifyContextMenuItem
is an abstract class that implements the interface for a menu item:
// Abstract class representing a menu item in the context menu
abstract class NotifyContextMenuItem
{
// Abstract property to get or set the text of the menu item
public abstract string Text { get; set; }
// Abstract property to get or set whether the menu item is enabled or disabled
public abstract bool IsEnabled { get; set; }
// Event to handle click actions on the menu item
public EventHandler<EventArgs>? Click;
}
We will implement this functionality later in a nested class to allow tighter integration with the menu:
class NotifyContextMenu {
sealed class MenuItem : NotifyContextMenuItem
{
// Static counter to generate unique IDs for menu items
private static uint idCount = 100;
// Reference to the parent context menu
private NotifyContextMenu menu;
// Field to store the text of the menu item
private string text = string.Empty;
// Field to store the enabled state of the menu item
private bool isEnabled = true;
// Constructor to initialize the menu item with a reference to the parent menu
public MenuItem(NotifyContextMenu menu)
{
// Ensure the parent menu is not null
ArgumentNullException.ThrowIfNull(menu);
this.menu = menu;
// Assign a unique ID to the menu item
Id = ++idCount;
}
// Property to get the unique ID of the menu item
public uint Id { get; }
// Override for the Text property from the base class
public override string Text
{
get => text; // Return the current text
set
{
// If the text hasn't changed, do nothing
if (text == value)
{
return;
}
// Update the text (use an empty string if null is provided)
text = value ?? string.Empty;
// Notify the parent menu to update this item
menu.UpdateMenuItem(this);
}
}
// Override for the IsEnabled property from the base class
public override bool IsEnabled
{
get => isEnabled; // Return the current enabled state
set
{
// If the enabled state hasn't changed, do nothing
if (isEnabled == value)
{
return;
}
// Update the enabled state
isEnabled = value;
// Notify the parent menu to update this item
menu.UpdateMenuItem(this);
}
}
// Method to programmatically trigger a click event on the menu item
public void PerformClick()
{
// Invoke the Click event if there are subscribers
Click?.Invoke(this, EventArgs.Empty);
}
}
}
Let’s take a closer look at the NotifyContextMenu
class. The context menu is wrapped in a DestroyMenuSafeHandle
to ensure that resources are properly released when the object is destroyed:
private DestroyMenuSafeHandle handle;
public NotifyContextMenu()
{
handle = PInvoke.CreatePopupMenu_SafeHandle();
}
The AddMenuItem
method creates a menu item with the specified text and returns the created NotifyContextMenuItem
instance for further customization:
public NotifyContextMenuItem AddMenuItem(string text)
{
// Create a new MenuItem instance, passing the current context menu as its parent
var item = new MenuItem(this)
{
Text = text // Set the text of the menu item
};
// Initialize flags for the menu item (default is MF_STRING for text items)
var flags = MENU_ITEM_FLAGS.MF_STRING;
// Check if the text is "--", which indicates a separator
if (text == "--")
{
// Add the MF_SEPARATOR flag to create a separator instead of a text item
flags |= MENU_ITEM_FLAGS.MF_SEPARATOR;
}
// Add the menu item to the native menu using the Windows API
PInvoke.AppendMenu(handle, flags, item.Id, item.Text);
// Add the item to the internal list of menu items for tracking
items.Add(item);
// Return the created menu item
return item;
}
The UpdateMenuItem
method updates a menu item. It is declared as private because it is called from the nested MenuItem
class:
private void UpdateMenuItem(MenuItem item)
{
// Initialize flags for the menu item (default is MF_STRING for text items)
var flags = MENU_ITEM_FLAGS.MF_STRING;
// Check if the item's text is "--", which indicates a separator
if (item.Text == "--")
{
// Add the MF_SEPARATOR flag to treat the item as a separator
flags |= MENU_ITEM_FLAGS.MF_SEPARATOR;
}
// Check if the item is disabled
if (item.IsEnabled == false)
{
// Add the MF_DISABLED flag to disable the menu item
flags |= MENU_ITEM_FLAGS.MF_DISABLED;
}
// Update the native menu item using the Windows API
PInvoke.ModifyMenu(
hMenu: handle, // Handle to the native menu
uPosition: item.Id, // ID of the menu item to update
uFlags: flags, // Combined flags for the item's state (e.g., enabled, disabled, separator)
uIDNewItem: item.Id, // ID of the updated item
lpNewItem: item.Text // New text for the menu item
);
}
When displaying the context menu, it’s important to handle input focus correctly: before showing the menu, the main application window must be activated, and after the menu is closed, focus should be returned to the previously active window. This is necessary to ensure that the user can invoke the menu and navigate its items using the keyboard (without relying on the mouse):
public void Show(HWND hWnd, int x, int y)
{
// Get the handle of the currently active window to restore focus later
var activeWindow = PInvoke.GetForegroundWindow();
// Set the focus to the specified window (hWnd) to ensure keyboard input works
PInvoke.SetForegroundWindow(hWnd);
try
{
// Display the context menu and wait for a command to be selected
var command = PInvoke.TrackPopupMenuEx(
handle, // Handle to the native menu
(uint)(TRACK_POPUP_MENU_FLAGS.TPM_RETURNCMD | TRACK_POPUP_MENU_FLAGS.TPM_NONOTIFY), // Flags: return the selected command ID and do not send notifications
x, // X-coordinate of the menu position
y, // Y-coordinate of the menu position
hWnd, // Handle to the window owning the menu
null // Optional rectangle to exclude (not used here)
);
// If no command was selected (command == 0), exit the method
if (command == 0)
{
return;
}
// Find the menu item corresponding to the selected command ID
var item = items.FirstOrDefault(x => x.Id == command.Value);
// If the item is found, trigger its click event
item?.PerformClick();
}
finally
{
// Restore focus to the previously active window, even if an exception occurs
PInvoke.SetForegroundWindow(activeWindow);
}
}
The menu is invoked from the NotifyIcon class. It can be opened either by right-clicking the icon or by using the “context menu” key on the keyboard. To ensure a consistent approach, we will use the coordinates of the icon’s rectangle and display the menu at its center:
private void ProcessMessage(uint messageId, uint wParam, int lParam)
{
// Handle the WM_TASKBARCREATED message, which indicates the taskbar has been recreated
if (messageId == WM_TASKBARCREATED)
{
Show(); // Re-show the tray icon if the taskbar is recreated
return;
}
// Ignore messages that are not intended for this tray icon
if (messageId != MESSAGE_ID)
{
return;
}
// Handle specific mouse or context menu events
switch (lParam)
{
case WM_LBUTTONUP: // Left mouse button release event
OnClick(new NotifyIconEventArgs { Rect = GetIconRectangle() }); // Trigger the Click event
break;
case WM_CONTEXTMENU: // Context menu key event
case WM_RBUTTONUP: // Right mouse button release event
ShowContextMenu(); // Display the context menu
break;
}
}
private void ShowContextMenu()
{
// Get the rectangle of the tray icon
var rect = GetIconRectangle();
// Show the context menu at the center of the icon's rectangle
ContextMenu.Show(
windowHelper.Handle, // Handle to the window owning the menu
(int)(rect.Left + rect.Right) / 2, // X-coordinate: center of the icon
(int)(rect.Top + rect.Bottom) / 2); // Y-coordinate: center of the icon
}
private unsafe Windows.Foundation.Rect GetIconRectangle()
{
// Create a NOTIFYICONIDENTIFIER structure to identify the tray icon
var notifyIcon = new NOTIFYICONIDENTIFIER
{
cbSize = (uint)sizeof(NOTIFYICONIDENTIFIER), // Size of the structure
hWnd = windowHelper.Handle, // Handle to the window associated with the icon
guidItem = Id // Unique identifier for the icon
};
// Retrieve the rectangle of the tray icon using the Shell_NotifyIconGetRect function
return PInvoke.Shell_NotifyIconGetRect(notifyIcon, out var rect) != 0
? Windows.Foundation.Rect.Empty // Return an empty rectangle if the function fails
: new Windows.Foundation.Rect(
rect.left, // X-coordinate of the top-left corner
rect.top, // Y-coordinate of the top-left corner
rect.right - rect.left, // Width of the rectangle
rect.bottom - rect.top); // Height of the rectangle
}
The full example is available at GitHub.