mirror of
https://github.com/Flow-Launcher/Flow.Launcher.git
synced 2026-03-11 08:54:32 +00:00
Add global hotkey support for Avalonia UI
This commit is contained in:
parent
207953159a
commit
065dc191ce
5 changed files with 453 additions and 1 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
308
Flow.Launcher.Avalonia/Helper/GlobalHotkey.cs
Normal file
308
Flow.Launcher.Avalonia/Helper/GlobalHotkey.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
95
Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs
Normal file
95
Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue