Add hotkey recorder with manual modifier tracking

- Add HotkeyRecorderDialog with global keyboard hook for capturing hotkeys
- Implement manual modifier state tracking to handle swallowed key events
- Add HotkeyControl button that opens the recorder dialog
- Add CheckAvailability and RemoveToggleHotkey to HotKeyMapper
- Expose GetKeyFromVk helper in GlobalHotkey infrastructure
- Add Settings pages (General, Plugin, Theme, Proxy, About)
- Add PreviewPanel for result previews in main window
- Fix hook reuse issue by clearing callback on close instead of disposing
This commit is contained in:
Hongtao Zhang 2026-01-18 02:10:53 -08:00
parent 29b26643cc
commit 0f9b9329dd
38 changed files with 1521 additions and 55 deletions

View file

@ -1,13 +1,15 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Flow.Launcher.Avalonia"
xmlns:sty="using:FluentAvalonia.Styling"
xmlns:i18n="using:Flow.Launcher.Avalonia.Resource"
x:Class="Flow.Launcher.Avalonia.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant.
"Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
<sty:FluentAvaloniaTheme />
<StyleInclude Source="avares://Flow.Launcher.Avalonia/Themes/Base.axaml"/>
</Application.Styles>
@ -20,4 +22,21 @@
<FontFamily x:Key="SegoeFluentIcons">avares://Flow.Launcher.Avalonia/Resources#Segoe Fluent Icons</FontFamily>
</ResourceDictionary>
</Application.Resources>
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="avares://Flow.Launcher.Avalonia/Images/app.ico"
ToolTipText="Flow Launcher"
Clicked="TrayIcon_OnClicked">
<TrayIcon.Menu>
<NativeMenu>
<NativeMenuItem Header="{i18n:Localize show}" Click="MenuShow_OnClick" />
<NativeMenuItem Header="{i18n:Localize settings}" Click="MenuSettings_OnClick" />
<NativeMenuItemSeparator />
<NativeMenuItem Header="{i18n:Localize exit}" Click="MenuExit_OnClick" />
</NativeMenu>
</TrayIcon.Menu>
</TrayIcon>
</TrayIcons>
</TrayIcon.Icons>
</Application>

View file

@ -1,4 +1,5 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
@ -6,6 +7,7 @@ using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Avalonia.Helper;
using Flow.Launcher.Avalonia.Resource;
using Flow.Launcher.Avalonia.ViewModel;
using Flow.Launcher.Avalonia.Views.SettingPages;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Infrastructure.Logger;
@ -26,19 +28,24 @@ public partial class App : Application
public static IPublicAPI? API { get; private set; }
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void Initialize()
{
// Configure DI before loading XAML so markup extensions can access services
LoadSettings();
ConfigureDI();
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
LoadSettings();
ConfigureDI();
API = Ioc.Default.GetRequiredService<IPublicAPI>();
_mainVM = Ioc.Default.GetRequiredService<MainViewModel>();
desktop.MainWindow = new MainWindow();
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
// Initialize hotkeys after window is created
HotKeyMapper.Initialize();
@ -51,6 +58,30 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted();
}
private void TrayIcon_OnClicked(object? sender, EventArgs e)
{
_mainVM?.ToggleFlowLauncher();
}
private void MenuShow_OnClick(object? sender, EventArgs e)
{
_mainVM?.Show();
}
private void MenuSettings_OnClick(object? sender, EventArgs e)
{
var settingsWindow = new SettingsWindow();
settingsWindow.Show();
}
private void MenuExit_OnClick(object? sender, EventArgs e)
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
}
private void LoadSettings()
{
try
@ -96,6 +127,10 @@ public partial class App : Application
await PluginManager.InitializePluginsAsync();
Log.Info(ClassName, "Plugins initialized");
// Update plugin translations after they are initialized
var i18n = Ioc.Default.GetRequiredService<Internationalization>();
i18n.UpdatePluginMetadataTranslations();
_mainVM?.OnPluginsReady();
}
catch (Exception e) { Log.Exception(ClassName, "Plugin init failed", e); }

View file

@ -107,7 +107,7 @@ public class AvaloniaPublicAPI : IPublicAPI
public void ShowMsg(string title, string subTitle, string iconPath, bool useMainWindowAsOwner = true) { }
public void ShowMsgWithButton(string title, string buttonText, Action buttonAction, string subTitle = "", string iconPath = "") { }
public void ShowMsgWithButton(string title, string buttonText, Action buttonAction, string subTitle, string iconPath, bool useMainWindowAsOwner = true) { }
public void OpenSettingDialog() { }
public void OpenSettingDialog() => _getMainViewModel()?.OpenSettings();
public void RegisterGlobalKeyboardCallback(Func<int, int, SpecialKeyState, bool> callback) { }
public void RemoveGlobalKeyboardCallback(Func<int, int, SpecialKeyState, bool> callback) { }
public T LoadSettingJsonStorage<T>() where T : new() => new T();

View file

@ -67,6 +67,7 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<AvaloniaResource Include="Resources\SegoeFluentIcons.ttf" />
<AvaloniaResource Include="..\Flow.Launcher\Resources\app.ico" Link="Images\app.ico" />
</ItemGroup>
</Project>

View file

