Search code examples
c#xamlkeyboard-shortcutswinui-3windows-template-studio

How to make a global keyboard accelerator/hotkey for a button?


I have a ToggleButton with a KeyboardAccelerator that toggles a button whenever the B key is pressed. The problem I am having is that button is only togglable using the keyboard accelerator when the window is focused and I want to be able to toggle that button reguardless if that window is focused or not.

How would I make a keyboard accelerator able to be used globally when my window is not focused?

<ToggleButton Content="Click me!">
    <ToggleButton.KeyboardAccelerators>
        <KeyboardAccelerator Key="B" />
    </ToggleButton.KeyboardAccelerators>
</ToggleButton>

Video of me toggling the button by pressing the B key with and without window focus:

Keyboard accelerator example

I reviewed the documentation about Keyboard interactions and the ToggleButton Class to see if there was some property or way I could set a global keyboard accelerator but wasn't able to find anything about such for my specific project which is a WinUI (C#) project made using Template Studio.


Solution

  • To create a global Windows hotkey we can use the RegisterHotKey function but it's not that easy with WinUI3 since this function works by sending a window message and the WinUI3 window message loop is not exposed.

    We can work around that with "subclassing" the WinUI3 window message loop, that's done in the WindowMessageHook utility below.

    Here is how you can use it in a WinUI3 window:

    MainWindow.xaml:

    <Window x:Class="WinUI3App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
        <StackPanel
            HorizontalAlignment="Center"
            VerticalAlignment="Center"
            Orientation="Horizontal">
            <Button x:Name="myButton" Click="myButton_Click">Click</Button>
        </StackPanel>
    </Window>
    

    MainWindow.xaml.cs:

    using System;
    using System.ComponentModel;
    using System.Runtime.CompilerServices;
    using System.Runtime.InteropServices;
    using System.Threading;
    using Microsoft.UI;
    using Microsoft.UI.Xaml;
    using Microsoft.UI.Xaml.Automation.Peers;
    using Windows.System;
    
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
    
            // hook this window's message
            var hook = new WindowMessageHook(this);
            Closed += (s, e) => hook.Dispose(); // unhook on close
            hook.Message += (s, e) =>
            {
                const int WM_HOTKEY = 0x312;
                if (e.Message == WM_HOTKEY)
                {
                    // click on the button using UI Automation
                    var pattern = (ButtonAutomationPeer)FrameworkElementAutomationPeer.FromElement(myButton).GetPattern(PatternInterface.Invoke);
                    pattern.Invoke();
                }
            };
    
            // register CTRL + B as a global hotkey
            var hwnd = Win32Interop.GetWindowFromWindowId(AppWindow.Id);
            var id = 1; // some arbitrary hotkey identifier
            if (!RegisterHotKey(hwnd, id, MOD.MOD_CONTROL, VirtualKey.B))
                throw new Win32Exception(Marshal.GetLastWin32Error());
    
            Closed += (s, e) => UnregisterHotKey(hwnd, id); // unregister hotkey on window close
        }
    
        private void myButton_Click(object sender, RoutedEventArgs e)
        {
            myButton.Content = "hello";
        }
    
        // interop code for Windows API hotkey functions
        [DllImport("user32", SetLastError = true)]
        private static extern bool RegisterHotKey(nint hWnd, int id, MOD fsModifiers, VirtualKey vk);
    
        [DllImport("user32", SetLastError = true)]
        private static extern bool UnregisterHotKey(nint hWnd, int id);
    
        [Flags]
        private enum MOD
        {
            MOD_ALT = 0x1,
            MOD_CONTROL = 0x2,
            MOD_SHIFT = 0x4,
            MOD_WIN = 0x8,
            MOD_NOREPEAT = 0x4000,
        }
    }
    

    Message hooking utility:

    public class WindowMessageHook : IEquatable<WindowMessageHook>, IDisposable
    {
        private delegate nint SUBCLASSPROC(nint hWnd, uint uMsg, nint wParam, nint lParam, nint uIdSubclass, uint dwRefData);
    
        private static readonly ConcurrentDictionary<nint, WindowMessageHook> _hooks = new();
        private static readonly SUBCLASSPROC _proc = SubclassProc;
    
        public event EventHandler<MessageEventArgs> Message;
        private nint _hWnd;
    
        public WindowMessageHook(Window window) : this(GetHandle(window)) { }
        public WindowMessageHook(nint hWnd)
        {
            if (hWnd == 0)
                throw new ArgumentException(null, nameof(hWnd));
    
            _hWnd = hWnd;
            _hooks.AddOrUpdate(hWnd, this, (k, o) =>
            {
                if (Equals(o)) return o;
                o.Dispose();
                return this;
            });
            if (!SetWindowSubclass(hWnd, _proc, 0, 0))
                throw new Win32Exception(Marshal.GetLastWin32Error());
        }
    
        protected virtual void OnMessage(object sender, MessageEventArgs e) => Message?.Invoke(sender, e);
        protected virtual void Dispose(bool disposing)
        {
            if (!disposing) return;
            var hWnd = Interlocked.Exchange(ref _hWnd, IntPtr.Zero);
            if (hWnd != IntPtr.Zero)
            {
                RemoveWindowSubclass(hWnd, _proc, 0);
                _hooks.Remove(hWnd, out _);
            }
        }
    
        ~WindowMessageHook() { Dispose(disposing: false); }
        public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); }
    
        [DllImport("comctl32", SetLastError = true)]
        private static extern bool SetWindowSubclass(nint hWnd, SUBCLASSPROC pfnSubclass, uint uIdSubclass, uint dwRefData);
    
        [DllImport("comctl32", SetLastError = true)]
        private static extern nint DefSubclassProc(nint hWnd, uint uMsg, nint wParam, nint lParam);
    
        [DllImport("comctl32", SetLastError = true)]
        private static extern bool RemoveWindowSubclass(nint hWnd, SUBCLASSPROC pfnSubclass, uint uIdSubclass);
    
        private static nint GetHandle(Window window)
        {
            ArgumentNullException.ThrowIfNull(window);
            return Win32Interop.GetWindowFromWindowId(window.AppWindow.Id);
        }
    
        private static nint SubclassProc(nint hWnd, uint uMsg, nint wParam, nint lParam, nint uIdSubclass, uint dwRefData)
        {
            if (_hooks.TryGetValue(hWnd, out var hook))
            {
                var e = new MessageEventArgs(hWnd, uMsg, wParam, lParam);
                hook.OnMessage(hook, e);
                if (e.Result.HasValue)
                    return e.Result.Value;
            }
            return DefSubclassProc(hWnd, uMsg, wParam, lParam);
        }
    
        public override int GetHashCode() => _hWnd.GetHashCode();
        public override string ToString() => _hWnd.ToString();
        public override bool Equals(object obj) => Equals(obj as Window);
        public virtual bool Equals(WindowMessageHook other) => other != null && _hWnd.Equals(other._hWnd);
    }
    
    public class MessageEventArgs : EventArgs
    {
        public MessageEventArgs(nint hWnd, uint uMsg, nint wParam, nint lParam)
        {
            HWnd = hWnd;
            Message = uMsg;
            WParam = wParam;
            LParam = lParam;
        }
    
        public nint HWnd { get; }
        public uint Message { get; }
        public nint WParam { get; }
        public nint LParam { get; }
        public virtual nint? Result { get; set; }
    }