From b538e88be988ddd240148e9c72f4c7d5582c7133 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 8 Jun 2026 07:22:45 +0200 Subject: [PATCH] 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 --- ILSpy.Tests/Search/SearchPaneNicetiesTests.cs | 197 ++++++++++++++++++ ILSpy/Search/SearchPane.axaml.cs | 85 +++++++- ILSpy/Search/SearchPaneModel.cs | 41 +++- 3 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 ILSpy.Tests/Search/SearchPaneNicetiesTests.cs diff --git a/ILSpy.Tests/Search/SearchPaneNicetiesTests.cs b/ILSpy.Tests/Search/SearchPaneNicetiesTests.cs new file mode 100644 index 000000000..6623bd23e --- /dev/null +++ b/ILSpy.Tests/Search/SearchPaneNicetiesTests.cs @@ -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; + +/// +/// Keyboard and mouse niceties carried over from the previous search pane: mode hotkeys +/// (Ctrl+T/M/S), search-box <-> result-list arrow-key focus transfer, and +/// open-in-new-tab (Ctrl+Enter / middle-click). +/// +[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(); + dockWorkspace.ShowToolPane(SearchPaneModel.PaneContentId); + var pane = await window.WaitForComponent(); + 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("SearchInput")!; + var results = pane.FindControl("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("SearchInput")!; + var results = pane.FindControl("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(); + + 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("SearchResults")!; + results.SelectedItem = vm.Results.First(r => r is MemberSearchResult); + int before = workspace.Documents!.VisibleDockables!.OfType().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().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(); + + 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().Count(); + + vm.Activate(hit, inNewTabPage: true); + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(20); + } + + workspace.Documents!.VisibleDockables!.OfType().Count() + .Should().BeGreaterThan(before, "Activate(inNewTabPage: true) must route through OpenNodeInNewTab"); + + vm.SearchTerm = string.Empty; + } +} diff --git a/ILSpy/Search/SearchPane.axaml.cs b/ILSpy/Search/SearchPane.axaml.cs index ca4562fa9..16a2f5d2b 100644 --- a/ILSpy/Search/SearchPane.axaml.cs +++ b/ILSpy/Search/SearchPane.axaml.cs @@ -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 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; } } diff --git a/ILSpy/Search/SearchPaneModel.cs b/ILSpy/Search/SearchPaneModel.cs index b01ff4ad8..e1c02d531 100644 --- a/ILSpy/Search/SearchPaneModel.cs +++ b/ILSpy/Search/SearchPaneModel.cs @@ -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). /// - public void Activate(SearchResult result) + public void Activate(SearchResult result) => Activate(result, inNewTabPage: false); + + /// + /// Navigates to . When 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 + /// -- wired to Ctrl+Enter and a + /// middle-click on a result row. + /// + public void Activate(SearchResult result, bool inNewTabPage) { ArgumentNullException.ThrowIfNull(result); if (result.Reference is null) @@ -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; } + /// + /// Selects the picker entry carrying , 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. + /// + 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 return null; } } + + static Docking.DockWorkspace? TryGetDockWorkspace() + { + try + { + return AppComposition.Current.GetExport(); + } + catch + { + return null; + } + } } }