@ -1,6 +1,7 @@
using System;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Avalonia.ViewModel;
using Flow.Launcher.Infrastructure.Hotkey;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
@ -45,12 +46,7 @@ internal static class HotKeyMapper
/// </summary>
internal static void SetToggleHotkey(string hotkeyString)
{
// Unregister existing hotkey
if (_toggleHotkeyId >= 0)
{
GlobalHotkey.Unregister(_toggleHotkeyId);
_toggleHotkeyId = -1;
}
RemoveToggleHotkey();
if (string.IsNullOrWhiteSpace(hotkeyString))
{
@ -78,12 +74,46 @@ internal static class HotKeyMapper
}
}
/// <summary>
/// Remove the current toggle hotkey.
/// </summary>
internal static void RemoveToggleHotkey()
{
if (_toggleHotkeyId >= 0)
{
GlobalHotkey.Unregister(_toggleHotkeyId);
_toggleHotkeyId = -1;
}
}
private static void OnToggleHotkey()
{
Log.Info(ClassName, "Toggle hotkey triggered");
_mainViewModel?.ToggleFlowLauncher();
}
/// <summary>
/// Checks if a hotkey is available for registration.
/// </summary>
internal static bool CheckAvailability(HotkeyModel hotkey)
{
var hotkeyString = hotkey.ToString();
var (mods, key) = GlobalHotkey.ParseHotkeyString(hotkeyString);
if (key == 0)
return false;
// Try to register and immediately unregister
int id = GlobalHotkey.Register(mods, key, () => { });
if (id >= 0)
{
GlobalHotkey.Unregister(id);
return true;
}
return false;
}
/// <summary>
/// Cleanup and unregister all hotkeys.
/// </summary>

View file

@ -37,6 +37,8 @@
<KeyBinding Gesture="Down" Command="{Binding SelectNextItemCommand}" />
<KeyBinding Gesture="Up" Command="{Binding SelectPrevItemCommand}" />
<KeyBinding Gesture="Shift+Enter" Command="{Binding LoadContextMenuCommand}" />
<KeyBinding Gesture="Ctrl+OemComma" Command="{Binding OpenSettingsCommand}" />
<KeyBinding Gesture="F1" Command="{Binding TogglePreviewCommand}" />
</Window.KeyBindings>
<!-- Main container with rounded corners and background -->
@ -89,7 +91,7 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MinWidth="80" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="0" /> <!-- Preview panel, hidden for now -->
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Result List (visible when in Results view) -->
@ -106,19 +108,20 @@
DataContext="{Binding ContextMenu}" />
</StackPanel>
<!-- Preview Panel Separator (hidden for now) -->
<!-- Preview Panel Separator -->
<GridSplitter Name="PreviewSplitter"
Grid.Column="1"
Width="5"
Background="Transparent"
IsVisible="False" />
IsVisible="{Binding IsPreviewOn}" />
<!-- Preview Panel (to be implemented) -->
<Grid Name="PreviewPanel"
Grid.Column="2"
IsVisible="False">
<!-- Preview content will go here -->
</Grid>
<!-- Preview Panel -->
<Border Name="PreviewPanelContainer"
Grid.Column="2"
Width="300"
IsVisible="{Binding IsPreviewOn}">
<views:PreviewPanel DataContext="{Binding PreviewSelectedItem}" />
</Border>
</Grid>
</Border>
</StackPanel>

View file

@ -7,9 +7,11 @@ using System.Threading;
using System.Xml.Linq;
using Avalonia;
using Avalonia.Controls;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Avalonia.Resource;
@ -71,6 +73,27 @@ public class Internationalization
}
}
/// <summary>
/// Update plugin metadata name & description and call OnCultureInfoChanged.
/// </summary>
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;

View file

@ -38,15 +38,18 @@ public class LocalizeExtension : MarkupExtension
return Fallback ?? "[No Key]";
}
var i18n = Ioc.Default.GetService<Internationalization>();
if (i18n == null)
try
{
return Fallback ?? $"[{Key}]";
// Try to get I18n service from DI
var i18n = Ioc.Default.GetService<Internationalization>();
if (i18n != null && i18n.HasTranslation(Key))
{
return i18n.GetTranslation(Key);
}
}
if (i18n.HasTranslation(Key))
catch
{
return i18n.GetTranslation(Key);
// Ioc.Default might throw if not configured yet
}
return Fallback ?? $"[{Key}]";
@ -65,13 +68,20 @@ public static class Translator
/// <returns>The translated string or the key in brackets if not found</returns>
public static string GetString(string key)
{
var i18n = Ioc.Default.GetService<Internationalization>();
if (i18n == null)
try
{
var i18n = Ioc.Default.GetService<Internationalization>();
if (i18n == null)
{
return $"[{key}]";
}
return i18n.GetTranslation(key);
}
catch
{
return $"[{key}]";
}
return i18n.GetTranslation(key);
}
/// <summary>

View file

@ -144,19 +144,13 @@
<Style Selector="Image.resultIcon">
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="Margin" Value="12,0,0,0" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!-- Result Glyph -->
<Style Selector="TextBlock.resultGlyph">
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="Margin" Value="12,0,0,0" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="TextAlignment" Value="Center" />
<Setter Property="FontSize" Value="20" />
<Setter Property="Foreground" Value="White" />
<Setter Property="TextAlignment" Value="Center" />
</Style>
<!-- Result Title -->

