From 4120407ac3b0815a952d4b8ed16bca915b4eeb07 Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Wed, 14 Jan 2026 23:01:35 -0800 Subject: [PATCH 01/43] Add initial Avalonia UI project for migration Create Flow.Launcher.Avalonia project as foundation for migrating from WPF to Avalonia UI framework. Key components: - MainWindow with query box and results list (matching WPF layout) - ViewModels: MainViewModel, ResultsViewModel, ResultViewModel - Themes/Base.axaml with converted styles from WPF - FluentAvaloniaUI for Windows 11 styling - References existing Core/Infrastructure/Plugin projects The project builds and runs alongside the existing WPF application. This is Phase 1 of the incremental migration approach. --- Flow.Launcher.Avalonia/App.axaml | 21 ++ Flow.Launcher.Avalonia/App.axaml.cs | 52 +++++ .../Converters/BoolToIsVisibleConverter.cs | 34 +++ .../Converters/CommonConverters.cs | 109 +++++++++ .../Flow.Launcher.Avalonia.csproj | 65 ++++++ Flow.Launcher.Avalonia/MainWindow.axaml | 131 +++++++++++ Flow.Launcher.Avalonia/MainWindow.axaml.cs | 118 ++++++++++ Flow.Launcher.Avalonia/Program.cs | 21 ++ Flow.Launcher.Avalonia/Themes/Base.axaml | 211 ++++++++++++++++++ Flow.Launcher.Avalonia/Themes/Resources.axaml | 23 ++ .../ViewModel/MainViewModel.cs | 151 +++++++++++++ .../ViewModel/ResultViewModel.cs | 30 +++ .../ViewModel/ResultsViewModel.cs | 91 ++++++++ .../Views/ResultListBox.axaml | 93 ++++++++ .../Views/ResultListBox.axaml.cs | 34 +++ Flow.Launcher.Avalonia/app.manifest | 19 ++ Flow.Launcher.sln | 41 ++-- 17 files changed, 1231 insertions(+), 13 deletions(-) create mode 100644 Flow.Launcher.Avalonia/App.axaml create mode 100644 Flow.Launcher.Avalonia/App.axaml.cs create mode 100644 Flow.Launcher.Avalonia/Converters/BoolToIsVisibleConverter.cs create mode 100644 Flow.Launcher.Avalonia/Converters/CommonConverters.cs create mode 100644 Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj create mode 100644 Flow.Launcher.Avalonia/MainWindow.axaml create mode 100644 Flow.Launcher.Avalonia/MainWindow.axaml.cs create mode 100644 Flow.Launcher.Avalonia/Program.cs create mode 100644 Flow.Launcher.Avalonia/Themes/Base.axaml create mode 100644 Flow.Launcher.Avalonia/Themes/Resources.axaml create mode 100644 Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs create mode 100644 Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs create mode 100644 Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs create mode 100644 Flow.Launcher.Avalonia/Views/ResultListBox.axaml create mode 100644 Flow.Launcher.Avalonia/Views/ResultListBox.axaml.cs create mode 100644 Flow.Launcher.Avalonia/app.manifest diff --git a/Flow.Launcher.Avalonia/App.axaml b/Flow.Launcher.Avalonia/App.axaml new file mode 100644 index 00000000000..7a48015b4e6 --- /dev/null +++ b/Flow.Launcher.Avalonia/App.axaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/App.axaml.cs b/Flow.Launcher.Avalonia/App.axaml.cs new file mode 100644 index 00000000000..0b8d0c827ce --- /dev/null +++ b/Flow.Launcher.Avalonia/App.axaml.cs @@ -0,0 +1,52 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.UserSettings; +using Microsoft.Extensions.DependencyInjection; +using System; + +namespace Flow.Launcher.Avalonia; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + // Set up dependency injection + var services = new ServiceCollection(); + ConfigureServices(services); + var serviceProvider = services.BuildServiceProvider(); + Ioc.Default.ConfigureServices(serviceProvider); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } + + private void ConfigureServices(IServiceCollection services) + { + // Register settings - for now create a default instance + // In production, this would load from the existing settings file + services.AddSingleton(_ => + { + var settings = new Settings(); + // Set some defaults for the Avalonia version + settings.WindowSize = 580; + settings.WindowHeightSize = 42; + settings.QueryBoxFontSize = 24; + settings.ItemHeightSize = 50; + settings.ResultItemFontSize = 14; + settings.ResultSubItemFontSize = 12; + settings.MaxResultsToShow = 6; + return settings; + }); + } +} diff --git a/Flow.Launcher.Avalonia/Converters/BoolToIsVisibleConverter.cs b/Flow.Launcher.Avalonia/Converters/BoolToIsVisibleConverter.cs new file mode 100644 index 00000000000..e8d0211a464 --- /dev/null +++ b/Flow.Launcher.Avalonia/Converters/BoolToIsVisibleConverter.cs @@ -0,0 +1,34 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace Flow.Launcher.Avalonia.Converters; + +/// +/// Converts a boolean value to IsVisible (Avalonia uses bool for visibility, not Visibility enum) +/// +public class BoolToIsVisibleConverter : IValueConverter +{ + /// + /// If true, inverts the boolean value (true becomes false, false becomes true) + /// + public bool Invert { get; set; } + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool boolValue) + { + return Invert ? !boolValue : boolValue; + } + return false; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool boolValue) + { + return Invert ? !boolValue : boolValue; + } + return false; + } +} diff --git a/Flow.Launcher.Avalonia/Converters/CommonConverters.cs b/Flow.Launcher.Avalonia/Converters/CommonConverters.cs new file mode 100644 index 00000000000..22b6349f329 --- /dev/null +++ b/Flow.Launcher.Avalonia/Converters/CommonConverters.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace Flow.Launcher.Avalonia.Converters; + +/// +/// Converts text with highlight ranges to formatted text with bold highlights. +/// This is a simplified version - full implementation would use Avalonia's TextDecorations. +/// +public class HighlightTextConverter : IMultiValueConverter +{ + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + // For now, just return the plain text + // Full implementation would create formatted inline text with highlights + if (values.Count >= 1 && values[0] is string text) + { + return text; + } + return string.Empty; + } +} + +/// +/// Converts query text and selected item to suggestion text. +/// +public class QuerySuggestionBoxConverter : IMultiValueConverter +{ + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + // values[0]: QueryTextBox (element) + // values[1]: SelectedItem + // values[2]: QueryText + + if (values.Count < 3) + return string.Empty; + + var queryText = values[2] as string ?? string.Empty; + + // For now, return empty - full implementation would show autocomplete suggestion + // based on the selected result's title + return string.Empty; + } +} + +/// +/// Converts integer index to ordinal number for hotkey display (1, 2, 3...). +/// +public class OrdinalConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int index) + { + // Convert 0-based index to 1-based display, wrapping 9 to 0 + var displayNumber = (index + 1) % 10; + return displayNumber.ToString(); + } + return "0"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +/// +/// Converts a size to a ratio of itself. +/// +public class SizeRatioConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is double size && parameter is string ratioStr && double.TryParse(ratioStr, out var ratio)) + { + return size * ratio; + } + return value; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +/// +/// Converts string to null if empty (for image sources). +/// +public class StringToNullConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string str && string.IsNullOrWhiteSpace(str)) + { + return null; + } + return value; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value?.ToString() ?? string.Empty; + } +} diff --git a/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj new file mode 100644 index 00000000000..99b50426fa0 --- /dev/null +++ b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj @@ -0,0 +1,65 @@ + + + + WinExe + net9.0-windows10.0.19041.0 + enable + true + app.manifest + true + ..\Flow.Launcher\Resources\app.ico + Flow.Launcher.Avalonia.Program + false + false + false + Flow.Launcher.Avalonia + Flow.Launcher.Avalonia + + + + ..\Output\Debug\Avalonia\ + DEBUG;TRACE;AVALONIA + + + + ..\Output\Release\Avalonia\ + TRACE;RELEASE;AVALONIA + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml b/Flow.Launcher.Avalonia/MainWindow.axaml new file mode 100644 index 00000000000..8904a0238c3 --- /dev/null +++ b/Flow.Launcher.Avalonia/MainWindow.axaml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml.cs b/Flow.Launcher.Avalonia/MainWindow.axaml.cs new file mode 100644 index 00000000000..8723174c340 --- /dev/null +++ b/Flow.Launcher.Avalonia/MainWindow.axaml.cs @@ -0,0 +1,118 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Avalonia.ViewModel; +using Flow.Launcher.Infrastructure.UserSettings; +using System; + +namespace Flow.Launcher.Avalonia; + +public partial class MainWindow : Window +{ + private MainViewModel? _viewModel; + private TextBox? _queryTextBox; + + public MainWindow() + { + InitializeComponent(); + + // Create and set the ViewModel + var settings = Ioc.Default.GetRequiredService(); + _viewModel = new MainViewModel(settings); + DataContext = _viewModel; + + // Get reference to the query text box + _queryTextBox = this.FindControl("QueryTextBox"); + + // Subscribe to window events + this.Deactivated += OnWindowDeactivated; + +#if DEBUG + this.AttachDevTools(); +#endif + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + // Focus the query text box when window loads + _queryTextBox?.Focus(); + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + + // Center the window on screen + CenterOnScreen(); + + // Focus and select all text + if (_queryTextBox != null) + { + _queryTextBox.Focus(); + _queryTextBox.SelectAll(); + } + } + + private void CenterOnScreen() + { + var screen = Screens.Primary; + if (screen != null) + { + var workingArea = screen.WorkingArea; + var x = (workingArea.Width - Width) / 2 + workingArea.X; + var y = workingArea.Height * 0.25 + workingArea.Y; // Position at 25% from top (like Flow Launcher) + Position = new PixelPoint((int)x, (int)y); + } + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + // Handle Escape to hide window + if (e.Key == Key.Escape) + { + Hide(); + e.Handled = true; + } + } + + private void OnWindowBorderPointerPressed(object? sender, PointerPressedEventArgs e) + { + // Allow dragging the window + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + BeginMoveDrag(e); + } + } + + // Note: In Avalonia, use the Deactivated event instead of override + // Subscribe in constructor: this.Deactivated += OnWindowDeactivated; + private void OnWindowDeactivated(object? sender, EventArgs e) + { + // Optionally hide window when it loses focus (like original Flow Launcher) + // Uncomment if desired: + // Hide(); + } + + /// + /// Shows the window and focuses the query text box + /// + public void ShowAndFocus() + { + Show(); + Activate(); + _queryTextBox?.Focus(); + _queryTextBox?.SelectAll(); + } +} diff --git a/Flow.Launcher.Avalonia/Program.cs b/Flow.Launcher.Avalonia/Program.cs new file mode 100644 index 00000000000..1c4a9c330d3 --- /dev/null +++ b/Flow.Launcher.Avalonia/Program.cs @@ -0,0 +1,21 @@ +using System; +using Avalonia; + +namespace Flow.Launcher.Avalonia; + +internal sealed class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/Flow.Launcher.Avalonia/Themes/Base.axaml b/Flow.Launcher.Avalonia/Themes/Base.axaml new file mode 100644 index 00000000000..5f1845f9602 --- /dev/null +++ b/Flow.Launcher.Avalonia/Themes/Base.axaml @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/Themes/Resources.axaml b/Flow.Launcher.Avalonia/Themes/Resources.axaml new file mode 100644 index 00000000000..29afcff9eed --- /dev/null +++ b/Flow.Launcher.Avalonia/Themes/Resources.axaml @@ -0,0 +1,23 @@ + + + + F1 M12000,12000z M0,0z M10354,10962C10326,10951 10279,10927 10249,10907 10216,10886 9476,10153 8370,9046 7366,8042 6541,7220 6536,7220 6532,7220 6498,7242 6461,7268 6213,7447 5883,7619 5592,7721 5194,7860 4802,7919 4360,7906 3612,7886 2953,7647 2340,7174 2131,7013 1832,6699 1664,6465 1394,6088 1188,5618 1097,5170 1044,4909 1030,4764 1030,4470 1030,4130 1056,3914 1135,3609 1263,3110 1511,2633 1850,2235 1936,2134 2162,1911 2260,1829 2781,1395 3422,1120 4090,1045 4271,1025 4667,1025 4848,1045 5505,1120 6100,1368 6630,1789 6774,1903 7081,2215 7186,2355 7362,2588 7467,2759 7579,2990 7802,3455 7911,3937 7911,4460 7911,4854 7861,5165 7737,5542 7684,5702 7675,5724 7602,5885 7517,6071 7390,6292 7270,6460 7242,6499 7220,6533 7220,6538 7220,6542 8046,7371 9055,8380 10441,9766 10898,10229 10924,10274 10945,10308 10966,10364 10976,10408 10990,10472 10991,10493 10980,10554 10952,10717 10840,10865 10690,10937 10621,10971 10607,10974 10510,10977 10425,10980 10395,10977 10354,10962z M4685,7050C5214,7001 5694,6809 6100,6484 6209,6396 6396,6209 6484,6100 7151,5267 7246,4110 6721,3190 6369,2571 5798,2137 5100,1956 4706,1855 4222,1855 3830,1957 3448,2056 3140,2210 2838,2453 2337,2855 2010,3427 1908,4080 1877,4274 1877,4656 1908,4850 1948,5105 2028,5370 2133,5590 2459,6272 3077,6782 3810,6973 3967,7014 4085,7034 4290,7053 4371,7061 4583,7059 4685,7050z + + + #E6202020 + #E3E0E3 + #FFFFF8 + #999999 + #333333 + #30FFFFFF + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs new file mode 100644 index 00000000000..94c52cc4b6a --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Infrastructure.UserSettings; + +namespace Flow.Launcher.Avalonia.ViewModel; + +/// +/// Simplified MainViewModel for the Avalonia version. +/// This will eventually be unified with the WPF MainViewModel. +/// +public partial class MainViewModel : ObservableObject +{ + private readonly Settings _settings; + + [ObservableProperty] + private string _queryText = string.Empty; + + [ObservableProperty] + private string _querySuggestionText = string.Empty; + + [ObservableProperty] + private bool _isQueryRunning; + + [ObservableProperty] + private bool _hasResults; + + [ObservableProperty] + private ResultsViewModel _results; + + public Settings Settings => _settings; + + public MainViewModel(Settings settings) + { + _settings = settings; + _results = new ResultsViewModel(settings); + + // Add some demo results for testing + AddDemoResults(); + } + + partial void OnQueryTextChanged(string value) + { + // Simulate query execution + if (!string.IsNullOrWhiteSpace(value)) + { + IsQueryRunning = true; + HasResults = true; + + // Simulate search + Task.Delay(100).ContinueWith(_ => + { + IsQueryRunning = false; + }, TaskScheduler.FromCurrentSynchronizationContext()); + } + else + { + HasResults = false; + IsQueryRunning = false; + } + } + + private void AddDemoResults() + { + // Add demo results for UI testing + Results.AddResult(new ResultViewModel + { + Title = "Welcome to Flow Launcher (Avalonia)", + SubTitle = "This is a demo result - Avalonia migration in progress", + IconPath = "Images/app.png" + }); + + Results.AddResult(new ResultViewModel + { + Title = "Settings", + SubTitle = "Open Flow Launcher settings", + IconPath = "Images/app.png" + }); + + Results.AddResult(new ResultViewModel + { + Title = "Notepad", + SubTitle = "C:\\Windows\\System32\\notepad.exe", + IconPath = "Images/app.png" + }); + + Results.AddResult(new ResultViewModel + { + Title = "Calculator", + SubTitle = "Microsoft Calculator", + IconPath = "Images/app.png" + }); + + HasResults = true; + } + + [RelayCommand] + private void Esc() + { + QueryText = string.Empty; + } + + [RelayCommand] + private void OpenResult(object? parameter) + { + var selectedResult = Results.SelectedItem; + if (selectedResult != null) + { + // Execute the result action + System.Diagnostics.Debug.WriteLine($"Opening result: {selectedResult.Title}"); + } + } + + [RelayCommand] + private void SelectNextItem() + { + Results.SelectNextItem(); + } + + [RelayCommand] + private void SelectPrevItem() + { + Results.SelectPrevItem(); + } + + [RelayCommand] + private void AutocompleteQuery() + { + if (Results.SelectedItem != null) + { + QueryText = Results.SelectedItem.Title; + } + } + + [RelayCommand] + private void ReloadPluginData() + { + // Placeholder for plugin data reload + System.Diagnostics.Debug.WriteLine("Reloading plugin data..."); + } + + [RelayCommand] + private void ReQuery() + { + // Placeholder for re-query + System.Diagnostics.Debug.WriteLine("Re-querying..."); + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs new file mode 100644 index 00000000000..a343683082d --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs @@ -0,0 +1,30 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Flow.Launcher.Infrastructure.UserSettings; + +namespace Flow.Launcher.Avalonia.ViewModel; + +/// +/// ViewModel for a single result item. +/// +public partial class ResultViewModel : ObservableObject +{ + [ObservableProperty] + private string _title = string.Empty; + + [ObservableProperty] + private string _subTitle = string.Empty; + + [ObservableProperty] + private string _iconPath = string.Empty; + + [ObservableProperty] + private bool _isSelected; + + [ObservableProperty] + private Settings? _settings; + + // Computed properties for display + public bool ShowIcon => !string.IsNullOrEmpty(IconPath); + + public bool ShowSubTitle => !string.IsNullOrEmpty(SubTitle); +} diff --git a/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs new file mode 100644 index 00000000000..1a006f3633c --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Flow.Launcher.Infrastructure.UserSettings; + +namespace Flow.Launcher.Avalonia.ViewModel; + +/// +/// ViewModel for the results list. +/// +public partial class ResultsViewModel : ObservableObject +{ + private readonly Settings _settings; + + [ObservableProperty] + private ObservableCollection _results = new(); + + [ObservableProperty] + private ResultViewModel? _selectedItem; + + [ObservableProperty] + private int _selectedIndex; + + [ObservableProperty] + private bool _isVisible = true; + + public Settings Settings => _settings; + + public int MaxHeight => (int)(_settings.MaxResultsToShow * _settings.ItemHeightSize); + + public ResultsViewModel(Settings settings) + { + _settings = settings; + } + + public void AddResult(ResultViewModel result) + { + result.Settings = _settings; + Results.Add(result); + + // Select first item if nothing selected + if (SelectedItem == null && Results.Count > 0) + { + SelectedIndex = 0; + SelectedItem = Results[0]; + } + } + + public void Clear() + { + Results.Clear(); + SelectedItem = null; + SelectedIndex = -1; + } + + public void SelectNextItem() + { + if (Results.Count == 0) return; + + var newIndex = SelectedIndex + 1; + if (newIndex >= Results.Count) + { + newIndex = 0; // Wrap to beginning + } + + SelectedIndex = newIndex; + SelectedItem = Results[newIndex]; + } + + public void SelectPrevItem() + { + if (Results.Count == 0) return; + + var newIndex = SelectedIndex - 1; + if (newIndex < 0) + { + newIndex = Results.Count - 1; // Wrap to end + } + + SelectedIndex = newIndex; + SelectedItem = Results[newIndex]; + } + + partial void OnSelectedIndexChanged(int value) + { + if (value >= 0 && value < Results.Count) + { + SelectedItem = Results[value]; + } + } +} diff --git a/Flow.Launcher.Avalonia/Views/ResultListBox.axaml b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml new file mode 100644 index 00000000000..144fc8e1844 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/Views/ResultListBox.axaml.cs b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml.cs new file mode 100644 index 00000000000..797f123e953 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml.cs @@ -0,0 +1,34 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; + +namespace Flow.Launcher.Avalonia.Views; + +public partial class ResultListBox : UserControl +{ + private ListBox? _listBox; + + public ResultListBox() + { + InitializeComponent(); + _listBox = this.FindControl("ResultsList"); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + // Handle left click on result item + var point = e.GetCurrentPoint(this); + if (point.Properties.IsLeftButtonPressed) + { + // The ListBox handles selection automatically + // Additional click handling can be added here + } + } +} diff --git a/Flow.Launcher.Avalonia/app.manifest b/Flow.Launcher.Avalonia/app.manifest new file mode 100644 index 00000000000..e07605041c2 --- /dev/null +++ b/Flow.Launcher.Avalonia/app.manifest @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + true/pm + PerMonitorV2 + + + + diff --git a/Flow.Launcher.sln b/Flow.Launcher.sln index e44b23232fb..d830065cd7c 100644 --- a/Flow.Launcher.sln +++ b/Flow.Launcher.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32901.215 @@ -71,6 +72,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher.Plugin.Plugin EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher.Plugin.WindowsSettings", "Plugins\Flow.Launcher.Plugin.WindowsSettings\Flow.Launcher.Plugin.WindowsSettings.csproj", "{5043CECE-E6A7-4867-9CBE-02D27D83747A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flow.Launcher.Avalonia", "Flow.Launcher.Avalonia\Flow.Launcher.Avalonia.csproj", "{6B30B56B-7CEA-4868-828D-1460A57ACF47}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -81,8 +84,20 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x64.ActiveCfg = Debug|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x64.Build.0 = Debug|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x86.ActiveCfg = Debug|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x86.Build.0 = Debug|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|Any CPU.Build.0 = Release|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x64.ActiveCfg = Release|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x64.Build.0 = Release|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x86.ActiveCfg = Release|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x86.Build.0 = Release|Any CPU {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|Any CPU.Build.0 = Debug|Any CPU {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|x64.ActiveCfg = Debug|Any CPU {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|x64.Build.0 = Debug|Any CPU {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -105,18 +120,6 @@ Global {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Release|x64.Build.0 = Release|Any CPU {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Release|x86.ActiveCfg = Release|Any CPU {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Release|x86.Build.0 = Release|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x64.ActiveCfg = Debug|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x64.Build.0 = Debug|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x86.ActiveCfg = Debug|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x86.Build.0 = Debug|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|Any CPU.Build.0 = Release|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x64.ActiveCfg = Release|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x64.Build.0 = Release|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x86.ActiveCfg = Release|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x86.Build.0 = Release|Any CPU {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Debug|Any CPU.Build.0 = Debug|Any CPU {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -286,6 +289,18 @@ Global {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Release|x64.Build.0 = Release|Any CPU {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Release|x86.ActiveCfg = Release|Any CPU {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Release|x86.Build.0 = Release|Any CPU + {6B30B56B-7CEA-4868-828D-1460A57ACF47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B30B56B-7CEA-4868-828D-1460A57ACF47}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B30B56B-7CEA-4868-828D-1460A57ACF47}.Debug|x64.ActiveCfg = Debug|Any CPU + {6B30B56B-7CEA-4868-828D-1460A57ACF47}.Debug|x64.Build.0 = Debug|Any CPU + {6B30B56B-7CEA-4868-828D-1460A57ACF47}.Debug|x86.ActiveCfg = Debug|Any CPU + {6B30B56B-7CEA-4868-828D-1460A57ACF47}.Debug|x86.Build.0 = Debug|Any CPU + {6B30B56B-7CEA-4868-828D-1460A57ACF47}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B30B56B-7CEA-4868-828D-1460A57ACF47}.Release|Any CPU.Build.0 = Release|Any CPU + {6B30B56B-7CEA-4868-828D-1460A57ACF47}.Release|x64.ActiveCfg = Release|Any CPU + {6B30B56B-7CEA-4868-828D-1460A57ACF47}.Release|x64.Build.0 = Release|Any CPU + {6B30B56B-7CEA-4868-828D-1460A57ACF47}.Release|x86.ActiveCfg = Release|Any CPU + {6B30B56B-7CEA-4868-828D-1460A57ACF47}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From e961dc5668e122afbed868fa9e2ffee749d7c668 Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Wed, 14 Jan 2026 23:20:19 -0800 Subject: [PATCH 02/43] Wire up Avalonia UI to actual plugin system - Load settings from disk via FlowLauncherJsonStorage - Initialize PluginManager and query plugins on text change - Add minimal AvaloniaPublicAPI implementing IPublicAPI - Execute plugin results on Enter key - Add WPF framework reference for IPublicAPI compatibility --- Flow.Launcher.Avalonia/App.axaml.cs | 93 ++++++--- Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs | 141 ++++++++++++++ .../Flow.Launcher.Avalonia.csproj | 5 + Flow.Launcher.Avalonia/MainWindow.axaml | 16 -- Flow.Launcher.Avalonia/MainWindow.axaml.cs | 1 + .../ViewModel/MainViewModel.cs | 179 +++++++++--------- .../ViewModel/ResultViewModel.cs | 11 ++ 7 files changed, 313 insertions(+), 133 deletions(-) create mode 100644 Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs diff --git a/Flow.Launcher.Avalonia/App.axaml.cs b/Flow.Launcher.Avalonia/App.axaml.cs index 0b8d0c827ce..73c274bcadd 100644 --- a/Flow.Launcher.Avalonia/App.axaml.cs +++ b/Flow.Launcher.Avalonia/App.axaml.cs @@ -1,52 +1,93 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Threading; using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Avalonia.ViewModel; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.Storage; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; using Microsoft.Extensions.DependencyInjection; using System; +using System.Threading.Tasks; namespace Flow.Launcher.Avalonia; public partial class App : Application { - public override void Initialize() - { - AvaloniaXamlLoader.Load(this); - } + private static readonly string ClassName = nameof(App); + private Settings? _settings; + private MainViewModel? _mainVM; + + public static IPublicAPI? API { get; private set; } + + public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void OnFrameworkInitializationCompleted() { - // Set up dependency injection - var services = new ServiceCollection(); - ConfigureServices(services); - var serviceProvider = services.BuildServiceProvider(); - Ioc.Default.ConfigureServices(serviceProvider); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { + LoadSettings(); + ConfigureDI(); + + API = Ioc.Default.GetRequiredService(); + _mainVM = Ioc.Default.GetRequiredService(); + desktop.MainWindow = new MainWindow(); - } + Dispatcher.UIThread.Post(async () => await InitializePluginsAsync(), DispatcherPriority.Background); + } base.OnFrameworkInitializationCompleted(); } - private void ConfigureServices(IServiceCollection services) + private void LoadSettings() + { + try + { + var storage = new FlowLauncherJsonStorage(); + _settings = storage.Load(); + _settings.SetStorage(storage); + } + catch (Exception e) + { + Log.Exception(ClassName, "Settings load failed", e); + _settings = new Settings + { + WindowSize = 580, WindowHeightSize = 42, QueryBoxFontSize = 24, + ItemHeightSize = 50, ResultItemFontSize = 14, ResultSubItemFontSize = 12, MaxResultsToShow = 6 + }; + } + } + + private void ConfigureDI() + { + var services = new ServiceCollection(); + services.AddSingleton(_settings!); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new AvaloniaPublicAPI( + sp.GetRequiredService(), + () => sp.GetRequiredService())); + Ioc.Default.ConfigureServices(services.BuildServiceProvider()); + } + + private async Task InitializePluginsAsync() { - // Register settings - for now create a default instance - // In production, this would load from the existing settings file - services.AddSingleton(_ => + try { - var settings = new Settings(); - // Set some defaults for the Avalonia version - settings.WindowSize = 580; - settings.WindowHeightSize = 42; - settings.QueryBoxFontSize = 24; - settings.ItemHeightSize = 50; - settings.ResultItemFontSize = 14; - settings.ResultSubItemFontSize = 12; - settings.MaxResultsToShow = 6; - return settings; - }); + Log.Info(ClassName, "Loading plugins..."); + PluginManager.LoadPlugins(_settings!.PluginSettings); + Log.Info(ClassName, $"Loaded {PluginManager.AllPlugins.Count} plugins"); + + await PluginManager.InitializePluginsAsync(); + Log.Info(ClassName, "Plugins initialized"); + + _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 new file mode 100644 index 00000000000..6b984dc69e6 --- /dev/null +++ b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Media; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedModels; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Avalonia.ViewModel; +using CommunityToolkit.Mvvm.DependencyInjection; + +namespace Flow.Launcher.Avalonia; + +/// +/// Minimal IPublicAPI for Avalonia - just enough for plugin queries to work. +/// +public class AvaloniaPublicAPI : IPublicAPI +{ + private readonly Settings _settings; + private readonly Func _getMainViewModel; + + public AvaloniaPublicAPI(Settings settings, Func getMainViewModel) + { + _settings = settings; + _getMainViewModel = getMainViewModel; + } + +#pragma warning disable CS0067 + public event VisibilityChangedEventHandler? VisibilityChanged; + public event ActualApplicationThemeChangedEventHandler? ActualApplicationThemeChanged; +#pragma warning restore CS0067 + + // Essential for plugins + public void ChangeQuery(string query, bool requery = false) => _getMainViewModel().QueryText = query; + public string GetTranslation(string key) => key; + public List GetAllPlugins() => PluginManager.AllPlugins; + public MatchResult FuzzySearch(string query, string stringToCompare) => + Ioc.Default.GetRequiredService().FuzzyMatch(query, stringToCompare); + + // Logging + public void LogDebug(string className, string message, [CallerMemberName] string methodName = "") => Log.Debug(className, message, methodName); + public void LogInfo(string className, string message, [CallerMemberName] string methodName = "") => Log.Info(className, message, methodName); + public void LogWarn(string className, string message, [CallerMemberName] string methodName = "") => Log.Warn(className, message, methodName); + public void LogError(string className, string message, [CallerMemberName] string methodName = "") => Log.Error(className, message, methodName); + public void LogException(string className, string message, Exception e, [CallerMemberName] string methodName = "") => Log.Exception(className, message, e, methodName); + + // Shell/URL operations + public void ShellRun(string cmd, string filename = "cmd.exe") => + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = filename, Arguments = $"/c {cmd}", UseShellExecute = true }); + public void OpenUrl(string url, bool? inPrivate = null) => + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = url, UseShellExecute = true }); + public void OpenUrl(Uri url, bool? inPrivate = null) => OpenUrl(url.ToString(), inPrivate); + public void OpenWebUrl(string url, bool? inPrivate = null) => OpenUrl(url, inPrivate); + public void OpenWebUrl(Uri url, bool? inPrivate = null) => OpenUrl(url.ToString(), inPrivate); + public void OpenAppUri(Uri appUri) => OpenUrl(appUri); + public void OpenAppUri(string appUri) => OpenUrl(appUri); + public void OpenDirectory(string DirectoryPath, string? FileNameOrFilePath = null) => + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = DirectoryPath, UseShellExecute = true }); + + // Clipboard + public void CopyToClipboard(string text, bool directCopy = false, bool showDefaultNotification = true) + { + if (global::Avalonia.Application.Current?.ApplicationLifetime is global::Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow?.Clipboard?.SetTextAsync(text); + } + + // HTTP (delegate to Infrastructure) + public Task HttpGetStringAsync(string url, CancellationToken token = default) => Infrastructure.Http.Http.GetAsync(url, token); + public Task HttpGetStreamAsync(string url, CancellationToken token = default) => Infrastructure.Http.Http.GetStreamAsync(url, token); + public Task HttpDownloadAsync(string url, string filePath, Action? reportProgress = null, CancellationToken token = default) => + Infrastructure.Http.Http.DownloadAsync(url, filePath, reportProgress, token); + + // Plugin management + public void AddActionKeyword(string pluginId, string newActionKeyword) => PluginManager.AddActionKeyword(pluginId, newActionKeyword); + public void RemoveActionKeyword(string pluginId, string oldActionKeyword) => PluginManager.RemoveActionKeyword(pluginId, oldActionKeyword); + public bool ActionKeywordAssigned(string actionKeyword) => PluginManager.ActionKeywordRegistered(actionKeyword); + public bool PluginModified(string id) => PluginManager.PluginModified(id); + + // Paths + public string GetDataDirectory() => DataLocation.DataDirectory(); + public string GetLogDirectory() => Log.CurrentLogDirectory; + + // Stubs - not critical for basic queries + public void RestartApp() { } + public void SaveAppAllSettings() { } + public void SavePluginSettings() { } + public Task ReloadAllPluginData() => Task.CompletedTask; + public void CheckForNewUpdate() { } + public void ShowMsgError(string title, string subTitle = "") => LogError("API", $"{title}: {subTitle}"); + public void ShowMsgErrorWithButton(string title, string buttonText, Action buttonAction, string subTitle = "") { } + public void ShowMainWindow() { } + public void FocusQueryTextBox() { } + public void HideMainWindow() => _getMainViewModel()?.RequestHide(); + public bool IsMainWindowVisible() => true; + public void ShowMsg(string title, string subTitle = "", string iconPath = "") { } + 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 RegisterGlobalKeyboardCallback(Func callback) { } + public void RemoveGlobalKeyboardCallback(Func callback) { } + public T LoadSettingJsonStorage() where T : new() => new T(); + public void SaveSettingJsonStorage() where T : new() { } + public void ToggleGameMode() { } + public void SetGameMode(bool value) { } + public bool IsGameModeOn() => false; + public void ReQuery(bool reselect = true) { var q = _getMainViewModel().QueryText; _getMainViewModel().QueryText = ""; _getMainViewModel().QueryText = q; } + public void BackToQueryResults() { } + public MessageBoxResult ShowMsgBox(string messageBoxText, string caption = "", MessageBoxButton button = MessageBoxButton.OK, MessageBoxImage icon = MessageBoxImage.None, MessageBoxResult defaultResult = MessageBoxResult.OK) => defaultResult; + public Task ShowProgressBoxAsync(string caption, Func, Task> reportProgressAsync, Action? cancelProgress = null) => reportProgressAsync(_ => { }); + public void StartLoadingBar() => _getMainViewModel().IsQueryRunning = true; + public void StopLoadingBar() => _getMainViewModel().IsQueryRunning = false; + public List GetAvailableThemes() => new(); + public ThemeData? GetCurrentTheme() => null; + public bool SetCurrentTheme(ThemeData theme) => false; + public void SavePluginCaches() { } + public Task LoadCacheBinaryStorageAsync(string cacheName, string cacheDirectory, T defaultData) where T : new() => Task.FromResult(defaultData); + public Task SaveCacheBinaryStorageAsync(string cacheName, string cacheDirectory) where T : new() => Task.CompletedTask; + public ValueTask LoadImageAsync(string path, bool loadFullImage = false, bool cacheImage = true) => new((ImageSource)null!); + public Task UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default) => Task.FromResult(true); + public IReadOnlyList GetPluginManifest() => new List(); + public Task UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath) => Task.FromResult(false); + public bool InstallPlugin(UserPlugin plugin, string zipFilePath) => false; + public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false) => Task.FromResult(false); + public bool IsApplicationDarkTheme() => true; + + public long StopwatchLogDebug(string className, string message, Action action, [CallerMemberName] string methodName = "") + { var sw = System.Diagnostics.Stopwatch.StartNew(); action(); sw.Stop(); LogDebug(className, $"{message}: {sw.ElapsedMilliseconds}ms", methodName); return sw.ElapsedMilliseconds; } + public async Task StopwatchLogDebugAsync(string className, string message, Func action, [CallerMemberName] string methodName = "") + { var sw = System.Diagnostics.Stopwatch.StartNew(); await action(); sw.Stop(); LogDebug(className, $"{message}: {sw.ElapsedMilliseconds}ms", methodName); return sw.ElapsedMilliseconds; } + public long StopwatchLogInfo(string className, string message, Action action, [CallerMemberName] string methodName = "") + { var sw = System.Diagnostics.Stopwatch.StartNew(); action(); sw.Stop(); LogInfo(className, $"{message}: {sw.ElapsedMilliseconds}ms", methodName); return sw.ElapsedMilliseconds; } + public async Task StopwatchLogInfoAsync(string className, string message, Func action, [CallerMemberName] string methodName = "") + { var sw = System.Diagnostics.Stopwatch.StartNew(); await action(); sw.Stop(); LogInfo(className, $"{message}: {sw.ElapsedMilliseconds}ms", methodName); return sw.ElapsedMilliseconds; } +} diff --git a/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj index 99b50426fa0..5972fd030fc 100644 --- a/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj +++ b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj @@ -49,6 +49,11 @@ + + + + + diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml b/Flow.Launcher.Avalonia/MainWindow.axaml index 8904a0238c3..42e18db4dc8 100644 --- a/Flow.Launcher.Avalonia/MainWindow.axaml +++ b/Flow.Launcher.Avalonia/MainWindow.axaml @@ -31,16 +31,9 @@ - - - - - - - @@ -52,14 +45,6 @@ - - - - - diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml.cs b/Flow.Launcher.Avalonia/MainWindow.axaml.cs index 8723174c340..b2c0700d9fd 100644 --- a/Flow.Launcher.Avalonia/MainWindow.axaml.cs +++ b/Flow.Launcher.Avalonia/MainWindow.axaml.cs @@ -22,6 +22,7 @@ public MainWindow() // Create and set the ViewModel var settings = Ioc.Default.GetRequiredService(); _viewModel = new MainViewModel(settings); + _viewModel.HideRequested += () => Hide(); 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 94c52cc4b6a..24a15209bc9 100644 --- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -1,26 +1,31 @@ using System; -using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; -using System.Windows.Input; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Avalonia.ViewModel; /// -/// Simplified MainViewModel for the Avalonia version. -/// This will eventually be unified with the WPF MainViewModel. +/// MainViewModel for Avalonia - minimal implementation for plugin queries. /// public partial class MainViewModel : ObservableObject { + private static readonly string ClassName = nameof(MainViewModel); private readonly Settings _settings; + private CancellationTokenSource? _queryTokenSource; + private bool _pluginsReady; - [ObservableProperty] - private string _queryText = string.Empty; + public event Action? HideRequested; [ObservableProperty] - private string _querySuggestionText = string.Empty; + private string _queryText = string.Empty; [ObservableProperty] private bool _isQueryRunning; @@ -37,115 +42,107 @@ public MainViewModel(Settings settings) { _settings = settings; _results = new ResultsViewModel(settings); - - // Add some demo results for testing - AddDemoResults(); } - partial void OnQueryTextChanged(string value) + public void OnPluginsReady() { - // Simulate query execution - if (!string.IsNullOrWhiteSpace(value)) - { - IsQueryRunning = true; - HasResults = true; - - // Simulate search - Task.Delay(100).ContinueWith(_ => - { - IsQueryRunning = false; - }, TaskScheduler.FromCurrentSynchronizationContext()); - } - else + _pluginsReady = true; + Log.Info(ClassName, "Plugins ready"); + if (!string.IsNullOrWhiteSpace(QueryText)) + _ = QueryAsync(); + } + + public void RequestHide() => HideRequested?.Invoke(); + + partial void OnQueryTextChanged(string value) => _ = QueryAsync(); + + private async Task QueryAsync() + { + _queryTokenSource?.Cancel(); + _queryTokenSource = new CancellationTokenSource(); + var token = _queryTokenSource.Token; + var queryText = QueryText.Trim(); + + if (string.IsNullOrWhiteSpace(queryText) || !_pluginsReady) { + Results.Clear(); HasResults = false; IsQueryRunning = false; + return; } - } - private void AddDemoResults() - { - // Add demo results for UI testing - Results.AddResult(new ResultViewModel - { - Title = "Welcome to Flow Launcher (Avalonia)", - SubTitle = "This is a demo result - Avalonia migration in progress", - IconPath = "Images/app.png" - }); - - Results.AddResult(new ResultViewModel - { - Title = "Settings", - SubTitle = "Open Flow Launcher settings", - IconPath = "Images/app.png" - }); - - Results.AddResult(new ResultViewModel - { - Title = "Notepad", - SubTitle = "C:\\Windows\\System32\\notepad.exe", - IconPath = "Images/app.png" - }); + IsQueryRunning = true; - Results.AddResult(new ResultViewModel + try { - Title = "Calculator", - SubTitle = "Microsoft Calculator", - IconPath = "Images/app.png" - }); + var query = QueryBuilder.Build(queryText, PluginManager.NonGlobalPlugins); + if (query == null) { Results.Clear(); HasResults = false; return; } - HasResults = true; - } + var plugins = PluginManager.ValidPluginsForQuery(query, dialogJump: false) + .Where(p => !p.Metadata.Disabled).ToList(); - [RelayCommand] - private void Esc() - { - QueryText = string.Empty; - } + if (plugins.Count == 0) { Results.Clear(); HasResults = false; return; } - [RelayCommand] - private void OpenResult(object? parameter) - { - var selectedResult = Results.SelectedItem; - if (selectedResult != null) - { - // Execute the result action - System.Diagnostics.Debug.WriteLine($"Opening result: {selectedResult.Title}"); - } - } + Results.Clear(); - [RelayCommand] - private void SelectNextItem() - { - Results.SelectNextItem(); - } + var tasks = plugins.Select(p => QueryPluginAsync(p, query, token)); + await Task.WhenAll(tasks); - [RelayCommand] - private void SelectPrevItem() - { - Results.SelectPrevItem(); + if (!token.IsCancellationRequested) + HasResults = Results.Results.Count > 0; + } + catch (OperationCanceledException) { } + catch (Exception e) { Log.Exception(ClassName, "Query error", e); } + finally { if (!token.IsCancellationRequested) IsQueryRunning = false; } } - [RelayCommand] - private void AutocompleteQuery() + private async Task QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token) { - if (Results.SelectedItem != null) + try { - QueryText = Results.SelectedItem.Title; + var delay = plugin.Metadata.SearchDelayTime ?? _settings.SearchDelayTime; + if (delay > 0) await Task.Delay(delay, token); + if (token.IsCancellationRequested) return; + + var results = await PluginManager.QueryForPluginAsync(plugin, query, token); + if (token.IsCancellationRequested || results == null || results.Count == 0) return; + + await global::Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + foreach (var r in results.OrderByDescending(r => r.Score).Take(_settings.MaxResultsToShow)) + { + if (token.IsCancellationRequested) return; + Results.AddResult(new ResultViewModel + { + Title = r.Title ?? "", + SubTitle = r.SubTitle ?? "", + IconPath = r.IcoPath ?? plugin.Metadata.IcoPath ?? "", + PluginResult = r + }); + } + HasResults = Results.Results.Count > 0; + }); } + catch (OperationCanceledException) { } + catch (Exception e) { Log.Exception(ClassName, $"Plugin {plugin.Metadata.Name} error", e); } } [RelayCommand] - private void ReloadPluginData() - { - // Placeholder for plugin data reload - System.Diagnostics.Debug.WriteLine("Reloading plugin data..."); - } + private void Esc() { QueryText = ""; HideRequested?.Invoke(); } [RelayCommand] - private void ReQuery() + private async Task OpenResultAsync() { - // Placeholder for re-query - System.Diagnostics.Debug.WriteLine("Re-querying..."); + var result = Results.SelectedItem?.PluginResult; + if (result == null) return; + try + { + if (await result.ExecuteAsync(new ActionContext { SpecialKeyState = SpecialKeyState.Default })) + HideRequested?.Invoke(); + } + catch (Exception e) { Log.Exception(ClassName, "Execute error", e); } } + + [RelayCommand] private void SelectNextItem() => Results.SelectNextItem(); + [RelayCommand] private void SelectPrevItem() => Results.SelectPrevItem(); } diff --git a/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs index a343683082d..324f317a1e1 100644 --- a/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Avalonia.ViewModel; @@ -23,8 +24,18 @@ public partial class ResultViewModel : ObservableObject [ObservableProperty] private Settings? _settings; + /// + /// The underlying plugin result. Used for executing actions and accessing additional properties. + /// + public Result? PluginResult { get; set; } + // Computed properties for display public bool ShowIcon => !string.IsNullOrEmpty(IconPath); public bool ShowSubTitle => !string.IsNullOrEmpty(SubTitle); + + /// + /// Gets the query suggestion text for autocomplete, if available. + /// + public string? QuerySuggestionText => PluginResult?.AutoCompleteText; } From 207953159a6963dfa15ee0934907919ecc168b70 Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Wed, 14 Jan 2026 23:29:10 -0800 Subject: [PATCH 03/43] Fix MainWindow to use shared ViewModel from DI and add console logging --- Flow.Launcher.Avalonia/MainWindow.axaml.cs | 5 ++--- Flow.Launcher.Infrastructure/Logger/Log.cs | 5 ++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml.cs b/Flow.Launcher.Avalonia/MainWindow.axaml.cs index b2c0700d9fd..47372a4e9fd 100644 --- a/Flow.Launcher.Avalonia/MainWindow.axaml.cs +++ b/Flow.Launcher.Avalonia/MainWindow.axaml.cs @@ -19,9 +19,8 @@ public MainWindow() { InitializeComponent(); - // Create and set the ViewModel - var settings = Ioc.Default.GetRequiredService(); - _viewModel = new MainViewModel(settings); + // Get the ViewModel from DI (same instance that App uses) + _viewModel = Ioc.Default.GetRequiredService(); _viewModel.HideRequested += () => Hide(); DataContext = _viewModel; diff --git a/Flow.Launcher.Infrastructure/Logger/Log.cs b/Flow.Launcher.Infrastructure/Logger/Log.cs index 09eb98f46be..2fdba2dfe54 100644 --- a/Flow.Launcher.Infrastructure/Logger/Log.cs +++ b/Flow.Launcher.Infrastructure/Logger/Log.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.IO; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; @@ -149,6 +149,9 @@ private static void LogInternal(LogLevel level, string className, string message var logger = LogManager.GetLogger(classNameWithMethod); logger.Log(level, message); + + // Also output to console for easy debugging + System.Console.WriteLine($"[{level}] {classNameWithMethod}: {message}"); } public static void Debug(string className, string message, [CallerMemberName] string methodName = "") From 065dc191ceac2e9f5163e6e2f13927231c1e98a7 Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Wed, 14 Jan 2026 23:50:54 -0800 Subject: [PATCH 04/43] Add global hotkey support for Avalonia UI --- Flow.Launcher.Avalonia/App.axaml.cs | 7 + Flow.Launcher.Avalonia/Helper/GlobalHotkey.cs | 308 ++++++++++++++++++ Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs | 95 ++++++ Flow.Launcher.Avalonia/MainWindow.axaml.cs | 1 + .../ViewModel/MainViewModel.cs | 43 ++- 5 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 Flow.Launcher.Avalonia/Helper/GlobalHotkey.cs create mode 100644 Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs diff --git a/Flow.Launcher.Avalonia/App.axaml.cs b/Flow.Launcher.Avalonia/App.axaml.cs index 73c274bcadd..7f217c3e580 100644 --- a/Flow.Launcher.Avalonia/App.axaml.cs +++ b/Flow.Launcher.Avalonia/App.axaml.cs @@ -3,6 +3,7 @@ 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 override void OnFrameworkInitializationCompleted() 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 00000000000..adec873e5d3 --- /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 00000000000..4a7791b235f --- /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 47372a4e9fd..718f5ae724f 100644 --- a/Flow.Launcher.Avalonia/MainWindow.axaml.cs +++ b/Flow.Launcher.Avalonia/MainWindow.axaml.cs @@ -22,6 +22,7 @@ public MainWindow() // 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 24a15209bc9..8fa3723399c 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 void OnPluginsReady() 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 @@ private async Task QueryPluginAsync(PluginPair plugin, Query query, Cancellation } [RelayCommand] - private void Esc() { QueryText = ""; HideRequested?.Invoke(); } + private void Esc() { Hide(); } [RelayCommand] private async Task OpenResultAsync() From f3d3f80db8eed197daf197f1fa59af159696b41e Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Thu, 15 Jan 2026 00:26:27 -0800 Subject: [PATCH 05/43] Add DynamicData sorting, result persistence, and Windows Shell icon loading - Use DynamicData SourceList with automatic sorting by score descending - Add ReplaceResults() with EditDiff for minimal UI updates (reduces flickering) - Keep previous results visible while typing until new results arrive - Add ImageLoader with Windows Shell API (IShellItemImageFactory) for exe/ico icons - Use AlphaFormat.Unpremul to correctly render transparent icons without white borders - Query all plugins in parallel and merge/sort results globally --- .../Flow.Launcher.Avalonia.csproj | 1 + Flow.Launcher.Avalonia/Helper/ImageLoader.cs | 448 ++++++++++++++++++ .../ViewModel/MainViewModel.cs | 65 ++- .../ViewModel/ResultViewModel.cs | 15 + .../ViewModel/ResultsViewModel.cs | 111 ++++- .../Views/ResultListBox.axaml | 12 +- 6 files changed, 606 insertions(+), 46 deletions(-) create mode 100644 Flow.Launcher.Avalonia/Helper/ImageLoader.cs diff --git a/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj index 5972fd030fc..f79c5358764 100644 --- a/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj +++ b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj @@ -37,6 +37,7 @@ + diff --git a/Flow.Launcher.Avalonia/Helper/ImageLoader.cs b/Flow.Launcher.Avalonia/Helper/ImageLoader.cs new file mode 100644 index 00000000000..ac232d03353 --- /dev/null +++ b/Flow.Launcher.Avalonia/Helper/ImageLoader.cs @@ -0,0 +1,448 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform.Storage; +using Flow.Launcher.Infrastructure.Logger; + +namespace Flow.Launcher.Avalonia.Helper; + +/// +/// Avalonia-compatible image loader with caching support. +/// Loads images from file paths, URLs, and data URIs. +/// Uses Windows Shell API for exe/ico thumbnails. +/// +public static class ImageLoader +{ + private static readonly string ClassName = nameof(ImageLoader); + + // Thread-safe cache + private static readonly ConcurrentDictionary _cache = new(); + private static readonly HttpClient _httpClient = new(); + + // Default image (lazy loaded) + private static IImage? _defaultImage; + + // Image file extensions that Avalonia can load directly + private static readonly string[] DirectLoadExtensions = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"]; + + /// + /// Default image shown when no icon is available. + /// + public static IImage? DefaultImage => _defaultImage ??= LoadDefaultImage(); + + /// + /// Load an image from the given path asynchronously. + /// Supports local files, HTTP/HTTPS URLs, and data URIs. + /// Use with Avalonia's ^ binding operator: {Binding Image^} + /// + public static Task LoadAsync(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return Task.FromResult(DefaultImage); + + // Check cache first - return immediately without Task.Run overhead + if (_cache.TryGetValue(path, out var cached)) + return Task.FromResult(cached); + + // Load on background thread to avoid blocking UI + return Task.Run(() => LoadCore(path)); + } + + /// + /// Core loading logic - runs on thread pool when not cached. + /// + private static async Task LoadCore(string path) + { + // Double-check cache (another thread may have loaded it) + if (_cache.TryGetValue(path, out var cached)) + return cached; + + try + { + IImage? image = null; + + if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + image = await LoadFromUrlAsync(path); + } + else if (path.StartsWith("data:image", StringComparison.OrdinalIgnoreCase)) + { + image = LoadFromDataUri(path); + } + else if (File.Exists(path)) + { + image = LoadFromFile(path); + } + else if (Directory.Exists(path)) + { + // Folder - get shell icon + image = LoadShellThumbnail(path); + } + + // Cache the result (even if null, to avoid repeated attempts) + image ??= DefaultImage; + _cache.TryAdd(path, image); + + return image; + } + catch (Exception ex) + { + Log.Debug(ClassName, $"Failed to load image: {path}, Error: {ex.Message}"); + _cache.TryAdd(path, DefaultImage); + return DefaultImage; + } + } + + private static IImage? LoadFromFile(string path) + { + try + { + var ext = Path.GetExtension(path).ToLowerInvariant(); + + // For standard image formats, load directly + if (Array.Exists(DirectLoadExtensions, e => e == ext)) + { + using var stream = File.OpenRead(path); + return new Bitmap(stream); + } + + // For exe, dll, ico, lnk, and other files - use Windows Shell API + return LoadShellThumbnail(path); + } + catch (Exception ex) + { + Log.Debug(ClassName, $"Failed to load file: {path}, Error: {ex.Message}"); + return null; + } + } + + /// + /// Load thumbnail/icon using Windows Shell API (IShellItemImageFactory). + /// Works for exe, dll, ico, folders, and any file type. + /// + private static IImage? LoadShellThumbnail(string path, int size = 64) + { + try + { + var hr = SHCreateItemFromParsingName(path, IntPtr.Zero, typeof(IShellItemImageFactory).GUID, out var shellItem); + if (hr != 0 || shellItem == null) + { + Log.Debug(ClassName, $"SHCreateItemFromParsingName failed for {path}, hr={hr}"); + return null; + } + + try + { + var imageFactory = (IShellItemImageFactory)shellItem; + var sz = new SIZE { cx = size, cy = size }; + + // Try to get thumbnail, fall back to icon + hr = imageFactory.GetImage(sz, SIIGBF.SIIGBF_BIGGERSIZEOK, out var hBitmap); + if (hr != 0 || hBitmap == IntPtr.Zero) + { + // Fallback to icon only + hr = imageFactory.GetImage(sz, SIIGBF.SIIGBF_ICONONLY, out hBitmap); + } + + if (hr != 0 || hBitmap == IntPtr.Zero) + { + Log.Debug(ClassName, $"GetImage failed for {path}, hr={hr}"); + return null; + } + + try + { + return ConvertHBitmapToAvaloniaBitmap(hBitmap); + } + finally + { + DeleteObject(hBitmap); + } + } + finally + { + Marshal.ReleaseComObject(shellItem); + } + } + catch (Exception ex) + { + Log.Debug(ClassName, $"Failed to load shell thumbnail: {path}, Error: {ex.Message}"); + return null; + } + } + + /// + /// Convert Windows HBITMAP to Avalonia Bitmap. + /// + private static Bitmap? ConvertHBitmapToAvaloniaBitmap(IntPtr hBitmap) + { + // Get bitmap info + var bmp = new BITMAP(); + if (GetObject(hBitmap, Marshal.SizeOf(), ref bmp) == 0) + return null; + + var width = bmp.bmWidth; + var height = bmp.bmHeight; + + // Create BITMAPINFO for DIB (top-down, 32-bit BGRA) + var bmi = new BITMAPINFO + { + bmiHeader = new BITMAPINFOHEADER + { + biSize = (uint)Marshal.SizeOf(), + biWidth = width, + biHeight = -height, // Negative = top-down DIB + biPlanes = 1, + biBitCount = 32, + biCompression = 0 // BI_RGB + } + }; + + // Allocate buffer for pixel data + var stride = width * 4; + var bufferSize = stride * height; + var buffer = new byte[bufferSize]; + + // Get the device context and extract DIB bits + var hdc = CreateCompatibleDC(IntPtr.Zero); + try + { + if (GetDIBits(hdc, hBitmap, 0, (uint)height, buffer, ref bmi, 0) == 0) + return null; + + // Analyze alpha channel to determine if image has transparency + bool hasTransparent = false; + bool hasOpaque = false; + bool hasPartialAlpha = false; + + for (int i = 3; i < bufferSize; i += 4) + { + byte a = buffer[i]; + if (a == 0) hasTransparent = true; + else if (a == 255) hasOpaque = true; + else hasPartialAlpha = true; + + // Early exit once we know it has alpha + if (hasPartialAlpha || (hasTransparent && hasOpaque)) + break; + } + + bool hasAlpha = hasPartialAlpha || (hasTransparent && hasOpaque); + + // If no alpha channel data, set all alpha to 255 (fully opaque) + if (!hasAlpha) + { + for (int i = 3; i < bufferSize; i += 4) + { + buffer[i] = 255; + } + } + + // Create Avalonia bitmap from pixel data + // Use Unpremul - this correctly renders transparent icons without white borders + var bitmap = new WriteableBitmap( + new PixelSize(width, height), + new Vector(96, 96), + global::Avalonia.Platform.PixelFormat.Bgra8888, + global::Avalonia.Platform.AlphaFormat.Unpremul); + + using (var fb = bitmap.Lock()) + { + Marshal.Copy(buffer, 0, fb.Address, bufferSize); + } + + return bitmap; + } + finally + { + DeleteDC(hdc); + } + } + + private static async Task LoadFromUrlAsync(string url) + { + try + { + using var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(); + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + memoryStream.Position = 0; + + return new Bitmap(memoryStream); + } + catch (Exception ex) + { + Log.Debug(ClassName, $"Failed to load URL: {url}, Error: {ex.Message}"); + return null; + } + } + + private static IImage? LoadFromDataUri(string dataUri) + { + try + { + // Parse data URI: data:image/png;base64,xxxxx + var commaIndex = dataUri.IndexOf(','); + if (commaIndex < 0) + return null; + + var base64Data = dataUri.Substring(commaIndex + 1); + var imageData = Convert.FromBase64String(base64Data); + + using var stream = new MemoryStream(imageData); + return new Bitmap(stream); + } + catch (Exception ex) + { + Log.Debug(ClassName, $"Failed to parse data URI: {ex.Message}"); + return null; + } + } + + private static IImage? LoadDefaultImage() + { + try + { + var appDir = AppDomain.CurrentDomain.BaseDirectory; + + // Try PNG first + var defaultIconPath = Path.Combine(appDir, "Images", "app.png"); + if (File.Exists(defaultIconPath)) + { + using var stream = File.OpenRead(defaultIconPath); + return new Bitmap(stream); + } + + // Try ICO via shell + defaultIconPath = Path.Combine(appDir, "Images", "app.ico"); + if (File.Exists(defaultIconPath)) + { + return LoadShellThumbnail(defaultIconPath); + } + } + catch (Exception ex) + { + Log.Debug(ClassName, $"Failed to load default image: {ex.Message}"); + } + + return null; + } + + /// + /// Try to get a cached image without loading. + /// + public static bool TryGetCached(string? path, out IImage? image) + { + if (!string.IsNullOrWhiteSpace(path) && _cache.TryGetValue(path, out image)) + return true; + image = null; + return false; + } + + /// + /// Clear the image cache. + /// + public static void ClearCache() => _cache.Clear(); + + #region Windows Shell API P/Invoke + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)] + private static extern int SHCreateItemFromParsingName( + [MarshalAs(UnmanagedType.LPWStr)] string pszPath, + IntPtr pbc, + [MarshalAs(UnmanagedType.LPStruct)] Guid riid, + [MarshalAs(UnmanagedType.Interface)] out object ppv); + + [ComImport] + [Guid("bcc18b79-ba16-442f-80c4-8a59c30c463b")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellItemImageFactory + { + [PreserveSig] + int GetImage(SIZE size, SIIGBF flags, out IntPtr phbm); + } + + [StructLayout(LayoutKind.Sequential)] + private struct SIZE + { + public int cx; + public int cy; + } + + [Flags] + private enum SIIGBF + { + SIIGBF_RESIZETOFIT = 0x00, + SIIGBF_BIGGERSIZEOK = 0x01, + SIIGBF_MEMORYONLY = 0x02, + SIIGBF_ICONONLY = 0x04, + SIIGBF_THUMBNAILONLY = 0x08, + SIIGBF_INCACHEONLY = 0x10 + } + + [DllImport("gdi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeleteObject(IntPtr hObject); + + [DllImport("gdi32.dll")] + private static extern IntPtr CreateCompatibleDC(IntPtr hdc); + + [DllImport("gdi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeleteDC(IntPtr hdc); + + [DllImport("gdi32.dll")] + private static extern int GetObject(IntPtr hgdiobj, int cbBuffer, ref BITMAP lpvObject); + + [DllImport("gdi32.dll")] + private static extern int GetDIBits(IntPtr hdc, IntPtr hbmp, uint uStartScan, uint cScanLines, + [Out] byte[] lpvBits, ref BITMAPINFO lpbi, uint uUsage); + + [StructLayout(LayoutKind.Sequential)] + private struct BITMAP + { + public int bmType; + public int bmWidth; + public int bmHeight; + public int bmWidthBytes; + public ushort bmPlanes; + public ushort bmBitsPixel; + public IntPtr bmBits; + } + + [StructLayout(LayoutKind.Sequential)] + private struct BITMAPINFOHEADER + { + public uint biSize; + public int biWidth; + public int biHeight; + public ushort biPlanes; + public ushort biBitCount; + public uint biCompression; + public uint biSizeImage; + public int biXPelsPerMeter; + public int biYPelsPerMeter; + public uint biClrUsed; + public uint biClrImportant; + } + + [StructLayout(LayoutKind.Sequential)] + private struct BITMAPINFO + { + public BITMAPINFOHEADER bmiHeader; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1)] + public uint[] bmiColors; + } + + #endregion +} diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs index 8fa3723399c..c87df5c3ec3 100644 --- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -104,7 +104,8 @@ private async Task QueryAsync() var token = _queryTokenSource.Token; var queryText = QueryText.Trim(); - if (string.IsNullOrWhiteSpace(queryText) || !_pluginsReady) + // Only clear results when query is empty + if (string.IsNullOrWhiteSpace(queryText)) { Results.Clear(); HasResults = false; @@ -112,60 +113,78 @@ private async Task QueryAsync() return; } + if (!_pluginsReady) + { + IsQueryRunning = false; + return; + } + IsQueryRunning = true; try { var query = QueryBuilder.Build(queryText, PluginManager.NonGlobalPlugins); - if (query == null) { Results.Clear(); HasResults = false; return; } + if (query == null) { HasResults = false; return; } var plugins = PluginManager.ValidPluginsForQuery(query, dialogJump: false) .Where(p => !p.Metadata.Disabled).ToList(); - if (plugins.Count == 0) { Results.Clear(); HasResults = false; return; } - - Results.Clear(); + if (plugins.Count == 0) { HasResults = false; return; } + // Query all plugins in parallel and collect results var tasks = plugins.Select(p => QueryPluginAsync(p, query, token)); - await Task.WhenAll(tasks); + var pluginResults = await Task.WhenAll(tasks); + + if (token.IsCancellationRequested) return; + + // Flatten, sort by score, take top N, and replace all at once + var allResults = pluginResults + .SelectMany(r => r) + .OrderByDescending(r => r.Score) + .Take(_settings.MaxResultsToShow) + .ToList(); - if (!token.IsCancellationRequested) + // Replace results with minimal UI updates (EditDiff) + await global::Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + Results.ReplaceResults(allResults); HasResults = Results.Results.Count > 0; + }); } catch (OperationCanceledException) { } catch (Exception e) { Log.Exception(ClassName, "Query error", e); } finally { if (!token.IsCancellationRequested) IsQueryRunning = false; } } - private async Task QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token) + private async Task> QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token) { + var resultList = new List(); + try { var delay = plugin.Metadata.SearchDelayTime ?? _settings.SearchDelayTime; if (delay > 0) await Task.Delay(delay, token); - if (token.IsCancellationRequested) return; + if (token.IsCancellationRequested) return resultList; var results = await PluginManager.QueryForPluginAsync(plugin, query, token); - if (token.IsCancellationRequested || results == null || results.Count == 0) return; + if (token.IsCancellationRequested || results == null || results.Count == 0) return resultList; - await global::Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + foreach (var r in results) { - foreach (var r in results.OrderByDescending(r => r.Score).Take(_settings.MaxResultsToShow)) + resultList.Add(new ResultViewModel { - if (token.IsCancellationRequested) return; - Results.AddResult(new ResultViewModel - { - Title = r.Title ?? "", - SubTitle = r.SubTitle ?? "", - IconPath = r.IcoPath ?? plugin.Metadata.IcoPath ?? "", - PluginResult = r - }); - } - HasResults = Results.Results.Count > 0; - }); + Title = r.Title ?? "", + SubTitle = r.SubTitle ?? "", + IconPath = r.IcoPath ?? plugin.Metadata.IcoPath ?? "", + Score = r.Score, + PluginResult = r + }); + } } catch (OperationCanceledException) { } catch (Exception e) { Log.Exception(ClassName, $"Plugin {plugin.Metadata.Name} error", e); } + + return resultList; } [RelayCommand] diff --git a/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs index 324f317a1e1..56fa55fb428 100644 --- a/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs @@ -1,4 +1,7 @@ +using System.Threading.Tasks; +using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; +using Flow.Launcher.Avalonia.Helper; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; @@ -24,6 +27,9 @@ public partial class ResultViewModel : ObservableObject [ObservableProperty] private Settings? _settings; + [ObservableProperty] + private int _score; + /// /// The underlying plugin result. Used for executing actions and accessing additional properties. /// @@ -38,4 +44,13 @@ public partial class ResultViewModel : ObservableObject /// Gets the query suggestion text for autocomplete, if available. /// public string? QuerySuggestionText => PluginResult?.AutoCompleteText; + + // Cached task for the image - created once per IconPath + private Task? _imageTask; + + /// + /// The icon image task. Use with Avalonia's ^ stream binding operator. + /// Returns a cached task to avoid re-loading on every property access. + /// + public Task Image => _imageTask ??= ImageLoader.LoadAsync(IconPath); } diff --git a/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs index 1a006f3633c..1880a652cbe 100644 --- a/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs @@ -1,19 +1,23 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; +using DynamicData; +using DynamicData.Binding; using Flow.Launcher.Infrastructure.UserSettings; namespace Flow.Launcher.Avalonia.ViewModel; /// /// ViewModel for the results list. +/// Uses DynamicData SourceList for automatic sorting by score. /// -public partial class ResultsViewModel : ObservableObject +public partial class ResultsViewModel : ObservableObject, IDisposable { private readonly Settings _settings; - - [ObservableProperty] - private ObservableCollection _results = new(); + private readonly SourceList _sourceList = new(); + private readonly ReadOnlyObservableCollection _results; + private readonly IDisposable _subscription; [ObservableProperty] private ResultViewModel? _selectedItem; @@ -24,6 +28,12 @@ public partial class ResultsViewModel : ObservableObject [ObservableProperty] private bool _isVisible = true; + /// + /// Sorted results collection bound to the UI. + /// Automatically sorted by Score descending. + /// + public ReadOnlyObservableCollection Results => _results; + public Settings Settings => _settings; public int MaxHeight => (int)(_settings.MaxResultsToShow * _settings.ItemHeightSize); @@ -31,61 +41,120 @@ public partial class ResultsViewModel : ObservableObject public ResultsViewModel(Settings settings) { _settings = settings; + + // Connect SourceList to sorted ReadOnlyObservableCollection + _subscription = _sourceList.Connect() + .Sort(SortExpressionComparer.Descending(r => r.Score)) + .Bind(out _results) + .Subscribe(); + } + + /// + /// Replace all results with new ones using EditDiff to minimize UI updates. + /// Items with matching Title+SubTitle are kept, reducing flickering. + /// + public void ReplaceResults(IEnumerable newResults) + { + foreach (var r in newResults) + { + r.Settings = _settings; + } + + // EditDiff calculates minimal changes needed + _sourceList.EditDiff(newResults, ResultViewModelComparer.Instance); + + // Select first item after replacement + if (_results.Count > 0) + { + SelectedIndex = 0; + SelectedItem = _results[0]; + } + else + { + SelectedItem = null; + SelectedIndex = -1; + } } public void AddResult(ResultViewModel result) { result.Settings = _settings; - Results.Add(result); - + _sourceList.Add(result); + // Select first item if nothing selected - if (SelectedItem == null && Results.Count > 0) + if (SelectedItem == null && _results.Count > 0) { SelectedIndex = 0; - SelectedItem = Results[0]; + SelectedItem = _results[0]; } } public void Clear() { - Results.Clear(); + _sourceList.Clear(); SelectedItem = null; SelectedIndex = -1; } public void SelectNextItem() { - if (Results.Count == 0) return; - + if (_results.Count == 0) return; + var newIndex = SelectedIndex + 1; - if (newIndex >= Results.Count) + if (newIndex >= _results.Count) { newIndex = 0; // Wrap to beginning } - + SelectedIndex = newIndex; - SelectedItem = Results[newIndex]; + SelectedItem = _results[newIndex]; } public void SelectPrevItem() { - if (Results.Count == 0) return; - + if (_results.Count == 0) return; + var newIndex = SelectedIndex - 1; if (newIndex < 0) { - newIndex = Results.Count - 1; // Wrap to end + newIndex = _results.Count - 1; // Wrap to end } - + SelectedIndex = newIndex; - SelectedItem = Results[newIndex]; + SelectedItem = _results[newIndex]; } partial void OnSelectedIndexChanged(int value) { - if (value >= 0 && value < Results.Count) + if (value >= 0 && value < _results.Count) + { + SelectedItem = _results[value]; + } + } + + public void Dispose() + { + _subscription.Dispose(); + _sourceList.Dispose(); + } + + /// + /// Comparer for EditDiff - considers results equal if Title and SubTitle match. + /// + private class ResultViewModelComparer : IEqualityComparer + { + public static readonly ResultViewModelComparer Instance = new(); + + public bool Equals(ResultViewModel? x, ResultViewModel? y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + return x.Title == y.Title && x.SubTitle == y.SubTitle; + } + + public int GetHashCode(ResultViewModel obj) { - SelectedItem = Results[value]; + return HashCode.Combine(obj.Title, obj.SubTitle); } } } diff --git a/Flow.Launcher.Avalonia/Views/ResultListBox.axaml b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml index 144fc8e1844..e643cec4d2a 100644 --- a/Flow.Launcher.Avalonia/Views/ResultListBox.axaml +++ b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml @@ -3,6 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel" + xmlns:helper="using:Flow.Launcher.Avalonia.Helper" mc:Ignorable="d" d:DesignWidth="580" d:DesignHeight="300" x:Class="Flow.Launcher.Avalonia.Views.ResultListBox" x:DataType="vm:ResultsViewModel"> @@ -17,6 +18,13 @@ ScrollViewer.HorizontalScrollBarVisibility="Disabled" ScrollViewer.VerticalScrollBarVisibility="Auto"> + + + + + + + @@ -29,10 +37,10 @@ - + From 2466b907c4a76bb836bca0aa968c52ea315232ee Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Thu, 15 Jan 2026 00:30:46 -0800 Subject: [PATCH 06/43] Fix UI blocking by running plugin queries on thread pool - Wrap each plugin query in Task.Run() to ensure synchronous plugin code doesn't block the UI thread - Show results progressively as each plugin completes using ConcurrentBag - Update UI after each plugin returns instead of waiting for all plugins --- .../ViewModel/MainViewModel.cs | 102 ++++++++++++------ 1 file changed, 67 insertions(+), 35 deletions(-) diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs index c87df5c3ec3..a88f4b41efe 100644 --- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -131,60 +132,91 @@ private async Task QueryAsync() if (plugins.Count == 0) { HasResults = false; return; } - // Query all plugins in parallel and collect results - var tasks = plugins.Select(p => QueryPluginAsync(p, query, token)); - var pluginResults = await Task.WhenAll(tasks); + // Use a thread-safe collection to accumulate results from all plugins + var allResults = new ConcurrentBag(); - if (token.IsCancellationRequested) return; + // Query all plugins in parallel - results shown progressively as each completes + var tasks = plugins.Select(async plugin => + { + var pluginResults = await QueryPluginAsync(plugin, query, token); + if (token.IsCancellationRequested) return; - // Flatten, sort by score, take top N, and replace all at once - var allResults = pluginResults - .SelectMany(r => r) - .OrderByDescending(r => r.Score) - .Take(_settings.MaxResultsToShow) - .ToList(); + // Add results to the bag + foreach (var r in pluginResults) + { + allResults.Add(r); + } - // Replace results with minimal UI updates (EditDiff) - await global::Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => - { - Results.ReplaceResults(allResults); - HasResults = Results.Results.Count > 0; + // Update UI with current accumulated results (progressive update) + if (!token.IsCancellationRequested) + { + await UpdateResultsOnUIThread(allResults, token); + } }); + + await Task.WhenAll(tasks); + + // Final update after all plugins complete + if (!token.IsCancellationRequested) + { + await UpdateResultsOnUIThread(allResults, token); + } } catch (OperationCanceledException) { } catch (Exception e) { Log.Exception(ClassName, "Query error", e); } finally { if (!token.IsCancellationRequested) IsQueryRunning = false; } } - private async Task> QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token) + private async Task UpdateResultsOnUIThread(ConcurrentBag allResults, CancellationToken token) { - var resultList = new List(); + if (token.IsCancellationRequested) return; - try + var sortedResults = allResults + .OrderByDescending(r => r.Score) + .Take(_settings.MaxResultsToShow) + .ToList(); + + await global::Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { - var delay = plugin.Metadata.SearchDelayTime ?? _settings.SearchDelayTime; - if (delay > 0) await Task.Delay(delay, token); - if (token.IsCancellationRequested) return resultList; + if (token.IsCancellationRequested) return; + Results.ReplaceResults(sortedResults); + HasResults = Results.Results.Count > 0; + }); + } - var results = await PluginManager.QueryForPluginAsync(plugin, query, token); - if (token.IsCancellationRequested || results == null || results.Count == 0) return resultList; + private Task> QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token) + { + // Run entirely on thread pool to avoid blocking UI if plugin has synchronous code + return Task.Run(async () => + { + var resultList = new List(); - foreach (var r in results) + try { - resultList.Add(new ResultViewModel + var delay = plugin.Metadata.SearchDelayTime ?? _settings.SearchDelayTime; + if (delay > 0) await Task.Delay(delay, token); + if (token.IsCancellationRequested) return resultList; + + var results = await PluginManager.QueryForPluginAsync(plugin, query, token); + if (token.IsCancellationRequested || results == null || results.Count == 0) return resultList; + + foreach (var r in results) { - Title = r.Title ?? "", - SubTitle = r.SubTitle ?? "", - IconPath = r.IcoPath ?? plugin.Metadata.IcoPath ?? "", - Score = r.Score, - PluginResult = r - }); + resultList.Add(new ResultViewModel + { + Title = r.Title ?? "", + SubTitle = r.SubTitle ?? "", + IconPath = r.IcoPath ?? plugin.Metadata.IcoPath ?? "", + Score = r.Score, + PluginResult = r + }); + } } - } - catch (OperationCanceledException) { } - catch (Exception e) { Log.Exception(ClassName, $"Plugin {plugin.Metadata.Name} error", e); } + catch (OperationCanceledException) { } + catch (Exception e) { Log.Exception(ClassName, $"Plugin {plugin.Metadata.Name} error", e); } - return resultList; + return resultList; + }, token); } [RelayCommand] From 192eb4d1c035eb318ba3119c1d27d379cff9d818 Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Thu, 15 Jan 2026 00:53:14 -0800 Subject: [PATCH 07/43] Add context menu support for results in Avalonia UI - Add ActiveView enum to track Results vs ContextMenu view state - Add ContextMenu ResultsViewModel and view switching logic - Implement LoadContextMenuCommand using PluginManager.GetContextMenusForPlugin - Add keyboard navigation: Shift+Enter/Right to open, Left/Escape to close - Update SelectNextItem/SelectPrevItem to navigate appropriate list - Update EscCommand to return from context menu before hiding --- Flow.Launcher.Avalonia/MainWindow.axaml | 15 +- Flow.Launcher.Avalonia/MainWindow.axaml.cs | 31 +++- .../ViewModel/MainViewModel.cs | 140 +++++++++++++++++- 3 files changed, 176 insertions(+), 10 deletions(-) diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml b/Flow.Launcher.Avalonia/MainWindow.axaml index 42e18db4dc8..5e603fe4473 100644 --- a/Flow.Launcher.Avalonia/MainWindow.axaml +++ b/Flow.Launcher.Avalonia/MainWindow.axaml @@ -34,6 +34,7 @@ + @@ -78,7 +79,7 @@ + IsVisible="{Binding ShowResultsArea}" /> @@ -89,12 +90,20 @@ - - + + + + + + + = (_viewModel.QueryText?.Length ?? 0)) + { + _viewModel.LoadContextMenuCommand.Execute(null); + e.Handled = true; + return; + } + } + + // Handle Left Arrow to go back from context menu + if (e.Key == Key.Left && _viewModel != null && _viewModel.IsContextMenuViewActive) + { + _viewModel.BackToResultsCommand.Execute(null); + e.Handled = true; + return; } } diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs index a88f4b41efe..be9c861707f 100644 --- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -13,6 +13,15 @@ namespace Flow.Launcher.Avalonia.ViewModel; +/// +/// Represents which view is currently active. +/// +public enum ActiveView +{ + Results, + ContextMenu +} + /// /// MainViewModel for Avalonia - minimal implementation for plugin queries. /// @@ -41,12 +50,46 @@ public partial class MainViewModel : ObservableObject [ObservableProperty] private ResultsViewModel _results; + [ObservableProperty] + private ResultsViewModel _contextMenu; + + [ObservableProperty] + private ActiveView _activeView = ActiveView.Results; + + /// + /// Whether the results view is currently active. + /// + public bool IsResultsViewActive => ActiveView == ActiveView.Results; + + /// + /// Whether the context menu view is currently active. + /// + public bool IsContextMenuViewActive => ActiveView == ActiveView.ContextMenu; + + /// + /// Whether to show the results/context menu area (separator + list). + /// + public bool ShowResultsArea => HasResults || IsContextMenuViewActive; + public Settings Settings => _settings; public MainViewModel(Settings settings) { _settings = settings; _results = new ResultsViewModel(settings); + _contextMenu = new ResultsViewModel(settings); + } + + partial void OnActiveViewChanged(ActiveView value) + { + OnPropertyChanged(nameof(IsResultsViewActive)); + OnPropertyChanged(nameof(IsContextMenuViewActive)); + OnPropertyChanged(nameof(ShowResultsArea)); + } + + partial void OnHasResultsChanged(bool value) + { + OnPropertyChanged(nameof(ShowResultsArea)); } public void OnPluginsReady() @@ -92,10 +135,22 @@ public void Hide() { MainWindowVisibility = false; QueryText = ""; + ActiveView = ActiveView.Results; + ContextMenu.Clear(); HideRequested?.Invoke(); Log.Info(ClassName, "Hide requested"); } + /// + /// Go back from context menu to results view. + /// + [RelayCommand] + private void BackToResults() + { + ActiveView = ActiveView.Results; + ContextMenu.Clear(); + } + partial void OnQueryTextChanged(string value) => _ = QueryAsync(); private async Task QueryAsync() @@ -220,21 +275,96 @@ private Task> QueryPluginAsync(PluginPair plugin, Query qu } [RelayCommand] - private void Esc() { Hide(); } + private void Esc() + { + // If in context menu, go back to results; otherwise hide window + if (ActiveView == ActiveView.ContextMenu) + { + BackToResults(); + } + else + { + Hide(); + } + } [RelayCommand] private async Task OpenResultAsync() { - var result = Results.SelectedItem?.PluginResult; + Result? result; + if (ActiveView == ActiveView.ContextMenu) + { + result = ContextMenu.SelectedItem?.PluginResult; + } + else + { + result = Results.SelectedItem?.PluginResult; + } + if (result == null) return; + try { if (await result.ExecuteAsync(new ActionContext { SpecialKeyState = SpecialKeyState.Default })) - HideRequested?.Invoke(); + { + Hide(); + } + else if (ActiveView == ActiveView.ContextMenu) + { + // If context menu action didn't hide, go back to results + BackToResults(); + } } catch (Exception e) { Log.Exception(ClassName, "Execute error", e); } } - [RelayCommand] private void SelectNextItem() => Results.SelectNextItem(); - [RelayCommand] private void SelectPrevItem() => Results.SelectPrevItem(); + /// + /// Load context menu for the currently selected result. + /// + [RelayCommand] + private void LoadContextMenu() + { + var selectedResult = Results.SelectedItem?.PluginResult; + if (selectedResult == null) return; + + try + { + var contextMenuResults = PluginManager.GetContextMenusForPlugin(selectedResult); + if (contextMenuResults == null || contextMenuResults.Count == 0) return; + + var contextMenuItems = contextMenuResults.Select(r => new ResultViewModel + { + Title = r.Title ?? "", + SubTitle = r.SubTitle ?? "", + IconPath = r.IcoPath ?? "", + Score = r.Score, + PluginResult = r + }).ToList(); + + ContextMenu.ReplaceResults(contextMenuItems); + ActiveView = ActiveView.ContextMenu; + } + catch (Exception e) + { + Log.Exception(ClassName, "Failed to load context menu", e); + } + } + + [RelayCommand] + private void SelectNextItem() + { + if (ActiveView == ActiveView.ContextMenu) + ContextMenu.SelectNextItem(); + else + Results.SelectNextItem(); + } + + [RelayCommand] + private void SelectPrevItem() + { + if (ActiveView == ActiveView.ContextMenu) + ContextMenu.SelectPrevItem(); + else + Results.SelectPrevItem(); + } } From 36e3530a59a4273fecb3e02b82f53d508f97a623 Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Thu, 15 Jan 2026 01:10:53 -0800 Subject: [PATCH 08/43] Add internationalization support for Avalonia UI - Create Internationalization service that parses WPF XAML language files - Load translations from main Languages/ folder and all plugin Languages/ folders - Add LocalizeExtension markup extension and Translator helper for XAML/code - Fix IPublicAPI.GetTranslation to use the i18n service for plugin context menus - Update MainWindow to use localized placeholder text --- Flow.Launcher.Avalonia/App.axaml.cs | 23 ++ Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs | 12 +- .../Converters/TranslationConverter.cs | 28 ++ Flow.Launcher.Avalonia/MainWindow.axaml | 3 +- .../Resource/Internationalization.cs | 241 ++++++++++++++++++ .../Resource/LocalizeExtension.cs | 94 +++++++ .../ViewModel/MainViewModel.cs | 1 + 7 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 Flow.Launcher.Avalonia/Converters/TranslationConverter.cs create mode 100644 Flow.Launcher.Avalonia/Resource/Internationalization.cs create mode 100644 Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs diff --git a/Flow.Launcher.Avalonia/App.axaml.cs b/Flow.Launcher.Avalonia/App.axaml.cs index 7f217c3e580..caedad3f718 100644 --- a/Flow.Launcher.Avalonia/App.axaml.cs +++ b/Flow.Launcher.Avalonia/App.axaml.cs @@ -4,6 +4,7 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Avalonia.Helper; +using Flow.Launcher.Avalonia.Resource; using Flow.Launcher.Avalonia.ViewModel; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure; @@ -22,8 +23,14 @@ public partial class App : Application private static readonly string ClassName = nameof(App); private Settings? _settings; private MainViewModel? _mainVM; + private Internationalization? _i18n; public static IPublicAPI? API { get; private set; } + + /// + /// Gets the internationalization service for translations. + /// + public static Internationalization? I18n { get; private set; } public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -32,6 +39,7 @@ public override void OnFrameworkInitializationCompleted() if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { LoadSettings(); + InitializeInternationalization(); ConfigureDI(); API = Ioc.Default.GetRequiredService(); @@ -69,6 +77,21 @@ private void LoadSettings() } } + private void InitializeInternationalization() + { + try + { + _i18n = new Internationalization(_settings!); + _i18n.Initialize(); + I18n = _i18n; + Log.Info(ClassName, "Internationalization initialized"); + } + catch (Exception e) + { + Log.Exception(ClassName, "Failed to initialize internationalization", e); + } + } + private void ConfigureDI() { var services = new ServiceCollection(); diff --git a/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs index 6b984dc69e6..da1cdc61865 100644 --- a/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs +++ b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs @@ -13,6 +13,7 @@ using Flow.Launcher.Plugin.SharedModels; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Avalonia.ViewModel; +using Flow.Launcher.Avalonia.Resource; using CommunityToolkit.Mvvm.DependencyInjection; namespace Flow.Launcher.Avalonia; @@ -38,7 +39,16 @@ public AvaloniaPublicAPI(Settings settings, Func getMainViewModel // Essential for plugins public void ChangeQuery(string query, bool requery = false) => _getMainViewModel().QueryText = query; - public string GetTranslation(string key) => key; + + public string GetTranslation(string key) + { + var i18n = App.I18n; + if (i18n == null) + return key; + + return i18n.GetTranslation(key); + } + public List GetAllPlugins() => PluginManager.AllPlugins; public MatchResult FuzzySearch(string query, string stringToCompare) => Ioc.Default.GetRequiredService().FuzzyMatch(query, stringToCompare); diff --git a/Flow.Launcher.Avalonia/Converters/TranslationConverter.cs b/Flow.Launcher.Avalonia/Converters/TranslationConverter.cs new file mode 100644 index 00000000000..dd24076af9c --- /dev/null +++ b/Flow.Launcher.Avalonia/Converters/TranslationConverter.cs @@ -0,0 +1,28 @@ +using Avalonia.Data.Converters; +using System; +using System.Globalization; + +namespace Flow.Launcher.Avalonia.Resource; + +/// +/// Converter to translate a key to its localized string in XAML bindings. +/// Usage: Text="{Binding Key, Converter={StaticResource TranslationConverter}}" +/// Or simpler: Use the Translator.GetString(key) helper from code-behind +/// +public class TranslationConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo? culture) + { + if (value is string key && !string.IsNullOrEmpty(key)) + { + return Translator.GetString(key); + } + + return parameter?.ToString() ?? "[No Translation]"; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo? culture) + { + throw new NotSupportedException(); + } +} diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml b/Flow.Launcher.Avalonia/MainWindow.axaml index 5e603fe4473..1d8a6ef321a 100644 --- a/Flow.Launcher.Avalonia/MainWindow.axaml +++ b/Flow.Launcher.Avalonia/MainWindow.axaml @@ -6,6 +6,7 @@ xmlns:views="using:Flow.Launcher.Avalonia.Views" xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel" xmlns:converters="using:Flow.Launcher.Avalonia.Converters" + xmlns:i18n="using:Flow.Launcher.Avalonia.Resource" mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="400" x:Class="Flow.Launcher.Avalonia.MainWindow" x:DataType="vm:MainViewModel" @@ -50,7 +51,7 @@ diff --git a/Flow.Launcher.Avalonia/Resource/Internationalization.cs b/Flow.Launcher.Avalonia/Resource/Internationalization.cs new file mode 100644 index 00000000000..3c71969fb76 --- /dev/null +++ b/Flow.Launcher.Avalonia/Resource/Internationalization.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Xml.Linq; +using Avalonia; +using Avalonia.Controls; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.UserSettings; + +namespace Flow.Launcher.Avalonia.Resource; + +/// +/// Internationalization service for Avalonia that parses WPF XAML language files. +/// +public class Internationalization +{ + private static readonly string ClassName = nameof(Internationalization); + private const string LanguagesFolder = "Languages"; + private const string DefaultLanguageCode = "en"; + private const string Extension = ".xaml"; + + private readonly Settings _settings; + private readonly Dictionary _translations = new(); + private readonly List _languageDirectories = []; + + // WPF XAML namespace for system:String + private static readonly XNamespace SystemNs = "clr-namespace:System;assembly=mscorlib"; + private static readonly XNamespace XNs = "http://schemas.microsoft.com/winfx/2006/xaml"; + + public Internationalization(Settings settings) + { + _settings = settings; + } + + /// + /// Initialize language resources based on settings. + /// + public void Initialize() + { + try + { + // Add Flow Launcher language directory + AddFlowLauncherLanguageDirectory(); + + // Add plugin language directories + AddPluginLanguageDirectories(); + + // Load English as base/fallback + LoadLanguageFile(DefaultLanguageCode); + + // Load the configured language on top if different from English + var languageCode = GetActualLanguageCode(); + if (!string.Equals(languageCode, DefaultLanguageCode, StringComparison.OrdinalIgnoreCase)) + { + LoadLanguageFile(languageCode); + } + + // Update culture info + ChangeCultureInfo(languageCode); + + Log.Info(ClassName, $"Loaded {_translations.Count} translations for language '{languageCode}'"); + } + catch (Exception e) + { + Log.Exception(ClassName, "Failed to initialize internationalization", e); + } + } + + private string GetActualLanguageCode() + { + var languageCode = _settings.Language; + + // Handle "system" language setting + if (languageCode == Constant.SystemLanguageCode) + { + languageCode = CultureInfo.CurrentCulture.TwoLetterISOLanguageName; + } + + return languageCode ?? DefaultLanguageCode; + } + + private void AddFlowLauncherLanguageDirectory() + { + var directory = Path.Combine(Constant.ProgramDirectory, LanguagesFolder); + if (Directory.Exists(directory)) + { + _languageDirectories.Add(directory); + Log.Debug(ClassName, $"Added language directory: {directory}"); + } + else + { + Log.Warn(ClassName, $"Language directory not found: {directory}"); + } + } + + private void AddPluginLanguageDirectories() + { + // Add plugin language directories (similar to WPF version) + var pluginsDir = Path.Combine(Constant.ProgramDirectory, "Plugins"); + if (!Directory.Exists(pluginsDir)) return; + + foreach (var dir in Directory.GetDirectories(pluginsDir)) + { + var pluginLanguageDir = Path.Combine(dir, LanguagesFolder); + if (Directory.Exists(pluginLanguageDir)) + { + _languageDirectories.Add(pluginLanguageDir); + Log.Debug(ClassName, $"Added plugin language directory: {pluginLanguageDir}"); + } + } + } + + private void LoadLanguageFile(string languageCode) + { + var filename = $"{languageCode}{Extension}"; + + foreach (var dir in _languageDirectories) + { + var filePath = Path.Combine(dir, filename); + if (!File.Exists(filePath)) + { + // Try fallback to English if specific language not found + if (!string.Equals(languageCode, DefaultLanguageCode, StringComparison.OrdinalIgnoreCase)) + { + filePath = Path.Combine(dir, $"{DefaultLanguageCode}{Extension}"); + } + + if (!File.Exists(filePath)) + { + continue; + } + } + + try + { + ParseWpfXamlFile(filePath); + } + catch (Exception e) + { + Log.Exception(ClassName, $"Failed to parse language file: {filePath}", e); + } + } + } + + /// + /// Parse a WPF XAML ResourceDictionary file and extract string resources. + /// + private void ParseWpfXamlFile(string filePath) + { + var doc = XDocument.Load(filePath); + var root = doc.Root; + if (root == null) return; + + var count = 0; + // Find all system:String elements - WPF XAML uses clr-namespace:System;assembly=mscorlib + foreach (var element in root.Descendants()) + { + // Check if this is a system:String element (namespace doesn't matter, just check local name) + if (element.Name.LocalName == "String") + { + // Get the x:Key attribute + var keyAttr = element.Attribute(XNs + "Key"); + if (keyAttr != null) + { + var key = keyAttr.Value; + var value = element.Value; + _translations[key] = value; + count++; + } + } + } + + Log.Debug(ClassName, $"Parsed {count} strings from {filePath}"); + } + + /// + /// Get a translated string by key. + /// + public string GetTranslation(string key) + { + if (_translations.TryGetValue(key, out var translation)) + { + Log.Debug(ClassName, $"Translation found for '{key}': '{translation}'"); + return translation; + } + + Log.Warn(ClassName, $"Translation not found for key: {key}"); + Log.Debug(ClassName, $"Available keys (first 20): {string.Join(", ", _translations.Keys.Take(20))}"); + return $"[{key}]"; + } + + /// + /// Check if a translation exists for the given key. + /// + public bool HasTranslation(string key) => _translations.ContainsKey(key); + + /// + /// Get all available translations (for debugging). + /// + public IReadOnlyDictionary GetAllTranslations() => _translations; + + private static void ChangeCultureInfo(string languageCode) + { + try + { + var culture = CultureInfo.CreateSpecificCulture(languageCode); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + Thread.CurrentThread.CurrentCulture = culture; + Thread.CurrentThread.CurrentUICulture = culture; + } + catch (CultureNotFoundException) + { + Log.Warn(ClassName, $"Culture not found for language code: {languageCode}"); + } + } + + /// + /// Change language at runtime. + /// + public void ChangeLanguage(string languageCode) + { + _translations.Clear(); + + // Reload English as base + LoadLanguageFile(DefaultLanguageCode); + + // Load new language on top + if (!string.Equals(languageCode, DefaultLanguageCode, StringComparison.OrdinalIgnoreCase)) + { + LoadLanguageFile(languageCode); + } + + ChangeCultureInfo(languageCode); + Log.Info(ClassName, $"Language changed to: {languageCode}"); + } +} diff --git a/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs b/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs new file mode 100644 index 00000000000..9b6c673be76 --- /dev/null +++ b/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs @@ -0,0 +1,94 @@ +using Avalonia.Data; +using Avalonia.Markup.Xaml; +using Avalonia.Markup.Xaml.MarkupExtensions; +using System; + +namespace Flow.Launcher.Avalonia.Resource; + +/// +/// Markup extension for accessing localized strings in XAML. +/// Usage: Text="{i18n:Localize queryTextBoxPlaceholder}" +/// +public class LocalizeExtension : MarkupExtension +{ + public LocalizeExtension() + { + } + + public LocalizeExtension(string key) + { + Key = key; + } + + /// + /// The translation key to look up. + /// + public string Key { get; set; } = string.Empty; + + /// + /// Fallback value if translation is not found. + /// + public string? Fallback { get; set; } + + public override object ProvideValue(IServiceProvider serviceProvider) + { + if (string.IsNullOrEmpty(Key)) + { + return Fallback ?? "[No Key]"; + } + + var i18n = App.I18n; + if (i18n == null) + { + return Fallback ?? $"[{Key}]"; + } + + if (i18n.HasTranslation(Key)) + { + return i18n.GetTranslation(Key); + } + + return Fallback ?? $"[{Key}]"; + } +} + +/// +/// Static helper class for accessing translations from code-behind. +/// +public static class Translator +{ + /// + /// Get a translated string by key. + /// + /// The translation key + /// The translated string or the key in brackets if not found + public static string GetString(string key) + { + var i18n = App.I18n; + if (i18n == null) + { + return $"[{key}]"; + } + + return i18n.GetTranslation(key); + } + + /// + /// Get a translated string with format arguments. + /// + /// The translation key + /// Format arguments + /// The formatted translated string + public static string GetString(string key, params object[] args) + { + var template = GetString(key); + try + { + return string.Format(template, args); + } + catch + { + return template; + } + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs index be9c861707f..898c2a019d3 100644 --- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Avalonia.Resource; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; From 5d80816f841be2806c7cd3372b00de008254c7dc Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Thu, 15 Jan 2026 01:17:29 -0800 Subject: [PATCH 09/43] Refactor i18n to be self-initializing via DI - Internationalization.Initialize() called in constructor when DI creates it - Remove InitializeInternationalization() method from App.axaml.cs - Update AvaloniaPublicAPI and LocalizeExtension to use Ioc.Default.GetService - Reorder ConfigureDI before i18n initialization --- Flow.Launcher.Avalonia/App.axaml.cs | 26 +++---------------- Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs | 13 +++------- .../Resource/Internationalization.cs | 1 + .../Resource/LocalizeExtension.cs | 5 ++-- 4 files changed, 11 insertions(+), 34 deletions(-) diff --git a/Flow.Launcher.Avalonia/App.axaml.cs b/Flow.Launcher.Avalonia/App.axaml.cs index caedad3f718..fae4c6a6466 100644 --- a/Flow.Launcher.Avalonia/App.axaml.cs +++ b/Flow.Launcher.Avalonia/App.axaml.cs @@ -23,14 +23,8 @@ public partial class App : Application private static readonly string ClassName = nameof(App); private Settings? _settings; private MainViewModel? _mainVM; - private Internationalization? _i18n; public static IPublicAPI? API { get; private set; } - - /// - /// Gets the internationalization service for translations. - /// - public static Internationalization? I18n { get; private set; } public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -39,7 +33,6 @@ public override void OnFrameworkInitializationCompleted() if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { LoadSettings(); - InitializeInternationalization(); ConfigureDI(); API = Ioc.Default.GetRequiredService(); @@ -77,31 +70,18 @@ private void LoadSettings() } } - private void InitializeInternationalization() - { - try - { - _i18n = new Internationalization(_settings!); - _i18n.Initialize(); - I18n = _i18n; - Log.Info(ClassName, "Internationalization initialized"); - } - catch (Exception e) - { - Log.Exception(ClassName, "Failed to initialize internationalization", e); - } - } - private void ConfigureDI() { var services = new ServiceCollection(); services.AddSingleton(_settings!); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => new AvaloniaPublicAPI( sp.GetRequiredService(), - () => sp.GetRequiredService())); + () => sp.GetRequiredService(), + sp.GetRequiredService())); Ioc.Default.ConfigureServices(services.BuildServiceProvider()); } diff --git a/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs index da1cdc61865..deac2575cb6 100644 --- a/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs +++ b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs @@ -25,11 +25,13 @@ public class AvaloniaPublicAPI : IPublicAPI { private readonly Settings _settings; private readonly Func _getMainViewModel; + private readonly Internationalization _i18n; - public AvaloniaPublicAPI(Settings settings, Func getMainViewModel) + public AvaloniaPublicAPI(Settings settings, Func getMainViewModel, Internationalization i18n) { _settings = settings; _getMainViewModel = getMainViewModel; + _i18n = i18n; } #pragma warning disable CS0067 @@ -40,14 +42,7 @@ public AvaloniaPublicAPI(Settings settings, Func getMainViewModel // Essential for plugins public void ChangeQuery(string query, bool requery = false) => _getMainViewModel().QueryText = query; - public string GetTranslation(string key) - { - var i18n = App.I18n; - if (i18n == null) - return key; - - return i18n.GetTranslation(key); - } + public string GetTranslation(string key) => _i18n.GetTranslation(key); public List GetAllPlugins() => PluginManager.AllPlugins; public MatchResult FuzzySearch(string query, string stringToCompare) => diff --git a/Flow.Launcher.Avalonia/Resource/Internationalization.cs b/Flow.Launcher.Avalonia/Resource/Internationalization.cs index 3c71969fb76..b5cc19ecdd2 100644 --- a/Flow.Launcher.Avalonia/Resource/Internationalization.cs +++ b/Flow.Launcher.Avalonia/Resource/Internationalization.cs @@ -34,6 +34,7 @@ public class Internationalization public Internationalization(Settings settings) { _settings = settings; + Initialize(); } /// diff --git a/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs b/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs index 9b6c673be76..e88ec87e226 100644 --- a/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs +++ b/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs @@ -1,6 +1,7 @@ using Avalonia.Data; using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml.MarkupExtensions; +using CommunityToolkit.Mvvm.DependencyInjection; using System; namespace Flow.Launcher.Avalonia.Resource; @@ -37,7 +38,7 @@ public override object ProvideValue(IServiceProvider serviceProvider) return Fallback ?? "[No Key]"; } - var i18n = App.I18n; + var i18n = Ioc.Default.GetService(); if (i18n == null) { return Fallback ?? $"[{Key}]"; @@ -64,7 +65,7 @@ public static class Translator /// The translated string or the key in brackets if not found public static string GetString(string key) { - var i18n = App.I18n; + var i18n = Ioc.Default.GetService(); if (i18n == null) { return $"[{key}]"; From c68a03e1936ca1a438a4e046980cf44648a8ef80 Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Thu, 15 Jan 2026 23:31:30 -0800 Subject: [PATCH 10/43] Delay window show until plugins are initialized - MainWindowVisibility starts as false (window hidden) - Window IsVisible bound to MainWindowVisibility - Set MainWindowVisibility = true in OnPluginsReady() after plugins load --- Flow.Launcher.Avalonia/MainWindow.axaml | 3 ++- Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml b/Flow.Launcher.Avalonia/MainWindow.axaml index 1d8a6ef321a..0175648714d 100644 --- a/Flow.Launcher.Avalonia/MainWindow.axaml +++ b/Flow.Launcher.Avalonia/MainWindow.axaml @@ -24,7 +24,8 @@ Background="Transparent" ExtendClientAreaToDecorationsHint="True" ExtendClientAreaChromeHints="NoChrome" - SizeToContent="Height"> + SizeToContent="Height" + IsVisible="{Binding MainWindowVisibility, Mode=TwoWay}"> diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs index 898c2a019d3..0ef2bd57fa9 100644 --- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -37,7 +37,7 @@ public partial class MainViewModel : ObservableObject public event Action? ShowRequested; [ObservableProperty] - private bool _mainWindowVisibility = true; + private bool _mainWindowVisibility = false; [ObservableProperty] private string _queryText = string.Empty; @@ -96,7 +96,8 @@ partial void OnHasResultsChanged(bool value) public void OnPluginsReady() { _pluginsReady = true; - Log.Info(ClassName, "Plugins ready"); + MainWindowVisibility = true; + Log.Info(ClassName, "Plugins ready - window shown"); if (!string.IsNullOrWhiteSpace(QueryText)) _ = QueryAsync(); } From ed668f371687b57c0eb7f1f38f1857447630d596 Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Thu, 15 Jan 2026 23:41:11 -0800 Subject: [PATCH 11/43] Add glyph icon support for results - Add Glyph and GlyphAvailable properties to ResultViewModel - Set Glyph from plugin Result when creating result items - Add resultGlyph style matching icon size (32x32) - Update ResultListBox to show glyph or image icon based on ShowGlyph property - ShowGlyph = true when UseGlyphIcons is enabled AND glyph is available --- Flow.Launcher.Avalonia/Themes/Base.axaml | 11 ++++++++++ .../ViewModel/MainViewModel.cs | 6 ++++-- .../ViewModel/ResultViewModel.cs | 21 +++++++++++++++++++ .../Views/ResultListBox.axaml | 17 ++++++++++----- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/Flow.Launcher.Avalonia/Themes/Base.axaml b/Flow.Launcher.Avalonia/Themes/Base.axaml index 5f1845f9602..c73dbd221e8 100644 --- a/Flow.Launcher.Avalonia/Themes/Base.axaml +++ b/Flow.Launcher.Avalonia/Themes/Base.axaml @@ -148,6 +148,17 @@ + + + diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs index 0b35f37bfff..df7e8348318 100644 --- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -7,6 +7,7 @@ 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 MainViewModel(Settings settings) _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 @@ partial void OnActiveViewChanged(ActiveView value) 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 @@ private void Esc() } } + [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 00000000000..97e739b4d0b --- /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 00000000000..b718870a9a2 --- /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 00000000000..1aa20d6332e --- /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 00000000000..53c581ece8c --- /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 00000000000..ad420e1f129 --- /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 00000000000..f27ec70f667 --- /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 00000000000..26a7ee70633 --- /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 00000000000..22b3a697184 --- /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 00000000000..14b25d6a2ff --- /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 00000000000..f4c95cc51d0 --- /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 00000000000..3008771bb18 --- /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 00000000000..24bb32259f5 --- /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 376a3f31b0e..b07f98abe5e 100644 --- a/Flow.Launcher.Avalonia/Views/ResultListBox.axaml +++ b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml @@ -34,22 +34,27 @@ Classes="resultBullet" VerticalAlignment="Stretch" /> - + - - - - - + + + + + + + + + + + + + + + + + - - - - - - - + diff --git a/Flow.Launcher.Avalonia/WpfResources/CustomControlTemplate.xaml b/Flow.Launcher.Avalonia/WpfResources/CustomControlTemplate.xaml new file mode 100644 index 00000000000..37b85b97076 --- /dev/null +++ b/Flow.Launcher.Avalonia/WpfResources/CustomControlTemplate.xaml @@ -0,0 +1,6102 @@ + + + + Segoe UI + + + + + + {DynamicResource SettingWindowFont} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0,0,0,4 + 12,6,0,6 + 11,5,32,6 + 32 + + + + + + + + + + + + + + + 0,0,0,4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + - - + + + diff --git a/Flow.Launcher.Core/packages.lock.json b/Flow.Launcher.Core/packages.lock.json index b499a5860c2..763ac07c24e 100644 --- a/Flow.Launcher.Core/packages.lock.json +++ b/Flow.Launcher.Core/packages.lock.json @@ -61,6 +61,26 @@ "System.IO.Pipelines": "8.0.0" } }, + "Avalonia": { + "type": "Transitive", + "resolved": "11.2.3", + "contentHash": "pD6woFAUfGcyEvMmrpctntU4jv4fT8752pfx1J5iRORVX3Ob0oQi8PWo0TXVaAJZiSfH0cdKTeKx0w0DzD0/mg==", + "dependencies": { + "Avalonia.BuildServices": "0.0.29", + "Avalonia.Remote.Protocol": "11.2.3", + "MicroCom.Runtime": "0.11.0" + } + }, + "Avalonia.BuildServices": { + "type": "Transitive", + "resolved": "0.0.29", + "contentHash": "U4eJLQdoDNHXtEba7MZUCwrBErBTxFp6sUewXBOdAhU0Kwzwaa/EKFcYm8kpcysjzKtfB4S0S9n0uxKZFz/ikw==" + }, + "Avalonia.Remote.Protocol": { + "type": "Transitive", + "resolved": "11.2.3", + "contentHash": "6V0aNtld48WmO8tAlWwlRlUmXYcOWv+1eJUSl1ETF+1blUe5yhcSmuWarPprO0hDk8Ta6wGfdfcrnVl2gITYcA==" + }, "Ben.Demystifier": { "type": "Transitive", "resolved": "0.4.1", @@ -127,6 +147,11 @@ "resolved": "2.5.192", "contentHash": "jaJuwcgovWIZ8Zysdyf3b7b34/BrADw4v82GaEZymUhDd3ScMPrYd/cttekeDteJJPXseJxp04yTIcxiVUjTWg==" }, + "MicroCom.Runtime": { + "type": "Transitive", + "resolved": "0.11.0", + "contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA==" + }, "Microsoft.NET.StringTools": { "type": "Transitive", "resolved": "17.6.3", @@ -1161,7 +1186,7 @@ "Ben.Demystifier": "[0.4.1, )", "BitFaster.Caching": "[2.5.4, )", "CommunityToolkit.Mvvm": "[8.4.0, )", - "Flow.Launcher.Plugin": "[5.0.0, )", + "Flow.Launcher.Plugin": "[5.1.0, )", "InputSimulator": "[1.0.4, )", "MemoryPack": "[1.21.4, )", "Microsoft.VisualStudio.Threading": "[17.14.15, )", @@ -1176,6 +1201,7 @@ "flow.launcher.plugin": { "type": "Project", "dependencies": { + "Avalonia": "[11.2.3, )", "JetBrains.Annotations": "[2025.2.2, )" } } diff --git a/Flow.Launcher.Infrastructure/packages.lock.json b/Flow.Launcher.Infrastructure/packages.lock.json index 47c94d5f6ab..469d48ae320 100644 --- a/Flow.Launcher.Infrastructure/packages.lock.json +++ b/Flow.Launcher.Infrastructure/packages.lock.json @@ -121,6 +121,26 @@ "resolved": "3.1.0.3", "contentHash": "VKcf8sUq/+LyY99WgLhOu7Q32ROEyR30/2xCCj9ADRi45wVC7kpXrYCf9vH1qirkmrIfpL8inoxAbrqAlfXxsQ==" }, + "Avalonia": { + "type": "Transitive", + "resolved": "11.2.3", + "contentHash": "pD6woFAUfGcyEvMmrpctntU4jv4fT8752pfx1J5iRORVX3Ob0oQi8PWo0TXVaAJZiSfH0cdKTeKx0w0DzD0/mg==", + "dependencies": { + "Avalonia.BuildServices": "0.0.29", + "Avalonia.Remote.Protocol": "11.2.3", + "MicroCom.Runtime": "0.11.0" + } + }, + "Avalonia.BuildServices": { + "type": "Transitive", + "resolved": "0.0.29", + "contentHash": "U4eJLQdoDNHXtEba7MZUCwrBErBTxFp6sUewXBOdAhU0Kwzwaa/EKFcYm8kpcysjzKtfB4S0S9n0uxKZFz/ikw==" + }, + "Avalonia.Remote.Protocol": { + "type": "Transitive", + "resolved": "11.2.3", + "contentHash": "6V0aNtld48WmO8tAlWwlRlUmXYcOWv+1eJUSl1ETF+1blUe5yhcSmuWarPprO0hDk8Ta6wGfdfcrnVl2gITYcA==" + }, "JetBrains.Annotations": { "type": "Transitive", "resolved": "2025.2.2", @@ -136,6 +156,11 @@ "resolved": "1.21.4", "contentHash": "g14EsSS85yn0lHTi0J9ivqlZMf09A2iI51fmI+0KkzIzyCbWOBWPi5mdaY7YWmXprk12aYh9u/qfWHQUYthlwg==" }, + "MicroCom.Runtime": { + "type": "Transitive", + "resolved": "0.11.0", + "contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA==" + }, "Microsoft.VisualStudio.Threading.Analyzers": { "type": "Transitive", "resolved": "17.14.15", @@ -190,6 +215,7 @@ "flow.launcher.plugin": { "type": "Project", "dependencies": { + "Avalonia": "[11.2.3, )", "JetBrains.Annotations": "[2025.2.2, )" } } diff --git a/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj b/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj index 649d59ad7b5..25135e64994 100644 --- a/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj +++ b/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj @@ -1,4 +1,4 @@ - + net9.0-windows @@ -68,6 +68,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs b/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs index f034243c3b4..5cab51c71b6 100644 --- a/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs +++ b/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs @@ -1,4 +1,5 @@ -using System.Windows.Controls; +using System.Windows.Controls; +using AvaloniaControl = Avalonia.Controls.Control; namespace Flow.Launcher.Plugin { @@ -12,5 +13,12 @@ public interface ISettingProvider /// /// Control CreateSettingPanel(); + + /// + /// Create settings panel control for Avalonia version + /// + /// + virtual AvaloniaControl CreateSettingPanelAvalonia() => null; } } + diff --git a/Flow.Launcher.Plugin/packages.lock.json b/Flow.Launcher.Plugin/packages.lock.json index 70f71f20d29..8e93f0fcaef 100644 --- a/Flow.Launcher.Plugin/packages.lock.json +++ b/Flow.Launcher.Plugin/packages.lock.json @@ -2,6 +2,17 @@ "version": 1, "dependencies": { "net9.0-windows7.0": { + "Avalonia": { + "type": "Direct", + "requested": "[11.2.3, )", + "resolved": "11.2.3", + "contentHash": "pD6woFAUfGcyEvMmrpctntU4jv4fT8752pfx1J5iRORVX3Ob0oQi8PWo0TXVaAJZiSfH0cdKTeKx0w0DzD0/mg==", + "dependencies": { + "Avalonia.BuildServices": "0.0.29", + "Avalonia.Remote.Protocol": "11.2.3", + "MicroCom.Runtime": "0.11.0" + } + }, "Fody": { "type": "Direct", "requested": "[6.9.3, )", @@ -44,6 +55,21 @@ "Fody": "6.6.4" } }, + "Avalonia.BuildServices": { + "type": "Transitive", + "resolved": "0.0.29", + "contentHash": "U4eJLQdoDNHXtEba7MZUCwrBErBTxFp6sUewXBOdAhU0Kwzwaa/EKFcYm8kpcysjzKtfB4S0S9n0uxKZFz/ikw==" + }, + "Avalonia.Remote.Protocol": { + "type": "Transitive", + "resolved": "11.2.3", + "contentHash": "6V0aNtld48WmO8tAlWwlRlUmXYcOWv+1eJUSl1ETF+1blUe5yhcSmuWarPprO0hDk8Ta6wGfdfcrnVl2gITYcA==" + }, + "MicroCom.Runtime": { + "type": "Transitive", + "resolved": "0.11.0", + "contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA==" + }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", "resolved": "8.0.0", diff --git a/Flow.Launcher/packages.lock.json b/Flow.Launcher/packages.lock.json index 2c685ea09e4..be2e9a195d3 100644 --- a/Flow.Launcher/packages.lock.json +++ b/Flow.Launcher/packages.lock.json @@ -163,6 +163,26 @@ "resolved": "6.3.0.90", "contentHash": "WVTb5MxwGqKdeasd3nG5udlV4t6OpvkFanziwI133K0/QJ5FvZmfzRQgpAjGTJhQfIA8GP7AzKQ3sTY9JOFk8Q==" }, + "Avalonia": { + "type": "Transitive", + "resolved": "11.2.3", + "contentHash": "pD6woFAUfGcyEvMmrpctntU4jv4fT8752pfx1J5iRORVX3Ob0oQi8PWo0TXVaAJZiSfH0cdKTeKx0w0DzD0/mg==", + "dependencies": { + "Avalonia.BuildServices": "0.0.29", + "Avalonia.Remote.Protocol": "11.2.3", + "MicroCom.Runtime": "0.11.0" + } + }, + "Avalonia.BuildServices": { + "type": "Transitive", + "resolved": "0.0.29", + "contentHash": "U4eJLQdoDNHXtEba7MZUCwrBErBTxFp6sUewXBOdAhU0Kwzwaa/EKFcYm8kpcysjzKtfB4S0S9n0uxKZFz/ikw==" + }, + "Avalonia.Remote.Protocol": { + "type": "Transitive", + "resolved": "11.2.3", + "contentHash": "6V0aNtld48WmO8tAlWwlRlUmXYcOWv+1eJUSl1ETF+1blUe5yhcSmuWarPprO0hDk8Ta6wGfdfcrnVl2gITYcA==" + }, "Ben.Demystifier": { "type": "Transitive", "resolved": "0.4.1", @@ -252,6 +272,11 @@ "resolved": "3.4.5", "contentHash": "R/d2VSdbRQHDWfPf5sjvMOWrWvxh/CswdMDKODppHTjsDLIkLQbQrnjmtAaVqu0qgUf8KFlVzEfxy3GIVoCK9g==" }, + "MicroCom.Runtime": { + "type": "Transitive", + "resolved": "0.11.0", + "contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA==" + }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "9.0.9", @@ -1599,7 +1624,7 @@ "Droplex": "[1.7.0, )", "FSharp.Core": "[9.0.303, )", "Flow.Launcher.Infrastructure": "[1.0.0, )", - "Flow.Launcher.Plugin": "[5.0.0, )", + "Flow.Launcher.Plugin": "[5.1.0, )", "Meziantou.Framework.Win32.Jobs": "[3.4.5, )", "Microsoft.IO.RecyclableMemoryStream": "[3.0.1, )", "SemanticVersioning": "[3.0.0, )", @@ -1613,7 +1638,7 @@ "Ben.Demystifier": "[0.4.1, )", "BitFaster.Caching": "[2.5.4, )", "CommunityToolkit.Mvvm": "[8.4.0, )", - "Flow.Launcher.Plugin": "[5.0.0, )", + "Flow.Launcher.Plugin": "[5.1.0, )", "InputSimulator": "[1.0.4, )", "MemoryPack": "[1.21.4, )", "Microsoft.VisualStudio.Threading": "[17.14.15, )", @@ -1628,6 +1653,7 @@ "flow.launcher.plugin": { "type": "Project", "dependencies": { + "Avalonia": "[11.2.3, )", "JetBrains.Annotations": "[2025.2.2, )" } } diff --git a/Plugins/Flow.Launcher.Plugin.Program/Flow.Launcher.Plugin.Program.csproj b/Plugins/Flow.Launcher.Plugin.Program/Flow.Launcher.Plugin.Program.csproj index e9515fab448..6fb36fa1af5 100644 --- a/Plugins/Flow.Launcher.Plugin.Program/Flow.Launcher.Plugin.Program.csproj +++ b/Plugins/Flow.Launcher.Plugin.Program/Flow.Launcher.Plugin.Program.csproj @@ -1,4 +1,4 @@ - + Library @@ -62,6 +62,9 @@ + + + diff --git a/Plugins/Flow.Launcher.Plugin.Program/Main.cs b/Plugins/Flow.Launcher.Plugin.Program/Main.cs index 456085fcaf2..9fa9692371e 100644 --- a/Plugins/Flow.Launcher.Plugin.Program/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Program/Main.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -12,11 +12,13 @@ using Flow.Launcher.Plugin.SharedCommands; using Microsoft.Extensions.Caching.Memory; using Path = System.IO.Path; +using AvaloniaControl = Avalonia.Controls.Control; namespace Flow.Launcher.Plugin.Program { public class Main : ISettingProvider, IAsyncPlugin, IPluginI18n, IContextMenu, IAsyncReloadable, IDisposable { + private static readonly string ClassName = nameof(Main); private const string Win32CacheName = "Win32"; @@ -417,6 +419,14 @@ public Control CreateSettingPanel() return new ProgramSetting(Context, _settings); } + public AvaloniaControl CreateSettingPanelAvalonia() + { + System.Console.WriteLine("Program plugin: CreateSettingPanelAvalonia called!"); + Context.API.LogInfo(ClassName, "Creating Avalonia setting panel"); + return new Views.Avalonia.ProgramSetting(Context, _settings); + } + + public string GetTranslatedPluginTitle() { return Context.API.GetTranslation("flowlauncher_plugin_program_plugin_name"); diff --git a/Plugins/Flow.Launcher.Plugin.Program/ViewModels/ProgramSettingViewModel.cs b/Plugins/Flow.Launcher.Plugin.Program/ViewModels/ProgramSettingViewModel.cs new file mode 100644 index 00000000000..78197447d4e --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.Program/ViewModels/ProgramSettingViewModel.cs @@ -0,0 +1,337 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Plugin.Program.Views.Models; +using Flow.Launcher.Plugin.Program.Views.Commands; +using Flow.Launcher.Plugin.Program.Programs; +using System.IO; +using System.Windows; +using Flow.Launcher.Plugin.Program.Views; +using Avalonia.Data.Converters; +using Avalonia.Media; +using System.Globalization; + +namespace Flow.Launcher.Plugin.Program.ViewModels; + +public partial class ProgramSettingViewModel : ObservableObject +{ + public static IValueConverter EnabledToColorConverter { get; } = new FuncValueConverter(enabled => + enabled ? Brushes.Green : Brushes.Red); + + public static IValueConverter IsNotNullConverter { get; } = new FuncValueConverter(obj => obj != null); + + private readonly PluginInitContext _context; + private readonly Settings _settings; + + [ObservableProperty] + private bool _enableUWP; + + [ObservableProperty] + private bool _enableStartMenuSource; + + [ObservableProperty] + private bool _enableRegistrySource; + + [ObservableProperty] + private bool _enablePATHSource; + + [ObservableProperty] + private bool _hideAppsPath; + + [ObservableProperty] + private bool _hideUninstallers; + + [ObservableProperty] + private bool _enableDescription; + + [ObservableProperty] + private bool _hideDuplicatedWindowsApp; + + public bool ShowUWPCheckbox => UWPPackage.SupportUWP(); + + [ObservableProperty] + private ObservableCollection _programSources = new(); + + [ObservableProperty] + private ProgramSource? _selectedProgramSource; + + [ObservableProperty] + private bool _isIndexing; + + [ObservableProperty] + private string _customSuffixes; + + [ObservableProperty] + private string _customProtocols; + + public ProgramSettingViewModel(PluginInitContext context, Settings settings) + { + _context = context; + _settings = settings; + + _enableUWP = _settings.EnableUWP; + _enableStartMenuSource = _settings.EnableStartMenuSource; + _enableRegistrySource = _settings.EnableRegistrySource; + _enablePATHSource = _settings.EnablePathSource; + _hideAppsPath = _settings.HideAppsPath; + _hideUninstallers = _settings.HideUninstallers; + _enableDescription = _settings.EnableDescription; + _hideDuplicatedWindowsApp = _settings.HideDuplicatedWindowsApp; + + _customSuffixes = string.Join(Settings.SuffixSeparator, _settings.CustomSuffixes); + _customProtocols = string.Join(Settings.SuffixSeparator, _settings.CustomProtocols); + + LoadProgramSources(); + } + + partial void OnCustomSuffixesChanged(string value) + { + _settings.CustomSuffixes = value.Split(Settings.SuffixSeparator, StringSplitOptions.RemoveEmptyEntries); + Main.ResetCache(); + } + + partial void OnCustomProtocolsChanged(string value) + { + _settings.CustomProtocols = value.Split(Settings.SuffixSeparator, StringSplitOptions.RemoveEmptyEntries); + Main.ResetCache(); + } + + public bool AppRefMS + { + get => _settings.BuiltinSuffixesStatus["appref-ms"]; + set + { + _settings.BuiltinSuffixesStatus["appref-ms"] = value; + OnPropertyChanged(); + Main.ResetCache(); + } + } + + public bool Exe + { + get => _settings.BuiltinSuffixesStatus["exe"]; + set + { + _settings.BuiltinSuffixesStatus["exe"] = value; + OnPropertyChanged(); + Main.ResetCache(); + } + } + + public bool Lnk + { + get => _settings.BuiltinSuffixesStatus["lnk"]; + set + { + _settings.BuiltinSuffixesStatus["lnk"] = value; + OnPropertyChanged(); + Main.ResetCache(); + } + } + + public bool Steam + { + get => _settings.BuiltinProtocolsStatus["steam"]; + set + { + _settings.BuiltinProtocolsStatus["steam"] = value; + OnPropertyChanged(); + Main.ResetCache(); + } + } + + public bool Epic + { + get => _settings.BuiltinProtocolsStatus["epic"]; + set + { + _settings.BuiltinProtocolsStatus["epic"] = value; + OnPropertyChanged(); + Main.ResetCache(); + } + } + + public bool Http + { + get => _settings.BuiltinProtocolsStatus["http"]; + set + { + _settings.BuiltinProtocolsStatus["http"] = value; + OnPropertyChanged(); + Main.ResetCache(); + } + } + + public bool UseCustomSuffixes + { + get => _settings.UseCustomSuffixes; + set + { + _settings.UseCustomSuffixes = value; + OnPropertyChanged(); + Main.ResetCache(); + } + } + + public bool UseCustomProtocols + { + get => _settings.UseCustomProtocols; + set + { + _settings.UseCustomProtocols = value; + OnPropertyChanged(); + Main.ResetCache(); + } + } + + + private void LoadProgramSources() + { + // For Avalonia, we want to maintain the same display list if possible, + // but for now let's just use a fresh one from settings + var sources = ProgramSettingDisplay.LoadProgramSources(); + ProgramSources = new ObservableCollection(sources); + + // We need to set the static list for ProgramSettingDisplay to work + ProgramSetting.ProgramSettingDisplayList = sources; + } + + [RelayCommand] + private async Task Reindex() + { + IsIndexing = true; + await Main.IndexProgramsAsync(); + IsIndexing = false; + } + + [RelayCommand] + private async Task LoadAllPrograms() + { + await ProgramSettingDisplay.DisplayAllProgramsAsync(); + // Refresh the observable collection + ProgramSources = new ObservableCollection(ProgramSetting.ProgramSettingDisplayList); + } + + [RelayCommand] + private async Task ToggleStatus() + { + if (SelectedProgramSource == null) return; + + var status = !SelectedProgramSource.Enabled; + await ProgramSettingDisplay.SetProgramSourcesStatusAsync(new List { SelectedProgramSource }, status); + + if (status) + ProgramSettingDisplay.RemoveDisabledFromSettings(); + else + ProgramSettingDisplay.StoreDisabledInSettings(); + + if (await new List { SelectedProgramSource }.IsReindexRequiredAsync()) + await Reindex(); + + // Trigger UI update for the item + OnPropertyChanged(nameof(ProgramSources)); + } + + [RelayCommand] + private async Task DeleteSource() + { + if (SelectedProgramSource == null) return; + + // Check if it's user added + if (!_settings.ProgramSources.Any(x => x.UniqueIdentifier == SelectedProgramSource.UniqueIdentifier)) + { + _context.API.ShowMsgBox(_context.API.GetTranslation("flowlauncher_plugin_program_delete_program_source_select_user_added")); + return; + } + + if (_context.API.ShowMsgBox(_context.API.GetTranslation("flowlauncher_plugin_program_delete_program_source"), + string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No) + { + return; + } + + var toDelete = SelectedProgramSource; + _settings.ProgramSources.RemoveAll(x => x.UniqueIdentifier == toDelete.UniqueIdentifier); + ProgramSetting.ProgramSettingDisplayList.Remove(toDelete); + ProgramSources.Remove(toDelete); + + if (await new List { toDelete }.IsReindexRequiredAsync()) + await Reindex(); + } + + [RelayCommand] + private async Task AddSource() + { + var dialog = new System.Windows.Forms.FolderBrowserDialog(); + if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) + { + var path = dialog.SelectedPath; + if (ProgramSources.Any(x => x.UniqueIdentifier.Equals(path, StringComparison.OrdinalIgnoreCase))) + { + _context.API.ShowMsgBox(_context.API.GetTranslation("flowlauncher_plugin_program_duplicate_program_source")); + return; + } + + var source = new ProgramSource(path, true); + _settings.ProgramSources.Add(source); + ProgramSetting.ProgramSettingDisplayList.Add(source); + ProgramSources.Add(source); + + await Reindex(); + } + } + + + + partial void OnEnableUWPChanged(bool value) + { + _settings.EnableUWP = value; + _ = Reindex(); + } + + partial void OnEnableStartMenuSourceChanged(bool value) + { + _settings.EnableStartMenuSource = value; + _ = Reindex(); + } + + partial void OnEnableRegistrySourceChanged(bool value) + { + _settings.EnableRegistrySource = value; + _ = Reindex(); + } + + partial void OnEnablePATHSourceChanged(bool value) + { + _settings.EnablePathSource = value; + _ = Reindex(); + } + + partial void OnHideAppsPathChanged(bool value) + { + _settings.HideAppsPath = value; + Main.ResetCache(); + } + + partial void OnHideUninstallersChanged(bool value) + { + _settings.HideUninstallers = value; + Main.ResetCache(); + } + + partial void OnEnableDescriptionChanged(bool value) + { + _settings.EnableDescription = value; + Main.ResetCache(); + } + + partial void OnHideDuplicatedWindowsAppChanged(bool value) + { + _settings.HideDuplicatedWindowsApp = value; + Main.ResetCache(); + } +} diff --git a/Plugins/Flow.Launcher.Plugin.Program/Views/Avalonia/ProgramSetting.axaml b/Plugins/Flow.Launcher.Plugin.Program/Views/Avalonia/ProgramSetting.axaml new file mode 100644 index 00000000000..4155715e97b --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.Program/Views/Avalonia/ProgramSetting.axaml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Watermark="{i18n:Localize searchplugin}" + Margin="0,0,0,10"> + + + + + - + - - - - - - + + + + + + + + + + + + - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + diff --git a/Flow.Launcher.Avalonia/Views/SettingPages/PluginsSettingsPage.axaml.cs b/Flow.Launcher.Avalonia/Views/SettingPages/PluginsSettingsPage.axaml.cs index 3355c66f124..da415efa14c 100644 --- a/Flow.Launcher.Avalonia/Views/SettingPages/PluginsSettingsPage.axaml.cs +++ b/Flow.Launcher.Avalonia/Views/SettingPages/PluginsSettingsPage.axaml.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Interactivity; using Flow.Launcher.Avalonia.ViewModel.SettingPages; namespace Flow.Launcher.Avalonia.Views.SettingPages; @@ -10,4 +11,12 @@ public PluginsSettingsPage() InitializeComponent(); DataContext = new PluginsSettingsViewModel(); } + + private void ClearSearchText_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is PluginsSettingsViewModel vm) + { + vm.SearchText = string.Empty; + } + } } From c0d17672af1a9ec06222d5d99a1f716075677a10 Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Mon, 26 Jan 2026 15:30:33 -0800 Subject: [PATCH 25/43] feat(avalonia): add Avalonia settings views for 8 plugins - Add i18n injection to Application.Resources for DynamicResource bindings - Create Avalonia settings views for plugins: - Explorer (ExplorerSettings + QuickAccessLinkSettings dialog) - BrowserBookmark (SettingsControl + CustomBrowserSettingWindow) - Calculator (CalculatorSettings) - ProcessKiller (SettingsControl) - PluginsManager (PluginsManagerSettings) - WebSearch (SettingsControl) - Shell (ShellSetting + converter) - Program (ProgramSetting) - Add IsAvalonia detection pattern for dual-framework dialog support - Update 11 plugin .csproj files with CopyToAvaloniaOutput targets - Add System.Threading.Tasks using for async RelayCommand support --- Flow.Launcher.Avalonia/App.axaml.cs | 4 + .../Resource/Internationalization.cs | 37 +- ...low.Launcher.Plugin.BrowserBookmark.csproj | 15 +- .../Main.cs | 8 +- .../Avalonia/CustomBrowserSettingWindow.axaml | 46 +++ .../CustomBrowserSettingWindow.axaml.cs | 58 ++++ .../Views/Avalonia/SettingsControl.axaml | 81 +++++ .../Views/Avalonia/SettingsControl.axaml.cs | 130 +++++++ .../Flow.Launcher.Plugin.Calculator.csproj | 15 +- .../Flow.Launcher.Plugin.Calculator/Main.cs | 8 +- .../Views/Avalonia/CalculatorSettings.axaml | 43 +++ .../Avalonia/CalculatorSettings.axaml.cs | 24 ++ .../Flow.Launcher.Plugin.Explorer.csproj | 15 +- Plugins/Flow.Launcher.Plugin.Explorer/Main.cs | 8 +- .../ViewModels/SettingsViewModel.cs | 56 ++- .../Views/Avalonia/ExplorerSettings.axaml | 320 ++++++++++++++++++ .../Views/Avalonia/ExplorerSettings.axaml.cs | 85 +++++ .../Avalonia/QuickAccessLinkSettings.axaml | 86 +++++ .../Avalonia/QuickAccessLinkSettings.axaml.cs | 240 +++++++++++++ ...low.Launcher.Plugin.PluginIndicator.csproj | 13 +- ...Flow.Launcher.Plugin.PluginsManager.csproj | 15 +- .../Main.cs | 11 +- .../Avalonia/PluginsManagerSettings.axaml | 29 ++ .../Avalonia/PluginsManagerSettings.axaml.cs | 24 ++ .../Flow.Launcher.Plugin.ProcessKiller.csproj | 18 +- .../Main.cs | 9 +- .../Views/Avalonia/SettingsControl.axaml | 29 ++ .../Views/Avalonia/SettingsControl.axaml.cs | 24 ++ .../Flow.Launcher.Plugin.Program.csproj | 11 + ...nOrCloseShellAfterPressEnabledConverter.cs | 22 ++ .../Flow.Launcher.Plugin.Shell.csproj | 15 +- Plugins/Flow.Launcher.Plugin.Shell/Main.cs | 8 +- .../Views/Avalonia/ShellSetting.axaml | 91 +++++ .../Views/Avalonia/ShellSetting.axaml.cs | 25 ++ .../Flow.Launcher.Plugin.Sys.csproj | 14 +- .../Flow.Launcher.Plugin.Url.csproj | 13 +- .../Flow.Launcher.Plugin.WebSearch.csproj | 18 +- .../Flow.Launcher.Plugin.WebSearch/Main.cs | 6 + .../Views/Avalonia/SettingsControl.axaml | 65 ++++ .../Views/Avalonia/SettingsControl.axaml.cs | 87 +++++ ...low.Launcher.Plugin.WindowsSettings.csproj | 13 +- 41 files changed, 1810 insertions(+), 29 deletions(-) create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/Avalonia/CustomBrowserSettingWindow.axaml create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/Avalonia/CustomBrowserSettingWindow.axaml.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/Avalonia/SettingsControl.axaml create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/Avalonia/SettingsControl.axaml.cs create mode 100644 Plugins/Flow.Launcher.Plugin.Calculator/Views/Avalonia/CalculatorSettings.axaml create mode 100644 Plugins/Flow.Launcher.Plugin.Calculator/Views/Avalonia/CalculatorSettings.axaml.cs create mode 100644 Plugins/Flow.Launcher.Plugin.Explorer/Views/Avalonia/ExplorerSettings.axaml create mode 100644 Plugins/Flow.Launcher.Plugin.Explorer/Views/Avalonia/ExplorerSettings.axaml.cs create mode 100644 Plugins/Flow.Launcher.Plugin.Explorer/Views/Avalonia/QuickAccessLinkSettings.axaml create mode 100644 Plugins/Flow.Launcher.Plugin.Explorer/Views/Avalonia/QuickAccessLinkSettings.axaml.cs create mode 100644 Plugins/Flow.Launcher.Plugin.PluginsManager/Views/Avalonia/PluginsManagerSettings.axaml create mode 100644 Plugins/Flow.Launcher.Plugin.PluginsManager/Views/Avalonia/PluginsManagerSettings.axaml.cs create mode 100644 Plugins/Flow.Launcher.Plugin.ProcessKiller/Views/Avalonia/SettingsControl.axaml create mode 100644 Plugins/Flow.Launcher.Plugin.ProcessKiller/Views/Avalonia/SettingsControl.axaml.cs create mode 100644 Plugins/Flow.Launcher.Plugin.Shell/Converters/Avalonia/LeaveShellOpenOrCloseShellAfterPressEnabledConverter.cs create mode 100644 Plugins/Flow.Launcher.Plugin.Shell/Views/Avalonia/ShellSetting.axaml create mode 100644 Plugins/Flow.Launcher.Plugin.Shell/Views/Avalonia/ShellSetting.axaml.cs create mode 100644 Plugins/Flow.Launcher.Plugin.WebSearch/Views/Avalonia/SettingsControl.axaml create mode 100644 Plugins/Flow.Launcher.Plugin.WebSearch/Views/Avalonia/SettingsControl.axaml.cs diff --git a/Flow.Launcher.Avalonia/App.axaml.cs b/Flow.Launcher.Avalonia/App.axaml.cs index 14919a92cc5..4afb52982bd 100644 --- a/Flow.Launcher.Avalonia/App.axaml.cs +++ b/Flow.Launcher.Avalonia/App.axaml.cs @@ -36,6 +36,10 @@ public override void Initialize() ConfigureDI(); AvaloniaXamlLoader.Load(this); + + // Inject translations into Application.Resources for DynamicResource bindings in plugins + var i18n = Ioc.Default.GetRequiredService(); + i18n.InjectIntoApplicationResources(); } public override void OnFrameworkInitializationCompleted() diff --git a/Flow.Launcher.Avalonia/Resource/Internationalization.cs b/Flow.Launcher.Avalonia/Resource/Internationalization.cs index 0705eca14fd..c783714f797 100644 --- a/Flow.Launcher.Avalonia/Resource/Internationalization.cs +++ b/Flow.Launcher.Avalonia/Resource/Internationalization.cs @@ -201,6 +201,37 @@ private void ParseWpfXamlFile(string filePath) Log.Debug(ClassName, $"Parsed {count} strings from {filePath}"); } + /// + /// Inject all translations into Avalonia's Application.Resources so {DynamicResource} works. + /// This enables plugin settings to use {DynamicResource key} for localization. + /// Must be called after Application is initialized. + /// + public void InjectIntoApplicationResources() + { + try + { + var app = Application.Current; + if (app == null) + { + Log.Warn(ClassName, "Cannot inject resources: Application.Current is null"); + return; + } + + var count = 0; + foreach (var kvp in _translations) + { + app.Resources[kvp.Key] = kvp.Value; + count++; + } + + Log.Info(ClassName, $"Injected {count} translations into Application.Resources"); + } + catch (Exception e) + { + Log.Exception(ClassName, "Failed to inject translations into Application.Resources", e); + } + } + /// /// Get a translated string by key. /// @@ -208,12 +239,10 @@ public string GetTranslation(string key) { if (_translations.TryGetValue(key, out var translation)) { - Log.Debug(ClassName, $"Translation found for '{key}': '{translation}'"); return translation; } Log.Warn(ClassName, $"Translation not found for key: {key}"); - Log.Debug(ClassName, $"Available keys (first 20): {string.Join(", ", _translations.Keys.Take(20))}"); return $"[{key}]"; } @@ -260,6 +289,10 @@ public void ChangeLanguage(string languageCode) } ChangeCultureInfo(languageCode); + + // Re-inject into Application.Resources for DynamicResource bindings + InjectIntoApplicationResources(); + Log.Info(ClassName, $"Language changed to: {languageCode}"); } } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj index 8249b354a8d..03ff64eb6b1 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj @@ -1,4 +1,4 @@ - + Library @@ -108,6 +108,19 @@ + + + + + + ..\..\Output\$(Configuration)\Avalonia\Plugins\$(AssemblyName)\ + + + + + + + diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs index 07ce510fb3e..2545dabbc63 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -10,6 +10,7 @@ using Flow.Launcher.Plugin.BrowserBookmark.Models; using Flow.Launcher.Plugin.BrowserBookmark.Views; using Flow.Launcher.Plugin.SharedCommands; +using AvaloniaControl = Avalonia.Controls.Control; namespace Flow.Launcher.Plugin.BrowserBookmark; @@ -225,6 +226,11 @@ public Control CreateSettingPanel() return new SettingsControl(_settings); } + public AvaloniaControl CreateSettingPanelAvalonia() + { + return new Views.Avalonia.SettingsControl(_settings); + } + public List LoadContextMenus(Result selectedResult) { return new List() diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/Avalonia/CustomBrowserSettingWindow.axaml b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/Avalonia/CustomBrowserSettingWindow.axaml new file mode 100644 index 00000000000..8015eb92785 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/Avalonia/CustomBrowserSettingWindow.axaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + +