Add global hotkey support for Avalonia UI

This commit is contained in:
Hongtao Zhang 2026-01-14 23:50:54 -08:00
parent 207953159a
commit 065dc191ce
5 changed files with 453 additions and 1 deletions

View file

@ -3,6 +3,7 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Avalonia.Helper;
using Flow.Launcher.Avalonia.ViewModel;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Infrastructure;
@ -38,7 +39,13 @@ public partial class App : Application
desktop.MainWindow = new MainWindow();
// Initialize hotkeys after window is created
HotKeyMapper.Initialize();
Dispatcher.UIThread.Post(async () => await InitializePluginsAsync(), DispatcherPriority.Background);
// Cleanup on exit
desktop.Exit += (_, _) => HotKeyMapper.Shutdown();
}
base.OnFrameworkInitializationCompleted();
}

View file

@ -0,0 +1,308 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
namespace Flow.Launcher.Avalonia.Helper;
/// <summary>
/// Win32-based global hotkey manager for Avalonia.
/// Uses RegisterHotKey/UnregisterHotKey Win32 APIs with a message-only window.
/// </summary>
public static class GlobalHotkey
{
private const int WM_HOTKEY = 0x0312;
[DllImport("user32.dll", SetLastError = true)]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr CreateWindowEx(
uint dwExStyle, string lpClassName, string lpWindowName,
uint dwStyle, int x, int y, int nWidth, int nHeight,
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
[DllImport("user32.dll")]
private static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
private static extern ushort RegisterClass(ref WNDCLASS lpWndClass);
[DllImport("kernel32.dll")]
private static extern IntPtr GetModuleHandle(string? lpModuleName);
[DllImport("user32.dll")]
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
[DllImport("user32.dll")]
private static extern bool TranslateMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
private delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)]
private struct WNDCLASS
{
public uint style;
public WndProcDelegate lpfnWndProc;
public int cbClsExtra;
public int cbWndExtra;
public IntPtr hInstance;
public IntPtr hIcon;
public IntPtr hCursor;
public IntPtr hbrBackground;
public string? lpszMenuName;
public string lpszClassName;
}
[StructLayout(LayoutKind.Sequential)]
private struct MSG
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public POINT pt;
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int x;
public int y;
}
// Modifier keys for RegisterHotKey
[Flags]
public enum Modifiers : uint
{
None = 0,
Alt = 0x0001,
Control = 0x0002,
Shift = 0x0004,
Win = 0x0008,
NoRepeat = 0x4000
}
// Virtual key codes
public static class VirtualKeys
{
public const uint Space = 0x20;
public const uint Enter = 0x0D;
public const uint Tab = 0x09;
public const uint A = 0x41;
public const uint B = 0x42;
public const uint C = 0x43;
public const uint D = 0x44;
public const uint E = 0x45;
public const uint F = 0x46;
public const uint G = 0x47;
public const uint H = 0x48;
public const uint I = 0x49;
public const uint J = 0x4A;
public const uint K = 0x4B;
public const uint L = 0x4C;
public const uint M = 0x4D;
public const uint N = 0x4E;
public const uint O = 0x4F;
public const uint P = 0x50;
public const uint Q = 0x51;
public const uint R = 0x52;
public const uint S = 0x53;
public const uint T = 0x54;
public const uint U = 0x55;
public const uint V = 0x56;
public const uint W = 0x57;
public const uint X = 0x58;
public const uint Y = 0x59;
public const uint Z = 0x5A;
}
private static IntPtr _messageWindow;
private static WndProcDelegate? _wndProc; // prevent GC
private static readonly Dictionary<int, Action> _hotkeyCallbacks = new();
private static int _nextHotkeyId = 1;
private static DispatcherTimer? _messageTimer;
private static bool _initialized;
/// <summary>
/// Initialize the hotkey system. Call once at app startup.
/// </summary>
public static void Initialize()
{
if (_initialized) return;
_wndProc = WndProc;
var wc = new WNDCLASS
{
lpfnWndProc = _wndProc,
hInstance = GetModuleHandle(null),
lpszClassName = "FlowLauncherAvaloniaHotkeyClass"
};
if (RegisterClass(ref wc) == 0)
{
Console.WriteLine("[GlobalHotkey] Failed to register window class");
return;
}
// Create message-only window (HWND_MESSAGE = -3)
_messageWindow = CreateWindowEx(
0, wc.lpszClassName, "FlowLauncherHotkeyWindow",
0, 0, 0, 0, 0,
new IntPtr(-3), IntPtr.Zero, wc.hInstance, IntPtr.Zero);
if (_messageWindow == IntPtr.Zero)
{
Console.WriteLine("[GlobalHotkey] Failed to create message window");
return;
}
// Poll for messages periodically (hotkeys come via WM_HOTKEY)
_messageTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50) };
_messageTimer.Tick += (_, _) => ProcessMessages();
_messageTimer.Start();
_initialized = true;
Console.WriteLine("[GlobalHotkey] Initialized successfully");
}
/// <summary>
/// Register a global hotkey.
/// </summary>
/// <param name="modifiers">Modifier keys (Alt, Ctrl, Shift, Win)</param>
/// <param name="key">Virtual key code</param>
/// <param name="callback">Action to invoke when hotkey is pressed</param>
/// <returns>Hotkey ID for later unregistration, or -1 on failure</returns>
public static int Register(Modifiers modifiers, uint key, Action callback)
{
if (!_initialized)
{
Console.WriteLine("[GlobalHotkey] Not initialized");
return -1;
}
int id = _nextHotkeyId++;
// Add NoRepeat to prevent repeated triggers while held
uint mods = (uint)modifiers | (uint)Modifiers.NoRepeat;
if (!RegisterHotKey(_messageWindow, id, mods, key))
{
int error = Marshal.GetLastWin32Error();
Console.WriteLine($"[GlobalHotkey] Failed to register hotkey (error {error})");
return -1;
}
_hotkeyCallbacks[id] = callback;
Console.WriteLine($"[GlobalHotkey] Registered hotkey id={id}, mods={modifiers}, key=0x{key:X2}");
return id;
}
/// <summary>
/// Unregister a previously registered hotkey.
/// </summary>
public static void Unregister(int hotkeyId)
{
if (hotkeyId < 0 || _messageWindow == IntPtr.Zero) return;
UnregisterHotKey(_messageWindow, hotkeyId);
_hotkeyCallbacks.Remove(hotkeyId);
Console.WriteLine($"[GlobalHotkey] Unregistered hotkey id={hotkeyId}");
}
/// <summary>
/// Cleanup all hotkeys and resources.
/// </summary>
public static void Shutdown()
{
_messageTimer?.Stop();
foreach (var id in _hotkeyCallbacks.Keys)
{
UnregisterHotKey(_messageWindow, id);
}
_hotkeyCallbacks.Clear();
if (_messageWindow != IntPtr.Zero)
{
DestroyWindow(_messageWindow);
_messageWindow = IntPtr.Zero;
}
_initialized = false;
Console.WriteLine("[GlobalHotkey] Shutdown complete");
}
private static void ProcessMessages()
{
while (PeekMessage(out var msg, _messageWindow, 0, 0, 1)) // PM_REMOVE = 1
{
TranslateMessage(ref msg);
DispatchMessage(ref msg);
}
}
private static IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
{
if (msg == WM_HOTKEY)
{
int id = wParam.ToInt32();
if (_hotkeyCallbacks.TryGetValue(id, out var callback))
{
Console.WriteLine($"[GlobalHotkey] Hotkey triggered id={id}");
Dispatcher.UIThread.Post(() => callback());
}
}
return DefWindowProc(hWnd, msg, wParam, lParam);
}
/// <summary>
/// Parse a hotkey string like "Alt + Space" into modifiers and key.
/// </summary>
public static (Modifiers mods, uint key) ParseHotkeyString(string hotkeyString)
{
var mods = Modifiers.None;
uint key = 0;
var parts = hotkeyString.Replace(" ", "").Split('+');
foreach (var part in parts)
{
switch (part.ToLowerInvariant())
{
case "alt": mods |= Modifiers.Alt; break;
case "ctrl":
case "control": mods |= Modifiers.Control; break;
case "shift": mods |= Modifiers.Shift; break;
case "win":
case "windows": mods |= Modifiers.Win; break;
case "space": key = VirtualKeys.Space; break;
case "enter":
case "return": key = VirtualKeys.Enter; break;
case "tab": key = VirtualKeys.Tab; break;
default:
// Try single letter
if (part.Length == 1 && char.IsLetter(part[0]))
{
key = (uint)char.ToUpperInvariant(part[0]);
}
break;
}
}
return (mods, key);
}
}