View file

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Flow.Launcher.Avalonia.Resource;
using Flow.Launcher.Avalonia.Views.SettingPages;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
@ -57,6 +58,12 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty]
private ActiveView _activeView = ActiveView.Results;
[ObservableProperty]
private ResultViewModel? _previewSelectedItem;
[ObservableProperty]
private bool _isPreviewOn;
/// <summary>
/// Whether the results view is currently active.
/// </summary>
@ -79,6 +86,22 @@ public partial class MainViewModel : ObservableObject
_settings = settings;
_results = new ResultsViewModel(settings);
_contextMenu = new ResultsViewModel(settings);
_results.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(ResultsViewModel.SelectedItem) && IsResultsViewActive)
{
PreviewSelectedItem = _results.SelectedItem;
}
};
_contextMenu.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(ResultsViewModel.SelectedItem) && IsContextMenuViewActive)
{
PreviewSelectedItem = _contextMenu.SelectedItem;
}
};
}
partial void OnActiveViewChanged(ActiveView value)
@ -86,6 +109,14 @@ public partial class MainViewModel : ObservableObject
OnPropertyChanged(nameof(IsResultsViewActive));
OnPropertyChanged(nameof(IsContextMenuViewActive));
OnPropertyChanged(nameof(ShowResultsArea));
PreviewSelectedItem = value == ActiveView.Results ? Results.SelectedItem : ContextMenu.SelectedItem;
}
[RelayCommand]
public void TogglePreview()
{
IsPreviewOn = !IsPreviewOn;
}
partial void OnHasResultsChanged(bool value)
@ -291,6 +322,17 @@ public partial class MainViewModel : ObservableObject
}
}
[RelayCommand]
public void OpenSettings()
{
global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
var settingsWindow = new SettingsWindow();
settingsWindow.Show();
Hide();
});
}
[RelayCommand]
private async Task OpenResultAsync()
{

View file

@ -0,0 +1,28 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Flow.Launcher.Infrastructure;
using System.Diagnostics;
using System.Threading.Tasks;
namespace Flow.Launcher.Avalonia.ViewModel.SettingPages;
public partial class AboutSettingsViewModel : ObservableObject
{
public string Version => Constant.Version;
public string Website => "https://www.flowlauncher.com";
public string GitHub => "https://github.com/Flow-Launcher/Flow.Launcher";
[RelayCommand]
private async Task OpenWebsite()
{
Process.Start(new ProcessStartInfo(Website) { UseShellExecute = true });
await Task.CompletedTask;
}
[RelayCommand]
private async Task OpenGitHub()
{
Process.Start(new ProcessStartInfo(GitHub) { UseShellExecute = true });
await Task.CompletedTask;
}
}

View file

@ -0,0 +1,103 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Core.Resource;
using Flow.Launcher.Infrastructure.UserSettings;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AvaloniaI18n = Flow.Launcher.Avalonia.Resource.Internationalization;
namespace Flow.Launcher.Avalonia.ViewModel.SettingPages;
public partial class GeneralSettingsViewModel : ObservableObject
{
private readonly Flow.Launcher.Infrastructure.UserSettings.Settings _settings;
private readonly AvaloniaI18n _i18n;
public GeneralSettingsViewModel()
{
_settings = Ioc.Default.GetRequiredService<Flow.Launcher.Infrastructure.UserSettings.Settings>();
_i18n = Ioc.Default.GetRequiredService<AvaloniaI18n>();
LoadLanguages();
}
[ObservableProperty]
private List<Language> _languages = new();
public Language? SelectedLanguage
{
get => Languages.FirstOrDefault(l => l.LanguageCode == _settings.Language);
set
{
if (value != null && value.LanguageCode != _settings.Language)
{
_settings.Language = value.LanguageCode;
_i18n.ChangeLanguage(value.LanguageCode);
OnPropertyChanged();
}
}
}
public bool StartOnStartup
{
get => _settings.StartFlowLauncherOnSystemStartup;
set
{
_settings.StartFlowLauncherOnSystemStartup = value;
OnPropertyChanged();
}
}
public bool HideWhenDeactivated
{
get => _settings.HideWhenDeactivated;
set
{
_settings.HideWhenDeactivated = value;
OnPropertyChanged();
}
}
public bool ShowAtTopmost
{
get => _settings.ShowAtTopmost;
set
{
_settings.ShowAtTopmost = value;
OnPropertyChanged();
}
}
public string PythonPath => _settings.PluginSettings.PythonExecutablePath ?? "Not set";
public string NodePath => _settings.PluginSettings.NodeExecutablePath ?? "Not set";
[RelayCommand]
private async Task SelectPython()
{
// TODO: Implement file picker
await Task.CompletedTask;
}
[RelayCommand]
private async Task SelectNode()
{
// TODO: Implement file picker
await Task.CompletedTask;
}
private void LoadLanguages()
{
// Minimal set of languages for now, can be expanded by loading from directory later
Languages = new List<Language>
{
new Language("en", "English"),
new Language("zh-cn", "中文 (简体)"),
new Language("zh-tw", "中文 (繁體)"),
new Language("ko", "한국어"),
new Language("ja", "日本語")
};
}
}

View file

@ -0,0 +1,31 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Avalonia.Helper;
using System;
namespace Flow.Launcher.Avalonia.ViewModel.SettingPages;
public partial class HotkeySettingsViewModel : ObservableObject
{
private readonly Settings _settings;
public HotkeySettingsViewModel()
{
_settings = Ioc.Default.GetRequiredService<Settings>();
}
public string ToggleHotkey
{
get => _settings.Hotkey;
set
{
if (_settings.Hotkey != value)
{
_settings.Hotkey = value;
HotKeyMapper.SetToggleHotkey(value);
OnPropertyChanged();
}
}
}
}

View file

@ -0,0 +1,69 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Plugin;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace Flow.Launcher.Avalonia.ViewModel.SettingPages;
public partial class PluginsSettingsViewModel : ObservableObject
{
public PluginsSettingsViewModel()
{
LoadPlugins();
}
[ObservableProperty]
private ObservableCollection<PluginItemViewModel> _plugins = new();
[ObservableProperty]
private string _searchText = string.Empty;
public IEnumerable<PluginItemViewModel> FilteredPlugins =>
string.IsNullOrWhiteSpace(SearchText)
? Plugins
: Plugins.Where(p => p.Name.Contains(SearchText, System.StringComparison.OrdinalIgnoreCase));
partial void OnSearchTextChanged(string value) => OnPropertyChanged(nameof(FilteredPlugins));
private void LoadPlugins()
{
var allPlugins = PluginManager.AllPlugins;
foreach (var plugin in allPlugins.OrderBy(p => p.Metadata.Name))
{
Plugins.Add(new PluginItemViewModel(plugin));
}
}
}
public partial class PluginItemViewModel : ObservableObject
{
private readonly PluginPair _plugin;
public PluginItemViewModel(PluginPair plugin)
{
_plugin = plugin;
}
public string Name => _plugin.Metadata.Name;
public string Description => _plugin.Metadata.Description;
public string Author => _plugin.Metadata.Author;
public string Version => _plugin.Metadata.Version;
public string IconPath => _plugin.Metadata.IcoPath;
public bool IsDisabled
{
get => _plugin.Metadata.Disabled;
set
{
if (_plugin.Metadata.Disabled != value)
{
_plugin.Metadata.Disabled = value;
OnPropertyChanged();
}
}
}
}

View file

@ -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<Settings>();
}
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();
}
}
}
}

