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.