diff --git a/Flow.Launcher.Avalonia/App.axaml.cs b/Flow.Launcher.Avalonia/App.axaml.cs
index 73c274bca..7f217c3e5 100644
--- a/Flow.Launcher.Avalonia/App.axaml.cs
+++ b/Flow.Launcher.Avalonia/App.axaml.cs
@@ -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();
}
diff --git a/Flow.Launcher.Avalonia/Helper/GlobalHotkey.cs b/Flow.Launcher.Avalonia/Helper/GlobalHotkey.cs
new file mode 100644
index 000000000..adec873e5
--- /dev/null
+++ b/Flow.Launcher.Avalonia/Helper/GlobalHotkey.cs
@@ -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;
+
+///
+/// Win32-based global hotkey manager for Avalonia.
+/// Uses RegisterHotKey/UnregisterHotKey Win32 APIs with a message-only window.
+///
+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 _hotkeyCallbacks = new();
+ private static int _nextHotkeyId = 1;
+ private static DispatcherTimer? _messageTimer;
+ private static bool _initialized;
+
+ ///
+ /// Initialize the hotkey system. Call once at app startup.
+ ///
+ 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");
+ }
+
+ ///
+ /// Register a global hotkey.
+ ///
+ /// Modifier keys (Alt, Ctrl, Shift, Win)
+ /// Virtual key code
+ /// Action to invoke when hotkey is pressed
+ /// Hotkey ID for later unregistration, or -1 on failure
+ 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;
+ }
+
+ ///
+ /// Unregister a previously registered hotkey.
+ ///
+ 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}");
+ }
+
+ ///
+ /// Cleanup all hotkeys and resources.
+ ///
+ 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);
+ }
+
+ ///
+ /// Parse a hotkey string like "Alt + Space" into modifiers and key.
+ ///
+ 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);
+ }
+}
diff --git a/Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs b/Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs
new file mode 100644
index 000000000..4a7791b23
--- /dev/null
+++ b/Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs
@@ -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;
+
+///
+/// Hotkey mapper for Avalonia - registers and manages global hotkeys.
+///
+internal static class HotKeyMapper
+{
+ private static readonly string ClassName = nameof(HotKeyMapper);
+
+ private static Settings? _settings;
+ private static MainViewModel? _mainViewModel;
+ private static int _toggleHotkeyId = -1;
+
+ ///
+ /// Initialize the hotkey system and register configured hotkeys.
+ ///
+ internal static void Initialize()
+ {
+ _mainViewModel = Ioc.Default.GetRequiredService();
+ _settings = Ioc.Default.GetService();
+
+ 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}");
+ }
+
+ ///
+ /// Set or update the toggle hotkey.
+ ///
+ 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();
+ }
+
+ ///
+ /// Cleanup and unregister all hotkeys.
+ ///
+ internal static void Shutdown()
+ {
+ GlobalHotkey.Shutdown();
+ Log.Info(ClassName, "HotKeyMapper shutdown");
+ }
+}
diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml.cs b/Flow.Launcher.Avalonia/MainWindow.axaml.cs
index 47372a4e9..718f5ae72 100644
--- a/Flow.Launcher.Avalonia/MainWindow.axaml.cs
+++ b/Flow.Launcher.Avalonia/MainWindow.axaml.cs
@@ -22,6 +22,7 @@ public partial class MainWindow : Window
// Get the ViewModel from DI (same instance that App uses)
_viewModel = Ioc.Default.GetRequiredService();
_viewModel.HideRequested += () => Hide();
+ _viewModel.ShowRequested += () => ShowAndFocus();
DataContext = _viewModel;
// Get reference to the query text box
diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs
index 24a15209b..8fa372339 100644
--- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs
+++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs
@@ -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();
+ ///
+ /// Toggle the main window visibility. Called by global hotkey.
+ ///
+ public void ToggleFlowLauncher()
+ {
+ Log.Info(ClassName, $"ToggleFlowLauncher called, currently visible: {MainWindowVisibility}");
+ if (MainWindowVisibility)
+ {
+ Hide();
+ }
+ else
+ {
+ Show();
+ }
+ }
+
+ ///
+ /// Show the main window.
+ ///
+ public void Show()
+ {
+ MainWindowVisibility = true;
+ ShowRequested?.Invoke();
+ Log.Info(ClassName, "Show requested");
+ }
+
+ ///
+ /// Hide the main window.
+ ///
+ 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()