View file

@ -0,0 +1,102 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Avalonia.Resource;
using FluentAvalonia.Styling;
using Avalonia;
using Avalonia.Styling;
using System.Collections.Generic;
using System.Linq;
namespace Flow.Launcher.Avalonia.ViewModel.SettingPages;
public partial class ThemeSettingsViewModel : ObservableObject
{
private readonly Settings _settings;
public ThemeSettingsViewModel()
{
_settings = Ioc.Default.GetRequiredService<Settings>();
}
public List<string> ThemeVariants => new() { "System", "Light", "Dark" };
public string SelectedThemeVariant
{
get => _settings.Theme switch
{
"Light" => "Light",
"Dark" => "Dark",
_ => "System"
};
set
{
if (value != SelectedThemeVariant)
{
_settings.Theme = value;
ApplyTheme(value);
OnPropertyChanged();
}
}
}
private void ApplyTheme(string variant)
{
if (Application.Current == null) return;
Application.Current.RequestedThemeVariant = variant switch
{
"Light" => ThemeVariant.Light,
"Dark" => ThemeVariant.Dark,
_ => ThemeVariant.Default
};
}
public int MaxResults
{
get => _settings.MaxResultsToShow;
set
{
if (_settings.MaxResultsToShow != value)
{
_settings.MaxResultsToShow = value;
OnPropertyChanged();
}
}
}
public List<int> MaxResultsRange => Enumerable.Range(1, 20).ToList();
public bool UseGlyphIcons
{
get => _settings.UseGlyphIcons;
set
{
if (_settings.UseGlyphIcons != value)
{
_settings.UseGlyphIcons = value;
OnPropertyChanged();
}
}
}
public double QueryBoxFontSize
{
get => _settings.QueryBoxFontSize;
set
{
_settings.QueryBoxFontSize = value;
OnPropertyChanged();
}
}
public double ResultItemFontSize
{
get => _settings.ResultItemFontSize;
set
{
_settings.ResultItemFontSize = value;
OnPropertyChanged();
}
}
}

View file

@ -0,0 +1,36 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="50"
x:Class="Flow.Launcher.Avalonia.Views.Controls.HotkeyControl"
x:Name="root">
<Button Command="{Binding #root.RecordHotkeyCommand}"
Padding="8,4"
Background="{DynamicResource ControlFillColorDefaultBrush}"
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1"
CornerRadius="4"
HorizontalAlignment="Left">
<ItemsControl ItemsSource="{Binding #root.KeysToDisplay}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="4" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Padding="6,2"
Background="{DynamicResource AccentFillColorDefaultBrush}"
CornerRadius="4">
<TextBlock Text="{Binding}"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
FontSize="12"
FontWeight="SemiBold" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Button>
</UserControl>

View file

@ -0,0 +1,89 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Flow.Launcher.Infrastructure.Hotkey;
using Flow.Launcher.Avalonia.Helper;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input;
namespace Flow.Launcher.Avalonia.Views.Controls
{
public partial class HotkeyControl : UserControl
{
public static readonly DirectProperty<HotkeyControl, string> HotkeyProperty =
AvaloniaProperty.RegisterDirect<HotkeyControl, string>(
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<string> KeysToDisplay { get; } = new();
public IAsyncRelayCommand RecordHotkeyCommand { get; }
public HotkeyControl()
{
RecordHotkeyCommand = new AsyncRelayCommand(OpenHotkeyRecorderDialog);
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void UpdateKeysDisplay()
{
KeysToDisplay.Clear();
if (string.IsNullOrEmpty(Hotkey))
{
KeysToDisplay.Add("None");
return;
}
var model = new HotkeyModel(Hotkey);
foreach (var key in model.EnumerateDisplayKeys())
{
KeysToDisplay.Add(key);
}
}
private async Task OpenHotkeyRecorderDialog()
{
var originalHotkey = Hotkey;
// Temporarily unregister so it doesn't conflict with availability check
HotKeyMapper.RemoveToggleHotkey();
var dialog = new HotkeyRecorderDialog(Hotkey);
var result = await dialog.ShowAsync();
if (result == HotkeyRecorderDialog.EResultType.Save)
{
Hotkey = dialog.ResultValue;
}
else if (result == HotkeyRecorderDialog.EResultType.Delete)
{
Hotkey = string.Empty;
}
else
{
// Restore original hotkey if cancelled
HotKeyMapper.SetToggleHotkey(originalHotkey);
}
}
}
}

View file

@ -0,0 +1,56 @@
<ui:ContentDialog xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:i18n="using:Flow.Launcher.Avalonia.Resource"
x:Class="Flow.Launcher.Avalonia.Views.Controls.HotkeyRecorderDialog"
x:Name="root"
Title="{i18n:Localize hotkeyRegTitle}"
PrimaryButtonText="{i18n:Localize commonSave}"
SecondaryButtonText="{i18n:Localize commonDelete}"
CloseButtonText="{i18n:Localize commonCancel}">
<StackPanel Spacing="20" Width="400">
<TextBlock Text="{i18n:Localize hotkeyRegGuide}" TextWrapping="Wrap" />
<Border Height="100"
Background="{DynamicResource ControlFillColorDefaultBrush}"
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1"
CornerRadius="8">
<ItemsControl ItemsSource="{Binding #root.KeysToDisplay}" HorizontalAlignment="Center" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="8" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Padding="12,8"
Background="{DynamicResource AccentFillColorDefaultBrush}"
CornerRadius="6">
<TextBlock Text="{Binding}"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
FontSize="20"
FontWeight="Bold" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<Border x:Name="Alert"
Padding="12"
Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
BorderBrush="{DynamicResource SystemFillColorCautionBrush}"
BorderThickness="1"
CornerRadius="4"
IsVisible="False">
<Grid ColumnDefinitions="Auto,*">
<ui:FontIcon Glyph="&#xf167;"
FontFamily="{StaticResource SymbolThemeFontFamily}"
Margin="0,0,12,0" />
<TextBlock x:Name="tbMsg" Grid.Column="1" TextWrapping="Wrap" />
</Grid>
</Border>
</StackPanel>
</ui:ContentDialog>

View file

@ -0,0 +1,185 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using FluentAvalonia.UI.Controls;
using Flow.Launcher.Avalonia.Helper;
using Flow.Launcher.Infrastructure.Hotkey;
using Flow.Launcher.Plugin;
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using InfrastructureGlobalHotkey = Flow.Launcher.Infrastructure.Hotkey.GlobalHotkey;
namespace Flow.Launcher.Avalonia.Views.Controls
{
public partial class HotkeyRecorderDialog : ContentDialog
{
public enum EResultType
{
Cancel,
Save,
Delete
}
public EResultType ResultType { get; private set; } = EResultType.Cancel;
public string ResultValue { get; private set; } = string.Empty;
public ObservableCollection<string> KeysToDisplay { get; } = new();
private bool _altDown;
private bool _ctrlDown;
private bool _shiftDown;
private bool _winDown;
public HotkeyRecorderDialog(string currentHotkey)
{
InitializeComponent();
var model = new HotkeyModel(currentHotkey);
UpdateKeysDisplay(model);
Opened += HotkeyRecorderDialog_Opened;
Closing += HotkeyRecorderDialog_Closing;
PrimaryButtonClick += (s, e) => { ResultType = EResultType.Save; ResultValue = string.Join("+", KeysToDisplay); };
SecondaryButtonClick += (s, e) => { ResultType = EResultType.Delete; };
}
protected override Type StyleKeyOverride => typeof(ContentDialog);
private void HotkeyRecorderDialog_Opened(object? sender, EventArgs args)
{
this.Focus();
// Initialize Global Hotkey Hook when dialog opens
try
{
// Sync initial modifier state
var state = InfrastructureGlobalHotkey.CheckModifiers();
_altDown = state.AltPressed;
_ctrlDown = state.CtrlPressed;
_shiftDown = state.ShiftPressed;
_winDown = state.WinPressed;
InfrastructureGlobalHotkey.hookedKeyboardCallback = GlobalKeyHook;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Hook Error: {ex.Message}");
}
}
private void HotkeyRecorderDialog_Closing(object? sender, EventArgs args)
{
// Clear the callback but DON'T dispose the static hook
InfrastructureGlobalHotkey.hookedKeyboardCallback = null;
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private bool GlobalKeyHook(KeyEvent keyEvent, int vkCode, SpecialKeyState state)
{
var wpfKey = InfrastructureGlobalHotkey.GetKeyFromVk(vkCode);
bool isKeyDown = (keyEvent == KeyEvent.WM_KEYDOWN || keyEvent == KeyEvent.WM_SYSKEYDOWN);
bool isKeyUp = (keyEvent == KeyEvent.WM_KEYUP || keyEvent == KeyEvent.WM_SYSKEYUP);
// Track modifier state manually (since we swallow keys, OS state is stale)
if (wpfKey == System.Windows.Input.Key.LeftAlt || wpfKey == System.Windows.Input.Key.RightAlt)
_altDown = isKeyDown;
else if (wpfKey == System.Windows.Input.Key.LeftCtrl || wpfKey == System.Windows.Input.Key.RightCtrl)
_ctrlDown = isKeyDown;
else if (wpfKey == System.Windows.Input.Key.LeftShift || wpfKey == System.Windows.Input.Key.RightShift)
_shiftDown = isKeyDown;
else if (wpfKey == System.Windows.Input.Key.LWin || wpfKey == System.Windows.Input.Key.RWin)
_winDown = isKeyDown;
// Only process key down events for UI updates
if (isKeyDown)
{
// Capture current modifier state for the UI thread
bool alt = _altDown;
bool ctrl = _ctrlDown;
bool shift = _shiftDown;
bool win = _winDown;
// Marshal to UI Thread
global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
HandleGlobalKey(vkCode, alt, ctrl, shift, win);
});
}
// Return false to SWALLOW the key (prevents other apps from receiving it)
return false;
}
private void HandleGlobalKey(int vkCode, bool altDown, bool ctrlDown, bool shiftDown, bool winDown)
{
var wpfKey = InfrastructureGlobalHotkey.GetKeyFromVk(vkCode);
// Don't treat modifiers as main keys
if (wpfKey == System.Windows.Input.Key.LeftAlt || wpfKey == System.Windows.Input.Key.RightAlt ||
wpfKey == System.Windows.Input.Key.LeftCtrl || wpfKey == System.Windows.Input.Key.RightCtrl ||
wpfKey == System.Windows.Input.Key.LeftShift || wpfKey == System.Windows.Input.Key.RightShift ||
wpfKey == System.Windows.Input.Key.LWin || wpfKey == System.Windows.Input.Key.RWin)
{
wpfKey = System.Windows.Input.Key.None;
}
var model = new HotkeyModel(
altDown,
shiftDown,
winDown,
ctrlDown,
wpfKey);
UpdateKeysDisplay(model);
// Update Save button enablement based on validity and availability
var isValid = model.Validate();
var isAvailable = isValid && HotKeyMapper.CheckAvailability(model);
IsPrimaryButtonEnabled = isAvailable;
var alert = this.FindControl<Border>("Alert");
var tbMsg = this.FindControl<TextBlock>("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<EResultType> ShowAsync()
{
var result = await base.ShowAsync();
if (result == ContentDialogResult.Primary)
return EResultType.Save;
if (result == ContentDialogResult.Secondary)
return EResultType.Delete;
return EResultType.Cancel;
}
}
}

View file

@ -0,0 +1,65 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel"
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="400"
x:Class="Flow.Launcher.Avalonia.Views.PreviewPanel"
x:DataType="vm:ResultViewModel">
<Grid RowDefinitions="Auto,Auto,*" Margin="20">
<!-- Large Icon -->
<Border Width="128" Height="128"
CornerRadius="8"
Background="{DynamicResource ControlFillColorDefaultBrush}"
Margin="0,0,0,20">
<Panel>
<!-- Image Icon -->
<Image Source="{Binding Image^}"
Stretch="Uniform"
IsVisible="{Binding !ShowGlyph}" />
<!-- Glyph Icon -->
<TextBlock Text="{Binding Glyph.Glyph}"
FontFamily="{Binding GlyphFontFamily}"
FontSize="64"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
IsVisible="{Binding ShowGlyph}" />
</Panel>
</Border>
<!-- Title -->
<SelectableTextBlock Grid.Row="1"
Text="{Binding Title}"
FontSize="20"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextAlignment="Center"
Margin="0,0,0,10" />
<!-- SubTitle and Details -->
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="10">
<SelectableTextBlock Text="{Binding SubTitle}"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
TextAlignment="Center" />
<!-- Separator -->
<Border Height="1" Background="{DynamicResource DividerStrokeColorDefaultBrush}" Margin="0,10" />
<!-- Additional Metadata could go here -->
<TextBlock Text="Score" FontWeight="Bold" FontSize="12" Foreground="{DynamicResource TextFillColorTertiaryBrush}" />
<TextBlock Text="{Binding Score}" FontSize="12" />
<TextBlock Text="Plugin" FontWeight="Bold" FontSize="12" Foreground="{DynamicResource TextFillColorTertiaryBrush}" Margin="0,10,0,0" />
<TextBlock Text="{Binding PluginResult.PluginDirectory}" FontSize="12" TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Flow.Launcher.Avalonia.Views;
public partial class PreviewPanel : UserControl
{
public PreviewPanel()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -34,22 +34,27 @@
Classes="resultBullet"
VerticalAlignment="Stretch" />
<!-- Icon and Text -->
<!-- Icon Area -->
<Grid Grid.Column="1" ColumnDefinitions="Auto,*" Margin="6,0,0,0">
<!-- Glyph Icon (shown when UseGlyphIcons is enabled and glyph is available) -->
<TextBlock Grid.Column="0"
Classes="resultGlyph"
Text="{Binding Glyph.Glyph}"
IsVisible="{Binding ShowGlyph}"
FontFamily="{Binding GlyphFontFamily}" />
<!-- Image Icon (shown when no glyph or UseGlyphIcons is disabled) -->
<Image Grid.Column="0"
Classes="resultIcon"
Source="{Binding Image^, FallbackValue={x:Static helper:ImageLoader.DefaultImage}}"
IsVisible="{Binding !ShowGlyph}"
RenderOptions.BitmapInterpolationMode="HighQuality" />
<!-- Icon Container (fixed size for perfect centering) -->
<Panel Grid.Column="0" Width="32" Height="32" Margin="12,0,0,0" VerticalAlignment="Center">
<!-- Glyph Icon -->
<TextBlock Classes="resultGlyph"
Text="{Binding Glyph.Glyph}"
IsVisible="{Binding ShowGlyph}"
FontFamily="{Binding GlyphFontFamily}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<!-- Image Icon -->
<Image Classes="resultIcon"
Source="{Binding Image^, FallbackValue={x:Static helper:ImageLoader.DefaultImage}}"
IsVisible="{Binding !ShowGlyph}"
RenderOptions.BitmapInterpolationMode="HighQuality"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Panel>
<!-- Title and SubTitle -->
<Grid Grid.Column="1"

View file

@ -0,0 +1,25 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel.SettingPages"
xmlns:i18n="using:Flow.Launcher.Avalonia.Resource"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
x:Class="Flow.Launcher.Avalonia.Views.SettingPages.AboutSettingsPage"
x:DataType="vm:AboutSettingsViewModel">
<StackPanel Spacing="20" HorizontalAlignment="Center" VerticalAlignment="Center">
<Image Source="avares://Flow.Launcher.Avalonia/Images/app.ico" Width="128" Height="128" />
<TextBlock Text="Flow Launcher" FontSize="32" FontWeight="Bold" HorizontalAlignment="Center" />
<TextBlock Text="{Binding Version, StringFormat='Version: {0}'}" Foreground="Gray" HorizontalAlignment="Center" />
<StackPanel Orientation="Horizontal" Spacing="20" HorizontalAlignment="Center" Margin="0,20,0,0">
<Button Content="{i18n:Localize website}" Command="{Binding OpenWebsiteCommand}" Width="120" />
<Button Content="GitHub" Command="{Binding OpenGitHubCommand}" Width="120" />
</StackPanel>
<TextBlock Text="Copyright © 2024 Flow Launcher" Foreground="Gray" FontSize="12" HorizontalAlignment="Center" Margin="0,50,0,0" />
</StackPanel>
</UserControl>

View file

@ -0,0 +1,13 @@
using Avalonia.Controls;
using Flow.Launcher.Avalonia.ViewModel.SettingPages;
namespace Flow.Launcher.Avalonia.Views.SettingPages;
public partial class AboutSettingsPage : UserControl
{
public AboutSettingsPage()
{
InitializeComponent();
DataContext = new AboutSettingsViewModel();
}
}

View file

@ -0,0 +1,71 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel.SettingPages"
xmlns:i18n="using:Flow.Launcher.Avalonia.Resource"
xmlns:core="using:Flow.Launcher.Core.Resource"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Flow.Launcher.Avalonia.Views.SettingPages.GeneralSettingsPage"
x:DataType="vm:GeneralSettingsViewModel">
<StackPanel Spacing="20">
<TextBlock Text="{i18n:Localize general}" FontSize="28" FontWeight="SemiBold" />
<!-- Language Selection -->
<ui:SettingsExpander Header="{i18n:Localize language}"
IconSource="Earth">
<ui:SettingsExpander.Footer>
<ComboBox ItemsSource="{Binding Languages}"
SelectedItem="{Binding SelectedLanguage}"
MinWidth="200">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="core:Language">
<TextBlock Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<!-- Startup Settings -->
<ui:SettingsExpander Header="{i18n:Localize startFlowLauncherOnSystemStartup}"
IconSource="Save">
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding StartOnStartup}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<!-- Behavior Settings -->
<ui:SettingsExpander Header="{i18n:Localize hideFlowLauncherWhenLoseFocus}"
IconSource="Hide">
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding HideWhenDeactivated}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="{i18n:Localize showAtTopmost}"
IconSource="Up">
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding ShowAtTopmost}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<!-- Python & Node Paths -->
<ui:SettingsExpander Header="{i18n:Localize pythonFilePath}"
Description="{Binding PythonPath}">
<ui:SettingsExpander.Footer>
<Button Content="{i18n:Localize select}" Command="{Binding SelectPythonCommand}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="{i18n:Localize nodeFilePath}"
Description="{Binding NodePath}">
<ui:SettingsExpander.Footer>
<Button Content="{i18n:Localize select}" Command="{Binding SelectNodeCommand}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</StackPanel>
</UserControl>

View file

@ -0,0 +1,13 @@
using Avalonia.Controls;
using Flow.Launcher.Avalonia.ViewModel.SettingPages;
namespace Flow.Launcher.Avalonia.Views.SettingPages;
public partial class GeneralSettingsPage : UserControl
{
public GeneralSettingsPage()
{
InitializeComponent();
DataContext = new GeneralSettingsViewModel();
}
}

View file

@ -0,0 +1,26 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel.SettingPages"
xmlns:i18n="using:Flow.Launcher.Avalonia.Resource"
xmlns:controls="using:Flow.Launcher.Avalonia.Views.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Flow.Launcher.Avalonia.Views.SettingPages.HotkeySettingsPage"
x:DataType="vm:HotkeySettingsViewModel">
<StackPanel Spacing="20">
<TextBlock Text="{i18n:Localize hotkey}" FontSize="28" FontWeight="SemiBold" />
<ui:SettingsExpander Header="{i18n:Localize toggleFlowLauncher}"
Description="{i18n:Localize toggleFlowLauncherTip}"
IconSource="Keyboard">
<ui:SettingsExpander.Footer>
<controls:HotkeyControl Hotkey="{Binding ToggleHotkey, Mode=TwoWay}" MinWidth="150" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<TextBlock Text="{i18n:Localize hotkeyFormatTip}" Foreground="Gray" FontSize="12" />
</StackPanel>
</UserControl>

View file

@ -0,0 +1,13 @@
using Avalonia.Controls;
using Flow.Launcher.Avalonia.ViewModel.SettingPages;
namespace Flow.Launcher.Avalonia.Views.SettingPages;
public partial class HotkeySettingsPage : UserControl
{
public HotkeySettingsPage()
{
InitializeComponent();
DataContext = new HotkeySettingsViewModel();
}
}

View file

@ -0,0 +1,47 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel.SettingPages"
xmlns:i18n="using:Flow.Launcher.Avalonia.Resource"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
x:Class="Flow.Launcher.Avalonia.Views.SettingPages.PluginsSettingsPage"
x:DataType="vm:PluginsSettingsViewModel">
<Grid RowDefinitions="Auto,Auto,*">
<TextBlock Grid.Row="0" Text="{i18n:Localize plugin}" FontSize="28" FontWeight="SemiBold" Margin="0,0,0,10" />
<TextBox Grid.Row="1"
Text="{Binding SearchText, Mode=TwoWay}"
Watermark="{i18n:Localize search}"
Margin="0,0,0,20" />
<ListBox Grid.Row="2"
ItemsSource="{Binding FilteredPlugins}"
Background="Transparent"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:PluginItemViewModel">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,5">
<Image Grid.Column="0" Source="{Binding IconPath}" Width="32" Height="32" Margin="0,0,15,0" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
<TextBlock Text="{Binding Version}" Foreground="Gray" FontSize="12" VerticalAlignment="Bottom" />
</StackPanel>
<TextBlock Text="{Binding Description}" Foreground="Gray" FontSize="12" TextWrapping="Wrap" />
<TextBlock Text="{Binding Author}" Foreground="LightGray" FontSize="11" />
</StackPanel>
<ToggleSwitch Grid.Column="2"
IsChecked="{Binding !IsDisabled}"
VerticalAlignment="Center"
OnContent="" OffContent="" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</UserControl>

View file

@ -0,0 +1,13 @@
using Avalonia.Controls;
using Flow.Launcher.Avalonia.ViewModel.SettingPages;
namespace Flow.Launcher.Avalonia.Views.SettingPages;
public partial class PluginsSettingsPage : UserControl
{
public PluginsSettingsPage()
{
InitializeComponent();
DataContext = new PluginsSettingsViewModel();
}
}

View file

@ -0,0 +1,47 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel.SettingPages"
xmlns:i18n="using:Flow.Launcher.Avalonia.Resource"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
x:Class="Flow.Launcher.Avalonia.Views.SettingPages.ProxySettingsPage"
x:DataType="vm:ProxySettingsViewModel">
<StackPanel Spacing="20">
<TextBlock Text="{i18n:Localize proxy}" FontSize="28" FontWeight="SemiBold" />
<ui:SettingsExpander Header="{i18n:Localize proxyEnabled}"
IconSource="Globe">
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding ProxyEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="{i18n:Localize proxyServer}">
<ui:SettingsExpander.Footer>
<TextBox Text="{Binding ProxyServer}" MinWidth="200" IsEnabled="{Binding ProxyEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="{i18n:Localize proxyPort}">
<ui:SettingsExpander.Footer>
<NumericUpDown Value="{Binding ProxyPort}" Minimum="0" Maximum="65535" IsEnabled="{Binding ProxyEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="{i18n:Localize proxyUserName}">
<ui:SettingsExpander.Footer>
<TextBox Text="{Binding ProxyUserName}" MinWidth="200" IsEnabled="{Binding ProxyEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="{i18n:Localize proxyPassword}">
<ui:SettingsExpander.Footer>
<TextBox Text="{Binding ProxyPassword}" PasswordChar="*" MinWidth="200" IsEnabled="{Binding ProxyEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</StackPanel>
</UserControl>

View file

@ -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();
}
}

