diff --git a/ILSpy.Tests/Search/SearchInputFocusTests.cs b/ILSpy.Tests/Search/SearchInputFocusTests.cs new file mode 100644 index 000000000..1b612f3cd --- /dev/null +++ b/ILSpy.Tests/Search/SearchInputFocusTests.cs @@ -0,0 +1,86 @@ +// 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.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; + +using AwesomeAssertions; + +using ILSpy.AppEnv; +using ILSpy.Docking; +using ILSpy.Search; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Search; + +[TestFixture] +public class SearchInputFocusTests +{ + [AvaloniaTest] + public async Task ShowSearchCommand_Raises_FocusRequested_So_The_View_Pushes_Focus_To_The_Input() + { + // Ctrl+E / Ctrl+Shift+F must leave the SearchInput TextBox focused so the user + // can start typing immediately. The VM raises FocusRequested; the SearchPane + // code-behind subscribes and calls Focus() on the TextBox. + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var search = AppComposition.Current.GetExport(); + var dockWorkspace = AppComposition.Current.GetExport(); + + var fired = 0; + search.FocusRequested += () => fired++; + + dockWorkspace.ShowSearchCommand.Execute(null); + + fired.Should().BeGreaterThan(0, + "ShowSearchCommand must request focus on the search input, not just activate the pane"); + } + + [AvaloniaTest] + public async Task SearchInput_Is_Focused_After_ShowSearchCommand_Pumps_Through_The_Dispatcher() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var pane = await window.WaitForComponent(); + var dockWorkspace = AppComposition.Current.GetExport(); + + dockWorkspace.ShowSearchCommand.Execute(null); + + // Focus shifts via a Dispatcher.UIThread.Post so the view has a frame for the + // pane to surface in the layout — pump the dispatcher before asserting. + Dispatcher.UIThread.RunJobs(); + + var input = pane.FindControl("SearchInput"); + ((object?)input).Should().NotBeNull(); + input!.IsFocused.Should().BeTrue( + "the TextBox holds keyboard focus after ShowSearchCommand so the user can start typing without a click"); + } +} diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index b5bd33fed..b5781cb9b 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -99,8 +99,7 @@ namespace ILSpy.Docking NavigateForwardCommand = new RelayCommand(NavigateForward, () => history.CanNavigateForward); NavigateToHistoryCommand = new RelayCommand(NavigateToHistory, entry => entry != null && (history.BackEntries.Contains(entry) || history.ForwardEntries.Contains(entry))); - ShowSearchCommand = new RelayCommand( - () => ShowToolPane(ILSpy.Search.SearchPaneModel.PaneContentId)); + ShowSearchCommand = new RelayCommand(ExecuteShowSearch); using (ILSpy.AppEnv.StartupLog.Phase("ILSpyDockFactory ctor + CreateLayout")) { factory = new ILSpyDockFactory(toolPaneRegistry); @@ -583,6 +582,26 @@ namespace ILSpy.Docking } } + void ExecuteShowSearch() + { + ShowToolPane(ILSpy.Search.SearchPaneModel.PaneContentId); + // Hand keyboard focus to the search input AFTER activating the pane — the view's + // code-behind subscribes to FocusRequested and posts the focus shift onto the + // dispatcher so the freshly-active pane has a frame to surface in the layout + // first. Resolving the pane through AppComposition (instead of injecting it) + // keeps the dock-workspace decoupled from the search namespace. + try + { + var search = AppEnv.AppComposition.Current.GetExport(); + search.RequestFocus(); + } + catch + { + // Composition isn't available in design-time previews / minimal tests; the + // activation alone is enough to be useful there. + } + } + static System.Collections.Generic.IEnumerable GetAllDockables(IDockable? root) { if (root == null) diff --git a/ILSpy/Search/SearchPane.axaml.cs b/ILSpy/Search/SearchPane.axaml.cs index 292f535b3..08e48bcb2 100644 --- a/ILSpy/Search/SearchPane.axaml.cs +++ b/ILSpy/Search/SearchPane.axaml.cs @@ -16,8 +16,11 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System; + using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Threading; using ICSharpCode.ILSpyX.Search; @@ -25,6 +28,8 @@ namespace ILSpy.Search { public partial class SearchPane : UserControl { + SearchPaneModel? boundModel; + public SearchPane() { InitializeComponent(); @@ -32,6 +37,25 @@ namespace ILSpy.Search SearchResults.KeyDown += OnResultKeyDown; } + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + if (boundModel != null) + boundModel.FocusRequested -= OnFocusRequested; + boundModel = DataContext as SearchPaneModel; + if (boundModel != null) + boundModel.FocusRequested += OnFocusRequested; + } + + void OnFocusRequested() + { + // Post the focus shift instead of running synchronously: ShowSearchCommand fires + // in the middle of SetActiveDockable, when the pane may not yet be the focusable + // visual root. A dispatcher tick lets the activation settle so .Focus() actually + // takes — without it the focus call no-ops because the TextBox isn't visible yet. + Dispatcher.UIThread.Post(() => SearchInput.Focus(), DispatcherPriority.Input); + } + void OnResultDoubleTapped(object? sender, TappedEventArgs e) { if (SearchResults.SelectedItem is SearchResult result && DataContext is SearchPaneModel vm) diff --git a/ILSpy/Search/SearchPaneModel.cs b/ILSpy/Search/SearchPaneModel.cs index ce19978d7..82d16c624 100644 --- a/ILSpy/Search/SearchPaneModel.cs +++ b/ILSpy/Search/SearchPaneModel.cs @@ -117,6 +117,17 @@ namespace ILSpy.Search [ObservableProperty] public partial bool IsSearching { get; set; } + /// + /// Raised when the pane should hand keyboard focus to its search input. The view's + /// code-behind subscribes and calls SearchInput.Focus() on the dispatcher; + /// the VM stays UI-framework-agnostic. Fires from , which + /// calls after bringing the + /// pane to active. + /// + public event Action? FocusRequested; + + public void RequestFocus() => FocusRequested?.Invoke(); + /// /// User clicked (or double-tapped) a result row. Walks the result's Reference /// to the matching assembly-tree node via