Add context menu support for results in Avalonia UI

- Add ActiveView enum to track Results vs ContextMenu view state
- Add ContextMenu ResultsViewModel and view switching logic
- Implement LoadContextMenuCommand using PluginManager.GetContextMenusForPlugin
- Add keyboard navigation: Shift+Enter/Right to open, Left/Escape to close
- Update SelectNextItem/SelectPrevItem to navigate appropriate list
- Update EscCommand to return from context menu before hiding
This commit is contained in:
Hongtao Zhang 2026-01-15 00:53:14 -08:00
parent 2466b907c4
commit 192eb4d1c0
3 changed files with 176 additions and 10 deletions

View file

@ -34,6 +34,7 @@
<KeyBinding Gesture="Enter" Command="{Binding OpenResultCommand}" />
<KeyBinding Gesture="Down" Command="{Binding SelectNextItemCommand}" />
<KeyBinding Gesture="Up" Command="{Binding SelectPrevItemCommand}" />
<KeyBinding Gesture="Shift+Enter" Command="{Binding LoadContextMenuCommand}" />
</Window.KeyBindings>
<!-- Main container with rounded corners and background -->
@ -78,7 +79,7 @@
<!-- Separator between query and results -->
<Rectangle Name="MiddleSeparator"
Classes="separator"
IsVisible="{Binding HasResults}" />
IsVisible="{Binding ShowResultsArea}" />
<!-- Results Area -->
<Border Name="ResultAreaBorder" Classes="resultAreaBorder">
@ -89,12 +90,20 @@
<ColumnDefinition Width="0" /> <!-- Preview panel, hidden for now -->
</Grid.ColumnDefinitions>
<!-- Result List -->
<StackPanel Name="ResultArea" Grid.Column="0">
<!-- Result List (visible when in Results view) -->
<StackPanel Name="ResultArea" Grid.Column="0"
IsVisible="{Binding IsResultsViewActive}">
<views:ResultListBox Name="ResultListBox"
DataContext="{Binding Results}" />
</StackPanel>
<!-- Context Menu List (visible when in ContextMenu view) -->
<StackPanel Name="ContextMenuArea" Grid.Column="0"
IsVisible="{Binding IsContextMenuViewActive}">
<views:ResultListBox Name="ContextMenuListBox"
DataContext="{Binding ContextMenu}" />
</StackPanel>
<!-- Preview Panel Separator (hidden for now) -->
<GridSplitter Name="PreviewSplitter"
Grid.Column="1"

View file

@ -80,11 +80,38 @@ public partial class MainWindow : Window
{
base.OnKeyDown(e);
// Handle Escape to hide window
// Handle Escape to hide window (handled by command, but keep as fallback)
if (e.Key == Key.Escape)
{
Hide();
_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;
}
}

View file

@ -13,6 +13,15 @@ using Flow.Launcher.Plugin;
namespace Flow.Launcher.Avalonia.ViewModel;
/// <summary>
/// Represents which view is currently active.
/// </summary>
public enum ActiveView
{
Results,
ContextMenu
}
/// <summary>
/// MainViewModel for Avalonia - minimal implementation for plugin queries.
/// </summary>
@ -41,12 +50,46 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty]
private ResultsViewModel _results;
[ObservableProperty]
private ResultsViewModel _contextMenu;
[ObservableProperty]
private ActiveView _activeView = ActiveView.Results;
/// <summary>
/// Whether the results view is currently active.
/// </summary>
public bool IsResultsViewActive => ActiveView == ActiveView.Results;
/// <summary>
/// Whether the context menu view is currently active.
/// </summary>
public bool IsContextMenuViewActive => ActiveView == ActiveView.ContextMenu;
/// <summary>
/// Whether to show the results/context menu area (separator + list).
/// </summary>
public bool ShowResultsArea => HasResults || IsContextMenuViewActive;
public Settings Settings => _settings;
public MainViewModel(Settings settings)
{
_settings = settings;
_results = new ResultsViewModel(settings);
_contextMenu = new ResultsViewModel(settings);
}
partial void OnActiveViewChanged(ActiveView value)
{
OnPropertyChanged(nameof(IsResultsViewActive));
OnPropertyChanged(nameof(IsContextMenuViewActive));
OnPropertyChanged(nameof(ShowResultsArea));
}
partial void OnHasResultsChanged(bool value)
{
OnPropertyChanged(nameof(ShowResultsArea));
}
public void OnPluginsReady()
@ -92,10 +135,22 @@ public partial class MainViewModel : ObservableObject
{
MainWindowVisibility = false;
QueryText = "";
ActiveView = ActiveView.Results;
ContextMenu.Clear();
HideRequested?.Invoke();
Log.Info(ClassName, "Hide requested");
}
/// <summary>
/// Go back from context menu to results view.
/// </summary>
[RelayCommand]
private void BackToResults()
{
ActiveView = ActiveView.Results;
ContextMenu.Clear();
}
partial void OnQueryTextChanged(string value) => _ = QueryAsync();
private async Task QueryAsync()
@ -220,21 +275,96 @@ public partial class MainViewModel : ObservableObject
}
[RelayCommand]
private void Esc() { Hide(); }
private void Esc()
{
// If in context menu, go back to results; otherwise hide window
if (ActiveView == ActiveView.ContextMenu)
{
BackToResults();
}
else
{
Hide();
}
}
[RelayCommand]
private async Task OpenResultAsync()
{
var result = Results.SelectedItem?.PluginResult;
Result? result;
if (ActiveView == ActiveView.ContextMenu)
{
result = ContextMenu.SelectedItem?.PluginResult;
}
else
{
result = Results.SelectedItem?.PluginResult;
}
if (result == null) return;
try
{
if (await result.ExecuteAsync(new ActionContext { SpecialKeyState = SpecialKeyState.Default }))
HideRequested?.Invoke();
{
Hide();
}
else if (ActiveView == ActiveView.ContextMenu)
{
// If context menu action didn't hide, go back to results
BackToResults();
}
}
catch (Exception e) { Log.Exception(ClassName, "Execute error", e); }
}
[RelayCommand] private void SelectNextItem() => Results.SelectNextItem();
[RelayCommand] private void SelectPrevItem() => Results.SelectPrevItem();
/// <summary>
/// Load context menu for the currently selected result.
/// </summary>
[RelayCommand]
private void LoadContextMenu()
{
var selectedResult = Results.SelectedItem?.PluginResult;
if (selectedResult == null) return;
try
{
var contextMenuResults = PluginManager.GetContextMenusForPlugin(selectedResult);
if (contextMenuResults == null || contextMenuResults.Count == 0) return;
var contextMenuItems = contextMenuResults.Select(r => new ResultViewModel
{
Title = r.Title ?? "",
SubTitle = r.SubTitle ?? "",
IconPath = r.IcoPath ?? "",
Score = r.Score,
PluginResult = r
}).ToList();
ContextMenu.ReplaceResults(contextMenuItems);
ActiveView = ActiveView.ContextMenu;
}
catch (Exception e)
{
Log.Exception(ClassName, "Failed to load context menu", e);
}
}
[RelayCommand]
private void SelectNextItem()
{
if (ActiveView == ActiveView.ContextMenu)
ContextMenu.SelectNextItem();
else
Results.SelectNextItem();
}
[RelayCommand]
private void SelectPrevItem()
{
if (ActiveView == ActiveView.ContextMenu)
ContextMenu.SelectPrevItem();
else
Results.SelectPrevItem();
}
}