diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c22699b0e31..a52075c414f 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -53,6 +53,20 @@ jobs: - name: Perform post_build tasks shell: powershell run: .\Scripts\post_build.ps1 + - name: Upload Avalonia Setup + uses: actions/upload-artifact@v7 + with: + name: Avalonia Setup + path: | + Output\Packages\Avalonia\Flow-Launcher-Avalonia-Setup.exe + compression-level: 0 + - name: Upload Avalonia Portable Version + uses: actions/upload-artifact@v7 + with: + name: Avalonia Portable Version + path: | + Output\Packages\Avalonia\Flow-Launcher-Avalonia-Portable.zip + compression-level: 0 - name: Upload Plugin Nupkg uses: actions/upload-artifact@v7 with: diff --git a/Flow.Launcher.Avalonia/App.axaml b/Flow.Launcher.Avalonia/App.axaml new file mode 100644 index 00000000000..2d23fa5158c --- /dev/null +++ b/Flow.Launcher.Avalonia/App.axaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + avares://Flow.Launcher.Avalonia/Resources#Segoe Fluent Icons + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/App.axaml.cs b/Flow.Launcher.Avalonia/App.axaml.cs new file mode 100644 index 00000000000..9f84e512592 --- /dev/null +++ b/Flow.Launcher.Avalonia/App.axaml.cs @@ -0,0 +1,228 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Avalonia.Helper; +using Flow.Launcher.Avalonia.Resource; +using Flow.Launcher.Avalonia.Views.Dialogs; +using Flow.Launcher.Avalonia.ViewModel; +using Flow.Launcher.Avalonia.Views.SettingPages; +using Flow.Launcher.Core; +using Flow.Launcher.Core.Configuration; +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.Diagnostics; +using System.Threading.Tasks; + +namespace Flow.Launcher.Avalonia; + +public partial class App : Application +{ + private static readonly string ClassName = nameof(App); + private Settings? _settings; + private MainViewModel? _mainVM; + private MainWindow? _mainWindow; + + public static IPublicAPI? API { get; private set; } + + public override void Initialize() + { + // Configure DI before loading XAML so markup extensions can access services + LoadSettings(); + ConfigureDI(); + + AvaloniaXamlLoader.Load(this); + + // Inject translations into Application.Resources for DynamicResource bindings in plugins + var i18n = Ioc.Default.GetRequiredService(); + i18n.InjectIntoApplicationResources(); + + #if DEBUG + this.AttachDeveloperTools(); + #endif + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + API = Ioc.Default.GetRequiredService(); + _mainVM = Ioc.Default.GetRequiredService(); + + _mainWindow = new MainWindow(); + // desktop.MainWindow = _mainWindow; // Prevent auto-show on startup + desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; + + // Initialize hotkeys after window is created + HotKeyMapper.Initialize(); + + AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; + TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + Dispatcher.UIThread.UnhandledException += OnUiUnhandledException; + + AutoStartup(); + + Dispatcher.UIThread.Post(async () => await InitializePluginsAsync(), DispatcherPriority.Background); + + // Cleanup on exit + desktop.Exit += (_, _) => + { + HotKeyMapper.Shutdown(); + AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException; + TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException; + Dispatcher.UIThread.UnhandledException -= OnUiUnhandledException; + }; + } + base.OnFrameworkInitializationCompleted(); + } + + /// + /// Check startup only for Release. + /// + [Conditional("RELEASE")] + private void AutoStartup() + { + if (_settings?.StartFlowLauncherOnSystemStartup != true) + { + return; + } + + try + { + Helper.AutoStartup.CheckIsEnabled(_settings.UseLogonTaskForStartup); + } + catch (Exception e) + { + _settings.StartFlowLauncherOnSystemStartup = false; + _settings.Save(); + API?.ShowMsgError(Translator.GetString("setAutoStartFailed"), e.Message); + } + } + + private void TrayIcon_OnClicked(object? sender, EventArgs e) + { + _mainVM?.ToggleFlowLauncher(); + } + + private void MenuShow_OnClick(object? sender, EventArgs e) + { + _mainVM?.Show(); + } + + private void MenuSettings_OnClick(object? sender, EventArgs e) + { + var settingsWindow = new SettingsWindow(); + settingsWindow.Show(); + } + + private void MenuExit_OnClick(object? sender, EventArgs e) + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(); + } + } + + private void LoadSettings() + { + try + { + var storage = new FlowLauncherJsonStorage(); + _settings = storage.Load(); + _settings.SetStorage(storage); + } + catch (Exception e) + { + Log.Exception(ClassName, "Settings load failed", e); + var storage = new FlowLauncherJsonStorage(); + _settings = new Settings + { + WindowSize = 580, WindowHeightSize = 42, QueryBoxFontSize = 24, + ItemHeightSize = 50, ResultItemFontSize = 14, ResultSubItemFontSize = 12, MaxResultsToShow = 6 + }; + _settings.SetStorage(storage); + } + } + + private void ConfigureDI() + { + var services = new ServiceCollection(); + services.AddSingleton(_settings!); + services.AddSingleton(sp => new Updater(sp.GetRequiredService(), Constant.GitHub)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new AvaloniaPublicAPI( + sp.GetRequiredService(), + () => sp.GetRequiredService(), + sp.GetRequiredService())); + Ioc.Default.ConfigureServices(services.BuildServiceProvider()); + } + + private async Task InitializePluginsAsync() + { + try + { + Log.Info(ClassName, "Loading plugins..."); + PluginManager.LoadPlugins(_settings!.PluginSettings); + Log.Info(ClassName, $"Loaded {PluginManager.GetAllLoadedPlugins().Count} plugins"); + + await PluginManager.InitializePluginsAsync(_mainVM!); + Log.Info(ClassName, "Plugins initialized"); + + // Update plugin translations after they are initialized + var i18n = Ioc.Default.GetRequiredService(); + i18n.UpdatePluginMetadataTranslations(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = _mainWindow; + } + + _mainVM?.OnPluginsReady(); + } + catch (Exception e) { Log.Exception(ClassName, "Plugin init failed", e); } + } + + private void OnUiUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) + { + Log.Exception(ClassName, "Unhandled UI exception", e.Exception); + ShowReportWindow(e.Exception); + e.Handled = true; + } + + private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) + { + if (e.ExceptionObject is Exception exception) + { + Log.Exception(ClassName, "Unhandled exception", exception); + ShowReportWindow(exception); + } + } + + private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + Log.Exception(ClassName, "Unobserved task exception occurred.", e.Exception); + e.SetObserved(); + } + + private static void ShowReportWindow(Exception exception) + { + Dispatcher.UIThread.Post(() => + { + var window = new ReportWindow(exception); + window.Show(); + window.Activate(); + }); + } +} diff --git a/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs new file mode 100644 index 00000000000..14ff41b3986 --- /dev/null +++ b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs @@ -0,0 +1,530 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Specialized; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Media; +using Flow.Launcher.Avalonia.Views.Dialogs; +using Flow.Launcher.Core; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.Storage; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedModels; +using Flow.Launcher.Plugin.SharedCommands; +using Flow.Launcher.Avalonia.Views.Controls; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Core.ExternalPlugins; +using Flow.Launcher.Avalonia.ViewModel; +using Flow.Launcher.Avalonia.Resource; +using CommunityToolkit.Mvvm.DependencyInjection; + +namespace Flow.Launcher.Avalonia; + +/// +/// IPublicAPI implementation for the Avalonia host. +/// +public class AvaloniaPublicAPI : IPublicAPI +{ + private readonly Settings _settings; + private readonly Func _getMainViewModel; + private readonly Internationalization _i18n; + private readonly object _saveSettingsLock = new(); + private readonly ConcurrentDictionary _pluginJsonStorages = new(); + private readonly ConcurrentDictionary<(string, string, Type), ISavable> _pluginBinaryStorages = new(); + private readonly object _globalKeyboardHandlersLock = new(); + private readonly List> _globalKeyboardHandlers = new(); + private Flow.Launcher.Core.Resource.Theme? _theme; + private bool _gameModeStatus; + + public AvaloniaPublicAPI(Settings settings, Func getMainViewModel, Internationalization i18n) + { + _settings = settings; + _getMainViewModel = getMainViewModel; + _i18n = i18n; + Flow.Launcher.Infrastructure.Hotkey.GlobalHotkey.hookedKeyboardCallback = KListenerHookedKeyboardCallback; + } + +#pragma warning disable CS0067 + public event VisibilityChangedEventHandler? VisibilityChanged; + public event ActualApplicationThemeChangedEventHandler? ActualApplicationThemeChanged; +#pragma warning restore CS0067 + public event EventHandler StringMatcherBehaviorChanged + { + add => _settings.StringMatcherBehaviorChanged += value; + remove => _settings.StringMatcherBehaviorChanged -= value; + } + + + // Essential for plugins + public void ChangeQuery(string query, bool requery = false) + { + var mainViewModel = _getMainViewModel(); + if (requery && string.Equals(mainViewModel.QueryText, query, StringComparison.Ordinal)) + { + ReQuery(); + return; + } + + mainViewModel.QueryText = query; + } + + public string GetTranslation(string key) => _i18n.GetTranslation(key); + + public List GetAllPlugins() => PluginManager.GetAllLoadedPlugins(); + public List GetAllInitializedPlugins(bool includeFailed) => PluginManager.GetAllInitializedPlugins(includeFailed); + 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) => OpenUri(new Uri(url), inPrivate); + public void OpenUrl(Uri url, bool? inPrivate = null) => OpenUri(url, inPrivate); + public void OpenWebUrl(string url, bool? inPrivate = null) => OpenUri(new Uri(url), inPrivate, true); + public void OpenWebUrl(Uri url, bool? inPrivate = null) => OpenUri(url, inPrivate, true); + public void OpenAppUri(Uri appUri) => OpenUri(appUri); + public void OpenAppUri(string appUri) => OpenUri(new Uri(appUri)); + + public void OpenDirectory(string DirectoryPath, string? FileNameOrFilePath = null) + { + try + { + var explorerInfo = _settings.CustomExplorer; + var explorerPath = explorerInfo.Path.Trim().ToLowerInvariant(); + var targetPath = FileNameOrFilePath is null + ? DirectoryPath + : Path.IsPathRooted(FileNameOrFilePath) + ? FileNameOrFilePath + : Path.Combine(DirectoryPath, FileNameOrFilePath); + + if (Path.GetFileNameWithoutExtension(explorerPath) == "explorer") + { + if (FileNameOrFilePath is null) + { + using var explorer = new Process(); + explorer.StartInfo = new ProcessStartInfo + { + FileName = DirectoryPath, + UseShellExecute = true + }; + explorer.Start(); + } + else + { + Win32Helper.OpenFolderAndSelectFile(targetPath); + } + } + else + { + using var explorer = new Process(); + explorer.StartInfo = new ProcessStartInfo + { + FileName = explorerInfo.Path.Replace("%d", DirectoryPath), + UseShellExecute = true, + Arguments = FileNameOrFilePath is null + ? explorerInfo.DirectoryArgument.Replace("%d", DirectoryPath) + : explorerInfo.FileArgument + .Replace("%d", DirectoryPath) + .Replace("%f", targetPath) + }; + explorer.Start(); + } + } + catch (COMException ex) when (ex.ErrorCode == unchecked((int)0x80004004)) + { + } + catch (Win32Exception ex) when (ex.NativeErrorCode == 2) + { + LogException(nameof(AvaloniaPublicAPI), "File manager not found", ex); + ShowMsgError(GetTranslation("fileManagerNotFoundTitle"), GetTranslation("fileManagerNotFound")); + } + catch (Exception ex) + { + LogException(nameof(AvaloniaPublicAPI), "Failed to open folder", ex); + ShowMsgError(GetTranslation("errorTitle"), GetTranslation("folderOpenError")); + } + } + + private void OpenUri(Uri uri, bool? inPrivate = null, bool forceBrowser = false) + { + if (uri.IsFile && !uri.LocalPath.FileOrLocationExists()) + { + ShowMsgError(GetTranslation("errorTitle"), string.Format(GetTranslation("fileNotFoundError"), uri.LocalPath)); + return; + } + + if (forceBrowser || uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) + { + var browserInfo = _settings.CustomBrowser; + var path = browserInfo.Path == "*" ? string.Empty : browserInfo.Path; + + try + { + if (browserInfo.OpenInTab) + { + uri.AbsoluteUri.OpenInBrowserTab(path, inPrivate ?? browserInfo.EnablePrivate, browserInfo.PrivateArg, browserInfo.ExtraArgs); + } + else + { + uri.AbsoluteUri.OpenInBrowserWindow(path, inPrivate ?? browserInfo.EnablePrivate, browserInfo.PrivateArg, browserInfo.ExtraArgs); + } + } + catch (Exception e) + { + var tabOrWindow = browserInfo.OpenInTab ? "tab" : "window"; + var includesExtraArgs = string.IsNullOrWhiteSpace(browserInfo.ExtraArgs) + ? string.Empty + : ", [including omitted Extra Args]"; + LogException(nameof(AvaloniaPublicAPI), $"Failed to open URL in browser {tabOrWindow}: {path}, {inPrivate ?? browserInfo.EnablePrivate}, {browserInfo.PrivateArg}{includesExtraArgs}", e); + ShowMsgError(GetTranslation("errorTitle"), GetTranslation("browserOpenError")); + } + } + else + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = uri.AbsoluteUri, + UseShellExecute = true + })?.Dispose(); + } + catch (Exception e) + { + LogException(nameof(AvaloniaPublicAPI), $"Failed to open: {uri.AbsoluteUri}", e); + ShowMsgError(GetTranslation("errorTitle"), e.Message); + } + } + } + + // Clipboard + public async void CopyToClipboard(string text, bool directCopy = false, bool showDefaultNotification = true) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + var isFile = File.Exists(text); + if (directCopy && (isFile || Directory.Exists(text))) + { + var exception = await RetryActionOnStaThreadAsync(() => + { + var paths = new StringCollection { text }; + Clipboard.SetFileDropList(paths); + }); + + if (exception == null) + { + if (showDefaultNotification) + { + ShowMsg($"{GetTranslation("copy")} {(isFile ? GetTranslation("fileTitle") : GetTranslation("folderTitle"))}", GetTranslation("completedSuccessfully")); + } + } + else + { + LogException(nameof(AvaloniaPublicAPI), "Failed to copy file/folder to clipboard", exception); + ShowMsgError(GetTranslation("failedToCopy")); + } + + return; + } + + var textException = await RetryActionOnStaThreadAsync(() => Clipboard.SetText(text)); + if (textException == null) + { + if (showDefaultNotification) + { + ShowMsg($"{GetTranslation("copy")} {GetTranslation("textTitle")}", GetTranslation("completedSuccessfully")); + } + } + else + { + LogException(nameof(AvaloniaPublicAPI), "Failed to copy text to clipboard", textException); + ShowMsgError(GetTranslation("failedToCopy")); + } + } + + private static async Task RetryActionOnStaThreadAsync(Action action, int retryCount = 6, int retryDelay = 150) + { + for (var i = 0; i < retryCount; i++) + { + try + { + await Win32Helper.StartSTATaskAsync(action).ConfigureAwait(false); + return null; + } + catch (Exception e) + { + if (i == retryCount - 1) + { + return e; + } + + await Task.Delay(retryDelay).ConfigureAwait(false); + } + } + + return null; + } + + // 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; + + public void RestartApp() + { + HideMainWindow(); + SaveAppAllSettings(); + + var exePath = Environment.ProcessPath; + if (!string.IsNullOrWhiteSpace(exePath)) + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = exePath, + UseShellExecute = true + }); + } + + if (global::Avalonia.Application.Current?.ApplicationLifetime is global::Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(); + } + } + + public void SaveAppAllSettings() + { + lock (_saveSettingsLock) + { + _settings.Save(); + PluginManager.Save(); + _getMainViewModel().Save(); + SavePluginSettings(); + SavePluginCaches(); + } + } + + public void SavePluginSettings() + { + foreach (var savable in _pluginJsonStorages.Values) + { + savable.Save(); + } + } + + public Task ReloadAllPluginData() => PluginManager.ReloadDataAsync(); + public void CheckForNewUpdate() => _ = Ioc.Default.GetRequiredService().UpdateAppAsync(false); + public void ShowMsgError(string title, string subTitle = "") => ShowMsg(title, subTitle, Constant.ErrorIcon, true); + public void ShowMsgErrorWithButton(string title, string buttonText, Action buttonAction, string subTitle = "") => + ShowMsgWithButton(title, buttonText, buttonAction, subTitle, Constant.ErrorIcon, true); + public void ShowMainWindow() => _getMainViewModel()?.Show(); + public void FocusQueryTextBox() => + _getMainViewModel()?.RequestQueryTextFocus(new QueryTextFocusRequest(false, false, QueryTextFocusMode.CaretAtEnd)); + public void HideMainWindow() => _getMainViewModel()?.RequestHide(); + public bool IsMainWindowVisible() => _getMainViewModel()?.MainWindowVisibility ?? false; + public void ShowMsg(string title, string subTitle = "", string iconPath = "") => + ShowMsg(title, subTitle, iconPath, true); + public void ShowMsg(string title, string subTitle, string iconPath, bool useMainWindowAsOwner = true) + { + NotificationWindow.ShowNotification(title, subTitle, iconPath); + } + public void ShowMsgWithButton(string title, string buttonText, Action buttonAction, string subTitle = "", string iconPath = "") => + ShowMsgWithButton(title, buttonText, buttonAction, subTitle, iconPath, true); + public void ShowMsgWithButton(string title, string buttonText, Action buttonAction, string subTitle, string iconPath, bool useMainWindowAsOwner = true) + { + NotificationWindow.ShowNotification(title, subTitle, iconPath, buttonText, buttonAction); + } + public void OpenSettingDialog() => _getMainViewModel()?.OpenSettings(); + public bool OpenPluginSettingsWindow(string pluginId) + { + try + { + var plugin = PluginManager.GetPluginForId(pluginId); + if (plugin?.Plugin is not ISettingProvider settingProvider) + { + return false; + } + + if (plugin.Plugin is JsonRPCPluginBase jsonRpcPlugin && !jsonRpcPlugin.NeedCreateSettingPanel()) + { + return false; + } + + if (settingProvider.CreateSettingPanelAvalonia() != null) + { + OpenSettingDialog(); + return true; + } + + var settingsControl = settingProvider.CreateSettingPanel(); + if (settingsControl == null) + { + return false; + } + + WpfSettingsWindow.Show(settingsControl, plugin.Metadata.Name); + return true; + } + catch (Exception e) + { + Log.Exception(nameof(AvaloniaPublicAPI), $"Failed to open plugin settings window for plugin id '{pluginId}'", e); + return false; + } + } + public void RegisterGlobalKeyboardCallback(Func callback) + { + lock (_globalKeyboardHandlersLock) + { + _globalKeyboardHandlers.Add(callback); + } + } + + public void RemoveGlobalKeyboardCallback(Func callback) + { + lock (_globalKeyboardHandlersLock) + { + _globalKeyboardHandlers.Remove(callback); + } + } + + public T LoadSettingJsonStorage() where T : new() + { + var type = typeof(T); + var storage = (PluginJsonStorage)_pluginJsonStorages.GetOrAdd(type, _ => new PluginJsonStorage()); + return storage.Load(); + } + + public void SaveSettingJsonStorage() where T : new() + { + var type = typeof(T); + var storage = (PluginJsonStorage)_pluginJsonStorages.GetOrAdd(type, _ => new PluginJsonStorage()); + storage.Save(); + } + + public void ToggleGameMode() => _gameModeStatus = !_gameModeStatus; + public void SetGameMode(bool value) => _gameModeStatus = value; + public bool IsGameModeOn() => _gameModeStatus; + public void ReQuery(bool reselect = true) => _getMainViewModel().ReQuery(reselect); + public void BackToQueryResults() => _getMainViewModel().BackToQueryResults(); + public MessageBoxResult ShowMsgBox(string messageBoxText, string caption = "", MessageBoxButton button = MessageBoxButton.OK, MessageBoxImage icon = MessageBoxImage.None, MessageBoxResult defaultResult = MessageBoxResult.OK) + { + if (System.Windows.Application.Current?.Dispatcher != null) + { + return System.Windows.Application.Current.Dispatcher.Invoke(() => + System.Windows.MessageBox.Show(messageBoxText, caption, button, icon, defaultResult)); + } + + return System.Windows.MessageBox.Show(messageBoxText, caption, button, icon, defaultResult); + } + public Task ShowProgressBoxAsync(string caption, Func, Task> reportProgressAsync, Action? cancelProgress = null) => + ProgressBoxWindow.ShowAsync(caption, reportProgressAsync, cancelProgress); + public void StartLoadingBar() => _getMainViewModel().IsQueryRunning = true; + public void StopLoadingBar() => _getMainViewModel().IsQueryRunning = false; + private Flow.Launcher.Core.Resource.Theme Theme => _theme ??= new Flow.Launcher.Core.Resource.Theme(this, _settings); + public List GetAvailableThemes() => Theme.GetAvailableThemes(); + public ThemeData GetCurrentTheme() => Theme.GetCurrentTheme(); + public bool SetCurrentTheme(ThemeData theme) => Theme.ChangeTheme(theme.FileNameWithoutExtension); + + public void SavePluginCaches() + { + foreach (var savable in _pluginBinaryStorages.Values) + { + savable.Save(); + } + } + + public async Task LoadCacheBinaryStorageAsync(string cacheName, string cacheDirectory, T defaultData) where T : new() + { + var type = typeof(T); + var storage = (PluginBinaryStorage)_pluginBinaryStorages.GetOrAdd((cacheName, cacheDirectory, type), + _ => new PluginBinaryStorage(cacheName, cacheDirectory)); + + try + { + return await storage.TryLoadAsync(defaultData); + } + catch (Exception e) + { + Log.Warn(nameof(AvaloniaPublicAPI), $"Failed to load cache '{cacheName}' from '{cacheDirectory}'. Falling back to default data. {e.Message}"); + return defaultData; + } + } + + public async Task SaveCacheBinaryStorageAsync(string cacheName, string cacheDirectory) where T : new() + { + var type = typeof(T); + var storage = (PluginBinaryStorage)_pluginBinaryStorages.GetOrAdd((cacheName, cacheDirectory, type), + _ => new PluginBinaryStorage(cacheName, cacheDirectory)); + + await storage.SaveAsync(); + } + + public ValueTask LoadImageAsync(string path, bool loadFullImage = false, bool cacheImage = true) => + Flow.Launcher.Infrastructure.Image.ImageLoader.LoadAsync(path, loadFullImage, cacheImage); + + public Task UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default) => + PluginsManifest.UpdateManifestAsync(usePrimaryUrlOnly, token); + public IReadOnlyList GetPluginManifest() => PluginsManifest.UserPlugins ?? new List(); + public Task UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath) => + PluginManager.UpdatePluginAsync(pluginMetadata, plugin, zipFilePath); + public bool InstallPlugin(UserPlugin plugin, string zipFilePath) => PluginManager.InstallPlugin(plugin, zipFilePath); + public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false) => + PluginManager.UninstallPluginAsync(pluginMetadata, removePluginSettings); + public bool IsApplicationDarkTheme() => + global::Avalonia.Application.Current?.ActualThemeVariant == global::Avalonia.Styling.ThemeVariant.Dark; + + 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; } + + private bool KListenerHookedKeyboardCallback(KeyEvent keyEvent, int vkCode, SpecialKeyState state) + { + var continueHook = true; + Func[] handlers; + lock (_globalKeyboardHandlersLock) + { + handlers = _globalKeyboardHandlers.ToArray(); + } + + foreach (var callback in handlers) + { + continueHook &= callback((int)keyEvent, vkCode, state); + } + + return continueHook; + } +} 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..669b9b5a0fb --- /dev/null +++ b/Flow.Launcher.Avalonia/Converters/CommonConverters.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace Flow.Launcher.Avalonia.Converters; + +/// +/// Converts text with highlight indices to InlineCollection with bold highlights. +/// Usage: MultiBinding with [0]=text string, [1]=List<int> of character indices to highlight. +/// +public class HighlightTextConverter : IMultiValueConverter +{ + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Count < 1 || values[0] is not string text || string.IsNullOrEmpty(text)) + return new InlineCollection { new Run(string.Empty) }; + + // If no highlight data, return plain text as single Run + if (values.Count < 2 || values[1] is not IList { Count: > 0 } highlightData) + return new InlineCollection { new Run(text) }; + + var inlines = new InlineCollection(); + var highlightSet = new HashSet(highlightData); + + // Build runs by grouping consecutive characters with same highlight state + var currentRun = new System.Text.StringBuilder(); + var currentIsHighlight = highlightSet.Contains(0); + + for (var i = 0; i < text.Length; i++) + { + var shouldHighlight = highlightSet.Contains(i); + + if (shouldHighlight != currentIsHighlight && currentRun.Length > 0) + { + // Flush current run + inlines.Add(CreateRun(currentRun.ToString(), currentIsHighlight)); + currentRun.Clear(); + currentIsHighlight = shouldHighlight; + } + + currentRun.Append(text[i]); + } + + // Flush final run + if (currentRun.Length > 0) + inlines.Add(CreateRun(currentRun.ToString(), currentIsHighlight)); + + return inlines; + } + + private static Run CreateRun(string text, bool isHighlight) + { + var run = new Run(text); + if (isHighlight) + { + run.FontWeight = FontWeight.Bold; + // Try to get from resources, fallback to gold + if (Application.Current != null && Application.Current.TryGetResource("HighlightForegroundBrush", null, out var brush) && brush is IBrush b) + { + run.Foreground = b; + } + else + { + run.Foreground = new SolidColorBrush(Colors.Gold); + } + } + return run; + } +} + +/// +/// 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, NumberStyles.Float, CultureInfo.InvariantCulture, 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/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/Flow.Launcher.Avalonia.csproj b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj new file mode 100644 index 00000000000..7055bff9d80 --- /dev/null +++ b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj @@ -0,0 +1,86 @@ + + + + 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 + en + + + + ..\Output\Avalonia\Debug\ + DEBUG;TRACE;AVALONIA + + + + ..\Output\Avalonia\Release\ + TRACE;RELEASE;AVALONIA + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + PreserveNewest + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/Helper/AutoStartup.cs b/Flow.Launcher.Avalonia/Helper/AutoStartup.cs new file mode 100644 index 00000000000..2cc4e5f6c4d --- /dev/null +++ b/Flow.Launcher.Avalonia/Helper/AutoStartup.cs @@ -0,0 +1,231 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Security.Principal; +using Flow.Launcher.Infrastructure; +using Microsoft.Win32; +using Microsoft.Win32.TaskScheduler; + +namespace Flow.Launcher.Avalonia.Helper; + +internal static class AutoStartup +{ + private static readonly string ClassName = nameof(AutoStartup); + + private const string StartupPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; + private const string LogonTaskName = $"{Constant.FlowLauncher} Startup"; + private const string LogonTaskDesc = $"{Constant.FlowLauncher} Auto Startup"; + + internal static void CheckIsEnabled(bool useLogonTaskForStartup) + { + var logonTaskEnabled = CheckLogonTask(); + var registryEnabled = CheckRegistry(); + + if (useLogonTaskForStartup) + { + if (!logonTaskEnabled) + { + Enable(true); + } + + if (registryEnabled) + { + Disable(false); + } + } + else + { + if (!registryEnabled) + { + Enable(false); + } + + if (logonTaskEnabled) + { + Disable(true); + } + } + } + + internal static void DisableViaLogonTaskAndRegistry() + { + Disable(true); + Disable(false); + } + + internal static void ChangeToViaLogonTask() + { + Disable(false); + Enable(true); + } + + internal static void ChangeToViaRegistry() + { + Disable(true); + Enable(false); + } + + private static bool CheckLogonTask() + { + using var taskService = new TaskService(); + var task = taskService.RootFolder.AllTasks.FirstOrDefault(t => t.Name == LogonTaskName); + if (task == null) + { + return false; + } + + try + { + if (task.Definition.Actions.FirstOrDefault() is Microsoft.Win32.TaskScheduler.Action taskAction) + { + var action = taskAction.ToString().Trim(); + var needsRecreation = !action.Equals(Constant.ExecutablePath, StringComparison.OrdinalIgnoreCase) + || task.Definition.Settings.Priority != ProcessPriorityClass.Normal; + + if (needsRecreation) + { + return UnscheduleLogonTask() && ScheduleLogonTask(); + } + } + + return true; + } + catch (Exception e) + { + App.API?.LogError(ClassName, $"Failed to check logon task: {e}"); + throw; + } + } + + private static bool CheckRegistry() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(StartupPath, true); + if (key == null) + { + return false; + } + + var action = (key.GetValue(Constant.FlowLauncher) as string) ?? string.Empty; + if (!action.Equals(Constant.ExecutablePath, StringComparison.OrdinalIgnoreCase) + && !action.Equals($"\"{Constant.ExecutablePath}\"", StringComparison.OrdinalIgnoreCase)) + { + return UnscheduleRegistry() && ScheduleRegistry(); + } + + return true; + } + catch (Exception e) + { + App.API?.LogError(ClassName, $"Failed to check registry: {e}"); + throw; + } + } + + private static void Disable(bool logonTask) + { + try + { + if (logonTask) + { + UnscheduleLogonTask(); + } + else + { + UnscheduleRegistry(); + } + } + catch (Exception e) + { + App.API?.LogError(ClassName, $"Failed to disable auto-startup: {e}"); + throw; + } + } + + private static void Enable(bool logonTask) + { + try + { + if (logonTask) + { + ScheduleLogonTask(); + } + else + { + ScheduleRegistry(); + } + } + catch (Exception e) + { + App.API?.LogError(ClassName, $"Failed to enable auto-startup: {e}"); + throw; + } + } + + private static bool ScheduleLogonTask() + { + using var td = TaskService.Instance.NewTask(); + td.RegistrationInfo.Description = LogonTaskDesc; + td.Triggers.Add(new LogonTrigger { UserId = WindowsIdentity.GetCurrent().Name, Delay = TimeSpan.FromSeconds(2) }); + td.Actions.Add(Constant.ExecutablePath); + + if (IsCurrentUserAdmin()) + { + td.Principal.RunLevel = TaskRunLevel.Highest; + } + + td.Settings.StopIfGoingOnBatteries = false; + td.Settings.DisallowStartIfOnBatteries = false; + td.Settings.ExecutionTimeLimit = TimeSpan.Zero; + td.Settings.Priority = ProcessPriorityClass.Normal; + + try + { + TaskService.Instance.RootFolder.RegisterTaskDefinition(LogonTaskName, td); + return true; + } + catch (Exception e) + { + App.API?.LogError(ClassName, $"Failed to schedule logon task: {e}"); + return false; + } + } + + private static bool UnscheduleLogonTask() + { + using var taskService = new TaskService(); + + try + { + taskService.RootFolder.DeleteTask(LogonTaskName); + return true; + } + catch (Exception e) + { + App.API?.LogError(ClassName, $"Failed to unschedule logon task: {e}"); + return false; + } + } + + private static bool IsCurrentUserAdmin() + { + var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + + private static bool UnscheduleRegistry() + { + using var key = Registry.CurrentUser.OpenSubKey(StartupPath, true); + key?.DeleteValue(Constant.FlowLauncher, false); + return true; + } + + private static bool ScheduleRegistry() + { + using var key = Registry.CurrentUser.OpenSubKey(StartupPath, true); + key?.SetValue(Constant.FlowLauncher, $"\"{Constant.ExecutablePath}\""); + return true; + } +} diff --git a/Flow.Launcher.Avalonia/Helper/FontLoader.cs b/Flow.Launcher.Avalonia/Helper/FontLoader.cs new file mode 100644 index 00000000000..5adfc6e3e19 --- /dev/null +++ b/Flow.Launcher.Avalonia/Helper/FontLoader.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using Avalonia; +using Avalonia.Media; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Avalonia.Helper; + +/// +/// Helper for loading fonts from file paths for glyph icons. +/// Supports Avalonia's font loading system with custom font files. +/// +public static class FontLoader +{ + private static readonly ConcurrentDictionary FontFamilyCache = new(); + + /// + /// Get a FontFamily from a GlyphInfo, handling file paths and resource paths. + /// + public static FontFamily? GetFontFamily(GlyphInfo glyph) + { + if (glyph == null || string.IsNullOrEmpty(glyph.FontFamily)) + return null; + + var fontFamilyPath = glyph.FontFamily; + + if (FontFamilyCache.TryGetValue(fontFamilyPath, out var cached)) + return cached; + + FontFamily? result = null; + + // 1. Try as embedded resource font + result = TryGetEmbeddedFont(fontFamilyPath); + + // 2. Try as system font (Avalonia handles this by name) + if (result == null) + result = TryGetSystemFont(fontFamilyPath); + + // 3. Try as file path + if (result == null && IsFilePath(fontFamilyPath)) + result = LoadFontFromFile(fontFamilyPath); + + if (result != null) + FontFamilyCache[fontFamilyPath] = result; + + return result; + } + + private static FontFamily? TryGetEmbeddedFont(string fontFamilyPath) + { + try + { + var fontName = ExtractFontName(fontFamilyPath); + if (string.IsNullOrEmpty(fontName)) + return null; + + // Check for Segoe Fluent Icons specifically (common for Flow Launcher plugins) + if (fontName.Contains("Segoe Fluent Icons", StringComparison.OrdinalIgnoreCase)) + { + // Try to get from Application Resources first + if (Application.Current != null && Application.Current.TryGetResource("SegoeFluentIcons", null, out var resource)) + { + if (resource is FontFamily family) + return family; + } + + // Fallback to direct URI - Folder based is usually better in Avalonia 11 + return SafeCreateFontFamily("avares://Flow.Launcher.Avalonia/Resources#Segoe Fluent Icons"); + } + + return null; + } + catch + { + return null; + } + } + + private static FontFamily? TryGetSystemFont(string fontNameOrPath) + { + try + { + var fontName = ExtractFontName(fontNameOrPath); + if (string.IsNullOrEmpty(fontName)) + return null; + + return SafeCreateFontFamily(fontName); + } + catch + { + return null; + } + } + + private static FontFamily? SafeCreateFontFamily(string nameOrUri) + { + try + { + var family = new FontFamily(nameOrUri); + + // Validate if font actually exists in the system by trying to get a glyph typeface + // This prevents returning a dummy FontFamily that will crash during rendering + if (FontManager.Current.TryGetGlyphTypeface(new Typeface(family), out _)) + { + return family; + } + } + catch + { + // Ignore + } + + return null; + } + + private static bool IsFilePath(string path) + { + return path.StartsWith("file:///", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("file://", StringComparison.OrdinalIgnoreCase) || + (path.Length > 2 && path[1] == ':' && (path[2] == '\\' || path[2] == '/')); + } + + private static string? ExtractFontName(string path) + { + if (string.IsNullOrEmpty(path)) return null; + + // If it's a file path or URL, try to extract the fragment (after #) + var hashIndex = path.IndexOf('#'); + if (hashIndex >= 0 && hashIndex < path.Length - 1) + { + return path.Substring(hashIndex + 1); + } + + // If it contains slashes or backslashes, it might be a path without a fragment + if (path.Contains('/') || path.Contains('\\')) + { + // Try to get the file name without extension as a fallback name + try + { + var fileName = Path.GetFileNameWithoutExtension(path); + if (!string.IsNullOrEmpty(fileName)) + return fileName; + } + catch + { + // Ignore + } + } + + // If it's not a path-like string, assume it's just the font name + return path; + } + + private static FontFamily? LoadFontFromFile(string fontFamilyPath) + { + try + { + var hashIndex = fontFamilyPath.IndexOf('#'); + var fontSource = hashIndex >= 0 ? fontFamilyPath[..hashIndex] : fontFamilyPath; + var fontName = hashIndex >= 0 && hashIndex < fontFamilyPath.Length - 1 + ? fontFamilyPath[(hashIndex + 1)..] + : null; + + string fontFilePath; + Uri fontFileUri; + if (Uri.TryCreate(fontSource, UriKind.Absolute, out var uri) && uri.IsFile) + { + fontFilePath = uri.LocalPath; + fontFileUri = uri; + } + else + { + fontFilePath = Path.GetFullPath(fontSource); + fontFileUri = new Uri(fontFilePath); + } + + if (!File.Exists(fontFilePath)) + return null; + + var uriString = fontFileUri.AbsoluteUri; + if (!string.IsNullOrEmpty(fontName)) + uriString += $"#{fontName}"; + + return SafeCreateFontFamily(uriString); + } + catch + { + return null; + } + } +} diff --git a/Flow.Launcher.Avalonia/Helper/GlobalHotkey.cs b/Flow.Launcher.Avalonia/Helper/GlobalHotkey.cs new file mode 100644 index 00000000000..eadaa3f01ef --- /dev/null +++ b/Flow.Launcher.Avalonia/Helper/GlobalHotkey.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using System.Windows.Input; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using Flow.Launcher.Infrastructure.Logger; + +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; + private const string ClassName = nameof(GlobalHotkey); + + [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) + { + Log.Error(ClassName, "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) + { + Log.Error(ClassName, "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; + Log.Info(ClassName, "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) + { + Log.Warn(ClassName, "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(); + Log.Error(ClassName, $"Failed to register hotkey (error {error})"); + return -1; + } + + _hotkeyCallbacks[id] = callback; + Log.Info(ClassName, $"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); + Log.Debug(ClassName, $"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; + Log.Info(ClassName, "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)) + { + Log.Debug(ClassName, $"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: + if (Enum.TryParse(part, true, out var parsedKey)) + { + var virtualKey = KeyInterop.VirtualKeyFromKey(parsedKey); + if (virtualKey != 0) + { + key = (uint)virtualKey; + } + } + else 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..1c38ee5bbbc --- /dev/null +++ b/Flow.Launcher.Avalonia/Helper/HotKeyMapper.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Avalonia.ViewModel; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.Hotkey; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.UserSettings; +using System.Windows.Input; + +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; + private static string _toggleHotkeyString = string.Empty; + private static readonly Dictionary _customQueryHotkeyIds = new(StringComparer.Ordinal); + + /// + /// 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); + + LoadCustomPluginHotkeys(); + + Log.Info(ClassName, $"HotKeyMapper initialized with hotkey: {_settings.Hotkey}"); + } + + /// + /// Set or update the toggle hotkey. + /// + internal static void SetToggleHotkey(string hotkeyString) + { + if (string.IsNullOrWhiteSpace(hotkeyString)) + { + RemoveToggleHotkey(); + Log.Warn(ClassName, "Empty hotkey string"); + return; + } + + var previousHotkeyId = _toggleHotkeyId; + var previousHotkeyString = _toggleHotkeyString; + + if (previousHotkeyId >= 0) + { + GlobalHotkey.Unregister(previousHotkeyId); + _toggleHotkeyId = -1; + } + + if (!TryRegisterHotkey(hotkeyString, OnToggleHotkey, out var newHotkeyId)) + { + Log.Error(ClassName, $"Failed to register hotkey: {hotkeyString}"); + + if (!string.IsNullOrWhiteSpace(previousHotkeyString)) + { + if (TryRegisterHotkey(previousHotkeyString, OnToggleHotkey, out var restoredHotkeyId)) + { + _toggleHotkeyId = restoredHotkeyId; + if (_toggleHotkeyId >= 0) + { + _toggleHotkeyString = previousHotkeyString; + Log.Warn(ClassName, $"Restored previous toggle hotkey: {previousHotkeyString}"); + } + } + } + + return; + } + + _toggleHotkeyId = newHotkeyId; + _toggleHotkeyString = hotkeyString; + Log.Info(ClassName, $"Registered toggle hotkey: {hotkeyString}"); + } + + /// + /// Remove the current toggle hotkey. + /// + internal static void RemoveToggleHotkey() + { + if (_toggleHotkeyId >= 0) + { + GlobalHotkey.Unregister(_toggleHotkeyId); + _toggleHotkeyId = -1; + } + + _toggleHotkeyString = string.Empty; + } + + internal static void LoadCustomPluginHotkeys() + { + if (_settings?.CustomPluginHotkeys is null) + { + return; + } + + foreach (var customHotkey in _settings.CustomPluginHotkeys) + { + if (!SetCustomQueryHotkey(customHotkey)) + { + Log.Warn(ClassName, $"Failed to load custom query hotkey '{customHotkey.Hotkey}' for query '{customHotkey.ActionKeyword}'"); + } + } + } + + internal static bool SetCustomQueryHotkey(CustomPluginHotkey hotkey) + { + if (string.IsNullOrWhiteSpace(hotkey.Hotkey) || string.IsNullOrWhiteSpace(hotkey.ActionKeyword)) + { + return false; + } + + RemoveHotkey(hotkey.Hotkey); + + if (!TryRegisterHotkey(hotkey.Hotkey, () => + { + if (ShouldIgnoreHotkeys()) + { + return; + } + + _mainViewModel?.ShowWithInjectedQuery(hotkey.ActionKeyword); + }, out var hotkeyId)) + { + return false; + } + + _customQueryHotkeyIds[hotkey.Hotkey] = hotkeyId; + return true; + } + + internal static void RemoveHotkey(string hotkeyString) + { + if (string.IsNullOrWhiteSpace(hotkeyString)) + { + return; + } + + if (_customQueryHotkeyIds.TryGetValue(hotkeyString, out var hotkeyId)) + { + GlobalHotkey.Unregister(hotkeyId); + _customQueryHotkeyIds.Remove(hotkeyString); + } + } + + private static void OnToggleHotkey() + { + if (ShouldIgnoreHotkeys()) + { + Log.Info(ClassName, "Toggle hotkey ignored"); + return; + } + + Log.Info(ClassName, "Toggle hotkey triggered"); + _mainViewModel?.ToggleFlowLauncher(); + } + + private static bool ShouldIgnoreHotkeys() + { + return _settings?.IgnoreHotkeysOnFullscreen == true && Win32Helper.IsForegroundWindowFullscreen() + || App.API?.IsGameModeOn() == true; + } + + /// + /// Checks if a hotkey is available for registration. + /// + internal static bool CheckAvailability(HotkeyModel hotkey) + { + if (!TryGetRegistrationParts(hotkey, out var mods, out var key)) + return false; + + // Try to register and immediately unregister + int id = GlobalHotkey.Register(mods, key, () => { }); + if (id >= 0) + { + GlobalHotkey.Unregister(id); + return true; + } + + return false; + } + + private static bool TryRegisterHotkey(string hotkeyString, Action callback, out int hotkeyId) + { + hotkeyId = -1; + + if (!TryGetRegistrationParts(new HotkeyModel(hotkeyString), out var modifiers, out var key)) + { + Log.Error(ClassName, $"Failed to parse hotkey: {hotkeyString}"); + return false; + } + + hotkeyId = GlobalHotkey.Register(modifiers, key, callback); + + if (hotkeyId < 0) + { + Log.Error(ClassName, $"Failed to register hotkey: {hotkeyString}"); + return false; + } + + return true; + } + + private static bool TryGetRegistrationParts(HotkeyModel hotkey, out GlobalHotkey.Modifiers modifiers, out uint key) + { + modifiers = GlobalHotkey.Modifiers.None; + key = 0; + + if (!hotkey.Validate(true)) + { + return false; + } + + if (hotkey.Alt) + { + modifiers |= GlobalHotkey.Modifiers.Alt; + } + + if (hotkey.Ctrl) + { + modifiers |= GlobalHotkey.Modifiers.Control; + } + + if (hotkey.Shift) + { + modifiers |= GlobalHotkey.Modifiers.Shift; + } + + if (hotkey.Win) + { + modifiers |= GlobalHotkey.Modifiers.Win; + } + + key = (uint)KeyInterop.VirtualKeyFromKey(hotkey.CharKey); + return key != 0; + } + + /// + /// Cleanup and unregister all hotkeys. + /// + internal static void Shutdown() + { + RemoveToggleHotkey(); + + foreach (var hotkeyId in _customQueryHotkeyIds.Values) + { + GlobalHotkey.Unregister(hotkeyId); + } + + _customQueryHotkeyIds.Clear(); + GlobalHotkey.Shutdown(); + Log.Info(ClassName, "HotKeyMapper shutdown"); + } +} diff --git a/Flow.Launcher.Avalonia/Helper/ImageLoader.cs b/Flow.Launcher.Avalonia/Helper/ImageLoader.cs new file mode 100644 index 00000000000..ef616659375 --- /dev/null +++ b/Flow.Launcher.Avalonia/Helper/ImageLoader.cs @@ -0,0 +1,443 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +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(); + + // 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 + { + await using var stream = await Flow.Launcher.Infrastructure.Http.Http.GetStreamAsync(url); + 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/Helper/ResultHelper.cs b/Flow.Launcher.Avalonia/Helper/ResultHelper.cs new file mode 100644 index 00000000000..e89f41395eb --- /dev/null +++ b/Flow.Launcher.Avalonia/Helper/ResultHelper.cs @@ -0,0 +1,42 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Avalonia.Helper; + +internal static class ResultHelper +{ + internal static async Task PopulateResultsAsync(string pluginId, string trimmedQuery, string title, string subTitle, string recordKey) + { + var plugin = PluginManager.GetPluginForId(pluginId); + if (plugin == null) + { + return null; + } + + var query = QueryBuilder.Build(trimmedQuery, trimmedQuery, PluginManager.GetNonGlobalPlugins()); + if (query == null) + { + return null; + } + + try + { + var freshResults = await PluginManager.QueryForPluginAsync(plugin, query, CancellationToken.None); + if (string.IsNullOrEmpty(recordKey)) + { + return freshResults?.FirstOrDefault(r => r.Title == title && r.SubTitle == subTitle); + } + + return freshResults?.FirstOrDefault(r => r.RecordKey == recordKey) + ?? freshResults?.FirstOrDefault(r => r.Title == title && r.SubTitle == subTitle); + } + catch (System.Exception e) + { + App.API?.LogException(nameof(ResultHelper), $"Failed to query results for plugin id {pluginId}", e); + return null; + } + } +} diff --git a/Flow.Launcher.Avalonia/Helper/TextBlockHelper.cs b/Flow.Launcher.Avalonia/Helper/TextBlockHelper.cs new file mode 100644 index 00000000000..117b447d48b --- /dev/null +++ b/Flow.Launcher.Avalonia/Helper/TextBlockHelper.cs @@ -0,0 +1,83 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; + +namespace Flow.Launcher.Avalonia.Helper; + +/// +/// Attached properties for TextBlock to enable binding Inlines from converters. +/// +public static class TextBlockHelper +{ + /// + /// Attached property for setting formatted text with highlights on a TextBlock. + /// Bind to this with a MultiBinding + HighlightTextConverter to get highlighted search results. + /// + public static readonly AttachedProperty FormattedTextProperty = + AvaloniaProperty.RegisterAttached( + "FormattedText", + typeof(TextBlockHelper)); + + static TextBlockHelper() + { + FormattedTextProperty.Changed.AddClassHandler(OnFormattedTextChanged); + } + + public static InlineCollection? GetFormattedText(TextBlock textBlock) + => textBlock.GetValue(FormattedTextProperty); + + public static void SetFormattedText(TextBlock textBlock, InlineCollection? value) + => textBlock.SetValue(FormattedTextProperty, value); + + private static void OnFormattedTextChanged(TextBlock textBlock, AvaloniaPropertyChangedEventArgs e) + { + textBlock.Inlines?.Clear(); + + if (e.NewValue is InlineCollection inlines) + { + // We need to copy the inlines because they can only belong to one parent + foreach (var inline in inlines) + { + var clone = CloneInline(inline); + if (clone != null) + { + textBlock.Inlines?.Add(clone); + } + } + } + } + + private static Inline? CloneInline(Inline inline) + { + if (inline is Run run) + { + return new Run(run.Text) + { + FontWeight = run.FontWeight, + Foreground = run.Foreground + }; + } + + if (inline is Span span) + { + var clone = new Span(); + foreach (var child in span.Inlines) + { + var childClone = CloneInline(child); + if (childClone != null) + { + clone.Inlines.Add(childClone); + } + } + + return clone; + } + + if (inline is LineBreak) + { + return new LineBreak(); + } + + return null; + } +} diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml b/Flow.Launcher.Avalonia/MainWindow.axaml new file mode 100644 index 00000000000..a0cc621506b --- /dev/null +++ b/Flow.Launcher.Avalonia/MainWindow.axaml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml.cs b/Flow.Launcher.Avalonia/MainWindow.axaml.cs new file mode 100644 index 00000000000..134cd0f58b3 --- /dev/null +++ b/Flow.Launcher.Avalonia/MainWindow.axaml.cs @@ -0,0 +1,385 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using Avalonia.VisualTree; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Avalonia.ViewModel; +using Flow.Launcher.Avalonia.Views.Dialogs; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin.SharedModels; +using System; +using System.ComponentModel; +#if DEBUG +using Avalonia.Diagnostics; +#endif + +namespace Flow.Launcher.Avalonia; + +public partial class MainWindow : Window +{ + private MainViewModel? _viewModel; + private TextBox? _queryTextBox; + private Settings? _settings; + private KeyGesture? _previewHotkeyGesture; + private KeyGesture? _openHistoryHotkeyGesture; + private KeyGesture? _cycleHistoryUpHotkeyGesture; + private KeyGesture? _cycleHistoryDownHotkeyGesture; + + public MainWindow() + { + InitializeComponent(); + + // Get the ViewModel and Settings from DI + _viewModel = Ioc.Default.GetRequiredService(); + _settings = Ioc.Default.GetRequiredService(); + _viewModel.HideRequested += () => Hide(); + _viewModel.QueryTextFocusRequested += HandleQueryTextFocusRequest; + DataContext = _viewModel; + + // Get settings for hotkey configuration + _settings = Ioc.Default.GetRequiredService(); + _settings.PropertyChanged += OnSettingsPropertyChanged; + UpdateHotkeyGestures(); + + // Get reference to the query text box + _queryTextBox = this.FindControl("QueryTextBox"); + _queryTextBox?.AddHandler(KeyDownEvent, OnQueryTextBoxKeyDown, RoutingStrategies.Tunnel, handledEventsToo: true); + + // Subscribe to window events + this.Deactivated += OnWindowDeactivated; + +#if DEBUG + this.AttachDevTools(); +#endif + } + + private void OnSettingsPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Settings.PreviewHotkey) + || e.PropertyName == nameof(Settings.OpenHistoryHotkey) + || e.PropertyName == nameof(Settings.CycleHistoryUpHotkey) + || e.PropertyName == nameof(Settings.CycleHistoryDownHotkey)) + { + UpdateHotkeyGestures(); + } + } + + private void UpdateHotkeyGestures() + { + if (_settings == null) return; + + _previewHotkeyGesture = ParseKeyGesture(_settings.PreviewHotkey); + _openHistoryHotkeyGesture = ParseKeyGesture(_settings.OpenHistoryHotkey); + _cycleHistoryUpHotkeyGesture = ParseKeyGesture(_settings.CycleHistoryUpHotkey); + _cycleHistoryDownHotkeyGesture = ParseKeyGesture(_settings.CycleHistoryDownHotkey); + } + + private static KeyGesture? ParseKeyGesture(string hotkey) + { + if (string.IsNullOrWhiteSpace(hotkey)) return null; + + try + { + // Try parsing as a standard key gesture + return KeyGesture.Parse(hotkey); + } + catch + { + // Fallback: manual parsing for common formats + var parts = hotkey.Split('+', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) return null; + + var keyPart = parts[^1].Trim(); + if (!Enum.TryParse(keyPart, true, out var key)) + return null; + + var modifiers = KeyModifiers.None; + for (int i = 0; i < parts.Length - 1; i++) + { + var mod = parts[i].Trim(); + if (Enum.TryParse(mod, true, out var parsedMod)) + modifiers |= parsedMod; + } + + return new KeyGesture(key, modifiers); + } + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + if (_settings?.FirstLaunch == true) + { + _settings.FirstLaunch = false; + _settings.ReleaseNotesVersion = Constant.Version; + + if (Win32Helper.IsBackdropSupported()) + { + _settings.BackdropType = BackdropTypes.Acrylic; + } + + _settings.Save(); + + var welcomeWindow = new WelcomeWindow(); + welcomeWindow.Show(this); + } + + // 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(); + + // QueryTextFocusRequested applies the final select/caret behavior. Avoid a transient SelectAll here. + _queryTextBox?.Focus(); + } + + 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); + + if (TryHandleDynamicHotkeys(e)) + { + return; + } + + // Handle Escape to hide window (handled by command, but keep as fallback) + if (e.Key == Key.Escape) + { + _viewModel?.EscCommand.Execute(null); + e.Handled = true; + return; + } + + // Handle Right Arrow to open context menu when cursor is at end of query + if (e.Key == Key.Right && _viewModel != null) + { + // Only trigger context menu if: + // 1. We're in results view + // 2. There's a selected result + // 3. Cursor is at the end of the query text + if (_viewModel.IsResultsViewActive && + _viewModel.Results.SelectedItem != null && + _queryTextBox != null && + _queryTextBox.CaretIndex >= (_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; + } + } + + 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) + { + // Hide window when it loses focus if the setting is enabled + if (_settings?.HideWhenDeactivated == true) + { + Hide(); + } + } + + private void OnQueryTextBoxKeyDown(object? sender, KeyEventArgs e) + { + if (sender is not TextBox) + { + return; + } + + TryHandleDynamicHotkeys(e); + } + + private bool TryHandleDynamicHotkeys(KeyEventArgs e) + { + if (_openHistoryHotkeyGesture != null + && e.Key == _openHistoryHotkeyGesture.Key + && e.KeyModifiers == _openHistoryHotkeyGesture.KeyModifiers) + { + _viewModel?.LoadHistoryCommand.Execute(null); + e.Handled = true; + return true; + } + + if (e.Source is Visual source && source.FindAncestorOfType() == null && e.Source is not TextBox) + { + return false; + } + + if (_previewHotkeyGesture != null + && e.Key == _previewHotkeyGesture.Key + && e.KeyModifiers == _previewHotkeyGesture.KeyModifiers) + { + _viewModel?.TogglePreviewCommand.Execute(null); + e.Handled = true; + return true; + } + + if (_cycleHistoryUpHotkeyGesture != null + && e.Key == _cycleHistoryUpHotkeyGesture.Key + && e.KeyModifiers == _cycleHistoryUpHotkeyGesture.KeyModifiers) + { + _viewModel?.ReverseHistoryCommand.Execute(null); + e.Handled = true; + return true; + } + + if (_cycleHistoryDownHotkeyGesture != null + && e.Key == _cycleHistoryDownHotkeyGesture.Key + && e.KeyModifiers == _cycleHistoryDownHotkeyGesture.KeyModifiers) + { + _viewModel?.ForwardHistoryCommand.Execute(null); + e.Handled = true; + return true; + } + + return false; + } + + private void HandleQueryTextFocusRequest(QueryTextFocusRequest request) + { + if (_queryTextBox == null) + { + return; + } + + if (!request.ShowWindow && !request.ActivateWindow) + { + ApplyQueryTextBoxSelection(request.Mode); + return; + } + + if (!request.ShowWindow && (!IsVisible || _queryTextBox.IsVisible != true)) + { + return; + } + + var revealAfterFocus = request.ShowWindow && !IsVisible; + if (revealAfterFocus) + { + Opacity = 0; + } + + if (request.ShowWindow) + { + SynchronizeQueryTextBoxText(); + Show(); + } + + if (request.ActivateWindow) + { + Activate(); + } + + if (revealAfterFocus) + { + Dispatcher.UIThread.Post(() => + { + SynchronizeQueryTextBoxText(); + ApplyQueryTextBoxFocus(request.Mode); + Dispatcher.UIThread.Post(() => Opacity = 1, DispatcherPriority.Render); + }, DispatcherPriority.ContextIdle); + return; + } + + ApplyQueryTextBoxFocus(request.Mode); + } + + private void SynchronizeQueryTextBoxText() + { + if (_queryTextBox == null || _viewModel == null) + { + return; + } + + if (_queryTextBox.Text != _viewModel.QueryText) + { + _queryTextBox.Text = _viewModel.QueryText; + } + } + + private void ApplyQueryTextBoxFocus(QueryTextFocusMode mode, bool selectionAlreadyApplied = false) + { + if (_queryTextBox == null) + { + return; + } + + _queryTextBox.Focus(); + + if (!selectionAlreadyApplied) + { + ApplyQueryTextBoxSelection(mode); + } + } + + private void ApplyQueryTextBoxSelection(QueryTextFocusMode mode) + { + if (_queryTextBox == null) + { + return; + } + + if (mode == QueryTextFocusMode.Focus) + { + return; + } + + if (mode == QueryTextFocusMode.SelectAll) + { + _queryTextBox.SelectionStart = 0; + _queryTextBox.SelectionEnd = _queryTextBox.Text?.Length ?? 0; + return; + } + + var textLength = _queryTextBox.Text?.Length ?? 0; + _queryTextBox.SelectionStart = textLength; + _queryTextBox.SelectionEnd = textLength; + _queryTextBox.CaretIndex = textLength; + } +} diff --git a/Flow.Launcher.Avalonia/Program.cs b/Flow.Launcher.Avalonia/Program.cs new file mode 100644 index 00000000000..05204141534 --- /dev/null +++ b/Flow.Launcher.Avalonia/Program.cs @@ -0,0 +1,94 @@ +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) + { + // Initialize WPF Application for plugins that rely on Application.Current.Resources + if (System.Windows.Application.Current == null) + { + var app = new System.Windows.Application + { + ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown + }; + + // Add common resources expected by plugins + // We load the copied WPF resources + try + { + // Load base theme resources (Colors like Color01B, etc.) + // TODO: Sync this with Avalonia theme (Light/Dark) + var themeDict = new System.Windows.ResourceDictionary + { + Source = new Uri("pack://application:,,,/Flow.Launcher.Avalonia;component/WpfResources/Dark.xaml") + }; + app.Resources.MergedDictionaries.Add(themeDict); + + var dict = new System.Windows.ResourceDictionary + { + Source = new Uri("pack://application:,,,/Flow.Launcher.Avalonia;component/WpfResources/CustomControlTemplate.xaml") + }; + app.Resources.MergedDictionaries.Add(dict); + + var dict2 = new System.Windows.ResourceDictionary + { + Source = new Uri("pack://application:,,,/Flow.Launcher.Avalonia;component/WpfResources/SettingWindowStyle.xaml") + }; + app.Resources.MergedDictionaries.Add(dict2); + } + catch (Exception ex) + { + // Fallback if loading fails - at least define the margin that caused the crash + System.Diagnostics.Debug.WriteLine($"Failed to load WPF resources: {ex}"); + var inner = ex.InnerException; + while (inner != null) + { + System.Diagnostics.Debug.WriteLine($"Inner: {inner}"); + inner = inner.InnerException; + } + + if (!app.Resources.Contains("SettingPanelMargin")) + { + app.Resources.Add("SettingPanelMargin", new System.Windows.Thickness(70, 13.5, 18, 13.5)); + } + if (!app.Resources.Contains("SettingPanelItemTopBottomMargin")) + { + app.Resources.Add("SettingPanelItemTopBottomMargin", new System.Windows.Thickness(0, 4.5, 0, 4.5)); + } + if (!app.Resources.Contains("SettingPanelItemRightMargin")) + { + app.Resources.Add("SettingPanelItemRightMargin", new System.Windows.Thickness(0, 0, 9, 0)); + } + if (!app.Resources.Contains("SettingPanelItemLeftMargin")) + { + app.Resources.Add("SettingPanelItemLeftMargin", new System.Windows.Thickness(9, 0, 0, 0)); + } + if (!app.Resources.Contains("SettingPanelItemLeftTopBottomMargin")) + { + app.Resources.Add("SettingPanelItemLeftTopBottomMargin", new System.Windows.Thickness(9, 4.5, 0, 4.5)); + } + if (!app.Resources.Contains("SettingPanelItemRightTopBottomMargin")) + { + app.Resources.Add("SettingPanelItemRightTopBottomMargin", new System.Windows.Thickness(0, 4.5, 9, 4.5)); + } + } + } + + 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/Resource/Internationalization.cs b/Flow.Launcher.Avalonia/Resource/Internationalization.cs new file mode 100644 index 00000000000..86b82670fa6 --- /dev/null +++ b/Flow.Launcher.Avalonia/Resource/Internationalization.cs @@ -0,0 +1,301 @@ +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.Core.Plugin; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Avalonia.Resource; + +/// +/// 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(); + } + + /// + /// 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); + } + } + + /// + /// Update plugin metadata name & description and call OnCultureInfoChanged. + /// + public void UpdatePluginMetadataTranslations() + { + foreach (var p in PluginManager.GetTranslationPlugins()) + { + if (p.Plugin is not IPluginI18n pluginI18N) continue; + try + { + p.Metadata.Name = pluginI18N.GetTranslatedPluginTitle(); + p.Metadata.Description = pluginI18N.GetTranslatedPluginDescription(); + pluginI18N.OnCultureInfoChanged(CultureInfo.CurrentCulture); + } + catch (Exception e) + { + Log.Exception(ClassName, $"Failed for <{p.Metadata.Name}>", e); + } + } + } + + private string GetActualLanguageCode() + { + var languageCode = _settings.Language; + + // 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() + { + foreach (var pluginsDir in PluginManager.Directories.Where(Directory.Exists).Distinct(StringComparer.OrdinalIgnoreCase)) + { + foreach (var dir in Directory.GetDirectories(pluginsDir)) + { + var pluginLanguageDir = Path.Combine(dir, LanguagesFolder); + if (Directory.Exists(pluginLanguageDir) && !_languageDirectories.Contains(pluginLanguageDir, StringComparer.OrdinalIgnoreCase)) + { + _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}"); + } + + /// + /// 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. + /// + public string GetTranslation(string key) + { + if (_translations.TryGetValue(key, out var translation)) + { + return translation; + } + + Log.Warn(ClassName, $"Translation not found for key: {key}"); + 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) + { + var resolvedLanguageCode = string.Equals(languageCode, Constant.SystemLanguageCode, StringComparison.OrdinalIgnoreCase) + ? CultureInfo.CurrentCulture.TwoLetterISOLanguageName + : languageCode; + + _translations.Clear(); + + // Reload English as base + LoadLanguageFile(DefaultLanguageCode); + + // Load new language on top + if (!string.Equals(resolvedLanguageCode, DefaultLanguageCode, StringComparison.OrdinalIgnoreCase)) + { + LoadLanguageFile(resolvedLanguageCode); + } + + ChangeCultureInfo(resolvedLanguageCode); + + // Re-inject into Application.Resources for DynamicResource bindings + InjectIntoApplicationResources(); + + Log.Info(ClassName, $"Language changed to: {resolvedLanguageCode}"); + } +} diff --git a/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs b/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs new file mode 100644 index 00000000000..b8337dac5c8 --- /dev/null +++ b/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs @@ -0,0 +1,111 @@ +using Avalonia.Data; +using Avalonia; +using Avalonia.Markup.Xaml; +using Avalonia.Markup.Xaml.MarkupExtensions; +using CommunityToolkit.Mvvm.DependencyInjection; +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]"; + } + + try + { + if (Application.Current?.Resources.ContainsKey(Key) == true) + { + return new DynamicResourceExtension(Key).ProvideValue(serviceProvider); + } + + // Try to get I18n service from DI before falling back. + var i18n = Ioc.Default.GetService(); + if (i18n != null && i18n.HasTranslation(Key)) + { + return new DynamicResourceExtension(Key).ProvideValue(serviceProvider); + } + } + catch + { + // Ioc.Default might throw if not configured yet + } + + 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) + { + try + { + var i18n = Ioc.Default.GetService(); + if (i18n == null) + { + return $"[{key}]"; + } + + return i18n.GetTranslation(key); + } + catch + { + return $"[{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/Storage/History.cs b/Flow.Launcher.Avalonia/Storage/History.cs new file mode 100644 index 00000000000..9980eebb404 --- /dev/null +++ b/Flow.Launcher.Avalonia/Storage/History.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Avalonia.Storage; + +public class History +{ + public List LastOpenedHistoryItems { get; set; } = []; + + private readonly int _maxHistory = 300; + + public void Add(string queryText, Result result) + { + if (string.IsNullOrWhiteSpace(queryText)) + { + return; + } + + if (string.IsNullOrEmpty(result.PluginID)) + { + return; + } + + var existingHistoryItem = LastOpenedHistoryItems.FirstOrDefault(x => x.Equals(queryText, result)); + if (existingHistoryItem is not null) + { + existingHistoryItem.ExecutedDateTime = DateTime.Now; + existingHistoryItem.Query = queryText; + return; + } + + if (LastOpenedHistoryItems.Count >= _maxHistory) + { + LastOpenedHistoryItems.RemoveAt(0); + } + + LastOpenedHistoryItems.Add(new LastOpenedHistoryResult(queryText, result)); + } +} + +public class LastOpenedHistoryResult +{ + public string Title { get; set; } = string.Empty; + + public string SubTitle { get; set; } = string.Empty; + + public string PluginID { get; set; } = string.Empty; + + public string Query { get; set; } = string.Empty; + + public string RecordKey { get; set; } = string.Empty; + + public DateTime ExecutedDateTime { get; set; } + + public LastOpenedHistoryResult() + { + } + + public LastOpenedHistoryResult(string queryText, Result result) + { + Title = result.Title ?? string.Empty; + SubTitle = result.SubTitle ?? string.Empty; + PluginID = result.PluginID ?? string.Empty; + Query = queryText; + RecordKey = result.RecordKey ?? string.Empty; + ExecutedDateTime = DateTime.Now; + } + + public bool Equals(string queryText, Result result) + { + if (string.IsNullOrEmpty(RecordKey) || string.IsNullOrEmpty(result.RecordKey)) + { + return Title == (result.Title ?? string.Empty) + && SubTitle == (result.SubTitle ?? string.Empty) + && PluginID == (result.PluginID ?? string.Empty) + && Query == queryText; + } + + return RecordKey == result.RecordKey + && PluginID == (result.PluginID ?? string.Empty) + && Query == queryText; + } +} diff --git a/Flow.Launcher.Avalonia/Themes/Base.axaml b/Flow.Launcher.Avalonia/Themes/Base.axaml new file mode 100644 index 00000000000..2ad4fce52ec --- /dev/null +++ b/Flow.Launcher.Avalonia/Themes/Base.axaml @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/Themes/Resources.axaml b/Flow.Launcher.Avalonia/Themes/Resources.axaml new file mode 100644 index 00000000000..37d84ff4cd3 --- /dev/null +++ b/Flow.Launcher.Avalonia/Themes/Resources.axaml @@ -0,0 +1,59 @@ + + + + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs new file mode 100644 index 00000000000..658231933ce --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -0,0 +1,1095 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Avalonia.Storage; +using Flow.Launcher.Avalonia.Resource; +using Flow.Launcher.Avalonia.Views.SettingPages; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure.Storage; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Avalonia.ViewModel; + +/// +/// Represents which view is currently active. +/// +public enum ActiveView +{ + Results, + ContextMenu, + History +} + +public enum QueryTextFocusMode +{ + Focus, + SelectAll, + CaretAtEnd +} + +public readonly record struct QueryTextFocusRequest(bool ShowWindow, bool ActivateWindow, QueryTextFocusMode Mode); + +/// +/// MainViewModel for Avalonia - minimal implementation for plugin queries. +/// +public partial class MainViewModel : ObservableObject, IResultUpdateRegister +{ + private static readonly string ClassName = nameof(MainViewModel); + private const int ProgressBarDelayMilliseconds = 200; + private readonly Settings _settings; + private readonly FlowLauncherJsonStorage _historyItemsStorage; + private readonly History _history; + private CancellationTokenSource? _queryTokenSource; + private string? _ignoredQueryText; + private string _queryTextBeforeHistory = string.Empty; + private bool _pluginsReady; + private int _lastHistoryIndex = 1; + private string _currentQueryOriginalText = string.Empty; + private string _lastQueryActionKeyword = string.Empty; + private CancellationTokenSource? _progressBarDelayTokenSource; + + // Channel-based debouncing for result updates (matches WPF approach) + private readonly Channel _resultsUpdateChannel; + private readonly ChannelWriter _resultsUpdateChannelWriter; + private readonly Task _resultsViewUpdateTask; + + public event Action? HideRequested; + public event Action? QueryTextFocusRequested; + + [ObservableProperty] + private bool _mainWindowVisibility = false; + + [ObservableProperty] + private string _queryText = string.Empty; + + [ObservableProperty] + private bool _isQueryRunning; + + [ObservableProperty] + private bool _isProgressBarVisible; + + [ObservableProperty] + private bool _hasResults; + + [ObservableProperty] + private ResultsViewModel _results; + + [ObservableProperty] + private ResultsViewModel _contextMenu; + + [ObservableProperty] + private ResultsViewModel _historyView; + + [ObservableProperty] + private ActiveView _activeView = ActiveView.Results; + + [ObservableProperty] + private ResultViewModel? _previewSelectedItem; + + [ObservableProperty] + private bool _isPreviewOn; + + /// + /// 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 the history view is currently active. + /// + public bool IsHistoryViewActive => ActiveView == ActiveView.History; + + /// + /// Whether to show the results/context menu area (separator + list). + /// Based on whether we have a non-empty query - NOT on collection count to prevent flickering. + /// + public bool ShowResultsArea => ActiveView == ActiveView.History || !string.IsNullOrWhiteSpace(QueryText) || ContextMenu.Results.Count > 0; + + public Settings Settings => _settings; + + public bool LastQuerySelected { get; set; } + + public MainViewModel(Settings settings) + { + _settings = settings; + _historyItemsStorage = new FlowLauncherJsonStorage(); + _history = _historyItemsStorage.Load(); + _results = new ResultsViewModel(settings); + _contextMenu = new ResultsViewModel(settings); + _historyView = new ResultsViewModel(settings); + + // Initialize channel-based debouncing for result updates + _resultsUpdateChannel = Channel.CreateUnbounded(); + _resultsUpdateChannelWriter = _resultsUpdateChannel.Writer; + _resultsViewUpdateTask = Task.Run(ProcessResultUpdatesAsync); + + _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; + } + }; + + _historyView.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(ResultsViewModel.SelectedItem) && IsHistoryViewActive) + { + PreviewSelectedItem = _historyView.SelectedItem; + } + }; + + // Subscribe to context menu collection changes for ShowResultsArea (context menu still uses count) + ((System.Collections.Specialized.INotifyCollectionChanged)_contextMenu.Results).CollectionChanged += (s, e) => OnPropertyChanged(nameof(ShowResultsArea)); + } + + /// + /// Background task that processes result updates with debouncing. + /// Waits 20ms to batch multiple plugin completions into a single UI update. + /// + private async Task ProcessResultUpdatesAsync() + { + var channelReader = _resultsUpdateChannel.Reader; + + while (await channelReader.WaitToReadAsync()) + { + try + { + // Wait 20ms to allow multiple plugin results to arrive + await Task.Delay(20); + + var pendingUpdates = new List(); + + while (channelReader.TryRead(out var update)) + { + if (!update.Token.IsCancellationRequested) + { + pendingUpdates.Add(update); + } + } + + if (pendingUpdates.Count == 0) + { + continue; + } + + var latestFullUpdateIndex = pendingUpdates.FindLastIndex(update => !update.IsPluginUpdate); + if (latestFullUpdateIndex >= 0) + { + await ApplyResultsUpdateAsync(pendingUpdates[latestFullUpdateIndex]); + for (var i = latestFullUpdateIndex + 1; i < pendingUpdates.Count; i++) + { + await ApplyResultsUpdateAsync(pendingUpdates[i]); + } + } + else + { + foreach (var update in pendingUpdates) + { + await ApplyResultsUpdateAsync(update); + } + } + } + catch (Exception e) + { + Log.Exception(ClassName, "Error processing result update batch", e); + } + } + } + + private async Task ApplyResultsUpdateAsync(ResultsForUpdate update) + { + if (update.Token.IsCancellationRequested) + { + return; + } + + var sortedResults = update.Results + .OrderByDescending(r => r.Score) + .ToList(); + + await global::Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + if (update.Token.IsCancellationRequested) return; + if (update.IsPluginUpdate) + { + Results.ReplaceResultsForPlugin(update.PluginId!, sortedResults); + } + else + { + Results.ReplaceResults(sortedResults); + } + + HasResults = Results.Results.Count > 0; + }); + } + + partial void OnActiveViewChanged(ActiveView value) + { + OnPropertyChanged(nameof(IsResultsViewActive)); + OnPropertyChanged(nameof(IsContextMenuViewActive)); + OnPropertyChanged(nameof(IsHistoryViewActive)); + OnPropertyChanged(nameof(ShowResultsArea)); + + PreviewSelectedItem = value switch + { + ActiveView.Results => Results.SelectedItem, + ActiveView.ContextMenu => ContextMenu.SelectedItem, + ActiveView.History => HistoryView.SelectedItem, + _ => null, + }; + } + + partial void OnIsQueryRunningChanged(bool value) + { + if (value) + { + StartProgressBarDelay(); + } + else + { + StopProgressBarDelay(); + } + } + + private void StartProgressBarDelay() + { + StopProgressBarDelay(); + + var delayTokenSource = new CancellationTokenSource(); + _progressBarDelayTokenSource = delayTokenSource; + _ = ShowProgressBarAfterDelayAsync(delayTokenSource.Token); + } + + private void StopProgressBarDelay() + { + _progressBarDelayTokenSource?.Cancel(); + _progressBarDelayTokenSource?.Dispose(); + _progressBarDelayTokenSource = null; + IsProgressBarVisible = false; + } + + private async Task ShowProgressBarAfterDelayAsync(CancellationToken token) + { + try + { + await Task.Delay(ProgressBarDelayMilliseconds, token); + if (!token.IsCancellationRequested && IsQueryRunning) + { + IsProgressBarVisible = true; + } + } + catch (OperationCanceledException) + { + } + } + + [RelayCommand] + public void TogglePreview() + { + IsPreviewOn = !IsPreviewOn; + } + + public void OnPluginsReady() + { + _pluginsReady = true; + MainWindowVisibility = !_settings.HideOnStartup; + Log.Info(ClassName, MainWindowVisibility ? "Plugins ready - window shown" : "Plugins ready - window kept hidden"); + if (!string.IsNullOrWhiteSpace(QueryText)) + _ = QueryAsync(); + } + + /// + /// Register a plugin to receive results updated event. + /// Required by IResultUpdateRegister for plugin initialization. + /// + public void RegisterResultsUpdatedEvent(PluginPair pair) + { + if (pair.Plugin is not IResultUpdated plugin) + { + return; + } + + plugin.ResultsUpdated += (_, e) => + { + if (e.Token.IsCancellationRequested || + !string.Equals(e.Query.OriginalQuery, _currentQueryOriginalText, StringComparison.Ordinal)) + { + return; + } + + var token = e.Token == default ? _queryTokenSource?.Token ?? CancellationToken.None : e.Token; + if (token.IsCancellationRequested) + { + return; + } + + var results = CreateResultViewModels(e.Results, pair); + if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(results, token, pair.Metadata.ID))) + { + Log.Error(ClassName, "Unable to add item to Result Update Queue"); + } + }; + } + + public void RequestHide() => HideRequested?.Invoke(); + + public void RequestQueryTextFocus(QueryTextFocusRequest request) + { + QueryTextFocusRequested?.Invoke(request); + } + + public void ReQuery(bool reselect = true) => _ = QueryAsync(); + + public void BackToQueryResults() => BackToResults(); + + public void Save() => _historyItemsStorage.Save(); + + [RelayCommand] + private void ReverseHistory() + { + var historyItems = _history.LastOpenedHistoryItems; + if (historyItems.Count == 0) + { + return; + } + + ApplyHistoryQueryText(historyItems[^_lastHistoryIndex].Query); + if (_lastHistoryIndex < historyItems.Count) + { + _lastHistoryIndex++; + } + } + + [RelayCommand] + private void ForwardHistory() + { + var historyItems = _history.LastOpenedHistoryItems; + if (historyItems.Count == 0) + { + return; + } + + ApplyHistoryQueryText(historyItems[^_lastHistoryIndex].Query); + if (_lastHistoryIndex > 1) + { + _lastHistoryIndex--; + } + } + + [RelayCommand] + private void LoadHistory() + { + if (ActiveView == ActiveView.Results) + { + _queryTextBeforeHistory = QueryText; + ActiveView = ActiveView.History; + QueryText = string.Empty; + _ = QueryAsync(); + + if (HistoryView.Results.Count > 0) + { + HistoryView.SelectedIndex = 0; + HistoryView.SelectedItem = HistoryView.Results[0]; + } + + return; + } + + if (ActiveView == ActiveView.History) + { + BackToResultsFromHistory(); + return; + } + + BackToResults(); + } + + /// + /// 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(); + } + } + + /// + /// Hide the main window. + /// + public void Hide() + { + PrepareLastQueryModeBeforeHide(); + HideRequested?.Invoke(); + MainWindowVisibility = false; + ResetTransientViewState(); + ApplyLastQueryModeForHide(); + Log.Info(ClassName, "Hide requested"); + } + + /// + /// Show the main window. + /// + public void Show() + { + ResetTransientViewState(); + var focusMode = ApplyLastQueryModeForShow(); + RequestQueryTextFocus(new QueryTextFocusRequest(true, true, focusMode)); + MainWindowVisibility = true; + Log.Info(ClassName, "Show requested"); + } + + private void ResetTransientViewState() + { + _lastHistoryIndex = 1; + _queryTextBeforeHistory = string.Empty; + ActiveView = ActiveView.Results; + ContextMenu.Clear(); + HistoryView.Clear(); + } + + private void PrepareLastQueryModeBeforeHide() + { + if (_settings.LastQueryMode != LastQueryMode.Selected) + { + return; + } + + LastQuerySelected = true; + RequestQueryTextFocus(new QueryTextFocusRequest(false, false, QueryTextFocusMode.SelectAll)); + } + + private void ApplyLastQueryModeForHide() + { + switch (_settings.LastQueryMode) + { + case LastQueryMode.Empty: + QueryText = string.Empty; + break; + case LastQueryMode.Preserved: + LastQuerySelected = true; + break; + case LastQueryMode.Selected: + break; + case LastQueryMode.ActionKeywordPreserved: + ApplyLastActionKeywordQueryText(); + LastQuerySelected = true; + break; + case LastQueryMode.ActionKeywordSelected: + ApplyLastActionKeywordQueryText(); + LastQuerySelected = true; + break; + } + } + + private QueryTextFocusMode ApplyLastQueryModeForShow() + { + switch (_settings.LastQueryMode) + { + case LastQueryMode.Empty: + QueryText = string.Empty; + return QueryTextFocusMode.SelectAll; + case LastQueryMode.Preserved: + LastQuerySelected = true; + return QueryTextFocusMode.CaretAtEnd; + case LastQueryMode.Selected: + return GetLastQueryFocusMode(); + case LastQueryMode.ActionKeywordPreserved: + ApplyLastActionKeywordQueryText(); + LastQuerySelected = true; + return QueryTextFocusMode.CaretAtEnd; + case LastQueryMode.ActionKeywordSelected: + ApplyLastActionKeywordQueryText(); + return GetLastQueryFocusMode(); + default: + return QueryTextFocusMode.SelectAll; + } + } + + private QueryTextFocusMode GetLastQueryFocusMode() + { + if (LastQuerySelected) + { + return QueryTextFocusMode.Focus; + } + + LastQuerySelected = true; + return QueryTextFocusMode.SelectAll; + } + + private void ApplyLastActionKeywordQueryText() + { + QueryText = string.IsNullOrEmpty(_lastQueryActionKeyword) + ? string.Empty + : $"{_lastQueryActionKeyword}{Query.ActionKeywordSeparator}"; + } + + /// + /// Show the main window with an injected query. + /// + public void ShowWithInjectedQuery(string queryText) + { + ResetTransientViewState(); + QueryText = queryText; + RequestQueryTextFocus(new QueryTextFocusRequest(true, true, QueryTextFocusMode.CaretAtEnd)); + MainWindowVisibility = true; + Log.Info(ClassName, "Show with injected query requested"); + } + + /// + /// Go back from context menu to results view. + /// + [RelayCommand] + private void BackToResults() + { + ActiveView = ActiveView.Results; + ContextMenu.Clear(); + } + + partial void OnQueryTextChanged(string value) + { + if (_ignoredQueryText is not null) + { + if (_ignoredQueryText == value) + { + _ignoredQueryText = null; + return; + } + + _ignoredQueryText = null; + } + + if (ActiveView == ActiveView.Results) + { + UpdateLastQueryActionKeyword(value); + } + + // Notify ShowResultsArea when query text changes (it depends on QueryText) + OnPropertyChanged(nameof(ShowResultsArea)); + _ = QueryAsync(_settings.SearchQueryResultsWithDelay); + } + + private void UpdateLastQueryActionKeyword(string queryText) + { + var query = QueryBuilder.Build(queryText, queryText.Trim(), PluginManager.GetNonGlobalPlugins()); + _lastQueryActionKeyword = query?.ActionKeyword ?? string.Empty; + } + + private async Task QueryAsync(bool searchDelay = false) + { + var previousQueryTokenSource = _queryTokenSource; + previousQueryTokenSource?.Cancel(); + previousQueryTokenSource?.Dispose(); + _queryTokenSource = new CancellationTokenSource(); + var token = _queryTokenSource.Token; + var queryText = QueryText.Trim(); + _currentQueryOriginalText = queryText; + + if (ActiveView == ActiveView.History) + { + QueryHistory(queryText); + IsQueryRunning = false; + return; + } + + // Only clear results when query is empty + if (string.IsNullOrWhiteSpace(queryText)) + { + Results.Clear(); + HasResults = false; + IsQueryRunning = false; + return; + } + + if (!_pluginsReady) + { + IsQueryRunning = false; + return; + } + + if (IsQueryRunning) + { + StartProgressBarDelay(); + } + else + { + IsQueryRunning = true; + } + + try + { + var query = await ConstructQueryAsync(QueryText, _settings.CustomShortcuts, _settings.BuiltinShortcuts); + if (query == null) + { + Results.Clear(); + HasResults = false; + return; + } + + _currentQueryOriginalText = query.OriginalQuery; + _lastQueryActionKeyword = query.ActionKeyword; + + var plugins = PluginManager.ValidPluginsForQuery(query, dialogJump: false) + .Where(p => !p.Metadata.Disabled).ToList(); + + if (plugins.Count == 0) + { + Results.Clear(); + HasResults = false; + return; + } + + // Use a thread-safe collection to accumulate results from all plugins + var allResults = new ConcurrentBag(); + + // Query all plugins in parallel - results shown progressively as each completes + var tasks = plugins.Select(async plugin => + { + var pluginResults = await QueryPluginAsync(plugin, query, token, searchDelay); + if (token.IsCancellationRequested) return; + + // Add results to the bag + foreach (var r in pluginResults) + { + allResults.Add(r); + } + + // Update UI with current accumulated results (progressive update via channel) + if (!token.IsCancellationRequested) + { + _resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(allResults.ToList(), token)); + } + }); + + await Task.WhenAll(tasks); + + // Final update after all plugins complete + if (!token.IsCancellationRequested) + { + _resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(allResults.ToList(), token)); + } + } + catch (OperationCanceledException) { } + catch (Exception e) { Log.Exception(ClassName, "Query error", e); } + finally { if (!token.IsCancellationRequested) IsQueryRunning = false; } + } + + private void QueryHistory(string queryText) + { + var normalizedQuery = queryText.ToLowerInvariant().Trim(); + var historyItems = _history.LastOpenedHistoryItems.OrderByDescending(item => item.ExecutedDateTime).ToList(); + var historyResults = historyItems + .Select((item, index) => CreateHistoryResult(item, historyItems.Count - index)) + .ToList(); + + if (!string.IsNullOrEmpty(normalizedQuery)) + { + historyResults = historyResults.Where(result => + { + var titleMatch = App.API.FuzzySearch(normalizedQuery, result.Title); + if (titleMatch.IsSearchPrecisionScoreMet()) + { + result.Score = titleMatch.Score; + return true; + } + + var subtitleMatch = App.API.FuzzySearch(normalizedQuery, result.SubTitle); + if (subtitleMatch.IsSearchPrecisionScoreMet()) + { + result.Score = subtitleMatch.Score; + return true; + } + + return false; + }).ToList(); + } + + HistoryView.ReplaceResults(historyResults); + HasResults = HistoryView.Results.Count > 0; + } + + private static ResultViewModel CreateHistoryResult(LastOpenedHistoryResult item, int score) + { + return new ResultViewModel + { + Title = item.Title, + SubTitle = item.SubTitle, + Score = score, + HistoryItem = item, + }; + } + + private List CreateResultViewModels(IEnumerable results, PluginPair plugin) + { + var resultViewModels = new List(); + + foreach (var result in results) + { + resultViewModels.Add(new ResultViewModel + { + Title = result.Title ?? string.Empty, + SubTitle = result.SubTitle ?? string.Empty, + IconPath = result.IcoPath ?? plugin.Metadata.IcoPath ?? string.Empty, + Score = result.Score, + PluginResult = result, + Glyph = result.Glyph, + TitleHighlightData = result.TitleHighlightData, + }); + } + + return resultViewModels; + } + + private Task> QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token, bool searchDelay) + { + // Run entirely on thread pool to avoid blocking UI if plugin has synchronous code + return Task.Run(async () => + { + var resultList = new List(); + + try + { + if (searchDelay && !query.IsHomeQuery) + { + 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; + + resultList.AddRange(CreateResultViewModels(results, plugin)); + } + catch (OperationCanceledException) { } + catch (Exception e) { Log.Exception(ClassName, $"Plugin {plugin.Metadata.Name} error", e); } + + return resultList; + }, token); + } + + private async Task ConstructQueryAsync(string queryText, IEnumerable customShortcuts, + IEnumerable builtInShortcuts) + { + if (string.IsNullOrWhiteSpace(queryText)) + { + return QueryBuilder.Build(string.Empty, string.Empty, PluginManager.GetNonGlobalPlugins()); + } + + var queryBuilder = new StringBuilder(queryText); + var queryBuilderTmp = new StringBuilder(queryText); + + foreach (var shortcut in customShortcuts.OrderByDescending(x => x.Key.Length)) + { + if (queryBuilder.ToString() == shortcut.Key) + { + queryBuilder.Replace(shortcut.Key, shortcut.Expand()); + } + + queryBuilder.Replace('@' + shortcut.Key, shortcut.Expand()); + } + + await BuildQueryAsync(builtInShortcuts, queryBuilder, queryBuilderTmp); + + return QueryBuilder.Build(queryText, queryBuilder.ToString().Trim(), PluginManager.GetNonGlobalPlugins()); + } + + private async Task BuildQueryAsync(IEnumerable builtInShortcuts, + StringBuilder queryBuilder, StringBuilder queryBuilderTmp) + { + var customExpanded = queryBuilder.ToString(); + var queryChanged = false; + + foreach (var shortcut in builtInShortcuts) + { + try + { + if (!customExpanded.Contains(shortcut.Key, StringComparison.Ordinal)) + { + continue; + } + + string expansion; + if (shortcut is BuiltinShortcutModel syncShortcut) + { + expansion = syncShortcut.Expand(); + } + else if (shortcut is AsyncBuiltinShortcutModel asyncShortcut) + { + expansion = await asyncShortcut.ExpandAsync(); + } + else + { + continue; + } + + queryBuilder.Replace(shortcut.Key, expansion); + queryBuilderTmp.Replace(shortcut.Key, expansion); + queryChanged = true; + } + catch (Exception e) + { + Log.Exception(ClassName, $"Error when expanding shortcut {shortcut.Key}", e); + } + } + + if (queryChanged) + { + ApplyExpandedQueryText(queryBuilderTmp.ToString()); + } + } + + private void ApplyExpandedQueryText(string expandedQueryText) + { + _ignoredQueryText = expandedQueryText; + QueryText = expandedQueryText; + RequestQueryTextFocus(new QueryTextFocusRequest(false, false, QueryTextFocusMode.CaretAtEnd)); + } + + private void ApplyHistoryQueryText(string historyQueryText) + { + QueryText = historyQueryText; + RequestQueryTextFocus(new QueryTextFocusRequest(false, false, QueryTextFocusMode.CaretAtEnd)); + } + + private void BackToResultsFromHistory(bool restoreQueryText = true) + { + ActiveView = ActiveView.Results; + HistoryView.Clear(); + HasResults = Results.Results.Count > 0; + + if (!restoreQueryText) + { + return; + } + + var queryToRestore = _queryTextBeforeHistory; + _queryTextBeforeHistory = string.Empty; + ApplyHistoryQueryText(queryToRestore); + } + + private async Task RecordHistoryAsync(string executedQueryText, Result result) + { + _history.Add(executedQueryText, result); + await _historyItemsStorage.SaveAsync(); + _lastHistoryIndex = 1; + } + + [RelayCommand] + private void Esc() + { + // If in context menu/history, go back to results; otherwise hide window + if (ActiveView == ActiveView.ContextMenu) + { + BackToResults(); + } + else if (ActiveView == ActiveView.History) + { + BackToResultsFromHistory(); + } + else + { + Hide(); + } + } + + [RelayCommand] + public void OpenSettings() + { + Hide(); + global::Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var settingsWindow = new SettingsWindow(); + settingsWindow.Show(); + }); + } + + [RelayCommand] + private async Task OpenResultAsync() + { + var queryResultsSelected = ActiveView == ActiveView.Results; + var executedQueryText = QueryText.Trim(); + Result? result; + if (ActiveView == ActiveView.ContextMenu) + { + result = ContextMenu.SelectedItem?.PluginResult; + } + else if (ActiveView == ActiveView.History) + { + await OpenHistoryResultAsync(); + return; + } + else + { + result = Results.SelectedItem?.PluginResult; + } + + if (result == null) return; + + try + { + if (await result.ExecuteAsync(new ActionContext { SpecialKeyState = SpecialKeyState.Default })) + { + if (queryResultsSelected) + { + await RecordHistoryAsync(executedQueryText, result); + } + + Hide(); + } + else if (ActiveView == ActiveView.ContextMenu) + { + // If context menu action didn't hide, go back to results + BackToResults(); + } + else if (queryResultsSelected) + { + await RecordHistoryAsync(executedQueryText, result); + } + } + catch (Exception e) { Log.Exception(ClassName, "Execute error", e); } + } + + private async Task OpenHistoryResultAsync() + { + var historyItem = HistoryView.SelectedItem?.HistoryItem; + if (historyItem == null) + { + return; + } + + try + { + var freshResult = await Helper.ResultHelper.PopulateResultsAsync(historyItem.PluginID, historyItem.Query, historyItem.Title, historyItem.SubTitle, historyItem.RecordKey); + if (freshResult != null) + { + var shouldHide = await freshResult.ExecuteAsync(new ActionContext { SpecialKeyState = SpecialKeyState.Default }); + if (shouldHide) + { + Hide(); + } + + return; + } + + BackToResultsFromHistory(false); + ApplyHistoryQueryText(historyItem.Query); + } + catch (Exception e) + { + Log.Exception(ClassName, "Execute history error", e); + } + } + + /// + /// Load context menu for the currently selected result. + /// + [RelayCommand] + private void LoadContextMenu() + { + if (ActiveView == ActiveView.History) + { + return; + } + + 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, + Glyph = r.Glyph + }).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 if (ActiveView == ActiveView.History) + HistoryView.SelectNextItem(); + else + Results.SelectNextItem(); + } + + [RelayCommand] + private void SelectPrevItem() + { + if (ActiveView == ActiveView.ContextMenu) + ContextMenu.SelectPrevItem(); + else if (ActiveView == ActiveView.History) + HistoryView.SelectPrevItem(); + else + Results.SelectPrevItem(); + } +} + +/// +/// Represents a batch of results from a plugin for UI update. +/// Used for channel-based debouncing. +/// +internal readonly struct ResultsForUpdate +{ + public IReadOnlyList Results { get; } + public CancellationToken Token { get; } + public string? PluginId { get; } + public bool IsPluginUpdate => !string.IsNullOrEmpty(PluginId); + + public ResultsForUpdate(IReadOnlyList results, CancellationToken token, string? pluginId = null) + { + Results = results; + Token = token; + PluginId = pluginId; + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs new file mode 100644 index 00000000000..8eb3c928642 --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using Flow.Launcher.Avalonia.Helper; +using Flow.Launcher.Avalonia.Storage; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; + +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; + + [ObservableProperty] + private int _score; + + [ObservableProperty] + private IList? _titleHighlightData; + + [ObservableProperty] + private IList? _subTitleHighlightData; + + /// + /// The underlying plugin result. Used for executing actions and accessing additional properties. + /// + public Result? PluginResult { get; set; } + + /// + /// Backing history item when this row represents an item from the history view. + /// + public LastOpenedHistoryResult? HistoryItem { 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; + + // 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); + partial void OnIconPathChanged(string value) + { + _imageTask = null; + OnPropertyChanged(nameof(Image)); + OnPropertyChanged(nameof(ShowIcon)); + OnPropertyChanged(nameof(ShowGlyph)); + } + + // Glyph support + private GlyphInfo? _glyph; + + public GlyphInfo? Glyph + { + get => _glyph; + set + { + if (SetProperty(ref _glyph, value)) + { + OnPropertyChanged(nameof(GlyphAvailable)); + OnPropertyChanged(nameof(ShowGlyph)); + OnPropertyChanged(nameof(GlyphFontFamily)); + } + } + } + + public bool GlyphAvailable => Glyph != null; + + public bool ShowGlyph => GlyphAvailable && (Settings?.UseGlyphIcons == true || !ShowIcon); + + /// + /// Gets the FontFamily for the glyph icon, handling file paths and resource paths. + /// + public FontFamily? GlyphFontFamily => Glyph != null ? FontLoader.GetFontFamily(Glyph) : null; +} diff --git a/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs new file mode 100644 index 00000000000..a221a42695a --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +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, IDisposable +{ + private readonly Settings _settings; + private readonly SourceList _sourceList = new(); + private readonly ReadOnlyObservableCollection _results; + private readonly IDisposable _subscription; + + [ObservableProperty] + private ResultViewModel? _selectedItem; + + [ObservableProperty] + private int _selectedIndex; + + [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); + + 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 atomic Edit to prevent flickering. + /// Edit batches changes and fires only one notification at the end. + /// + public void ReplaceResults(IEnumerable newResults) + { + var resultsList = newResults.ToList(); + foreach (var r in resultsList) + { + r.Settings = _settings; + } + + // Update highlight data and score for items that will be kept by EditDiff. + // This is necessary because EditDiff reuses existing ResultViewModel instances + // based on Title+SubTitle equality, but highlight indices are query-specific. + // For example, "Chrome" highlighted for "chr" [0,1,2] vs "chrome" [0,1,2,3,4,5]. + var existingItems = new Dictionary<(string PluginId, string RecordKey, string Query, string Title, string SubTitle), ResultViewModel>(); + foreach (var existingItem in _sourceList.Items) + { + existingItems.TryAdd(ResultViewModelComparer.GetIdentityKey(existingItem), existingItem); + } + foreach (var newItem in resultsList) + { + if (existingItems.TryGetValue(ResultViewModelComparer.GetIdentityKey(newItem), out var existing)) + { + existing.PluginResult = newItem.PluginResult; + existing.HistoryItem = newItem.HistoryItem; + existing.IconPath = newItem.IconPath; + existing.Glyph = newItem.Glyph; + existing.TitleHighlightData = newItem.TitleHighlightData; + existing.SubTitleHighlightData = newItem.SubTitleHighlightData; + existing.Score = newItem.Score; + } + } + + // EditDiff calculates minimal changes needed - items with same Title+SubTitle are kept + _sourceList.EditDiff(resultsList, ResultViewModelComparer.Instance); + + // Select first item after replacement + if (_results.Count > 0) + { + SelectedIndex = 0; + SelectedItem = _results[0]; + } + else + { + SelectedItem = null; + SelectedIndex = -1; + } + } + + public void ReplaceResultsForPlugin(string pluginId, IEnumerable pluginResults) + { + var mergedResults = _sourceList.Items + .Where(result => !string.Equals(ResultViewModelComparer.GetPluginId(result), pluginId, StringComparison.Ordinal)) + .Concat(pluginResults) + .ToList(); + + ReplaceResults(mergedResults); + } + + public void AddResult(ResultViewModel result) + { + result.Settings = _settings; + _sourceList.Add(result); + + // Select first item if nothing selected + if (SelectedItem == null && _results.Count > 0) + { + SelectedIndex = 0; + SelectedItem = _results[0]; + } + } + + public void Clear() + { + _sourceList.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]; + } + } + + 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 GetIdentityKey(x).Equals(GetIdentityKey(y)); + } + + public int GetHashCode(ResultViewModel obj) + { + return GetIdentityKey(obj).GetHashCode(); + } + + public static string GetPluginId(ResultViewModel item) + { + return item.PluginResult?.PluginID ?? item.HistoryItem?.PluginID ?? string.Empty; + } + + public static (string PluginId, string RecordKey, string Query, string Title, string SubTitle) GetIdentityKey(ResultViewModel item) + { + return ( + item.PluginResult?.PluginID ?? item.HistoryItem?.PluginID ?? string.Empty, + item.PluginResult?.RecordKey ?? item.HistoryItem?.RecordKey ?? string.Empty, + item.HistoryItem?.Query ?? string.Empty, + item.Title, + item.SubTitle); + } + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/AboutSettingsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/AboutSettingsViewModel.cs new file mode 100644 index 00000000000..2ed0ddabc80 --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/AboutSettingsViewModel.cs @@ -0,0 +1,392 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Diagnostics; +using System.Threading.Tasks; +using System.Windows; +using Avalonia.Media; +using Avalonia.Controls.ApplicationLifetimes; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Avalonia.Resource; +using Flow.Launcher.Avalonia.Views.Dialogs; +using Flow.Launcher.Avalonia.Views.SettingPages; +using Flow.Launcher.Core; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.UserSettings; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages; + +public partial class AboutSettingsViewModel : ObservableObject +{ + private static readonly string ClassName = nameof(AboutSettingsViewModel); + + private readonly Settings _settings; + private readonly Updater _updater; + private readonly Internationalization _i18n; + + public AboutSettingsViewModel() + { + _settings = Ioc.Default.GetRequiredService(); + _updater = Ioc.Default.GetRequiredService(); + _i18n = Ioc.Default.GetRequiredService(); + + LogLevels = DropdownDataGeneric.GetEnumData("LogLevel"); + AvailableFonts = FontManager.Current.SystemFonts.OrderBy(font => font.Name).Select(font => font.Name).Distinct().ToList(); + } + + public string Version => Constant.Version switch + { + "1.0.0" => Constant.Dev, + _ => Constant.Version + }; + + public string Website => Constant.Website; + public string SponsorPage => Constant.SponsorPage; + public string ReleaseNotesUrl => Constant.GitHub + "/releases/latest"; + public string Documentation => Constant.Documentation; + public string Docs => Constant.Docs; + public string GitHub => Constant.GitHub; + public string Crowdin => Constant.CrowdinProjectUrl; + public string ActivatedTimes => string.Format(Translate("about_activate_times", "You have activated Flow Launcher {0} times"), _settings.ActivateTimes); + + public string LogFolderSize => $"{Translate("clearlogfolder", "Clear Logs")} ({BytesToReadableString(GetLogFiles().Sum(file => file.Length))})"; + + public string CacheFolderSize => $"{Translate("clearcachefolder", "Clear Caches")} ({BytesToReadableString(GetCacheFiles().Sum(file => file.Length))})"; + + public List> LogLevels { get; } + + public List AvailableFonts { get; } + + public DropdownDataGeneric? SelectedLogLevelItem + { + get => LogLevels.FirstOrDefault(x => x.Value.Equals(_settings.LogLevel)); + set + { + if (value == null || _settings.LogLevel == value.Value) + { + return; + } + + _settings.LogLevel = value.Value; + Log.SetLogLevel(value.Value); + OnPropertyChanged(); + OnPropertyChanged(nameof(LogLevel)); + } + } + + public LOGLEVEL LogLevel + { + get => _settings.LogLevel; + set + { + if (_settings.LogLevel == value) + { + return; + } + + _settings.LogLevel = value; + Log.SetLogLevel(value); + OnPropertyChanged(); + OnPropertyChanged(nameof(SelectedLogLevelItem)); + } + } + + public string SettingWindowFont + { + get => _settings.SettingWindowFont; + set + { + if (_settings.SettingWindowFont == value) + { + return; + } + + _settings.SettingWindowFont = value; + OnPropertyChanged(); + } + } + + [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; + } + + [RelayCommand] + private async Task OpenDocumentation() + { + Process.Start(new ProcessStartInfo(Documentation) { UseShellExecute = true }); + await Task.CompletedTask; + } + + [RelayCommand] + private async Task OpenCrowdin() + { + Process.Start(new ProcessStartInfo(Crowdin) { UseShellExecute = true }); + await Task.CompletedTask; + } + + [RelayCommand] + private async Task OpenIconsWebsite() + { + Process.Start(new ProcessStartInfo("https://icons8.com/") { UseShellExecute = true }); + await Task.CompletedTask; + } + + [RelayCommand] + private async Task OpenSponsorPage() + { + Process.Start(new ProcessStartInfo(SponsorPage) { UseShellExecute = true }); + await Task.CompletedTask; + } + + [RelayCommand] + private async Task OpenReleaseNotes() + { + if (global::Avalonia.Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || + desktop.MainWindow is null) + { + return; + } + + var window = new ReleaseNotesWindow(); + window.Show(desktop.MainWindow); + await Task.CompletedTask; + } + + [RelayCommand] + private async Task OpenWelcome() + { + if (global::Avalonia.Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || + desktop.MainWindow is null) + { + return; + } + + var window = new WelcomeWindow(); + await window.ShowDialog(desktop.MainWindow); + } + + [RelayCommand] + private async Task UpdateApp() + { + await _updater.UpdateAppAsync(false); + } + + [RelayCommand] + private void OpenSettingsFolder() + { + App.API?.OpenDirectory(DataLocation.SettingsDirectory); + } + + [RelayCommand] + private void OpenParentOfSettingsFolder() + { + var settingsFolderPath = Path.Combine(DataLocation.SettingsDirectory); + var parentFolderPath = Path.GetDirectoryName(settingsFolderPath); + if (!string.IsNullOrWhiteSpace(parentFolderPath)) + { + App.API?.OpenDirectory(parentFolderPath); + } + } + + [RelayCommand] + private void OpenCacheFolder() + { + App.API?.OpenDirectory(DataLocation.CacheDirectory); + } + + [RelayCommand] + private void OpenLogsFolder() + { + App.API?.OpenDirectory(GetLogDir(Constant.Version).FullName); + } + + [RelayCommand] + private void AskClearLogFolderConfirmation() + { + var confirmResult = App.API?.ShowMsgBox( + Translate("clearlogfolderMessage", "Are you sure you want to delete all logs?"), + Translate("clearlogfolder", "Clear Logs"), + MessageBoxButton.YesNo) ?? MessageBoxResult.No; + + if (confirmResult == MessageBoxResult.Yes && !ClearLogFolder()) + { + App.API?.ShowMsgBox(Translate("clearfolderfailMessage", "Failed to clear part of folders and files. Please see log file for more information")); + } + } + + [RelayCommand] + private void AskClearCacheFolderConfirmation() + { + var confirmResult = App.API?.ShowMsgBox( + Translate("clearcachefolderMessage", "Are you sure you want to delete all caches?"), + Translate("clearcachefolder", "Clear Caches"), + MessageBoxButton.YesNo) ?? MessageBoxResult.No; + + if (confirmResult == MessageBoxResult.Yes && !ClearCacheFolder()) + { + App.API?.ShowMsgBox(Translate("clearfolderfailMessage", "Failed to clear part of folders and files. Please see log file for more information")); + } + } + + [RelayCommand] + private void ResetSettingWindowFont() + { + SettingWindowFont = Win32Helper.GetSystemDefaultFont(false); + } + + private bool ClearLogFolder() + { + var success = true; + var logDirectory = GetLogDir(); + var logFiles = GetLogFiles(); + + logFiles.ForEach(file => + { + try + { + file.Delete(); + } + catch (Exception e) + { + App.API?.LogException(ClassName, $"Failed to delete log file: {file.Name}", e); + success = false; + } + }); + + logDirectory.EnumerateDirectories("*", SearchOption.TopDirectoryOnly) + .Where(dir => !Constant.Version.Equals(dir.Name, StringComparison.Ordinal)) + .ToList() + .ForEach(dir => + { + try + { + dir.Delete(recursive: false); + } + catch (Exception e) + { + App.API?.LogException(ClassName, $"Failed to delete log directory: {dir.Name}", e); + success = false; + } + }); + + OnPropertyChanged(nameof(LogFolderSize)); + return success; + } + + private bool ClearCacheFolder() + { + var success = true; + var pluginCacheDirectory = GetPluginCacheDir(); + var cacheFiles = GetCacheFiles(); + + cacheFiles.ForEach(file => + { + try + { + file.Delete(); + } + catch (Exception e) + { + App.API?.LogException(ClassName, $"Failed to delete cache file: {file.Name}", e); + success = false; + } + }); + + if (pluginCacheDirectory.Exists) + { + pluginCacheDirectory.EnumerateDirectories("*", SearchOption.TopDirectoryOnly) + .ToList() + .ForEach(dir => + { + try + { + dir.Delete(recursive: true); + } + catch (Exception e) + { + App.API?.LogException(ClassName, $"Failed to delete cache directory: {dir.Name}", e); + success = false; + } + }); + + try + { + pluginCacheDirectory.Delete(recursive: false); + } + catch (Exception e) + { + App.API?.LogException(ClassName, $"Failed to delete cache directory: {pluginCacheDirectory.Name}", e); + success = false; + } + } + + OnPropertyChanged(nameof(CacheFolderSize)); + return success; + } + + private static DirectoryInfo GetLogDir(string version = "") + { + return new DirectoryInfo(Path.Combine(DataLocation.LogDirectory, version)); + } + + private static List GetLogFiles(string version = "") + { + var directory = GetLogDir(version); + return directory.Exists ? directory.EnumerateFiles("*", SearchOption.AllDirectories).ToList() : []; + } + + private static DirectoryInfo GetCacheDir() + { + return new DirectoryInfo(DataLocation.CacheDirectory); + } + + private static DirectoryInfo GetPluginCacheDir() + { + return new DirectoryInfo(DataLocation.PluginCacheDirectory); + } + + private static List GetCacheFiles() + { + var directory = GetCacheDir(); + return directory.Exists ? directory.EnumerateFiles("*", SearchOption.AllDirectories).ToList() : []; + } + + private static string BytesToReadableString(long bytes) + { + const int scale = 1024; + string[] orders = ["GB", "MB", "KB", "B"]; + long max = (long)Math.Pow(scale, orders.Length - 1); + + foreach (var order in orders) + { + if (bytes > max) + { + return $"{decimal.Divide(bytes, max):##.##} {order}"; + } + + max /= scale; + } + + return "0 B"; + } + + private string Translate(string key, string fallback) + { + var value = _i18n.GetTranslation(key); + return value.StartsWith('[') ? fallback : value; + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/DropdownDataGeneric.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/DropdownDataGeneric.cs new file mode 100644 index 00000000000..02bb2fabd93 --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/DropdownDataGeneric.cs @@ -0,0 +1,48 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages; + +public partial class DropdownDataGeneric : ObservableObject where T : struct, Enum +{ + private readonly string _keyPrefix; + private readonly Func _getData; + + [ObservableProperty] + private string _display = string.Empty; + + public T Value { get; } + + public DropdownDataGeneric(T value, string keyPrefix, Func getData) + { + Value = value; + _keyPrefix = keyPrefix; + _getData = getData; + UpdateLabels(); + } + + public void UpdateLabels() + { + var key = _keyPrefix + _getData(Value); + Display = App.API?.GetTranslation(key) ?? key; + } + + public static List> GetEnumData(string keyPrefix, Func? getData = null) + { + getData ??= (v => v.ToString()); + return Enum.GetValues() + .Select(v => new DropdownDataGeneric(v, keyPrefix, getData)) + .ToList(); + } +} + +public static class DropdownDataGeneric +{ + public static List> GetEnumData(string keyPrefix, Func? getData = null) where T : struct, Enum + { + return DropdownDataGeneric.GetEnumData(keyPrefix, getData); + } +} + diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/GeneralSettingsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/GeneralSettingsViewModel.cs new file mode 100644 index 00000000000..3c6ecf47dd9 --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/GeneralSettingsViewModel.cs @@ -0,0 +1,849 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.DependencyInjection; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using Flow.Launcher.Avalonia.Helper; +using Flow.Launcher.Avalonia.Resource; +using Flow.Launcher.Avalonia.Views.SettingPages; +using Flow.Launcher.Core; +using Flow.Launcher.Core.Configuration; +using Flow.Launcher.Core.Resource; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.DialogJump; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin.SharedModels; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +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; + private readonly Updater _updater; + private readonly Portable _portable; + + public GeneralSettingsViewModel() + { + _settings = Ioc.Default.GetRequiredService(); + _i18n = Ioc.Default.GetRequiredService(); + _updater = Ioc.Default.GetRequiredService(); + _portable = Ioc.Default.GetRequiredService(); + + LoadLanguages(); + LoadSearchWindowOptions(); + LoadSearchPrecisionOptions(); + LoadLastQueryModeOptions(); + LoadHistoryStyleOptions(); + LoadDoublePinyinSchemas(); + LoadDialogJumpOptions(); + } + + // Direct access to settings for bindings + public Flow.Launcher.Infrastructure.UserSettings.Settings Settings => _settings; + + public string Crowdin => Constant.CrowdinProjectUrl; + + #region Languages + + [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(); + UpdateLabels(); + } + } + } + + private void UpdateLabels() + { + SearchWindowScreens.ForEach(x => x.UpdateLabels()); + SearchWindowAligns.ForEach(x => x.UpdateLabels()); + SearchPrecisionScores.ForEach(x => x.UpdateLabels()); + LastQueryModes.ForEach(x => x.UpdateLabels()); + DoublePinyinSchemas.ForEach(x => x.UpdateLabels()); + DialogJumpWindowPositions.ForEach(x => x.UpdateLabels()); + DialogJumpResultBehaviours.ForEach(x => x.UpdateLabels()); + DialogJumpFileResultBehaviours.ForEach(x => x.UpdateLabels()); + HistoryStyles.ForEach(x => x.UpdateLabel(_i18n)); + } + + + #endregion + + #region Startup Settings + + public bool StartOnStartup + { + get => _settings.StartFlowLauncherOnSystemStartup; + set + { + if (_settings.StartFlowLauncherOnSystemStartup == value) + { + return; + } + + try + { + if (value) + { + if (UseLogonTaskForStartup) + { + AutoStartup.ChangeToViaLogonTask(); + } + else + { + AutoStartup.ChangeToViaRegistry(); + } + } + else + { + AutoStartup.DisableViaLogonTaskAndRegistry(); + } + } + catch (Exception e) + { + App.API?.ShowMsgError(Translator.GetString("setAutoStartFailed"), e.Message); + return; + } + + _settings.StartFlowLauncherOnSystemStartup = value; + OnPropertyChanged(); + } + } + + public bool UseLogonTaskForStartup + { + get => _settings.UseLogonTaskForStartup; + set + { + if (_settings.UseLogonTaskForStartup == value) + { + return; + } + + if (StartOnStartup) + { + try + { + if (value) + { + AutoStartup.ChangeToViaLogonTask(); + } + else + { + AutoStartup.ChangeToViaRegistry(); + } + } + catch (Exception e) + { + App.API?.ShowMsgError(Translator.GetString("setAutoStartFailed"), e.Message); + return; + } + } + + _settings.UseLogonTaskForStartup = value; + OnPropertyChanged(); + } + } + + public bool HideOnStartup + { + get => _settings.HideOnStartup; + set + { + _settings.HideOnStartup = value; + OnPropertyChanged(); + } + } + + #endregion + + #region Behavior Settings + + public bool HideWhenDeactivated + { + get => _settings.HideWhenDeactivated; + set + { + _settings.HideWhenDeactivated = value; + OnPropertyChanged(); + } + } + + public bool HideNotifyIcon + { + get => _settings.HideNotifyIcon; + set + { + _settings.HideNotifyIcon = value; + OnPropertyChanged(); + } + } + + public bool ShowAtTopmost + { + get => _settings.ShowAtTopmost; + set + { + _settings.ShowAtTopmost = value; + OnPropertyChanged(); + } + } + + public bool IgnoreHotkeysOnFullscreen + { + get => _settings.IgnoreHotkeysOnFullscreen; + set + { + _settings.IgnoreHotkeysOnFullscreen = value; + OnPropertyChanged(); + } + } + + public bool AlwaysPreview + { + get => _settings.AlwaysPreview; + set + { + _settings.AlwaysPreview = value; + OnPropertyChanged(); + } + } + + #endregion + + #region Update Settings + + public bool AutoUpdates + { + get => _settings.AutoUpdates; + set + { + _settings.AutoUpdates = value; + + if (value) + { + _ = _updater.UpdateAppAsync(false); + } + + OnPropertyChanged(); + } + } + + public bool AutoUpdatePlugins + { + get => _settings.AutoUpdatePlugins; + set + { + _settings.AutoUpdatePlugins = value; + OnPropertyChanged(); + } + } + + #endregion + + #region Search Window Position + + [ObservableProperty] + private List> _searchWindowScreens = new(); + + public SearchWindowScreens SelectedSearchWindowScreen + { + get => _settings.SearchWindowScreen; + set + { + _settings.SearchWindowScreen = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsCustomWindowScreen)); + OnPropertyChanged(nameof(IsSearchWindowAlignVisible)); + } + } + + [ObservableProperty] + private List> _searchWindowAligns = new(); + + public List ScreenNumbers => Enumerable.Range(1, GetScreenCount()).ToList(); + + public SearchWindowAligns SelectedSearchWindowAlign + { + get => _settings.SearchWindowAlign; + set + { + _settings.SearchWindowAlign = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsCustomSearchWindowAlign)); + } + } + + public int CustomScreenNumber + { + get => _settings.CustomScreenNumber; + set + { + _settings.CustomScreenNumber = value; + OnPropertyChanged(); + } + } + + public double CustomWindowLeft + { + get => _settings.CustomWindowLeft; + set + { + _settings.CustomWindowLeft = value; + OnPropertyChanged(); + } + } + + public double CustomWindowTop + { + get => _settings.CustomWindowTop; + set + { + _settings.CustomWindowTop = value; + OnPropertyChanged(); + } + } + + public bool IsCustomWindowScreen => SelectedSearchWindowScreen == Flow.Launcher.Infrastructure.UserSettings.SearchWindowScreens.Custom; + + public bool IsSearchWindowAlignVisible => SelectedSearchWindowScreen != Flow.Launcher.Infrastructure.UserSettings.SearchWindowScreens.RememberLastLaunchLocation; + + public bool IsCustomSearchWindowAlign => SelectedSearchWindowAlign == Flow.Launcher.Infrastructure.UserSettings.SearchWindowAligns.Custom; + + #endregion + + #region Query Settings + + [ObservableProperty] + private List> _searchPrecisionScores = new(); + + public SearchPrecisionScore SelectedSearchPrecision + { + get => _settings.QuerySearchPrecision; + set + { + _settings.QuerySearchPrecision = value; + OnPropertyChanged(); + } + } + + [ObservableProperty] + private List> _lastQueryModes = new(); + + + public LastQueryMode SelectedLastQueryMode + { + get => _settings.LastQueryMode; + set + { + _settings.LastQueryMode = value; + OnPropertyChanged(); + } + } + + public bool SearchQueryResultsWithDelay + { + get => _settings.SearchQueryResultsWithDelay; + set + { + _settings.SearchQueryResultsWithDelay = value; + OnPropertyChanged(); + } + } + + public int SearchDelayTime + { + get => _settings.SearchDelayTime; + set + { + _settings.SearchDelayTime = value; + OnPropertyChanged(); + } + } + + #endregion + + #region Home Page Settings + + public bool ShowHomePage + { + get => _settings.ShowHomePage; + set + { + _settings.ShowHomePage = value; + OnPropertyChanged(); + } + } + + public bool ShowHistoryResultsForHomePage + { + get => _settings.ShowHistoryResultsForHomePage; + set + { + _settings.ShowHistoryResultsForHomePage = value; + OnPropertyChanged(); + } + } + + public int MaxHistoryResultsToShow + { + get => _settings.MaxHistoryResultsToShowForHomePage; + set + { + _settings.MaxHistoryResultsToShowForHomePage = value; + OnPropertyChanged(); + } + } + + [ObservableProperty] + private List _historyStyles = new(); + + public HistoryStyleOption? SelectedHistoryStyle + { + get => HistoryStyles.FirstOrDefault(x => x.Value == _settings.HistoryStyle); + set + { + if (value == null || _settings.HistoryStyle == value.Value) + { + return; + } + + _settings.HistoryStyle = value.Value; + OnPropertyChanged(); + } + } + + #endregion + + #region Miscellaneous Settings + + public string SelectedFileManagerDisplayName => _settings.CustomExplorer.DisplayName; + + public string SelectedBrowserDisplayName => _settings.CustomBrowser.DisplayName; + + public bool AutoRestartAfterChanging + { + get => _settings.AutoRestartAfterChanging; + set + { + _settings.AutoRestartAfterChanging = value; + OnPropertyChanged(); + } + } + + public bool ShowUnknownSourceWarning + { + get => _settings.ShowUnknownSourceWarning; + set + { + _settings.ShowUnknownSourceWarning = value; + OnPropertyChanged(); + } + } + + public bool AlwaysStartEn + { + get => _settings.AlwaysStartEn; + set + { + _settings.AlwaysStartEn = value; + OnPropertyChanged(); + } + } + + public bool ShouldUsePinyin + { + get => _settings.ShouldUsePinyin; + set + { + if (!value && UseDoublePinyin) + { + UseDoublePinyin = false; + } + + _settings.ShouldUsePinyin = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsDoublePinyinVisible)); + } + } + + public bool ShowTaskbarWhenOpened + { + get => _settings.ShowTaskbarWhenInvoked; + set + { + _settings.ShowTaskbarWhenInvoked = value; + OnPropertyChanged(); + } + } + + public bool PortableMode + { + get => DataLocation.PortableDataLocationInUse(); + set + { + if (DataLocation.PortableDataLocationInUse() == value) + { + return; + } + + if (!_portable.CanUpdatePortability()) + { + return; + } + + if (value) + { + _portable.EnablePortableMode(); + } + else + { + _portable.DisablePortableMode(); + } + + OnPropertyChanged(); + } + } + + public bool EnableDialogJump + { + get => _settings.EnableDialogJump; + set + { + if (_settings.EnableDialogJump == value) + { + return; + } + + _settings.EnableDialogJump = value; + DialogJump.SetupDialogJump(value); + + OnPropertyChanged(); + } + } + + public bool ShowDialogJumpWindow + { + get => _settings.ShowDialogJumpWindow; + set + { + _settings.ShowDialogJumpWindow = value; + OnPropertyChanged(); + } + } + + [ObservableProperty] + private List> _doublePinyinSchemas = new(); + + [ObservableProperty] + private List> _dialogJumpWindowPositions = new(); + + [ObservableProperty] + private List> _dialogJumpResultBehaviours = new(); + + [ObservableProperty] + private List> _dialogJumpFileResultBehaviours = new(); + + public bool UseDoublePinyin + { + get => _settings.UseDoublePinyin; + set + { + _settings.UseDoublePinyin = value; + OnPropertyChanged(); + } + } + + public bool IsDoublePinyinVisible => ShouldUsePinyin; + + public DoublePinyinSchemas SelectedDoublePinyinSchema + { + get => _settings.DoublePinyinSchema; + set + { + _settings.DoublePinyinSchema = value; + OnPropertyChanged(); + } + } + + public DialogJumpWindowPositions SelectedDialogJumpWindowPosition + { + get => _settings.DialogJumpWindowPosition; + set + { + _settings.DialogJumpWindowPosition = value; + OnPropertyChanged(); + } + } + + public DialogJumpResultBehaviours SelectedDialogJumpResultBehaviour + { + get => _settings.DialogJumpResultBehaviour; + set + { + _settings.DialogJumpResultBehaviour = value; + OnPropertyChanged(); + } + } + + public DialogJumpFileResultBehaviours SelectedDialogJumpFileResultBehaviour + { + get => _settings.DialogJumpFileResultBehaviour; + set + { + _settings.DialogJumpFileResultBehaviour = value; + OnPropertyChanged(); + } + } + + public bool KoreanIMERegistryKeyExists + { + get + { + var registryKeyExists = Win32Helper.IsKoreanIMEExist(); + var koreanLanguageInstalled = InputLanguage.InstalledInputLanguages.Cast().Any(lang => lang.Culture.Name.StartsWith("ko", StringComparison.OrdinalIgnoreCase)); + var isWindows11 = Win32Helper.IsWindows11(); + return (isWindows11 && koreanLanguageInstalled) || registryKeyExists; + } + } + + public bool LegacyKoreanIMEEnabled + { + get => Win32Helper.IsLegacyKoreanIMEEnabled(); + set + { + if (Win32Helper.SetLegacyKoreanIMEEnabled(value)) + { + OnPropertyChanged(); + } + else + { + App.API?.ShowMsgError(_i18n.GetTranslation("KoreanImeSettingChangeFailTitle"), _i18n.GetTranslation("KoreanImeSettingChangeFailSubTitle")); + } + } + } + + #endregion + + #region Paths + + public string PythonPath => _settings.PluginSettings.PythonExecutablePath ?? "Not set"; + public string NodePath => _settings.PluginSettings.NodeExecutablePath ?? "Not set"; + + [RelayCommand] + private async Task SelectPython() + { + var topLevel = TopLevel.GetTopLevel(global::Avalonia.Application.Current?.ApplicationLifetime is global::Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop ? desktop.MainWindow : null); + if (topLevel == null) + { + return; + } + + var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = _i18n.GetTranslation("selectPythonExecutable"), + AllowMultiple = false, + FileTypeFilter = + [ + new FilePickerFileType("Python") { Patterns = ["pythonw.exe", "python.exe"] }, + FilePickerFileTypes.All + ] + }); + + var selectedFile = files.FirstOrDefault()?.Path.LocalPath; + if (!string.IsNullOrWhiteSpace(selectedFile)) + { + _settings.PluginSettings.PythonExecutablePath = selectedFile; + OnPropertyChanged(nameof(PythonPath)); + } + } + + [RelayCommand] + private async Task SelectNode() + { + var topLevel = TopLevel.GetTopLevel(global::Avalonia.Application.Current?.ApplicationLifetime is global::Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop ? desktop.MainWindow : null); + if (topLevel == null) + { + return; + } + + var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = _i18n.GetTranslation("selectNodeExecutable"), + AllowMultiple = false, + FileTypeFilter = + [ + new FilePickerFileType("Node") { Patterns = ["*.exe"] }, + FilePickerFileTypes.All + ] + }); + + var selectedFile = files.FirstOrDefault()?.Path.LocalPath; + if (!string.IsNullOrWhiteSpace(selectedFile)) + { + _settings.PluginSettings.NodeExecutablePath = selectedFile; + OnPropertyChanged(nameof(NodePath)); + } + } + + [RelayCommand] + private async Task SelectFileManager() + { + if (global::Avalonia.Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || + desktop.MainWindow is null) + { + return; + } + + var window = new SelectFileManagerWindow(); + var result = await window.ShowDialog(desktop.MainWindow); + if (!result) + { + return; + } + + OnPropertyChanged(nameof(SelectedFileManagerDisplayName)); + } + + [RelayCommand] + private async Task SelectBrowser() + { + if (global::Avalonia.Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || + desktop.MainWindow is null) + { + return; + } + + var window = new SelectBrowserWindow(); + var result = await window.ShowDialog(desktop.MainWindow); + if (!result) + { + return; + } + + OnPropertyChanged(nameof(SelectedBrowserDisplayName)); + } + + [RelayCommand] + private void OpenImeSettings() + { + Win32Helper.OpenImeSettings(); + } + + [RelayCommand] + private async Task OpenCrowdin() + { + App.API?.OpenUrl(Crowdin); + await Task.CompletedTask; + } + + #endregion + + #region Load Options + + private void LoadLanguages() + { + Languages = new List + { + new(Constant.SystemLanguageCode, "System"), + new("en", "English"), + new("zh-cn", "中文 (简体)"), + new("zh-tw", "中文 (繁體)"), + new("ko", "한국어"), + new("ja", "日本語") + }; + } + + private void LoadSearchWindowOptions() + { + SearchWindowScreens = DropdownDataGeneric.GetEnumData("SearchWindowScreen"); + SearchWindowAligns = DropdownDataGeneric.GetEnumData("SearchWindowAlign"); + } + + private void LoadSearchPrecisionOptions() + { + SearchPrecisionScores = DropdownDataGeneric.GetEnumData("SearchPrecision"); + } + + private void LoadLastQueryModeOptions() + { + LastQueryModes = DropdownDataGeneric.GetEnumData("LastQuery"); + } + + private void LoadHistoryStyleOptions() + { + HistoryStyles = + [ + new HistoryStyleOption(HistoryStyle.Query, _i18n.GetTranslation("queryHistory")), + new HistoryStyleOption(HistoryStyle.LastOpened, _i18n.GetTranslation("executedHistory")) + ]; + } + + private void LoadDoublePinyinSchemas() + { + DoublePinyinSchemas = DropdownDataGeneric.GetEnumData("DoublePinyinSchemas"); + } + + private void LoadDialogJumpOptions() + { + DialogJumpWindowPositions = DropdownDataGeneric.GetEnumData("DialogJumpWindowPosition"); + DialogJumpResultBehaviours = DropdownDataGeneric.GetEnumData("DialogJumpResultBehaviour"); + DialogJumpFileResultBehaviours = DropdownDataGeneric.GetEnumData("DialogJumpFileResultBehaviour"); + } + + private static int GetScreenCount() + { + if (global::Avalonia.Application.Current?.ApplicationLifetime is global::Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop && + desktop.MainWindow?.Screens?.All is { Count: > 0 } screens) + { + return screens.Count; + } + + return 1; + } + + public sealed class HistoryStyleOption + { + public HistoryStyleOption(HistoryStyle value, string display) + { + Value = value; + Display = display; + } + + public HistoryStyle Value { get; } + + public string Display { get; private set; } + + public void UpdateLabel(AvaloniaI18n i18n) + { + Display = Value switch + { + HistoryStyle.Query => i18n.GetTranslation("queryHistory"), + HistoryStyle.LastOpened => i18n.GetTranslation("executedHistory"), + _ => Display + }; + } + } + + #endregion +} + diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/HotkeySettingsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/HotkeySettingsViewModel.cs new file mode 100644 index 00000000000..1e52a52d876 --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/HotkeySettingsViewModel.cs @@ -0,0 +1,521 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using FluentAvalonia.UI.Controls; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Infrastructure.Hotkey; +using Flow.Launcher.Avalonia.Helper; +using Flow.Launcher.Avalonia.Resource; +using Flow.Launcher.Avalonia.ViewModel; +using Flow.Launcher.Avalonia.Views.SettingPages; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages; + +public partial class HotkeySettingsViewModel : ObservableObject +{ + private readonly Settings _settings; + private readonly Internationalization _i18n; + private readonly MainViewModel _mainViewModel; + + public HotkeySettingsViewModel() + { + _settings = Ioc.Default.GetRequiredService(); + _i18n = Ioc.Default.GetRequiredService(); + _mainViewModel = Ioc.Default.GetRequiredService(); + } + + // Expose settings collections for custom hotkeys/shortcuts + public ObservableCollection CustomPluginHotkeys => _settings.CustomPluginHotkeys; + public ObservableCollection CustomShortcuts => _settings.CustomShortcuts; + public ObservableCollection BuiltinShortcuts => _settings.BuiltinShortcuts; + + // Open Result Modifiers + public string[] OpenResultModifiersList => new[] + { + KeyConstant.Alt, + KeyConstant.Ctrl, + $"{KeyConstant.Ctrl}+{KeyConstant.Alt}" + }; + + public string OpenResultModifiers + { + get => _settings.OpenResultModifiers; + set + { + if (_settings.OpenResultModifiers != value) + { + _settings.OpenResultModifiers = value; + OnPropertyChanged(); + } + } + } + + public bool ShowOpenResultHotkey + { + get => _settings.ShowOpenResultHotkey; + set + { + if (_settings.ShowOpenResultHotkey != value) + { + _settings.ShowOpenResultHotkey = value; + OnPropertyChanged(); + } + } + } + + // Main Toggle Hotkey + public string ToggleHotkey + { + get => _settings.Hotkey; + set + { + if (_settings.Hotkey != value) + { + _settings.Hotkey = value; + HotKeyMapper.SetToggleHotkey(value); + OnPropertyChanged(); + } + } + } + + // Preview Hotkey + public string PreviewHotkey + { + get => _settings.PreviewHotkey; + set + { + if (_settings.PreviewHotkey != value) + { + _settings.PreviewHotkey = value; + OnPropertyChanged(); + } + } + } + + // Dialog Jump Hotkey + public string DialogJumpHotkey + { + get => _settings.DialogJumpHotkey; + set + { + if (_settings.DialogJumpHotkey != value) + { + _settings.DialogJumpHotkey = value; + OnPropertyChanged(); + } + } + } + + // Auto-complete Hotkeys + public string AutoCompleteHotkey + { + get => _settings.AutoCompleteHotkey; + set + { + if (_settings.AutoCompleteHotkey != value) + { + _settings.AutoCompleteHotkey = value; + OnPropertyChanged(); + } + } + } + + public string AutoCompleteHotkey2 + { + get => _settings.AutoCompleteHotkey2; + set + { + if (_settings.AutoCompleteHotkey2 != value) + { + _settings.AutoCompleteHotkey2 = value; + OnPropertyChanged(); + } + } + } + + // Select Next Item Hotkeys + public string SelectNextItemHotkey + { + get => _settings.SelectNextItemHotkey; + set + { + if (_settings.SelectNextItemHotkey != value) + { + _settings.SelectNextItemHotkey = value; + OnPropertyChanged(); + } + } + } + + public string SelectNextItemHotkey2 + { + get => _settings.SelectNextItemHotkey2; + set + { + if (_settings.SelectNextItemHotkey2 != value) + { + _settings.SelectNextItemHotkey2 = value; + OnPropertyChanged(); + } + } + } + + // Select Prev Item Hotkeys + public string SelectPrevItemHotkey + { + get => _settings.SelectPrevItemHotkey; + set + { + if (_settings.SelectPrevItemHotkey != value) + { + _settings.SelectPrevItemHotkey = value; + OnPropertyChanged(); + } + } + } + + public string SelectPrevItemHotkey2 + { + get => _settings.SelectPrevItemHotkey2; + set + { + if (_settings.SelectPrevItemHotkey2 != value) + { + _settings.SelectPrevItemHotkey2 = value; + OnPropertyChanged(); + } + } + } + + // Select Page Hotkeys + public string SelectNextPageHotkey + { + get => _settings.SelectNextPageHotkey; + set + { + if (_settings.SelectNextPageHotkey != value) + { + _settings.SelectNextPageHotkey = value; + OnPropertyChanged(); + } + } + } + + public string SelectPrevPageHotkey + { + get => _settings.SelectPrevPageHotkey; + set + { + if (_settings.SelectPrevPageHotkey != value) + { + _settings.SelectPrevPageHotkey = value; + OnPropertyChanged(); + } + } + } + + // Context Menu Hotkey + public string OpenContextMenuHotkey + { + get => _settings.OpenContextMenuHotkey; + set + { + if (_settings.OpenContextMenuHotkey != value) + { + _settings.OpenContextMenuHotkey = value; + OnPropertyChanged(); + } + } + } + + // Setting Window Hotkey + public string SettingWindowHotkey + { + get => _settings.SettingWindowHotkey; + set + { + if (_settings.SettingWindowHotkey != value) + { + _settings.SettingWindowHotkey = value; + OnPropertyChanged(); + } + } + } + + // History Hotkeys + public string OpenHistoryHotkey + { + get => _settings.OpenHistoryHotkey; + set + { + if (_settings.OpenHistoryHotkey != value) + { + _settings.OpenHistoryHotkey = value; + OnPropertyChanged(); + } + } + } + + public string CycleHistoryUpHotkey + { + get => _settings.CycleHistoryUpHotkey; + set + { + if (_settings.CycleHistoryUpHotkey != value) + { + _settings.CycleHistoryUpHotkey = value; + OnPropertyChanged(); + } + } + } + + public string CycleHistoryDownHotkey + { + get => _settings.CycleHistoryDownHotkey; + set + { + if (_settings.CycleHistoryDownHotkey != value) + { + _settings.CycleHistoryDownHotkey = value; + OnPropertyChanged(); + } + } + } + + // Selected items for lists + [ObservableProperty] + private CustomPluginHotkey? _selectedCustomPluginHotkey; + + [ObservableProperty] + private CustomShortcutModel? _selectedCustomShortcut; + + // Custom Plugin Hotkey Commands + [RelayCommand] + private async Task CustomHotkeyDelete() + { + if (SelectedCustomPluginHotkey is null) + { + await ShowMessageAsync("Custom Query Hotkey", "Please select a custom query hotkey first."); + return; + } + + var confirmed = await ShowConfirmationAsync( + Translate("delete", "Delete"), + $"Delete the custom query hotkey '{SelectedCustomPluginHotkey.Hotkey}'?"); + + if (!confirmed) + { + return; + } + + HotKeyMapper.RemoveHotkey(SelectedCustomPluginHotkey.Hotkey); + CustomPluginHotkeys.Remove(SelectedCustomPluginHotkey); + } + + [RelayCommand] + private async Task CustomHotkeyEdit() + { + if (SelectedCustomPluginHotkey is null) + { + await ShowMessageAsync("Custom Query Hotkey", "Please select a custom query hotkey first."); + return; + } + + var settingItem = CustomPluginHotkeys.FirstOrDefault(o => + o.ActionKeyword == SelectedCustomPluginHotkey.ActionKeyword && o.Hotkey == SelectedCustomPluginHotkey.Hotkey); + + if (settingItem is null) + { + await ShowMessageAsync("Custom Query Hotkey", "The selected custom query hotkey is no longer valid."); + return; + } + + HotKeyMapper.RemoveHotkey(settingItem.Hotkey); + + var window = new CustomQueryHotkeyWindow(settingItem, DoesCustomHotkeyExist); + var result = await ShowWindowDialogAsync(window); + if (!result) + { + if (!string.IsNullOrWhiteSpace(settingItem.Hotkey)) + { + _ = HotKeyMapper.SetCustomQueryHotkey(settingItem); + } + + return; + } + + var index = CustomPluginHotkeys.IndexOf(settingItem); + if (index >= 0 && index < CustomPluginHotkeys.Count) + { + var updatedHotkey = new CustomPluginHotkey(window.Hotkey, window.ActionKeyword); + if (HotKeyMapper.SetCustomQueryHotkey(updatedHotkey)) + { + CustomPluginHotkeys[index] = updatedHotkey; + } + else + { + _ = HotKeyMapper.SetCustomQueryHotkey(settingItem); + await ShowMessageAsync("Custom Query Hotkey", $"Failed to register hotkey '{updatedHotkey.Hotkey}'. It may already be in use by another application."); + } + } + } + + [RelayCommand] + private async Task CustomHotkeyAdd() + { + var window = new CustomQueryHotkeyWindow(DoesCustomHotkeyExist); + var result = await ShowWindowDialogAsync(window); + if (!result) + { + return; + } + + var customHotkey = new CustomPluginHotkey(window.Hotkey, window.ActionKeyword); + if (HotKeyMapper.SetCustomQueryHotkey(customHotkey)) + { + CustomPluginHotkeys.Add(customHotkey); + } + else + { + await ShowMessageAsync("Custom Query Hotkey", $"Failed to register hotkey '{customHotkey.Hotkey}'. It may already be in use by another application."); + } + } + + // Custom Shortcut Commands + [RelayCommand] + private async Task CustomShortcutDelete() + { + if (SelectedCustomShortcut is null) + { + await ShowMessageAsync("Custom Shortcut", "Please select a custom shortcut first."); + return; + } + + var confirmed = await ShowConfirmationAsync( + Translate("delete", "Delete"), + $"Delete the custom shortcut '{SelectedCustomShortcut.Key}'?"); + + if (!confirmed) + { + return; + } + + CustomShortcuts.Remove(SelectedCustomShortcut); + } + + [RelayCommand] + private async Task CustomShortcutEdit() + { + if (SelectedCustomShortcut is null) + { + await ShowMessageAsync("Custom Shortcut", "Please select a custom shortcut first."); + return; + } + + var settingItem = CustomShortcuts.FirstOrDefault(o => + o.Key == SelectedCustomShortcut.Key && o.Value == SelectedCustomShortcut.Value); + + if (settingItem is null) + { + await ShowMessageAsync("Custom Shortcut", "The selected custom shortcut is no longer valid."); + return; + } + + var window = new CustomShortcutWindow(settingItem.Key, settingItem.Value, DoesShortcutExist); + var result = await ShowWindowDialogAsync(window); + if (!result) + { + return; + } + + var index = CustomShortcuts.IndexOf(settingItem); + if (index >= 0 && index < CustomShortcuts.Count) + { + CustomShortcuts[index] = new CustomShortcutModel(window.ShortcutKey, window.ShortcutValue); + } + } + + [RelayCommand] + private async Task CustomShortcutAdd() + { + var window = new CustomShortcutWindow(DoesShortcutExist); + var result = await ShowWindowDialogAsync(window); + if (!result) + { + return; + } + + CustomShortcuts.Add(new CustomShortcutModel(window.ShortcutKey, window.ShortcutValue)); + } + + internal bool DoesShortcutExist(string key) + { + return CustomShortcuts.Any(v => v.Key == key) || + BuiltinShortcuts.Any(v => v.Key == key); + } + + internal bool DoesCustomHotkeyExist(string hotkey) + { + return CustomPluginHotkeys.Any(v => v.Hotkey == hotkey); + } + + private async Task ShowWindowDialogAsync(Window window) + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow is not null) + { + return await window.ShowDialog(desktop.MainWindow); + } + + window.Show(); + return false; + } + + private async Task ShowMessageAsync(string title, string message) + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || desktop.MainWindow is null) + { + return; + } + + var dialog = new ContentDialog + { + Title = title, + Content = message, + CloseButtonText = Translate("commonOK", "OK") + }; + + await dialog.ShowAsync(desktop.MainWindow); + } + + private async Task ShowConfirmationAsync(string title, string message) + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || desktop.MainWindow is null) + { + return false; + } + + var dialog = new ContentDialog + { + Title = title, + Content = message, + PrimaryButtonText = Translate("delete", "Delete"), + CloseButtonText = Translate("cancel", "Cancel") + }; + + var result = await dialog.ShowAsync(desktop.MainWindow); + return result == ContentDialogResult.Primary; + } + + private string Translate(string key, string fallback) + { + var value = _i18n.GetTranslation(key); + return value.StartsWith('[') ? fallback : value; + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginStoreItemViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginStoreItemViewModel.cs new file mode 100644 index 00000000000..035866862de --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginStoreItemViewModel.cs @@ -0,0 +1,114 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Plugin; +using Flow.Launcher.Avalonia.Helper; +using Version = SemanticVersioning.Version; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages +{ + public partial class PluginStoreItemViewModel : ObservableObject + { + private readonly UserPlugin _newPlugin; + private readonly PluginPair _oldPluginPair; + + public PluginStoreItemViewModel(UserPlugin plugin) + { + _newPlugin = plugin; + _oldPluginPair = PluginManager.GetPluginForId(plugin.ID); + + _ = LoadIconAsync(); + } + + public string ID => _newPlugin.ID; + public string Name => _newPlugin.Name; + public string Description => _newPlugin.Description; + public string Author => _newPlugin.Author; + public string Version => _newPlugin.Version; + public string Language => _newPlugin.Language; + public string Website => _newPlugin.Website; + public string UrlDownload => _newPlugin.UrlDownload; + public string UrlSourceCode => _newPlugin.UrlSourceCode; + public string IcoPath => _newPlugin.IcoPath; + + public bool LabelInstalled => _oldPluginPair != null; + public bool LabelUpdate => LabelInstalled && new Version(_newPlugin.Version) > new Version(_oldPluginPair.Metadata.Version); + + internal const string None = "None"; + internal const string RecentlyUpdated = "RecentlyUpdated"; + internal const string NewRelease = "NewRelease"; + internal const string Installed = "Installed"; + + public string Category + { + get + { + string category = None; + if (DateTime.Now - _newPlugin.LatestReleaseDate < TimeSpan.FromDays(7)) + { + category = RecentlyUpdated; + } + if (DateTime.Now - _newPlugin.DateAdded < TimeSpan.FromDays(7)) + { + category = NewRelease; + } + if (_oldPluginPair != null) + { + category = Installed; + } + + return category; + } + } + + [ObservableProperty] + private global::Avalonia.Media.IImage? _icon; + + private async Task LoadIconAsync() + { + try + { + Icon = await ImageLoader.LoadAsync(_newPlugin.IcoPath); + } + catch + { + // Ignore errors, Icon will remain null + } + } + + [RelayCommand] + private async Task Install() + { + await PluginInstaller.InstallPluginAndCheckRestartAsync(_newPlugin); + } + + [RelayCommand] + private async Task Uninstall() + { + if (_oldPluginPair != null) + { + await PluginInstaller.UninstallPluginAndCheckRestartAsync(_oldPluginPair.Metadata); + } + } + + [RelayCommand] + private async Task Update() + { + if (_oldPluginPair != null) + { + await PluginInstaller.UpdatePluginAndCheckRestartAsync(_newPlugin, _oldPluginPair.Metadata); + } + } + + [RelayCommand] + private void OpenUrl(string url) + { + if (!string.IsNullOrEmpty(url)) + { + App.API.OpenUrl(url); + } + } + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginStoreSettingsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginStoreSettingsViewModel.cs new file mode 100644 index 00000000000..da0aa12695d --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginStoreSettingsViewModel.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Avalonia.Views.Dialogs; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages +{ + public partial class PluginStoreSettingsViewModel : ObservableObject + { + public PluginStoreSettingsViewModel() + { + // Fire and forget - load async without blocking + _ = LoadPluginsAsync(); + } + + [ObservableProperty] + private bool _isLoading; + + private async Task LoadPluginsAsync() + { + IsLoading = true; + try + { + // First, try to show cached plugins immediately + LoadPluginsFromManifest(); + + // If no cached plugins, fetch from remote + if (ExternalPlugins.Count == 0) + { + await App.API.UpdatePluginManifestAsync(); + LoadPluginsFromManifest(); + } + } + catch (Exception ex) + { + Flow.Launcher.Infrastructure.Logger.Log.Exception(nameof(PluginStoreSettingsViewModel), "Failed to load plugins", ex); + } + finally + { + IsLoading = false; + } + } + + private void LoadPluginsFromManifest() + { + var plugins = App.API.GetPluginManifest(); + if (plugins != null && plugins.Count > 0) + { + ExternalPlugins = plugins + .Select(p => new PluginStoreItemViewModel(p)) + .OrderByDescending(p => p.Category == PluginStoreItemViewModel.NewRelease) + .ThenByDescending(p => p.Category == PluginStoreItemViewModel.RecentlyUpdated) + .ThenByDescending(p => p.Category == PluginStoreItemViewModel.None) + .ThenByDescending(p => p.Category == PluginStoreItemViewModel.Installed) + .ToList(); + } + } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredPlugins))] + private string _filterText = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredPlugins))] + private bool _showDotNet = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredPlugins))] + private bool _showPython = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredPlugins))] + private bool _showNodeJs = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredPlugins))] + private bool _showExecutable = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredPlugins))] + private IList _externalPlugins = new List(); + + public IEnumerable FilteredPlugins + { + get + { + if (ExternalPlugins == null) return new List(); + + return ExternalPlugins.Where(SatisfiesFilter); + } + } + + private bool SatisfiesFilter(PluginStoreItemViewModel plugin) + { + // Check plugin language + var pluginShown = false; + if (AllowedLanguage.IsDotNet(plugin.Language)) + { + pluginShown = ShowDotNet; + } + else if (AllowedLanguage.IsPython(plugin.Language)) + { + pluginShown = ShowPython; + } + else if (AllowedLanguage.IsNodeJs(plugin.Language)) + { + pluginShown = ShowNodeJs; + } + else if (AllowedLanguage.IsExecutable(plugin.Language)) + { + pluginShown = ShowExecutable; + } + + if (!pluginShown) return false; + + // Check plugin name & description + if (string.IsNullOrEmpty(FilterText)) return true; + + var nameMatch = App.API.FuzzySearch(FilterText, plugin.Name); + var descMatch = App.API.FuzzySearch(FilterText, plugin.Description); + + return nameMatch.IsSearchPrecisionScoreMet() || descMatch.IsSearchPrecisionScoreMet(); + } + + [RelayCommand] + private async Task RefreshExternalPluginsAsync() + { + IsLoading = true; + try + { + // Fetch fresh data from remote + await App.API.UpdatePluginManifestAsync(); + // Reload from manifest (whether update succeeded or not, use latest cached) + LoadPluginsFromManifest(); + } + finally + { + IsLoading = false; + } + } + + [RelayCommand] + private async Task InstallPluginAsync() + { + // In Avalonia we need a window to show the dialog. + // We can get the top level window or pass it as a parameter. + // For now, let's assume we can get the active window or use a service. + // Since we are in a ViewModel, we should avoid direct UI references if possible, + // but for file dialogs it's common to need a TopLevel. + + var topLevel = TopLevel.GetTopLevel(global::Avalonia.Application.Current?.ApplicationLifetime is global::Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop ? desktop.MainWindow : null); + + if (topLevel == null) return; + + var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = App.API.GetTranslation("SelectZipFile"), + AllowMultiple = false, + FileTypeFilter = new[] { new FilePickerFileType("Zip Files") { Patterns = new[] { "*.zip" } } } + }); + + if (files.Count > 0) + { + var file = files[0].Path.LocalPath; + if (!string.IsNullOrEmpty(file)) + { + await PluginInstaller.InstallPluginAndCheckRestartAsync(file); + } + } + } + + [RelayCommand] + private async Task CheckPluginUpdatesAsync() + { + await PluginInstaller.CheckForPluginUpdatesAsync((plugins) => + { + Dispatcher.UIThread.Post(() => _ = ShowPluginUpdateWindowAsync(plugins)); + }, silentUpdate: false); + } + + private static async Task ShowPluginUpdateWindowAsync(List plugins) + { + try + { + if (global::Avalonia.Application.Current?.ApplicationLifetime is not global::Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop || + desktop.MainWindow is null) + { + return; + } + + var dialog = new PluginUpdateWindow(plugins); + await dialog.ShowDialog(desktop.MainWindow); + } + catch (Exception ex) + { + App.API?.LogException(nameof(PluginStoreSettingsViewModel), "Failed to show plugin update window", ex); + } + } + + [RelayCommand] + private void ClearFilterText() + { + FilterText = string.Empty; + } + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginsSettingsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginsSettingsViewModel.cs new file mode 100644 index 00000000000..4fbd7e4534a --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/PluginsSettingsViewModel.cs @@ -0,0 +1,540 @@ +using System; +using System.ComponentModel; +using Avalonia; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Avalonia.Resource; +using Flow.Launcher.Avalonia.Views.Controls; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure.Image; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using FluentAvalonia.UI.Controls; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages; + +public partial class PluginsSettingsViewModel : ObservableObject, IDisposable +{ + private readonly Settings _settings; + private readonly Internationalization _i18n; + private readonly PropertyChangedEventHandler _settingsPropertyChangedHandler; + + public PluginsSettingsViewModel() + { + _settings = Ioc.Default.GetRequiredService(); + _i18n = Ioc.Default.GetRequiredService(); + + LoadDisplayModes(); + LoadPlugins(); + + _settingsPropertyChangedHandler = (_, e) => + { + if (e.PropertyName == nameof(Settings.Language)) + { + foreach (var item in DisplayModes) + { + item.UpdateLabels(); + } + } + }; + _settings.PropertyChanged += _settingsPropertyChangedHandler; + } + + [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, StringComparison.OrdinalIgnoreCase) || + p.Description.Contains(SearchText, StringComparison.OrdinalIgnoreCase) || + p.ActionKeywordsText.Contains(SearchText, StringComparison.OrdinalIgnoreCase) + ); + + partial void OnSearchTextChanged(string value) => OnPropertyChanged(nameof(FilteredPlugins)); + + private void LoadPlugins() + { + var allPlugins = PluginManager.GetAllLoadedPlugins(); + foreach (var plugin in allPlugins.OrderBy(p => p.Metadata.Disabled).ThenBy(p => p.Metadata.Name)) + { + Plugins.Add(new PluginItemViewModel(plugin, _settings)); + } + } + public void Dispose() + { + _settings.PropertyChanged -= _settingsPropertyChangedHandler; + + foreach (var plugin in Plugins) + { + plugin.Dispose(); + } + } + + #region Display Mode + + public enum DisplayMode + { + OnOff, + Priority, + SearchDelay, + HomeOnOff + } + + public partial class DisplayModeItem : ObservableObject + { + private readonly Internationalization _i18n; + public DisplayMode Value { get; } + + [ObservableProperty] + private string _display; + + public DisplayModeItem(DisplayMode value, Internationalization i18n) + { + Value = value; + _i18n = i18n; + UpdateLabels(); + } + + public void UpdateLabels() + { + Display = Value switch + { + DisplayMode.OnOff => _i18n.GetTranslation("DisplayModeOnOff"), + DisplayMode.Priority => _i18n.GetTranslation("DisplayModePriority"), + DisplayMode.SearchDelay => _i18n.GetTranslation("DisplayModeSearchDelay"), + DisplayMode.HomeOnOff => _i18n.GetTranslation("DisplayModeHomeOnOff"), + _ => Value.ToString() + }; + } + } + + + [ObservableProperty] + private List _displayModes = new(); + + [ObservableProperty] + private DisplayModeItem? _selectedDisplayModeItem; + + partial void OnSelectedDisplayModeItemChanged(DisplayModeItem? value) + { + if (value != null) + UpdateDisplayModeFlags(value.Value); + } + + [ObservableProperty] + private bool _isOnOffSelected = true; + + [ObservableProperty] + private bool _isPrioritySelected; + + [ObservableProperty] + private bool _isSearchDelaySelected; + + [ObservableProperty] + private bool _isHomeOnOffSelected; + + private void LoadDisplayModes() + { + DisplayModes = new List + { + new(DisplayMode.OnOff, _i18n), + new(DisplayMode.Priority, _i18n), + new(DisplayMode.SearchDelay, _i18n), + new(DisplayMode.HomeOnOff, _i18n) + }; + + // Set default + SelectedDisplayModeItem = DisplayModes[0]; + } + + + private void UpdateDisplayModeFlags(DisplayMode mode) + { + IsOnOffSelected = mode == DisplayMode.OnOff; + IsPrioritySelected = mode == DisplayMode.Priority; + IsSearchDelaySelected = mode == DisplayMode.SearchDelay; + IsHomeOnOffSelected = mode == DisplayMode.HomeOnOff; + } + + #endregion + + [RelayCommand] + private async Task OpenHelper(Control source) + { + var helpDialog = new ContentDialog + { + Title = _i18n.GetTranslation("flowlauncher_settings"), + Content = new StackPanel + { + Spacing = 10, + Children = + { + new TextBlock + { + Text = _i18n.GetTranslation("priority"), + FontSize = 18, + FontWeight = FontWeight.Bold, + TextWrapping = TextWrapping.Wrap + }, + new TextBlock + { + Text = _i18n.GetTranslation("priority_tips"), + TextWrapping = TextWrapping.Wrap + }, + new TextBlock + { + Text = _i18n.GetTranslation("searchDelay"), + FontSize = 18, + FontWeight = FontWeight.Bold, + Margin = new Thickness(0, 10, 0, 0), + TextWrapping = TextWrapping.Wrap + }, + new TextBlock + { + Text = _i18n.GetTranslation("searchDelayTimeTips"), + TextWrapping = TextWrapping.Wrap + }, + new TextBlock + { + Text = _i18n.GetTranslation("homeTitle"), + FontSize = 18, + FontWeight = FontWeight.Bold, + Margin = new Thickness(0, 10, 0, 0), + TextWrapping = TextWrapping.Wrap + }, + new TextBlock + { + Text = _i18n.GetTranslation("homeTips"), + TextWrapping = TextWrapping.Wrap + } + } + }, + PrimaryButtonText = _i18n.GetTranslation("commonOK"), + CloseButtonText = null + }; + + await helpDialog.ShowAsync(); + } +} + +public partial class PluginItemViewModel : ObservableObject, IDisposable +{ + private readonly PluginPair _plugin; + private readonly Settings _settings; + private readonly ISettingProvider? _settingProvider; + private readonly Internationalization _i18n; + private readonly PropertyChangedEventHandler _metadataChangedHandler; + + public PluginItemViewModel(PluginPair plugin, Settings settings) + { + _plugin = plugin; + _settings = settings; + _i18n = Ioc.Default.GetRequiredService(); + + PluginSettingsObject = _settings.PluginSettings.GetPluginSettings(plugin.Metadata.ID); + + // Initialize settings provider + if (plugin.Plugin is ISettingProvider settingProvider) + { + if (plugin.Plugin is JsonRPCPluginBase jsonRpcPlugin) + { + if (jsonRpcPlugin.NeedCreateSettingPanel()) + { + _settingProvider = settingProvider; + HasSettings = true; + } + } + else + { + _settingProvider = settingProvider; + HasSettings = true; + } + } + + // Initialize Avalonia settings if available + if (HasSettings && _settingProvider != null) + { + try + { + AvaloniaSettingControl = _settingProvider.CreateSettingPanelAvalonia() as Control; + HasNativeAvaloniaSettings = AvaloniaSettingControl != null; + } + catch (Exception ex) + { + Flow.Launcher.Infrastructure.Logger.Log.Exception(nameof(PluginItemViewModel), $"Failed to create Avalonia settings for {Name}", ex); + } + } + + // Listen to metadata changes + _metadataChangedHandler = (_, args) => + { + if (args.PropertyName == nameof(PluginMetadata.AvgQueryTime)) + OnPropertyChanged(nameof(QueryTime)); + if (args.PropertyName == nameof(PluginMetadata.ActionKeywords)) + OnPropertyChanged(nameof(ActionKeywordsText)); + }; + _plugin.Metadata.PropertyChanged += _metadataChangedHandler; + + _ = LoadIconAsync(); + } + + public Infrastructure.UserSettings.Plugin PluginSettingsObject { get; } + + private async Task LoadIconAsync() + { + Icon = await Flow.Launcher.Avalonia.Helper.ImageLoader.LoadAsync(_plugin.Metadata.IcoPath); + } + public void Dispose() + { + _plugin.Metadata.PropertyChanged -= _metadataChangedHandler; + } + + [ObservableProperty] + private IImage? _icon; + + [ObservableProperty] + private bool _hasSettings; + + [ObservableProperty] + private bool _hasNativeAvaloniaSettings; + + /// + /// True if plugin has settings but only WPF settings (no native Avalonia) + /// + public bool HasWpfOnlySettings => HasSettings && !HasNativeAvaloniaSettings; + + [ObservableProperty] + private Control? _avaloniaSettingControl; + + [ObservableProperty] + private bool _isExpanded; + + 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 string ID => _plugin.Metadata.ID; + + public string ActionKeywordsText => string.Join(Query.ActionKeywordSeparator, _plugin.Metadata.ActionKeywords); + + public string InitTime => $"{_plugin.Metadata.InitTime}ms"; + public string QueryTime => $"{_plugin.Metadata.AvgQueryTime}ms"; + + public bool IsDisabled + { + get => _plugin.Metadata.Disabled; + set + { + if (_plugin.Metadata.Disabled != value) + { + _plugin.Metadata.Disabled = value; + PluginSettingsObject.Disabled = value; + OnPropertyChanged(); + // Also update the inverse property for binding convenience + OnPropertyChanged(nameof(PluginState)); + } + } + } + + public bool PluginState + { + get => !IsDisabled; + set => IsDisabled = !value; + } + + public bool PluginHomeState + { + get => !_plugin.Metadata.HomeDisabled; + set + { + if (_plugin.Metadata.HomeDisabled != !value) + { + _plugin.Metadata.HomeDisabled = !value; + PluginSettingsObject.HomeDisabled = !value; + OnPropertyChanged(); + } + } + } + + public int Priority + { + get => _plugin.Metadata.Priority; + set + { + if (_plugin.Metadata.Priority != value) + { + _plugin.Metadata.Priority = value; + PluginSettingsObject.Priority = value; + OnPropertyChanged(); + } + } + } + + public double PluginSearchDelayTime + { + get => _plugin.Metadata.SearchDelayTime == null ? double.NaN : _plugin.Metadata.SearchDelayTime.Value; + set + { + if (double.IsNaN(value)) + { + _plugin.Metadata.SearchDelayTime = null; + PluginSettingsObject.SearchDelayTime = null; + } + else + { + _plugin.Metadata.SearchDelayTime = (int)value; + PluginSettingsObject.SearchDelayTime = (int)value; + } + OnPropertyChanged(); + OnPropertyChanged(nameof(SearchDelayTimeText)); + } + } + + public string SearchDelayTimeText => _plugin.Metadata.SearchDelayTime == null ? + _i18n.GetTranslation("default") : + _i18n.GetTranslation($"SearchDelayTime{_plugin.Metadata.SearchDelayTime}"); + + public bool SearchDelayEnabled => _settings.SearchQueryResultsWithDelay; + public string DefaultSearchDelay => _settings.SearchDelayTime.ToString(); + public bool HomeEnabled => _settings.ShowHomePage && PluginManager.IsHomePlugin(_plugin.Metadata.ID); + + [RelayCommand] + private void OpenSettings() + { + if (_settingProvider == null) return; + + if (HasNativeAvaloniaSettings) + { + IsExpanded = !IsExpanded; + return; + } + + try + { + // Create the WPF settings panel and show in a standalone WPF window + var settingsControl = _settingProvider.CreateSettingPanel(); + if (settingsControl != null) + { + WpfSettingsWindow.Show(settingsControl, Name); + } + } + catch (Exception ex) + { + Flow.Launcher.Infrastructure.Logger.Log.Exception(nameof(PluginItemViewModel), $"Failed to open settings for {Name}", ex); + } + } + + [RelayCommand] + private void OpenPluginDirectory() + { + var directory = _plugin.Metadata.PluginDirectory; + if (!string.IsNullOrEmpty(directory)) + App.API.OpenDirectory(directory); + } + + [RelayCommand] + private void OpenSourceCodeLink() + { + if (!string.IsNullOrEmpty(_plugin.Metadata.Website)) + App.API.OpenUrl(_plugin.Metadata.Website); + } + + [RelayCommand] + private async Task OpenDeletePluginWindow() + { + // We need to implement a dialog for confirmation + var dialog = new ContentDialog + { + Title = _i18n.GetTranslation("plugin_uninstall_title"), + Content = string.Format(_i18n.GetTranslation("plugin_uninstall_content"), Name), + PrimaryButtonText = _i18n.GetTranslation("yes"), + CloseButtonText = _i18n.GetTranslation("no") + }; + + var result = await dialog.ShowAsync(); + if (result == ContentDialogResult.Primary) + { + await PluginInstaller.UninstallPluginAndCheckRestartAsync(_plugin.Metadata); + } + } + + [RelayCommand] + private async Task SetActionKeywords() + { + // Simple dialog to edit keywords + var textBox = new TextBox + { + Text = ActionKeywordsText, + AcceptsReturn = false + }; + + var dialog = new ContentDialog + { + Title = _i18n.GetTranslation("actionKeywords"), + Content = new StackPanel + { + Spacing = 10, + Children = + { + new TextBlock { Text = _i18n.GetTranslation("actionKeywordsDescription") }, + textBox + } + }, + PrimaryButtonText = _i18n.GetTranslation("done"), + CloseButtonText = _i18n.GetTranslation("cancel") + }; + + var result = await dialog.ShowAsync(); + if (result == ContentDialogResult.Primary) + { + var oldKeywords = _plugin.Metadata.ActionKeywords; + var newKeywords = textBox.Text? + .Split(Query.ActionKeywordSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(k => k.Trim()) + .Where(k => !string.IsNullOrEmpty(k)) + .Distinct(StringComparer.Ordinal) + .ToList() ?? []; + + if (newKeywords.Count == 0) + { + newKeywords.Add(Query.GlobalPluginWildcardSign); + } + + var addedKeywords = newKeywords.Except(oldKeywords, StringComparer.Ordinal).ToList(); + var removedKeywords = oldKeywords.Except(newKeywords, StringComparer.Ordinal).ToList(); + + var api = App.API; + if (api == null) + { + return; + } + + foreach (var keyword in removedKeywords) + { + api.RemoveActionKeyword(_plugin.Metadata.ID, keyword); + } + + foreach (var keyword in addedKeywords) + { + api.AddActionKeyword(_plugin.Metadata.ID, keyword); + } + + PluginSettingsObject.ActionKeywords = _plugin.Metadata.ActionKeywords.ToList(); + OnPropertyChanged(nameof(ActionKeywordsText)); + } + } +} 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..9eeb9dc8ab5 --- /dev/null +++ b/Flow.Launcher.Avalonia/ViewModel/SettingPages/ThemeSettingsViewModel.cs @@ -0,0 +1,758 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Xml; +using WpfFrameworkElement = System.Windows.FrameworkElement; +using WpfResourceDictionary = System.Windows.ResourceDictionary; +using WpfSetter = System.Windows.Setter; +using WpfStyle = System.Windows.Style; +using WpfTextBlock = System.Windows.Controls.TextBlock; +using WpfTextBox = System.Windows.Controls.TextBox; +using Avalonia; +using Avalonia.Media; +using Avalonia.Styling; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Avalonia.Resource; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin.SharedModels; +using AvaloniaI18n = Flow.Launcher.Avalonia.Resource.Internationalization; + +namespace Flow.Launcher.Avalonia.ViewModel.SettingPages; + +public partial class ThemeSettingsViewModel : ObservableObject, IDisposable +{ + private readonly Settings _settings; + private readonly AvaloniaI18n _i18n; + private readonly System.ComponentModel.PropertyChangedEventHandler _settingsPropertyChangedHandler; + + private const string ThemeMetadataNamePrefix = "Name:"; + private const string ThemeMetadataIsDarkPrefix = "IsDark:"; + private const string ThemeMetadataHasBlurPrefix = "HasBlur:"; + + public ThemeSettingsViewModel() + { + _settings = Ioc.Default.GetRequiredService(); + _i18n = Ioc.Default.GetRequiredService(); + + ColorSchemeOptions = DropdownDataGeneric.GetEnumData("ColorScheme"); + BackdropTypesList = DropdownDataGeneric.GetEnumData("BackdropTypes"); + AnimationSpeedOptions = DropdownDataGeneric.GetEnumData("AnimationSpeed"); + AvailableFonts = FontManager.Current.SystemFonts.OrderBy(font => font.Name).Select(font => font.Name).Distinct().ToList(); + Themes = LoadThemes(); + + ApplyColorScheme(SelectedColorScheme); + + _settingsPropertyChangedHandler = (_, e) => + { + if (e.PropertyName == nameof(Settings.Language)) + { + UpdateLabels(); + } + }; + _settings.PropertyChanged += _settingsPropertyChangedHandler; + } + + public List> ColorSchemeOptions { get; } + + public List> BackdropTypesList { get; } + + public List> AnimationSpeedOptions { get; } + + public List AvailableFonts { get; } + + public List Themes { get; } + + public ThemeData? SelectedTheme + { + get => Themes.FirstOrDefault(theme => theme.FileNameWithoutExtension == _settings.Theme) ?? Themes.FirstOrDefault(); + set + { + if (value == null || _settings.Theme == value.FileNameWithoutExtension) + { + return; + } + + if (App.API?.SetCurrentTheme(value) == true) + { + OnPropertyChanged(); + OnPropertyChanged(nameof(IsBackdropEnabled)); + } + } + } + + public ColorSchemes SelectedColorScheme + { + get => Enum.TryParse(_settings.ColorScheme, true, out var result) ? result : ColorSchemes.System; + set + { + if (SelectedColorScheme == value) + { + return; + } + + _settings.ColorScheme = value.ToString(); + ApplyColorScheme(value); + OnPropertyChanged(); + } + } + + public string BackdropSubText => !Win32Helper.IsBackdropSupported() ? Translate("BackdropTypeDisabledToolTip", "Backdrop supported starting from Windows 11 build 22000 and above") : string.Empty; + + public bool IsBackdropEnabled => Win32Helper.IsBackdropSupported(); + + public BackdropTypes SelectedBackdropType + { + get => _settings.BackdropType; + set + { + if (_settings.BackdropType == value) + { + return; + } + + _settings.BackdropType = value; + OnPropertyChanged(); + } + } + + public bool DropShadowEffect + { + get => _settings.UseDropShadowEffect; + set + { + if (_settings.UseDropShadowEffect == value) + { + return; + } + + _settings.UseDropShadowEffect = value; + OnPropertyChanged(); + } + } + + public bool KeepMaxResults + { + get => _settings.KeepMaxResults; + set + { + _settings.KeepMaxResults = value; + OnPropertyChanged(); + } + } + + public int MaxResults + { + get => _settings.MaxResultsToShow; + set + { + _settings.MaxResultsToShow = value; + OnPropertyChanged(); + } + } + + public IEnumerable MaxResultsRange => Enumerable.Range(2, 16); + + public double WindowHeightSize + { + get => _settings.WindowHeightSize; + set + { + _settings.WindowHeightSize = value; + OnPropertyChanged(); + } + } + + public double ItemHeightSize + { + get => _settings.ItemHeightSize; + set + { + _settings.ItemHeightSize = value; + OnPropertyChanged(); + } + } + + public double QueryBoxFontSize + { + get => _settings.QueryBoxFontSize; + set + { + _settings.QueryBoxFontSize = value; + OnPropertyChanged(); + } + } + + public double ResultItemFontSize + { + get => _settings.ResultItemFontSize; + set + { + _settings.ResultItemFontSize = value; + OnPropertyChanged(); + } + } + + public double ResultSubItemFontSize + { + get => _settings.ResultSubItemFontSize; + set + { + _settings.ResultSubItemFontSize = value; + OnPropertyChanged(); + } + } + + public string QueryFont + { + get => _settings.QueryBoxFont; + set + { + if (_settings.QueryBoxFont == value) + { + return; + } + + _settings.QueryBoxFont = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(QueryTypefaceOptions)); + OnPropertyChanged(nameof(SelectedQueryTypeface)); + } + } + + public string ResultFont + { + get => _settings.ResultFont; + set + { + if (_settings.ResultFont == value) + { + return; + } + + _settings.ResultFont = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ResultTypefaceOptions)); + OnPropertyChanged(nameof(SelectedResultTypeface)); + } + } + + public string ResultSubFont + { + get => _settings.ResultSubFont; + set + { + if (_settings.ResultSubFont == value) + { + return; + } + + _settings.ResultSubFont = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ResultSubTypefaceOptions)); + OnPropertyChanged(nameof(SelectedResultSubTypeface)); + } + } + + public List QueryTypefaceOptions => GetTypefaceOptions(QueryFont); + + public FontTypefaceOption? SelectedQueryTypeface + { + get => FindTypefaceOption(QueryTypefaceOptions, _settings.QueryBoxFontStyle, _settings.QueryBoxFontWeight, _settings.QueryBoxFontStretch); + set + { + if (value == null) + { + return; + } + + _settings.QueryBoxFontStyle = value.Style; + _settings.QueryBoxFontWeight = value.Weight; + _settings.QueryBoxFontStretch = value.Stretch; + OnPropertyChanged(); + } + } + + public List ResultTypefaceOptions => GetTypefaceOptions(ResultFont); + + public FontTypefaceOption? SelectedResultTypeface + { + get => FindTypefaceOption(ResultTypefaceOptions, _settings.ResultFontStyle, _settings.ResultFontWeight, _settings.ResultFontStretch); + set + { + if (value == null) + { + return; + } + + _settings.ResultFontStyle = value.Style; + _settings.ResultFontWeight = value.Weight; + _settings.ResultFontStretch = value.Stretch; + OnPropertyChanged(); + } + } + + public List ResultSubTypefaceOptions => GetTypefaceOptions(ResultSubFont); + + public FontTypefaceOption? SelectedResultSubTypeface + { + get => FindTypefaceOption(ResultSubTypefaceOptions, _settings.ResultSubFontStyle, _settings.ResultSubFontWeight, _settings.ResultSubFontStretch); + set + { + if (value == null) + { + return; + } + + _settings.ResultSubFontStyle = value.Style; + _settings.ResultSubFontWeight = value.Weight; + _settings.ResultSubFontStretch = value.Stretch; + OnPropertyChanged(); + } + } + + public bool UseGlyphIcons + { + get => _settings.UseGlyphIcons; + set + { + _settings.UseGlyphIcons = value; + OnPropertyChanged(); + } + } + + public bool UseClock + { + get => _settings.UseClock; + set + { + _settings.UseClock = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ClockText)); + } + } + + public bool UseDate + { + get => _settings.UseDate; + set + { + _settings.UseDate = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(DateText)); + } + } + + public List TimeFormatList { get; } = + [ + "h:mm", + "hh:mm", + "H:mm", + "HH:mm", + "tt h:mm", + "tt hh:mm", + "h:mm tt", + "hh:mm tt", + "hh:mm:ss tt", + "HH:mm:ss" + ]; + + public List DateFormatList { get; } = + [ + "MM'/'dd dddd", + "MM'/'dd ddd", + "MM'/'dd", + "MM'-'dd", + "MMMM', 'dd", + "dd'/'MM", + "dd'-'MM", + "ddd MM'/'dd", + "dddd MM'/'dd", + "dddd", + "ddd dd'/'MM", + "dddd dd'/'MM", + "dddd dd', 'MMMM", + "dd', 'MMMM", + "dd.MM.yy", + "dd.MM.yyyy", + "dd MMMM yyyy", + "yyyy-MM-dd" + ]; + + public string TimeFormat + { + get => _settings.TimeFormat; + set + { + _settings.TimeFormat = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ClockText)); + } + } + + public string DateFormat + { + get => _settings.DateFormat; + set + { + _settings.DateFormat = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(DateText)); + } + } + + public string ClockText => DateTime.Now.ToString(TimeFormat, CultureInfo.CurrentUICulture); + + public string DateText => DateTime.Now.ToString(DateFormat, CultureInfo.CurrentUICulture); + + public string ClockAndDateText => $"{Translate("Clock", "Clock")} / {Translate("Date", "Date")}"; + + public bool ShowPlaceholder + { + get => _settings.ShowPlaceholder; + set + { + _settings.ShowPlaceholder = value; + OnPropertyChanged(); + } + } + + public string PlaceholderText + { + get => _settings.PlaceholderText; + set + { + _settings.PlaceholderText = value; + OnPropertyChanged(); + } + } + + public string PlaceholderTextTip => string.Format(Translate("PlaceholderTextTip", "Change placeholder text. Input empty will use: {0}"), Translate("queryTextBoxPlaceholder", "Type here to search")); + + public bool UseAnimation + { + get => _settings.UseAnimation; + set + { + _settings.UseAnimation = value; + OnPropertyChanged(); + } + } + + public AnimationSpeeds SelectedAnimationSpeed + { + get => _settings.AnimationSpeed; + set + { + _settings.AnimationSpeed = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsCustomAnimationSpeed)); + } + } + + public bool IsCustomAnimationSpeed => SelectedAnimationSpeed == AnimationSpeeds.Custom; + + public int CustomAnimationLength + { + get => _settings.CustomAnimationLength; + set + { + _settings.CustomAnimationLength = value; + OnPropertyChanged(); + } + } + + public bool UseSound + { + get => _settings.UseSound; + set + { + _settings.UseSound = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ShowWmpWarning)); + OnPropertyChanged(nameof(EnableVolumeAdjustment)); + } + } + + public bool ShowWmpWarning => !_settings.WMPInstalled && UseSound; + + public bool EnableVolumeAdjustment => _settings.WMPInstalled; + + public double SoundEffectVolume + { + get => _settings.SoundVolume; + set + { + _settings.SoundVolume = value; + OnPropertyChanged(); + } + } + + public bool ShowBadges + { + get => _settings.ShowBadges; + set + { + _settings.ShowBadges = value; + OnPropertyChanged(); + } + } + + public bool ShowBadgesGlobalOnly + { + get => _settings.ShowBadgesGlobalOnly; + set + { + _settings.ShowBadgesGlobalOnly = value; + OnPropertyChanged(); + } + } + + [RelayCommand] + private void OpenThemesFolder() + { + App.API?.OpenDirectory(DataLocation.ThemesDirectory); + } + + [RelayCommand] + private void OpenThemeGallery() + { + Process.Start(new ProcessStartInfo("https://github.com/Flow-Launcher/Flow.Launcher/discussions/1438") { UseShellExecute = true }); + } + + [RelayCommand] + private void OpenThemeBuilder() + { + Process.Start(new ProcessStartInfo("https://www.flowlauncher.com/theme-builder/") { UseShellExecute = true }); + } + + [RelayCommand] + private void ResetSizing() + { + QueryBoxFontSize = 16; + ResultItemFontSize = 16; + ResultSubItemFontSize = 13; + WindowHeightSize = 42; + ItemHeightSize = 58; + QueryFont = Win32Helper.GetSystemDefaultFont(); + ResultFont = Win32Helper.GetSystemDefaultFont(); + ResultSubFont = Win32Helper.GetSystemDefaultFont(); + } + + [RelayCommand] + private void ImportSizing() + { + if (SelectedTheme == null) + { + return; + } + + var themePath = GetThemePath(SelectedTheme.FileNameWithoutExtension); + if (string.IsNullOrWhiteSpace(themePath) || !File.Exists(themePath)) + { + return; + } + + try + { + var resourceDictionary = new WpfResourceDictionary + { + Source = new Uri(themePath, UriKind.Absolute) + }; + + if (resourceDictionary["QueryBoxStyle"] is WpfStyle queryBoxStyle) + { + if (TryGetSetterValue(queryBoxStyle, WpfTextBox.FontSizeProperty, out var fontSize)) + { + QueryBoxFontSize = fontSize; + } + + if (TryGetSetterValue(queryBoxStyle, WpfFrameworkElement.HeightProperty, out var height)) + { + WindowHeightSize = height; + } + } + + if (resourceDictionary["ResultItemHeight"] is double itemHeight) + { + ItemHeightSize = itemHeight; + } + + if (resourceDictionary["ItemTitleStyle"] is WpfStyle itemTitleStyle && + TryGetSetterValue(itemTitleStyle, WpfTextBlock.FontSizeProperty, out var resultFontSize)) + { + ResultItemFontSize = resultFontSize; + } + + if (resourceDictionary["ItemSubTitleStyle"] is WpfStyle itemSubTitleStyle && + TryGetSetterValue(itemSubTitleStyle, WpfTextBlock.FontSizeProperty, out var subResultFontSize)) + { + ResultSubItemFontSize = subResultFontSize; + } + } + catch (Exception) + { + } + } + + private void UpdateLabels() + { + ColorSchemeOptions.ForEach(x => x.UpdateLabels()); + BackdropTypesList.ForEach(x => x.UpdateLabels()); + AnimationSpeedOptions.ForEach(x => x.UpdateLabels()); + OnPropertyChanged(nameof(ClockAndDateText)); + } + + private void ApplyColorScheme(ColorSchemes scheme) + { + if (Application.Current == null) + { + return; + } + + Application.Current.RequestedThemeVariant = scheme switch + { + ColorSchemes.Light => ThemeVariant.Light, + ColorSchemes.Dark => ThemeVariant.Dark, + _ => ThemeVariant.Default + }; + } + + private List LoadThemes() + { + var themeDirectories = new[] + { + Path.Combine(Constant.ProgramDirectory, Constant.Themes), + Path.Combine(DataLocation.DataDirectory(), Constant.Themes) + }; + + var themes = new List(); + foreach (var directory in themeDirectories.Where(Directory.Exists)) + { + foreach (var path in Directory.GetFiles(directory, "*.xaml") + .Where(path => !path.EndsWith("Base.xaml", StringComparison.OrdinalIgnoreCase))) + { + try + { + themes.Add(GetThemeDataFromPath(path)); + } + catch (Exception ex) + { + Flow.Launcher.Infrastructure.Logger.Log.Exception(nameof(ThemeSettingsViewModel), $"Failed to load theme metadata from {path}", ex); + } + } + } + + return themes.OrderBy(theme => theme.Name).ToList(); + } + + public void Dispose() + { + _settings.PropertyChanged -= _settingsPropertyChangedHandler; + } + + private static List GetTypefaceOptions(string familyName) + { + return + [ + new FontTypefaceOption("Normal", "Normal", "Normal", "Normal"), + new FontTypefaceOption("Bold", "Normal", "Bold", "Normal"), + new FontTypefaceOption("Italic", "Italic", "Normal", "Normal"), + new FontTypefaceOption("Bold Italic", "Italic", "Bold", "Normal") + ]; + } + + private static FontTypefaceOption? FindTypefaceOption(IEnumerable options, string? style, string? weight, string? stretch) + { + return options.FirstOrDefault(option => + string.Equals(option.Style, style, StringComparison.OrdinalIgnoreCase) && + string.Equals(option.Weight, weight, StringComparison.OrdinalIgnoreCase) && + string.Equals(option.Stretch, stretch, StringComparison.OrdinalIgnoreCase)) + ?? options.FirstOrDefault(); + } + + private static bool TryGetSetterValue(WpfStyle style, System.Windows.DependencyProperty property, out T value) + { + var setter = style.Setters + .OfType() + .FirstOrDefault(currentSetter => currentSetter.Property == property); + + if (setter?.Value is T typedValue) + { + value = typedValue; + return true; + } + + value = default!; + return false; + } + + private static string GetThemePath(string themeName) + { + var themeDirectories = new[] + { + Path.Combine(Constant.ProgramDirectory, Constant.Themes), + Path.Combine(DataLocation.DataDirectory(), Constant.Themes) + }; + + foreach (var directory in themeDirectories) + { + var candidate = Path.Combine(directory, themeName + ".xaml"); + if (File.Exists(candidate)) + { + return candidate; + } + } + + return string.Empty; + } + + private static ThemeData GetThemeDataFromPath(string path) + { + using var reader = XmlReader.Create(path); + reader.Read(); + + var extensionlessName = Path.GetFileNameWithoutExtension(path); + if (reader.NodeType is not XmlNodeType.Comment) + { + return new ThemeData(extensionlessName, extensionlessName); + } + + var commentLines = reader.Value.Trim().Split('\n').Select(value => value.Trim()); + + var name = extensionlessName; + bool? isDark = null; + bool? hasBlur = null; + + foreach (var line in commentLines) + { + if (line.StartsWith(ThemeMetadataNamePrefix, StringComparison.OrdinalIgnoreCase)) + { + name = line[ThemeMetadataNamePrefix.Length..].Trim(); + } + else if (line.StartsWith(ThemeMetadataIsDarkPrefix, StringComparison.OrdinalIgnoreCase)) + { + isDark = bool.Parse(line[ThemeMetadataIsDarkPrefix.Length..].Trim()); + } + else if (line.StartsWith(ThemeMetadataHasBlurPrefix, StringComparison.OrdinalIgnoreCase)) + { + hasBlur = bool.Parse(line[ThemeMetadataHasBlurPrefix.Length..].Trim()); + } + } + + return new ThemeData(extensionlessName, name, isDark, hasBlur); + } + + private string Translate(string key, string fallback) + { + var value = _i18n.GetTranslation(key); + return value.StartsWith('[') ? fallback : value; + } + + public sealed record FontTypefaceOption(string Display, string Style, string Weight, string Stretch); +} diff --git a/Flow.Launcher.Avalonia/Views/Controls/Card.axaml b/Flow.Launcher.Avalonia/Views/Controls/Card.axaml new file mode 100644 index 00000000000..70127225ef0 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/Card.axaml @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/Views/Controls/Card.axaml.cs b/Flow.Launcher.Avalonia/Views/Controls/Card.axaml.cs new file mode 100644 index 00000000000..792da875c73 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/Card.axaml.cs @@ -0,0 +1,46 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Flow.Launcher.Avalonia.Views.Controls +{ + public partial class Card : ContentControl + { + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title), string.Empty); + + public static readonly StyledProperty SubProperty = + AvaloniaProperty.Register(nameof(Sub), string.Empty); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon), string.Empty); + + public string Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string Sub + { + get => GetValue(SubProperty); + set => SetValue(SubProperty, value); + } + + public string Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public Card() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Flow.Launcher.Avalonia/Views/Controls/CardGroup.axaml b/Flow.Launcher.Avalonia/Views/Controls/CardGroup.axaml new file mode 100644 index 00000000000..12c4580f28d --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/CardGroup.axaml @@ -0,0 +1,30 @@ + + + + + + + diff --git a/Flow.Launcher.Avalonia/Views/Controls/CardGroup.axaml.cs b/Flow.Launcher.Avalonia/Views/Controls/CardGroup.axaml.cs new file mode 100644 index 00000000000..32692508776 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/CardGroup.axaml.cs @@ -0,0 +1,18 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Flow.Launcher.Avalonia.Views.Controls +{ + public partial class CardGroup : ItemsControl + { + public CardGroup() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Flow.Launcher.Avalonia/Views/Controls/ExCard.axaml b/Flow.Launcher.Avalonia/Views/Controls/ExCard.axaml new file mode 100644 index 00000000000..3c5b9bb5ad3 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/ExCard.axaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/Views/Controls/ExCard.axaml.cs b/Flow.Launcher.Avalonia/Views/Controls/ExCard.axaml.cs new file mode 100644 index 00000000000..7bab9741ea6 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/ExCard.axaml.cs @@ -0,0 +1,64 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Flow.Launcher.Avalonia.Views.Controls +{ + public partial class ExCard : ContentControl + { + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title), string.Empty); + + public static readonly StyledProperty SubProperty = + AvaloniaProperty.Register(nameof(Sub), string.Empty); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon), string.Empty); + + public static readonly StyledProperty SideContentProperty = + AvaloniaProperty.Register(nameof(SideContent)); + + public static readonly StyledProperty IsExpandedProperty = + AvaloniaProperty.Register(nameof(IsExpanded), false); + + public string Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string Sub + { + get => GetValue(SubProperty); + set => SetValue(SubProperty, value); + } + + public string Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public object? SideContent + { + get => GetValue(SideContentProperty); + set => SetValue(SideContentProperty, value); + } + + public bool IsExpanded + { + get => GetValue(IsExpandedProperty); + set => SetValue(IsExpandedProperty, value); + } + + public ExCard() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} 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..74737558565 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/HotkeyControl.axaml.cs @@ -0,0 +1,122 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Flow.Launcher.Infrastructure.Hotkey; +using Flow.Launcher.Avalonia.Helper; +using Flow.Launcher.Avalonia.Resource; +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 static readonly DirectProperty UnregisterToggleHotkeyWhileRecordingProperty = + AvaloniaProperty.RegisterDirect( + nameof(UnregisterToggleHotkeyWhileRecording), + o => o.UnregisterToggleHotkeyWhileRecording, + (o, v) => o.UnregisterToggleHotkeyWhileRecording = v); + + private bool _unregisterToggleHotkeyWhileRecording; + public bool UnregisterToggleHotkeyWhileRecording + { + get => _unregisterToggleHotkeyWhileRecording; + set => SetAndRaise(UnregisterToggleHotkeyWhileRecordingProperty, ref _unregisterToggleHotkeyWhileRecording, value); + } + + 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(Translator.GetString("none")); + return; + } + + var model = new HotkeyModel(Hotkey); + foreach (var key in model.EnumerateDisplayKeys()) + { + KeysToDisplay.Add(key); + } + } + + private async Task OpenHotkeyRecorderDialog() + { + var originalHotkey = Hotkey; + var shouldUnregisterToggle = UnregisterToggleHotkeyWhileRecording; + + if (shouldUnregisterToggle) + { + HotKeyMapper.RemoveToggleHotkey(); + } + + try + { + var dialog = new HotkeyRecorderDialog(Hotkey); + var result = await dialog.ShowAsync(); + + if (result == HotkeyRecorderDialog.EResultType.Save) + { + Hotkey = dialog.ResultValue; + + if (shouldUnregisterToggle) + { + HotKeyMapper.SetToggleHotkey(dialog.ResultValue); + } + } + else if (result == HotkeyRecorderDialog.EResultType.Delete) + { + Hotkey = string.Empty; + } + else if (shouldUnregisterToggle) + { + HotKeyMapper.SetToggleHotkey(originalHotkey); + } + } + catch + { + if (shouldUnregisterToggle) + { + HotKeyMapper.SetToggleHotkey(originalHotkey); + } + + throw; + } + } + } +} diff --git a/Flow.Launcher.Avalonia/Views/Controls/HotkeyDisplay.axaml b/Flow.Launcher.Avalonia/Views/Controls/HotkeyDisplay.axaml new file mode 100644 index 00000000000..f8f7fb24e89 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/HotkeyDisplay.axaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/Views/Controls/HotkeyDisplay.axaml.cs b/Flow.Launcher.Avalonia/Views/Controls/HotkeyDisplay.axaml.cs new file mode 100644 index 00000000000..eff640db73a --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/HotkeyDisplay.axaml.cs @@ -0,0 +1,66 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Flow.Launcher.Infrastructure.Hotkey; +using System.Collections.ObjectModel; + +namespace Flow.Launcher.Avalonia.Views.Controls; + +/// +/// A read-only control that displays a hotkey as a series of key badges. +/// Unlike HotkeyControl, this is not editable. +/// +public partial class HotkeyDisplay : UserControl +{ + public static readonly DirectProperty KeysProperty = + AvaloniaProperty.RegisterDirect( + nameof(Keys), + o => o.Keys, + (o, v) => o.Keys = v); + + private string _keys = string.Empty; + public string Keys + { + get => _keys; + set + { + if (SetAndRaise(KeysProperty, ref _keys, value)) + { + UpdateKeysDisplay(); + } + } + } + + public ObservableCollection KeysToDisplay { get; } = new(); + + public HotkeyDisplay() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void UpdateKeysDisplay() + { + KeysToDisplay.Clear(); + + if (string.IsNullOrEmpty(Keys)) + { + return; + } + + // Handle multiple hotkeys separated by space (e.g., "Ctrl+[ Ctrl+]") + var hotkeys = Keys.Split(' ', System.StringSplitOptions.RemoveEmptyEntries); + foreach (var hotkey in hotkeys) + { + var parts = hotkey.Split('+', System.StringSplitOptions.RemoveEmptyEntries | System.StringSplitOptions.TrimEntries); + foreach (var part in parts) + { + KeysToDisplay.Add(part); + } + } + } +} 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..987cc559420 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/HotkeyRecorderDialog.axaml.cs @@ -0,0 +1,197 @@ +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.Logger; +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; + private Func? _previousKeyboardCallback; + + public HotkeyRecorderDialog(string currentHotkey) + { + InitializeComponent(); + + try + { + var model = new HotkeyModel(currentHotkey); + UpdateKeysDisplay(model); + } + catch (Exception e) + { + Log.Exception(nameof(HotkeyRecorderDialog), $"Failed to parse current hotkey '{currentHotkey}'", e); + } + + 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; + + _previousKeyboardCallback = InfrastructureGlobalHotkey.hookedKeyboardCallback; + InfrastructureGlobalHotkey.hookedKeyboardCallback = GlobalKeyHook; + } + catch (Exception ex) + { + Log.Exception(nameof(HotkeyRecorderDialog), "Failed to attach global keyboard hook", ex); + } + } + + private void HotkeyRecorderDialog_Closing(object? sender, EventArgs args) + { + if (InfrastructureGlobalHotkey.hookedKeyboardCallback == GlobalKeyHook) + { + InfrastructureGlobalHotkey.hookedKeyboardCallback = _previousKeyboardCallback; + } + } + + 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/Controls/WpfSettingsWindow.cs b/Flow.Launcher.Avalonia/Views/Controls/WpfSettingsWindow.cs new file mode 100644 index 00000000000..d9456e06b6e --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Controls/WpfSettingsWindow.cs @@ -0,0 +1,76 @@ +using System; +using Avalonia.Controls.ApplicationLifetimes; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Interop; + +namespace Flow.Launcher.Avalonia.Views.Controls; + +/// +/// A standalone WPF Window that hosts plugin settings controls. +/// This avoids scrolling and rendering issues with embedded HwndSource. +/// +public class WpfSettingsWindow : Window +{ + public WpfSettingsWindow(Control settingsControl, string pluginName) + { + Title = $"{pluginName} Settings"; + Width = 800; + Height = 600; + MinWidth = 400; + MinHeight = 300; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + + // Set proper background to avoid black background issue + Background = SystemColors.ControlBrush; + + // Wrap in a ScrollViewer for proper scrolling + var scrollViewer = new ScrollViewer + { + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + Content = settingsControl, + Padding = new Thickness(10) + }; + + Content = scrollViewer; + } + + private static void SetAvaloniaOwner(WpfSettingsWindow window) + { + if (global::Avalonia.Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + { + return; + } + + var ownerHandle = desktop.MainWindow?.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; + if (ownerHandle == IntPtr.Zero) + { + return; + } + + new WindowInteropHelper(window).Owner = ownerHandle; + window.WindowStartupLocation = WindowStartupLocation.CenterOwner; + } + + /// + /// Shows the settings window for the given plugin. + /// + public static void Show(Control settingsControl, string pluginName) + { + var window = new WpfSettingsWindow(settingsControl, pluginName); + SetAvaloniaOwner(window); + window.Show(); + } + + /// + /// Shows the settings window as a modal dialog. + /// + public static void ShowDialog(Control settingsControl, string pluginName) + { + var window = new WpfSettingsWindow(settingsControl, pluginName); + SetAvaloniaOwner(window); + window.ShowDialog(); + } +} diff --git a/Flow.Launcher.Avalonia/Views/Dialogs/NotificationWindow.axaml b/Flow.Launcher.Avalonia/Views/Dialogs/NotificationWindow.axaml new file mode 100644 index 00000000000..259fe4188b1 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/Dialogs/NotificationWindow.axaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/Views/SettingPages/PluginStoreSettingsPage.axaml.cs b/Flow.Launcher.Avalonia/Views/SettingPages/PluginStoreSettingsPage.axaml.cs new file mode 100644 index 00000000000..a5879afcc15 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/SettingPages/PluginStoreSettingsPage.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Flow.Launcher.Avalonia.ViewModel.SettingPages; + +namespace Flow.Launcher.Avalonia.Views.SettingPages +{ + public partial class PluginStoreSettingsPage : UserControl + { + public PluginStoreSettingsPage() + { + InitializeComponent(); + DataContext = new PluginStoreSettingsViewModel(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Flow.Launcher.Avalonia/Views/SettingPages/PluginsSettingsPage.axaml b/Flow.Launcher.Avalonia/Views/SettingPages/PluginsSettingsPage.axaml new file mode 100644 index 00000000000..5f7696778e4 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/SettingPages/PluginsSettingsPage.axaml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/Views/SettingPages/PluginsSettingsPage.axaml.cs b/Flow.Launcher.Avalonia/Views/SettingPages/PluginsSettingsPage.axaml.cs new file mode 100644 index 00000000000..cb0c39aa2dc --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/SettingPages/PluginsSettingsPage.axaml.cs @@ -0,0 +1,36 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Flow.Launcher.Avalonia.ViewModel.SettingPages; + +namespace Flow.Launcher.Avalonia.Views.SettingPages; + +public partial class PluginsSettingsPage : UserControl +{ + public PluginsSettingsPage() + { + InitializeComponent(); + DataContext = new PluginsSettingsViewModel(); + DetachedFromVisualTree += OnDetachedFromVisualTree; + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + DetachedFromVisualTree -= OnDetachedFromVisualTree; + + if (DataContext is IDisposable disposable) + { + disposable.Dispose(); + DataContext = null; + } + } + + private void ClearSearchText_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is PluginsSettingsViewModel vm) + { + vm.SearchText = string.Empty; + } + } +} diff --git a/Flow.Launcher.Avalonia/Views/SettingPages/ProxySettingsPage.axaml b/Flow.Launcher.Avalonia/Views/SettingPages/ProxySettingsPage.axaml new file mode 100644 index 00000000000..7d90ba54139 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/SettingPages/ProxySettingsPage.axaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher.Avalonia/Views/SettingPages/ProxySettingsPage.axaml.cs b/Flow.Launcher.Avalonia/Views/SettingPages/ProxySettingsPage.axaml.cs new file mode 100644 index 00000000000..4b43fe18fb2 --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/SettingPages/ProxySettingsPage.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Flow.Launcher.Avalonia.ViewModel.SettingPages; + +namespace Flow.Launcher.Avalonia.Views.SettingPages; + +public partial class ProxySettingsPage : UserControl +{ + public ProxySettingsPage() + { + InitializeComponent(); + DataContext = new ProxySettingsViewModel(); + } +} diff --git a/Flow.Launcher.Avalonia/Views/SettingPages/ReleaseNotesWindow.axaml b/Flow.Launcher.Avalonia/Views/SettingPages/ReleaseNotesWindow.axaml new file mode 100644 index 00000000000..2ff132427bb --- /dev/null +++ b/Flow.Launcher.Avalonia/Views/SettingPages/ReleaseNotesWindow.axaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +