Browse Source

Add keyboard and mouse niceties to the search pane

Restores the result-navigation gestures the pane shipped with before the
port: Ctrl+T/M/S jump the picker to Type/Member/Constant, Down/Up arrow
hand focus between the search box and the result list, and Ctrl+Enter or a
middle-click open a result in a new document tab instead of reusing the
active one. New-tab activation routes through the existing
OpenNodeInNewTab so it matches the tree's open-in-new-tab behaviour.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 4 weeks ago
parent
commit
b538e88be9
  1. 197
      ILSpy.Tests/Search/SearchPaneNicetiesTests.cs
  2. 85
      ILSpy/Search/SearchPane.axaml.cs
  3. 41
      ILSpy/Search/SearchPaneModel.cs

197
ILSpy.Tests/Search/SearchPaneNicetiesTests.cs

@ -0,0 +1,197 @@ @@ -0,0 +1,197 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using Avalonia.Input;
using Avalonia.Threading;
using AwesomeAssertions;
using ICSharpCode.ILSpyX.Search;
using ILSpy.AppEnv;
using ILSpy.Search;
using ILSpy.TextView;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Search;
/// <summary>
/// Keyboard and mouse niceties carried over from the previous search pane: mode hotkeys
/// (Ctrl+T/M/S), search-box &lt;-&gt; result-list arrow-key focus transfer, and
/// open-in-new-tab (Ctrl+Enter / middle-click).
/// </summary>
[TestFixture]
public class SearchPaneNicetiesTests
{
static SearchResult Dummy(string name) => new() {
Name = name,
Location = "loc",
Assembly = "asm",
Image = null!,
LocationImage = null!,
AssemblyImage = null!,
};
static async Task<(MainWindow window, SearchPane pane, SearchPaneModel vm)> ShowPaneAsync()
{
var (window, _) = await TestHarness.BootAsync();
var dockWorkspace = AppComposition.Current.GetExport<global::ILSpy.Docking.DockWorkspace>();
dockWorkspace.ShowToolPane(SearchPaneModel.PaneContentId);
var pane = await window.WaitForComponent<SearchPane>();
var vm = (SearchPaneModel)pane.DataContext!;
return (window, pane, vm);
}
static void RaiseKey(Control target, Key key, KeyModifiers modifiers)
{
target.RaiseEvent(new KeyEventArgs {
Key = key,
KeyModifiers = modifiers,
RoutedEvent = InputElement.KeyDownEvent,
Source = target,
});
}
[AvaloniaTest]
public async Task Ctrl_T_Selects_Type_Mode()
{
var (_, pane, vm) = await ShowPaneAsync();
vm.SelectedSearchMode = vm.SearchModes.First(m => m.Mode == SearchMode.TypeAndMember);
RaiseKey(pane, Key.T, KeyModifiers.Control);
vm.SelectedSearchMode.Mode.Should().Be(SearchMode.Type, "Ctrl+T switches the picker to Type mode");
}
[AvaloniaTest]
public async Task Ctrl_M_Selects_Member_Mode()
{
var (_, pane, vm) = await ShowPaneAsync();
vm.SelectedSearchMode = vm.SearchModes.First(m => m.Mode == SearchMode.TypeAndMember);
RaiseKey(pane, Key.M, KeyModifiers.Control);
vm.SelectedSearchMode.Mode.Should().Be(SearchMode.Member, "Ctrl+M switches the picker to Member mode");
}
[AvaloniaTest]
public async Task Ctrl_S_Selects_Constant_Mode()
{
var (_, pane, vm) = await ShowPaneAsync();
vm.SelectedSearchMode = vm.SearchModes.First(m => m.Mode == SearchMode.TypeAndMember);
RaiseKey(pane, Key.S, KeyModifiers.Control);
vm.SelectedSearchMode.Mode.Should().Be(SearchMode.Literal, "Ctrl+S switches the picker to Constant (literal) mode");
}
[AvaloniaTest]
public async Task Down_Arrow_In_Search_Input_Moves_To_The_First_Result()
{
var (_, pane, vm) = await ShowPaneAsync();
vm.Results.Add(Dummy("Alpha"));
vm.Results.Add(Dummy("Beta"));
var input = pane.FindControl<TextBox>("SearchInput")!;
var results = pane.FindControl<ListBox>("SearchResults")!;
RaiseKey(input, Key.Down, KeyModifiers.None);
Dispatcher.UIThread.RunJobs();
results.SelectedIndex.Should().Be(0, "Down in the search box selects the first result so the user can keep navigating with the keyboard");
}
[AvaloniaTest]
public async Task Up_Arrow_On_The_First_Result_Returns_Focus_To_The_Search_Input()
{
var (_, pane, vm) = await ShowPaneAsync();
vm.Results.Add(Dummy("Alpha"));
var input = pane.FindControl<TextBox>("SearchInput")!;
var results = pane.FindControl<ListBox>("SearchResults")!;
results.SelectedIndex = 0;
RaiseKey(results, Key.Up, KeyModifiers.None);
Dispatcher.UIThread.RunJobs();
results.SelectedIndex.Should().Be(-1, "Up from the top of the list clears the selection on the way back to the search box");
input.IsFocused.Should().BeTrue("Up from the first result returns keyboard focus to the search input");
}
[AvaloniaTest]
public async Task Ctrl_Enter_On_A_Result_Opens_It_In_A_New_Tab()
{
var (_, pane, vm) = await ShowPaneAsync();
var workspace = AppComposition.Current.GetExport<global::ILSpy.Docking.DockWorkspace>();
vm.SelectedSearchMode = vm.SearchModes.First(m => m.Mode == SearchMode.Type);
vm.SearchTerm = "Enumerable";
await Waiters.WaitForAsync(() => vm.Results.Any(r => r is MemberSearchResult),
timeout: TimeSpan.FromSeconds(30));
var results = pane.FindControl<ListBox>("SearchResults")!;
results.SelectedItem = vm.Results.First(r => r is MemberSearchResult);
int before = workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count();
RaiseKey(results, Key.Enter, KeyModifiers.Control);
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count()
.Should().BeGreaterThan(before, "Ctrl+Enter on a result opens it in a new document tab instead of reusing the active one");
vm.SearchTerm = string.Empty;
}
[AvaloniaTest]
public async Task Activate_With_NewTab_Opens_A_New_Document_Tab()
{
var (_, _, vm) = await ShowPaneAsync();
var workspace = AppComposition.Current.GetExport<global::ILSpy.Docking.DockWorkspace>();
vm.SelectedSearchMode = vm.SearchModes.First(m => m.Mode == SearchMode.Type);
vm.SearchTerm = "Enumerable";
await Waiters.WaitForAsync(() => vm.Results.Any(r => r is MemberSearchResult),
timeout: TimeSpan.FromSeconds(30));
var hit = (MemberSearchResult)vm.Results.First(r => r is MemberSearchResult);
int before = workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count();
vm.Activate(hit, inNewTabPage: true);
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
workspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().Count()
.Should().BeGreaterThan(before, "Activate(inNewTabPage: true) must route through OpenNodeInNewTab");
vm.SearchTerm = string.Empty;
}
}

85
ILSpy/Search/SearchPane.axaml.cs

@ -36,19 +36,63 @@ namespace ILSpy.Search @@ -36,19 +36,63 @@ namespace ILSpy.Search
InitializeComponent();
SearchResults.DoubleTapped += OnResultDoubleTapped;
SearchResults.KeyDown += OnResultKeyDown;
SearchResults.AddHandler(PointerPressedEvent, OnResultsPointerPressed, RoutingStrategies.Tunnel);
SearchResults.AddHandler(PointerReleasedEvent, OnResultsPointerReleased, RoutingStrategies.Tunnel);
SearchInput.KeyDown += OnSearchInputKeyDown;
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
// Mode accelerators: Ctrl+T/M/S jump the picker to Type / Member / Constant. These
// mirror the previous app's shortcuts and work from anywhere in the pane (the search
// box doesn't consume bare Ctrl+letter chords, so the event bubbles up to here).
if (e.Handled || e.KeyModifiers != KeyModifiers.Control)
return;
if (DataContext is not SearchPaneModel vm)
return;
switch (e.Key)
{
case Key.T:
vm.SelectMode(SearchMode.Type);
e.Handled = true;
break;
case Key.M:
vm.SelectMode(SearchMode.Member);
e.Handled = true;
break;
case Key.S:
vm.SelectMode(SearchMode.Literal);
e.Handled = true;
break;
}
}
void OnSearchInputKeyDown(object? sender, KeyEventArgs e)
{
// Down arrow drops into the result list, selecting the first row and moving
// keyboard focus there so the user can keep arrowing through hits without the
// mouse. No-op when there are no results to step into.
if (e.Key == Key.Down)
{
if (DataContext is SearchPaneModel vm && vm.Results.Count > 0)
{
SearchResults.SelectedIndex = 0;
SearchResults.Focus();
e.Handled = true;
}
return;
}
// Escape mirrors the clear-X button. Goes through the bound view-model so the
// orchestrator's cancel-and-restart path fires the same way it does when the
// user backspaces the box empty by hand.
if (e.Key != Key.Escape)
return;
if (DataContext is SearchPaneModel vm && vm.SearchTerm.Length > 0)
if (DataContext is SearchPaneModel vmEsc && vmEsc.SearchTerm.Length > 0)
{
vm.SearchTerm = string.Empty;
vmEsc.SearchTerm = string.Empty;
e.Handled = true;
}
}
@ -94,13 +138,46 @@ namespace ILSpy.Search @@ -94,13 +138,46 @@ namespace ILSpy.Search
void OnResultKeyDown(object? sender, KeyEventArgs e)
{
// Up from the top of the list hands focus back to the search box (and clears the
// selection on the way out) so the box and the list feel like one continuous strip.
if (e.Key == Key.Up && SearchResults.SelectedIndex == 0)
{
SearchResults.SelectedIndex = -1;
SearchInput.Focus();
e.Handled = true;
return;
}
// Enter as the keyboard equivalent of double-tap so users navigating the list
// with arrow keys can activate without reaching for the mouse.
// with arrow keys can activate without reaching for the mouse. Ctrl+Enter opens
// the result in a new tab instead of reusing the active one.
if (e.Key != Key.Enter)
return;
if (SearchResults.SelectedItem is SearchResult result && DataContext is SearchPaneModel vm)
{
vm.Activate(result);
vm.Activate(result, e.KeyModifiers.HasFlag(KeyModifiers.Control));
e.Handled = true;
}
}
void OnResultsPointerPressed(object? sender, PointerPressedEventArgs e)
{
// Middle-click doesn't select a row on its own, so claim the row under the cursor
// here; the matching release then activates it in a new tab.
if (!e.GetCurrentPoint(SearchResults).Properties.IsMiddleButtonPressed)
return;
if ((e.Source as Control)?.DataContext is SearchResult result)
SearchResults.SelectedItem = result;
}
void OnResultsPointerReleased(object? sender, PointerReleasedEventArgs e)
{
// Middle-click opens the result in a new tab, matching the tree's open-in-new-tab gesture.
if (e.InitialPressMouseButton != MouseButton.Middle)
return;
if (SearchResults.SelectedItem is SearchResult result && DataContext is SearchPaneModel vm)
{
vm.Activate(result, inNewTabPage: true);
e.Handled = true;
}
}

41
ILSpy/Search/SearchPaneModel.cs

@ -159,7 +159,16 @@ namespace ILSpy.Search @@ -159,7 +159,16 @@ namespace ILSpy.Search
/// (resource and assembly results have non-entity references that the assembly tree
/// doesn't index — a follow-up commit can extend FindTreeNode to cover them).
/// </summary>
public void Activate(SearchResult result)
public void Activate(SearchResult result) => Activate(result, inNewTabPage: false);
/// <summary>
/// Navigates to <paramref name="result"/>. When <paramref name="inNewTabPage"/> is
/// false the assembly-tree selection moves to the matching node (reusing the active
/// document tab); when true the node is opened in a fresh document tab via
/// <see cref="Docking.DockWorkspace.OpenNodeInNewTab"/> -- wired to Ctrl+Enter and a
/// middle-click on a result row.
/// </summary>
public void Activate(SearchResult result, bool inNewTabPage)
{
ArgumentNullException.ThrowIfNull(result);
if (result.Reference is null)
@ -168,10 +177,26 @@ namespace ILSpy.Search @@ -168,10 +177,26 @@ namespace ILSpy.Search
if (atm == null)
return;
var node = atm.FindTreeNode(result.Reference);
if (node != null)
if (node == null)
return;
if (inNewTabPage)
TryGetDockWorkspace()?.OpenNodeInNewTab(node);
else
atm.SelectedItem = node;
}
/// <summary>
/// Selects the picker entry carrying <paramref name="mode"/>, if any. Backs the
/// Ctrl+T / Ctrl+M / Ctrl+S accelerators in the pane's view; a no-op when no entry
/// exposes the requested mode.
/// </summary>
public void SelectMode(SearchMode mode)
{
var entry = SearchModes.FirstOrDefault(m => m.Mode == mode);
if (entry != null)
SelectedSearchMode = entry;
}
RunningSearch? currentSearch;
void RestartSearch()
@ -260,5 +285,17 @@ namespace ILSpy.Search @@ -260,5 +285,17 @@ namespace ILSpy.Search
return null;
}
}
static Docking.DockWorkspace? TryGetDockWorkspace()
{
try
{
return AppComposition.Current.GetExport<Docking.DockWorkspace>();
}
catch
{
return null;
}
}
}
}

Loading…
Cancel
Save