From 0f9b9329dd30126bbba8eb34a531f0cc65cd8b77 Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Sun, 18 Jan 2026 02:10:53 -0800 Subject: [PATCH] Add hotkey recorder with manual modifier tracking - Add HotkeyRecorderDialog with global keyboard hook for capturing hotkeys - Implement manual modifier state tracking to handle swallowed key events - Add HotkeyControl button that opens the recorder dialog - Add CheckAvailability and RemoveToggleHotkey to HotKeyMapper - Expose GetKeyFromVk helper in GlobalHotkey infrastructure - Add Settings pages (General, Plugin, Theme, Proxy, About) - Add PreviewPanel for result previews in main window - Fix hook reuse issue by clearing callback on close instead of disposing --- Flow.Launcher.Avalonia/App.axaml | 21 +- Flow.Launcher.Avalonia/App.axaml.cs | 43 +++- Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs | 2 +- .../Flow.Launcher.Avalonia.csproj | 1 + Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs | 42 +++- Flow.Launcher.Avalonia/MainWindow.axaml | 21 +- .../Resource/Internationalization.cs | 23 +++ .../Resource/LocalizeExtension.cs | 30 ++- Flow.Launcher.Avalonia/Themes/Base.axaml | 10 +- .../ViewModel/MainViewModel.cs | 42 ++++ .../SettingPages/AboutSettingsViewModel.cs | 28 +++ .../SettingPages/GeneralSettingsViewModel.cs | 103 ++++++++++ .../SettingPages/HotkeySettingsViewModel.cs | 31 +++ .../SettingPages/PluginsSettingsViewModel.cs | 69 +++++++ .../SettingPages/ProxySettingsViewModel.cs | 81 ++++++++ .../SettingPages/ThemeSettingsViewModel.cs | 102 ++++++++++ .../Views/Controls/HotkeyControl.axaml | 36 ++++ .../Views/Controls/HotkeyControl.axaml.cs | 89 +++++++++ .../Views/Controls/HotkeyRecorderDialog.axaml | 56 ++++++ .../Controls/HotkeyRecorderDialog.axaml.cs | 185 ++++++++++++++++++ .../Views/PreviewPanel.axaml | 65 ++++++ .../Views/PreviewPanel.axaml.cs | 17 ++ .../Views/ResultListBox.axaml | 33 ++-- .../SettingPages/AboutSettingsPage.axaml | 25 +++ .../SettingPages/AboutSettingsPage.axaml.cs | 13 ++ .../SettingPages/GeneralSettingsPage.axaml | 71 +++++++ .../SettingPages/GeneralSettingsPage.axaml.cs | 13 ++ .../SettingPages/HotkeySettingsPage.axaml | 26 +++ .../SettingPages/HotkeySettingsPage.axaml.cs | 13 ++ .../SettingPages/PluginsSettingsPage.axaml | 47 +++++ .../SettingPages/PluginsSettingsPage.axaml.cs | 13 ++ .../SettingPages/ProxySettingsPage.axaml | 47 +++++ .../SettingPages/ProxySettingsPage.axaml.cs | 13 ++ .../Views/SettingPages/SettingsWindow.axaml | 30 +++ .../SettingPages/SettingsWindow.axaml.cs | 46 +++++ .../SettingPages/ThemeSettingsPage.axaml | 66 +++++++ .../SettingPages/ThemeSettingsPage.axaml.cs | 13 ++ .../Hotkey/GlobalHotkey.cs | 10 +- 38 files changed, 1521 insertions(+), 55 deletions(-) create mode 100644 Flow.Launcher.Avalonia/ViewModel/SettingPages/AboutSettingsViewModel.cs create mode 100644 Flow.Launcher.Avalonia/ViewModel/SettingPages/GeneralSettingsViewModel.cs create mode 100644 Flow.Launcher.Avalonia/ViewModel/SettingPages/HotkeySettingsViewModel.cs create mode 100644 Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginsSettingsViewModel.cs create mode 100644 Flow.Launcher.Avalonia/ViewModel/SettingPages/ProxySettingsViewModel.cs create mode 100644 Flow.Launcher.Avalonia/ViewModel/SettingPages/ThemeSettingsViewModel.cs create mode 100644 Flow.Launcher.Avalonia/Views/Controls/HotkeyControl.axaml create mode 100644 Flow.Launcher.Avalonia/Views/Controls/HotkeyControl.axaml.cs create mode 100644 Flow.Launcher.Avalonia/Views/Controls/HotkeyRecorderDialog.axaml create mode 100644 Flow.Launcher.Avalonia/Views/Controls/HotkeyRecorderDialog.axaml.cs create mode 100644 Flow.Launcher.Avalonia/Views/PreviewPanel.axaml create mode 100644 Flow.Launcher.Avalonia/Views/PreviewPanel.axaml.cs create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/AboutSettingsPage.axaml create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/AboutSettingsPage.axaml.cs create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/GeneralSettingsPage.axaml create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/GeneralSettingsPage.axaml.cs create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/HotkeySettingsPage.axaml create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/HotkeySettingsPage.axaml.cs create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/PluginsSettingsPage.axaml create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/PluginsSettingsPage.axaml.cs create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/ProxySettingsPage.axaml create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/ProxySettingsPage.axaml.cs create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/SettingsWindow.axaml create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/SettingsWindow.axaml.cs create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/ThemeSettingsPage.axaml create mode 100644 Flow.Launcher.Avalonia/Views/SettingPages/ThemeSettingsPage.axaml.cs diff --git a/Flow.Launcher.Avalonia/App.axaml b/Flow.Launcher.Avalonia/App.axaml index a43fc0b4f..2d23fa515 100644 --- a/Flow.Launcher.Avalonia/App.axaml +++ b/Flow.Launcher.Avalonia/App.axaml @@ -1,13 +1,15 @@ - + @@ -20,4 +22,21 @@ avares://Flow.Launcher.Avalonia/Resources#Segoe Fluent Icons + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/App.axaml.cs b/Flow.Launcher.Avalonia/App.axaml.cs index fae4c6a64..2a0f45aa2 100644 --- a/Flow.Launcher.Avalonia/App.axaml.cs +++ b/Flow.Launcher.Avalonia/App.axaml.cs @@ -1,4 +1,5 @@ using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Threading; @@ -6,6 +7,7 @@ using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Avalonia.Helper; using Flow.Launcher.Avalonia.Resource; using Flow.Launcher.Avalonia.ViewModel; +using Flow.Launcher.Avalonia.Views.SettingPages; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Logger; @@ -26,19 +28,24 @@ public partial class App : Application public static IPublicAPI? API { get; private set; } - public override void Initialize() => AvaloniaXamlLoader.Load(this); + public override void Initialize() + { + // Configure DI before loading XAML so markup extensions can access services + LoadSettings(); + ConfigureDI(); + + AvaloniaXamlLoader.Load(this); + } public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - LoadSettings(); - ConfigureDI(); - API = Ioc.Default.GetRequiredService(); _mainVM = Ioc.Default.GetRequiredService(); desktop.MainWindow = new MainWindow(); + desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; // Initialize hotkeys after window is created HotKeyMapper.Initialize(); @@ -51,6 +58,30 @@ public partial class App : Application base.OnFrameworkInitializationCompleted(); } + private void TrayIcon_OnClicked(object? sender, EventArgs e) + { + _mainVM?.ToggleFlowLauncher(); + } + + private void MenuShow_OnClick(object? sender, EventArgs e) + { + _mainVM?.Show(); + } + + private void MenuSettings_OnClick(object? sender, EventArgs e) + { + var settingsWindow = new SettingsWindow(); + settingsWindow.Show(); + } + + private void MenuExit_OnClick(object? sender, EventArgs e) + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(); + } + } + private void LoadSettings() { try @@ -96,6 +127,10 @@ public partial class App : Application await PluginManager.InitializePluginsAsync(); Log.Info(ClassName, "Plugins initialized"); + // Update plugin translations after they are initialized + var i18n = Ioc.Default.GetRequiredService(); + i18n.UpdatePluginMetadataTranslations(); + _mainVM?.OnPluginsReady(); } catch (Exception e) { Log.Exception(ClassName, "Plugin init failed", e); } diff --git a/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs index deac2575c..d05a5733d 100644 --- a/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs +++ b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs @@ -107,7 +107,7 @@ public class AvaloniaPublicAPI : IPublicAPI public void ShowMsg(string title, string subTitle, string iconPath, bool useMainWindowAsOwner = true) { } public void ShowMsgWithButton(string title, string buttonText, Action buttonAction, string subTitle = "", string iconPath = "") { } public void ShowMsgWithButton(string title, string buttonText, Action buttonAction, string subTitle, string iconPath, bool useMainWindowAsOwner = true) { } - public void OpenSettingDialog() { } + public void OpenSettingDialog() => _getMainViewModel()?.OpenSettings(); public void RegisterGlobalKeyboardCallback(Func callback) { } public void RemoveGlobalKeyboardCallback(Func callback) { } public T LoadSettingJsonStorage() where T : new() => new T(); diff --git a/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj index 19dc2acbb..740443bc3 100644 --- a/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj +++ b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj @@ -67,6 +67,7 @@ PreserveNewest + diff --git a/Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs b/Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs index 4a7791b23..18e0db7f0 100644 --- a/Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs +++ b/Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs @@ -1,6 +1,7 @@ using System; using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Avalonia.ViewModel; +using Flow.Launcher.Infrastructure.Hotkey; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; @@ -45,12 +46,7 @@ internal static class HotKeyMapper /// internal static void SetToggleHotkey(string hotkeyString) { - // Unregister existing hotkey - if (_toggleHotkeyId >= 0) - { - GlobalHotkey.Unregister(_toggleHotkeyId); - _toggleHotkeyId = -1; - } + RemoveToggleHotkey(); if (string.IsNullOrWhiteSpace(hotkeyString)) { @@ -78,12 +74,46 @@ internal static class HotKeyMapper } } + /// + /// Remove the current toggle hotkey. + /// + internal static void RemoveToggleHotkey() + { + if (_toggleHotkeyId >= 0) + { + GlobalHotkey.Unregister(_toggleHotkeyId); + _toggleHotkeyId = -1; + } + } + private static void OnToggleHotkey() { Log.Info(ClassName, "Toggle hotkey triggered"); _mainViewModel?.ToggleFlowLauncher(); } + /// + /// Checks if a hotkey is available for registration. + /// + internal static bool CheckAvailability(HotkeyModel hotkey) + { + var hotkeyString = hotkey.ToString(); + var (mods, key) = GlobalHotkey.ParseHotkeyString(hotkeyString); + + if (key == 0) + return false; + + // Try to register and immediately unregister + int id = GlobalHotkey.Register(mods, key, () => { }); + if (id >= 0) + { + GlobalHotkey.Unregister(id); + return true; + } + + return false; + } + /// /// Cleanup and unregister all hotkeys. /// diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml b/Flow.Launcher.Avalonia/MainWindow.axaml index 017564871..43baeb64f 100644 --- a/Flow.Launcher.Avalonia/MainWindow.axaml +++ b/Flow.Launcher.Avalonia/MainWindow.axaml @@ -37,6 +37,8 @@ + + @@ -89,7 +91,7 @@ - + @@ -106,19 +108,20 @@ DataContext="{Binding ContextMenu}" /> - + + IsVisible="{Binding IsPreviewOn}" /> - - - - + + + + diff --git a/Flow.Launcher.Avalonia/Resource/Internationalization.cs b/Flow.Launcher.Avalonia/Resource/Internationalization.cs index b5cc19ecd..0705eca14 100644 --- a/Flow.Launcher.Avalonia/Resource/Internationalization.cs +++ b/Flow.Launcher.Avalonia/Resource/Internationalization.cs @@ -7,9 +7,11 @@ using System.Threading; using System.Xml.Linq; using Avalonia; using Avalonia.Controls; +using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Avalonia.Resource; @@ -71,6 +73,27 @@ public class Internationalization } } + /// + /// Update plugin metadata name & description and call OnCultureInfoChanged. + /// + public void UpdatePluginMetadataTranslations() + { + foreach (var p in PluginManager.GetTranslationPlugins()) + { + if (p.Plugin is not IPluginI18n pluginI18N) continue; + try + { + p.Metadata.Name = pluginI18N.GetTranslatedPluginTitle(); + p.Metadata.Description = pluginI18N.GetTranslatedPluginDescription(); + pluginI18N.OnCultureInfoChanged(CultureInfo.CurrentCulture); + } + catch (Exception e) + { + Log.Exception(ClassName, $"Failed for <{p.Metadata.Name}>", e); + } + } + } + private string GetActualLanguageCode() { var languageCode = _settings.Language; diff --git a/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs b/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs index e88ec87e2..cbac2454a 100644 --- a/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs +++ b/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs @@ -38,15 +38,18 @@ public class LocalizeExtension : MarkupExtension return Fallback ?? "[No Key]"; } - var i18n = Ioc.Default.GetService(); - if (i18n == null) + try { - return Fallback ?? $"[{Key}]"; + // Try to get I18n service from DI + var i18n = Ioc.Default.GetService(); + if (i18n != null && i18n.HasTranslation(Key)) + { + return i18n.GetTranslation(Key); + } } - - if (i18n.HasTranslation(Key)) + catch { - return i18n.GetTranslation(Key); + // Ioc.Default might throw if not configured yet } return Fallback ?? $"[{Key}]"; @@ -65,13 +68,20 @@ public static class Translator /// The translated string or the key in brackets if not found public static string GetString(string key) { - var i18n = Ioc.Default.GetService(); - if (i18n == null) + try + { + var i18n = Ioc.Default.GetService(); + if (i18n == null) + { + return $"[{key}]"; + } + + return i18n.GetTranslation(key); + } + catch { return $"[{key}]"; } - - return i18n.GetTranslation(key); } /// diff --git a/Flow.Launcher.Avalonia/Themes/Base.axaml b/Flow.Launcher.Avalonia/Themes/Base.axaml index c73dbd221..740e01ae2 100644 --- a/Flow.Launcher.Avalonia/Themes/Base.axaml +++ b/Flow.Launcher.Avalonia/Themes/Base.axaml @@ -144,19 +144,13 @@ diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs index 0b35f37bf..df7e83483 100644 --- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Flow.Launcher.Avalonia.Resource; +using Flow.Launcher.Avalonia.Views.SettingPages; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; @@ -57,6 +58,12 @@ public partial class MainViewModel : ObservableObject [ObservableProperty] private ActiveView _activeView = ActiveView.Results; + [ObservableProperty] + private ResultViewModel? _previewSelectedItem; + + [ObservableProperty] + private bool _isPreviewOn; + /// /// Whether the results view is currently active. /// @@ -79,6 +86,22 @@ public partial class MainViewModel : ObservableObject _settings = settings; _results = new ResultsViewModel(settings); _contextMenu = new ResultsViewModel(settings); + + _results.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(ResultsViewModel.SelectedItem) && IsResultsViewActive) + { + PreviewSelectedItem = _results.SelectedItem; + } + }; + + _contextMenu.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(ResultsViewModel.SelectedItem) && IsContextMenuViewActive) + { + PreviewSelectedItem = _contextMenu.SelectedItem; + } + }; } partial void OnActiveViewChanged(ActiveView value) @@ -86,6 +109,14 @@ public partial class MainViewModel : ObservableObject OnPropertyChanged(nameof(IsResultsViewActive)); OnPropertyChanged(nameof(IsContextMenuViewActive)); OnPropertyChanged(nameof(ShowResultsArea)); + + PreviewSelectedItem = value == ActiveView.Results ? Results.SelectedItem : ContextMenu.SelectedItem; + } + + [RelayCommand] + public void TogglePreview() + { + IsPreviewOn = !IsPreviewOn; } partial void OnHasResultsChanged(bool value) @@ -291,6 +322,17 @@ public partial class MainViewModel : ObservableObject } } + [RelayCommand] + public void OpenSettings() + { + global::Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var settingsWindow = new SettingsWindow(); + settingsWindow.Show(); + Hide(); + }); + } + [RelayCommand] private async Task OpenResultAsync() { diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/AboutSettingsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/AboutSettingsViewModel.cs new file mode 100644 index 000000000..97e739b4d --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/AboutSettingsViewModel.cs @@ -0,0 +1,28 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Infrastructure; +using System.Diagnostics; +using System.Threading.Tasks; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages; + +public partial class AboutSettingsViewModel : ObservableObject +{ + public string Version => Constant.Version; + public string Website => "https://www.flowlauncher.com"; + public string GitHub => "https://github.com/Flow-Launcher/Flow.Launcher"; + + [RelayCommand] + private async Task OpenWebsite() + { + Process.Start(new ProcessStartInfo(Website) { UseShellExecute = true }); + await Task.CompletedTask; + } + + [RelayCommand] + private async Task OpenGitHub() + { + Process.Start(new ProcessStartInfo(GitHub) { UseShellExecute = true }); + await Task.CompletedTask; + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/GeneralSettingsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/GeneralSettingsViewModel.cs new file mode 100644 index 000000000..b718870a9 --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/GeneralSettingsViewModel.cs @@ -0,0 +1,103 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Core.Resource; +using Flow.Launcher.Infrastructure.UserSettings; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AvaloniaI18n = Flow.Launcher.Avalonia.Resource.Internationalization; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages; + +public partial class GeneralSettingsViewModel : ObservableObject +{ + private readonly Flow.Launcher.Infrastructure.UserSettings.Settings _settings; + private readonly AvaloniaI18n _i18n; + + public GeneralSettingsViewModel() + { + _settings = Ioc.Default.GetRequiredService(); + _i18n = Ioc.Default.GetRequiredService(); + + LoadLanguages(); + } + + [ObservableProperty] + private List _languages = new(); + + public Language? SelectedLanguage + { + get => Languages.FirstOrDefault(l => l.LanguageCode == _settings.Language); + set + { + if (value != null && value.LanguageCode != _settings.Language) + { + _settings.Language = value.LanguageCode; + _i18n.ChangeLanguage(value.LanguageCode); + OnPropertyChanged(); + } + } + } + + public bool StartOnStartup + { + get => _settings.StartFlowLauncherOnSystemStartup; + set + { + _settings.StartFlowLauncherOnSystemStartup = value; + OnPropertyChanged(); + } + } + + public bool HideWhenDeactivated + { + get => _settings.HideWhenDeactivated; + set + { + _settings.HideWhenDeactivated = value; + OnPropertyChanged(); + } + } + + public bool ShowAtTopmost + { + get => _settings.ShowAtTopmost; + set + { + _settings.ShowAtTopmost = value; + OnPropertyChanged(); + } + } + + public string PythonPath => _settings.PluginSettings.PythonExecutablePath ?? "Not set"; + public string NodePath => _settings.PluginSettings.NodeExecutablePath ?? "Not set"; + + [RelayCommand] + private async Task SelectPython() + { + // TODO: Implement file picker + await Task.CompletedTask; + } + + [RelayCommand] + private async Task SelectNode() + { + // TODO: Implement file picker + await Task.CompletedTask; + } + + private void LoadLanguages() + { + // Minimal set of languages for now, can be expanded by loading from directory later + Languages = new List + { + new Language("en", "English"), + new Language("zh-cn", "中文 (简体)"), + new Language("zh-tw", "中文 (繁體)"), + new Language("ko", "한국어"), + new Language("ja", "日本語") + }; + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/HotkeySettingsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/HotkeySettingsViewModel.cs new file mode 100644 index 000000000..1aa20d633 --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/HotkeySettingsViewModel.cs @@ -0,0 +1,31 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Avalonia.Helper; +using System; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages; + +public partial class HotkeySettingsViewModel : ObservableObject +{ + private readonly Settings _settings; + + public HotkeySettingsViewModel() + { + _settings = Ioc.Default.GetRequiredService(); + } + + public string ToggleHotkey + { + get => _settings.Hotkey; + set + { + if (_settings.Hotkey != value) + { + _settings.Hotkey = value; + HotKeyMapper.SetToggleHotkey(value); + OnPropertyChanged(); + } + } + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginsSettingsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginsSettingsViewModel.cs new file mode 100644 index 000000000..53c581ece --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginsSettingsViewModel.cs @@ -0,0 +1,69 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Plugin; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages; + +public partial class PluginsSettingsViewModel : ObservableObject +{ + public PluginsSettingsViewModel() + { + LoadPlugins(); + } + + [ObservableProperty] + private ObservableCollection _plugins = new(); + + [ObservableProperty] + private string _searchText = string.Empty; + + public IEnumerable FilteredPlugins => + string.IsNullOrWhiteSpace(SearchText) + ? Plugins + : Plugins.Where(p => p.Name.Contains(SearchText, System.StringComparison.OrdinalIgnoreCase)); + + partial void OnSearchTextChanged(string value) => OnPropertyChanged(nameof(FilteredPlugins)); + + private void LoadPlugins() + { + var allPlugins = PluginManager.AllPlugins; + foreach (var plugin in allPlugins.OrderBy(p => p.Metadata.Name)) + { + Plugins.Add(new PluginItemViewModel(plugin)); + } + } +} + +public partial class PluginItemViewModel : ObservableObject +{ + private readonly PluginPair _plugin; + + public PluginItemViewModel(PluginPair plugin) + { + _plugin = plugin; + } + + public string Name => _plugin.Metadata.Name; + public string Description => _plugin.Metadata.Description; + public string Author => _plugin.Metadata.Author; + public string Version => _plugin.Metadata.Version; + public string IconPath => _plugin.Metadata.IcoPath; + + public bool IsDisabled + { + get => _plugin.Metadata.Disabled; + set + { + if (_plugin.Metadata.Disabled != value) + { + _plugin.Metadata.Disabled = value; + OnPropertyChanged(); + } + } + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/ProxySettingsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/ProxySettingsViewModel.cs new file mode 100644 index 000000000..ad420e1f1 --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/ProxySettingsViewModel.cs @@ -0,0 +1,81 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.UserSettings; +using System; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages; + +public partial class ProxySettingsViewModel : ObservableObject +{ + private readonly Settings _settings; + + public ProxySettingsViewModel() + { + _settings = Ioc.Default.GetRequiredService(); + } + + public bool ProxyEnabled + { + get => _settings.Proxy.Enabled; + set + { + if (_settings.Proxy.Enabled != value) + { + _settings.Proxy.Enabled = value; + OnPropertyChanged(); + } + } + } + + public string ProxyServer + { + get => _settings.Proxy.Server; + set + { + if (_settings.Proxy.Server != value) + { + _settings.Proxy.Server = value; + OnPropertyChanged(); + } + } + } + + public int ProxyPort + { + get => _settings.Proxy.Port; + set + { + if (_settings.Proxy.Port != value) + { + _settings.Proxy.Port = value; + OnPropertyChanged(); + } + } + } + + public string ProxyUserName + { + get => _settings.Proxy.UserName; + set + { + if (_settings.Proxy.UserName != value) + { + _settings.Proxy.UserName = value; + OnPropertyChanged(); + } + } + } + + public string ProxyPassword + { + get => _settings.Proxy.Password; + set + { + if (_settings.Proxy.Password != value) + { + _settings.Proxy.Password = value; + OnPropertyChanged(); + } + } + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/ThemeSettingsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/ThemeSettingsViewModel.cs new file mode 100644 index 000000000..f27ec70f6 --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/ThemeSettingsViewModel.cs @@ -0,0 +1,102 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Avalonia.Resource; +using FluentAvalonia.Styling; +using Avalonia; +using Avalonia.Styling; +using System.Collections.Generic; +using System.Linq; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages; + +public partial class ThemeSettingsViewModel : ObservableObject +{ + private readonly Settings _settings; + + public ThemeSettingsViewModel() + { + _settings = Ioc.Default.GetRequiredService(); + } + + public List ThemeVariants => new() { "System", "Light", "Dark" }; + + public string SelectedThemeVariant + { + get => _settings.Theme switch + { + "Light" => "Light", + "Dark" => "Dark", + _ => "System" + }; + set + { + if (value != SelectedThemeVariant) + { + _settings.Theme = value; + ApplyTheme(value); + OnPropertyChanged(); + } + } + } + + private void ApplyTheme(string variant) + { + if (Application.Current == null) return; + + Application.Current.RequestedThemeVariant = variant switch + { + "Light" => ThemeVariant.Light, + "Dark" => ThemeVariant.Dark, + _ => ThemeVariant.Default + }; + } + + public int MaxResults + { + get => _settings.MaxResultsToShow; + set + { + if (_settings.MaxResultsToShow != value) + { + _settings.MaxResultsToShow = value; + OnPropertyChanged(); + } + } + } + + public List MaxResultsRange => Enumerable.Range(1, 20).ToList(); + + public bool UseGlyphIcons + { + get => _settings.UseGlyphIcons; + set + { + if (_settings.UseGlyphIcons != value) + { + _settings.UseGlyphIcons = value; + OnPropertyChanged(); + } + } + } + + public double QueryBoxFontSize + { + get => _settings.QueryBoxFontSize; + set + { + _settings.QueryBoxFontSize = value; + OnPropertyChanged(); + } + } + + public double ResultItemFontSize + { + get => _settings.ResultItemFontSize; + set + { + _settings.ResultItemFontSize = value; + OnPropertyChanged(); + } + } +} diff --git a/Flow.Launcher.Avalonia/Views/Controls/HotkeyControl.axaml b/Flow.Launcher.Avalonia/Views/Controls/HotkeyControl.axaml new file mode 100644 index 000000000..26a7ee706 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/HotkeyControl.axaml @@ -0,0 +1,36 @@ + + + + diff --git a/Flow.Launcher.Avalonia/Views/Controls/HotkeyControl.axaml.cs b/Flow.Launcher.Avalonia/Views/Controls/HotkeyControl.axaml.cs new file mode 100644 index 000000000..22b3a6971 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/HotkeyControl.axaml.cs @@ -0,0 +1,89 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Flow.Launcher.Infrastructure.Hotkey; +using Flow.Launcher.Avalonia.Helper; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Input; + +namespace Flow.Launcher.Avalonia.Views.Controls +{ + public partial class HotkeyControl : UserControl + { + public static readonly DirectProperty HotkeyProperty = + AvaloniaProperty.RegisterDirect( + nameof(Hotkey), + o => o.Hotkey, + (o, v) => o.Hotkey = v); + + private string _hotkey = string.Empty; + public string Hotkey + { + get => _hotkey; + set + { + if (SetAndRaise(HotkeyProperty, ref _hotkey, value)) + { + UpdateKeysDisplay(); + } + } + } + + public ObservableCollection KeysToDisplay { get; } = new(); + + public IAsyncRelayCommand RecordHotkeyCommand { get; } + + public HotkeyControl() + { + RecordHotkeyCommand = new AsyncRelayCommand(OpenHotkeyRecorderDialog); + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void UpdateKeysDisplay() + { + KeysToDisplay.Clear(); + if (string.IsNullOrEmpty(Hotkey)) + { + KeysToDisplay.Add("None"); + return; + } + + var model = new HotkeyModel(Hotkey); + foreach (var key in model.EnumerateDisplayKeys()) + { + KeysToDisplay.Add(key); + } + } + + private async Task OpenHotkeyRecorderDialog() + { + var originalHotkey = Hotkey; + + // Temporarily unregister so it doesn't conflict with availability check + HotKeyMapper.RemoveToggleHotkey(); + + var dialog = new HotkeyRecorderDialog(Hotkey); + var result = await dialog.ShowAsync(); + + if (result == HotkeyRecorderDialog.EResultType.Save) + { + Hotkey = dialog.ResultValue; + } + else if (result == HotkeyRecorderDialog.EResultType.Delete) + { + Hotkey = string.Empty; + } + else + { + // Restore original hotkey if cancelled + HotKeyMapper.SetToggleHotkey(originalHotkey); + } + } + } +} diff --git a/Flow.Launcher.Avalonia/Views/Controls/HotkeyRecorderDialog.axaml b/Flow.Launcher.Avalonia/Views/Controls/HotkeyRecorderDialog.axaml new file mode 100644 index 000000000..14b25d6a2 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/HotkeyRecorderDialog.axaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/Views/Controls/HotkeyRecorderDialog.axaml.cs b/Flow.Launcher.Avalonia/Views/Controls/HotkeyRecorderDialog.axaml.cs new file mode 100644 index 000000000..f4c95cc51 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/HotkeyRecorderDialog.axaml.cs @@ -0,0 +1,185 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using FluentAvalonia.UI.Controls; +using Flow.Launcher.Avalonia.Helper; +using Flow.Launcher.Infrastructure.Hotkey; +using Flow.Launcher.Plugin; +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using InfrastructureGlobalHotkey = Flow.Launcher.Infrastructure.Hotkey.GlobalHotkey; + +namespace Flow.Launcher.Avalonia.Views.Controls +{ + public partial class HotkeyRecorderDialog : ContentDialog + { + public enum EResultType + { + Cancel, + Save, + Delete + } + + public EResultType ResultType { get; private set; } = EResultType.Cancel; + public string ResultValue { get; private set; } = string.Empty; + public ObservableCollection KeysToDisplay { get; } = new(); + + private bool _altDown; + private bool _ctrlDown; + private bool _shiftDown; + private bool _winDown; + + public HotkeyRecorderDialog(string currentHotkey) + { + InitializeComponent(); + + var model = new HotkeyModel(currentHotkey); + UpdateKeysDisplay(model); + + Opened += HotkeyRecorderDialog_Opened; + Closing += HotkeyRecorderDialog_Closing; + + PrimaryButtonClick += (s, e) => { ResultType = EResultType.Save; ResultValue = string.Join("+", KeysToDisplay); }; + SecondaryButtonClick += (s, e) => { ResultType = EResultType.Delete; }; + } + + protected override Type StyleKeyOverride => typeof(ContentDialog); + + private void HotkeyRecorderDialog_Opened(object? sender, EventArgs args) + { + this.Focus(); + + // Initialize Global Hotkey Hook when dialog opens + try + { + // Sync initial modifier state + var state = InfrastructureGlobalHotkey.CheckModifiers(); + _altDown = state.AltPressed; + _ctrlDown = state.CtrlPressed; + _shiftDown = state.ShiftPressed; + _winDown = state.WinPressed; + + InfrastructureGlobalHotkey.hookedKeyboardCallback = GlobalKeyHook; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Hook Error: {ex.Message}"); + } + } + + private void HotkeyRecorderDialog_Closing(object? sender, EventArgs args) + { + // Clear the callback but DON'T dispose the static hook + InfrastructureGlobalHotkey.hookedKeyboardCallback = null; + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private bool GlobalKeyHook(KeyEvent keyEvent, int vkCode, SpecialKeyState state) + { + var wpfKey = InfrastructureGlobalHotkey.GetKeyFromVk(vkCode); + bool isKeyDown = (keyEvent == KeyEvent.WM_KEYDOWN || keyEvent == KeyEvent.WM_SYSKEYDOWN); + bool isKeyUp = (keyEvent == KeyEvent.WM_KEYUP || keyEvent == KeyEvent.WM_SYSKEYUP); + + // Track modifier state manually (since we swallow keys, OS state is stale) + if (wpfKey == System.Windows.Input.Key.LeftAlt || wpfKey == System.Windows.Input.Key.RightAlt) + _altDown = isKeyDown; + else if (wpfKey == System.Windows.Input.Key.LeftCtrl || wpfKey == System.Windows.Input.Key.RightCtrl) + _ctrlDown = isKeyDown; + else if (wpfKey == System.Windows.Input.Key.LeftShift || wpfKey == System.Windows.Input.Key.RightShift) + _shiftDown = isKeyDown; + else if (wpfKey == System.Windows.Input.Key.LWin || wpfKey == System.Windows.Input.Key.RWin) + _winDown = isKeyDown; + + // Only process key down events for UI updates + if (isKeyDown) + { + // Capture current modifier state for the UI thread + bool alt = _altDown; + bool ctrl = _ctrlDown; + bool shift = _shiftDown; + bool win = _winDown; + + // Marshal to UI Thread + global::Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + HandleGlobalKey(vkCode, alt, ctrl, shift, win); + }); + } + + // Return false to SWALLOW the key (prevents other apps from receiving it) + return false; + } + + private void HandleGlobalKey(int vkCode, bool altDown, bool ctrlDown, bool shiftDown, bool winDown) + { + var wpfKey = InfrastructureGlobalHotkey.GetKeyFromVk(vkCode); + + // Don't treat modifiers as main keys + if (wpfKey == System.Windows.Input.Key.LeftAlt || wpfKey == System.Windows.Input.Key.RightAlt || + wpfKey == System.Windows.Input.Key.LeftCtrl || wpfKey == System.Windows.Input.Key.RightCtrl || + wpfKey == System.Windows.Input.Key.LeftShift || wpfKey == System.Windows.Input.Key.RightShift || + wpfKey == System.Windows.Input.Key.LWin || wpfKey == System.Windows.Input.Key.RWin) + { + wpfKey = System.Windows.Input.Key.None; + } + + var model = new HotkeyModel( + altDown, + shiftDown, + winDown, + ctrlDown, + wpfKey); + + UpdateKeysDisplay(model); + + // Update Save button enablement based on validity and availability + var isValid = model.Validate(); + var isAvailable = isValid && HotKeyMapper.CheckAvailability(model); + + IsPrimaryButtonEnabled = isAvailable; + + var alert = this.FindControl("Alert"); + var tbMsg = this.FindControl("tbMsg"); + if (alert != null && tbMsg != null) + { + if (isValid && !isAvailable) + { + // TODO: Get actual translation + tbMsg.Text = "Hotkey already in use"; + alert.IsVisible = true; + } + else + { + alert.IsVisible = false; + } + } + } + + private void UpdateKeysDisplay(HotkeyModel model) + { + KeysToDisplay.Clear(); + foreach (var key in model.EnumerateDisplayKeys()) + { + KeysToDisplay.Add(key); + } + } + + public new async Task ShowAsync() + { + var result = await base.ShowAsync(); + if (result == ContentDialogResult.Primary) + return EResultType.Save; + if (result == ContentDialogResult.Secondary) + return EResultType.Delete; + return EResultType.Cancel; + } + } +} diff --git a/Flow.Launcher.Avalonia/Views/PreviewPanel.axaml b/Flow.Launcher.Avalonia/Views/PreviewPanel.axaml new file mode 100644 index 000000000..3008771bb --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/PreviewPanel.axaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/Views/PreviewPanel.axaml.cs b/Flow.Launcher.Avalonia/Views/PreviewPanel.axaml.cs new file mode 100644 index 000000000..24bb32259 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/PreviewPanel.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Flow.Launcher.Avalonia.Views; + +public partial class PreviewPanel : UserControl +{ + public PreviewPanel() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} diff --git a/Flow.Launcher.Avalonia/Views/ResultListBox.axaml b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml index 376a3f31b..b07f98abe 100644 --- a/Flow.Launcher.Avalonia/Views/ResultListBox.axaml +++ b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml @@ -34,22 +34,27 @@ Classes="resultBullet" VerticalAlignment="Stretch" /> - + - - - - - + + + + + + + + + + + + + + + + +