View file

@ -0,0 +1,30 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:i18n="using:Flow.Launcher.Avalonia.Resource"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Flow.Launcher.Avalonia.Views.SettingPages.SettingsWindow"
Title="Flow Launcher Settings"
Width="900" Height="650"
WindowStartupLocation="CenterScreen">
<ui:NavigationView Name="NavView"
PaneDisplayMode="Left"
OpenPaneLength="200"
IsSettingsVisible="False">
<ui:NavigationView.MenuItems>
<ui:NavigationViewItem Content="{i18n:Localize general}" Tag="General" IconSource="Setting" IsSelected="True" />
<ui:NavigationViewItem Content="{i18n:Localize plugin}" Tag="Plugins" IconSource="Library" />
<ui:NavigationViewItem Content="{i18n:Localize theme}" Tag="Theme" IconSource="DarkTheme" />
<ui:NavigationViewItem Content="{i18n:Localize hotkey}" Tag="Hotkey" IconSource="Keyboard" />
<ui:NavigationViewItem Content="{i18n:Localize proxy}" Tag="Proxy" IconSource="Globe" />
<ui:NavigationViewItem Content="{i18n:Localize about}" Tag="About" IconSource="Help" />
</ui:NavigationView.MenuItems>
<ScrollViewer Name="ContentFrame" Padding="20">
<!-- Content will be loaded here -->
</ScrollViewer>
</ui:NavigationView>
</Window>