View file

@ -0,0 +1,95 @@
using System;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Avalonia.ViewModel;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
namespace Flow.Launcher.Avalonia.Helper;
/// <summary>
/// Hotkey mapper for Avalonia - registers and manages global hotkeys.
/// </summary>
internal static class HotKeyMapper
{
private static readonly string ClassName = nameof(HotKeyMapper);
private static Settings? _settings;
private static MainViewModel? _mainViewModel;
private static int _toggleHotkeyId = -1;
/// <summary>
/// Initialize the hotkey system and register configured hotkeys.
/// </summary>
internal static void Initialize()
{
_mainViewModel = Ioc.Default.GetRequiredService<MainViewModel>();
_settings = Ioc.Default.GetService<Settings>();
if (_settings == null)
{
Log.Warn(ClassName, "Settings not available, using default hotkey");
return;
}
// Initialize the global hotkey system
GlobalHotkey.Initialize();
// Register the main toggle hotkey
SetToggleHotkey(_settings.Hotkey);
Log.Info(ClassName, $"HotKeyMapper initialized with hotkey: {_settings.Hotkey}");
}
/// <summary>
/// Set or update the toggle hotkey.
/// </summary>
internal static void SetToggleHotkey(string hotkeyString)
{
// Unregister existing hotkey
if (_toggleHotkeyId >= 0)
{
GlobalHotkey.Unregister(_toggleHotkeyId);
_toggleHotkeyId = -1;
}
if (string.IsNullOrWhiteSpace(hotkeyString))
{
Log.Warn(ClassName, "Empty hotkey string");
return;
}
var (mods, key) = GlobalHotkey.ParseHotkeyString(hotkeyString);
if (key == 0)
{
Log.Error(ClassName, $"Failed to parse hotkey: {hotkeyString}");
return;
}
_toggleHotkeyId = GlobalHotkey.Register(mods, key, OnToggleHotkey);
if (_toggleHotkeyId < 0)
{
Log.Error(ClassName, $"Failed to register hotkey: {hotkeyString}");
}
else
{
Log.Info(ClassName, $"Registered toggle hotkey: {hotkeyString}");
}
}
private static void OnToggleHotkey()
{
Log.Info(ClassName, "Toggle hotkey triggered");
_mainViewModel?.ToggleFlowLauncher();
}
/// <summary>
/// Cleanup and unregister all hotkeys.
/// </summary>
internal static void Shutdown()
{
GlobalHotkey.Shutdown();
Log.Info(ClassName, "HotKeyMapper shutdown");
}
}

