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()