View file

@ -0,0 +1,46 @@
using Avalonia.Controls;
using FluentAvalonia.UI.Controls;
using Flow.Launcher.Avalonia.ViewModel.SettingPages;
using System;
namespace Flow.Launcher.Avalonia.Views.SettingPages;
public partial class SettingsWindow : Window
{
public SettingsWindow()
{
InitializeComponent();
NavView.SelectionChanged += NavView_SelectionChanged;
// Load default page
LoadPage("General");
}
private void NavView_SelectionChanged(object? sender, NavigationViewSelectionChangedEventArgs e)
{
if (e.SelectedItem is NavigationViewItem item && item.Tag is string tag)
{
LoadPage(tag);
}
}
private void LoadPage(string tag)
{
Control? page = tag switch
{
"General" => new GeneralSettingsPage(),
"Plugins" => new PluginsSettingsPage(),
"Theme" => new ThemeSettingsPage(),
"Hotkey" => new HotkeySettingsPage(),
"Proxy" => new ProxySettingsPage(),
"About" => new AboutSettingsPage(),
_ => new TextBlock { Text = $"Page {tag} not implemented yet", HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Center, VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Center }
};
if (page != null)
{
ContentFrame.Content = page;
}
}
}

View file

@ -0,0 +1,66 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel.SettingPages"
xmlns:i18n="using:Flow.Launcher.Avalonia.Resource"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
x:Class="Flow.Launcher.Avalonia.Views.SettingPages.ThemeSettingsPage"
x:DataType="vm:ThemeSettingsViewModel">
<StackPanel Spacing="20">
<TextBlock Text="{i18n:Localize appearance}" FontSize="28" FontWeight="SemiBold" />
<!-- Theme Selection -->
<ui:SettingsExpander Header="{i18n:Localize theme}"
IconSource="DarkTheme">
<ui:SettingsExpander.Footer>
<ComboBox ItemsSource="{Binding ThemeVariants}"
SelectedItem="{Binding SelectedThemeVariant}"
MinWidth="150" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<!-- Glyph Icons -->
<ui:SettingsExpander Header="{i18n:Localize useGlyphUI}"
Description="{i18n:Localize useGlyphUIEffect}"
IconSource="Font">
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding UseGlyphIcons}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<!-- Max Results -->
<ui:SettingsExpander Header="{i18n:Localize maxShowResults}"
IconSource="List">
<ui:SettingsExpander.Footer>
<ComboBox ItemsSource="{Binding MaxResultsRange}"
SelectedItem="{Binding MaxResults}"
MinWidth="100" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<!-- Font Sizes -->
<ui:SettingsExpander Header="{i18n:Localize queryBoxFont}"
IconSource="FontSize">
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{Binding QueryBoxFontSize}" VerticalAlignment="Center" Width="30" />
<Slider Minimum="10" Maximum="60" Value="{Binding QueryBoxFontSize}" Width="200" />
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="{i18n:Localize resultItemFont}"
IconSource="FontSize">
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{Binding ResultItemFontSize}" VerticalAlignment="Center" Width="30" />
<Slider Minimum="8" Maximum="40" Value="{Binding ResultItemFontSize}" Width="200" />
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</StackPanel>
</UserControl>

View file

@ -0,0 +1,13 @@
using Avalonia.Controls;
using Flow.Launcher.Avalonia.ViewModel.SettingPages;
namespace Flow.Launcher.Avalonia.Views.SettingPages;
public partial class ThemeSettingsPage : UserControl
{
public ThemeSettingsPage()
{
InitializeComponent();
DataContext = new ThemeSettingsViewModel();
}
}

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Flow.Launcher.Plugin;
@ -19,9 +19,15 @@ namespace Flow.Launcher.Infrastructure.Hotkey
private static readonly UnhookWindowsHookExSafeHandle hookId;
public delegate bool KeyboardCallback(KeyEvent keyEvent, int vkCode, SpecialKeyState state);
internal static Func<KeyEvent, int, SpecialKeyState, bool> hookedKeyboardCallback;
public static Func<KeyEvent, int, SpecialKeyState, bool> hookedKeyboardCallback;
public static System.Windows.Input.Key GetKeyFromVk(int vkCode)
{
return System.Windows.Input.KeyInterop.KeyFromVirtualKey(vkCode);
}
static GlobalHotkey()
{
// Set the hook
hookId = SetHook(_procKeyboard, WINDOWS_HOOK_ID.WH_KEYBOARD_LL);