View file

@ -22,6 +22,7 @@ public partial class MainWindow : Window
// Get the ViewModel from DI (same instance that App uses)
_viewModel = Ioc.Default.GetRequiredService<MainViewModel>();
_viewModel.HideRequested += () => Hide();
_viewModel.ShowRequested += () => ShowAndFocus();
DataContext = _viewModel;
// Get reference to the query text box

View file

@ -23,6 +23,10 @@ public partial class MainViewModel : ObservableObject
private bool _pluginsReady;
public event Action? HideRequested;
public event Action? ShowRequested;
[ObservableProperty]
private bool _mainWindowVisibility = true;
[ObservableProperty]
private string _queryText = string.Empty;
@ -54,6 +58,43 @@ public partial class MainViewModel : ObservableObject
public void RequestHide() => HideRequested?.Invoke();
/// <summary>
/// Toggle the main window visibility. Called by global hotkey.
/// </summary>
public void ToggleFlowLauncher()
{
Log.Info(ClassName, $"ToggleFlowLauncher called, currently visible: {MainWindowVisibility}");
if (MainWindowVisibility)
{
Hide();
}
else
{
Show();
}
}
/// <summary>
/// Show the main window.
/// </summary>
public void Show()
{
MainWindowVisibility = true;
ShowRequested?.Invoke();
Log.Info(ClassName, "Show requested");
}
/// <summary>
/// Hide the main window.
/// </summary>
public void Hide()
{
MainWindowVisibility = false;
QueryText = "";
HideRequested?.Invoke();
Log.Info(ClassName, "Hide requested");
}
partial void OnQueryTextChanged(string value) => _ = QueryAsync();
private async Task QueryAsync()
@ -128,7 +169,7 @@ public partial class MainViewModel : ObservableObject
}
[RelayCommand]
private void Esc() { QueryText = ""; HideRequested?.Invoke(); }
private void Esc() { Hide(); }
[RelayCommand]
private async Task OpenResultAsync()