Browse Source

ShowSearchCommand also focuses the search input

Ctrl+E / Ctrl+Shift+F previously activated the pane but left
keyboard focus wherever it was — the user still had to click the
TextBox before typing. Add a FocusRequested event on SearchPaneModel
that the view subscribes to and pushes Focus() on the SearchInput
TextBox through Dispatcher.UIThread.Post (a tick lets the freshly-
active pane surface in the layout so .Focus() actually takes).

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
84fa404ded
  1. 86
      ILSpy.Tests/Search/SearchInputFocusTests.cs
  2. 23
      ILSpy/Docking/DockWorkspace.cs
  3. 24
      ILSpy/Search/SearchPane.axaml.cs
  4. 11
      ILSpy/Search/SearchPaneModel.cs

86
ILSpy.Tests/Search/SearchInputFocusTests.cs

@ -0,0 +1,86 @@ @@ -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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var search = AppComposition.Current.GetExport<SearchPaneModel>();
var dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var pane = await window.WaitForComponent<SearchPane>();
var dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
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<TextBox>("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");
}
}

23
ILSpy/Docking/DockWorkspace.cs

@ -99,8 +99,7 @@ namespace ILSpy.Docking @@ -99,8 +99,7 @@ namespace ILSpy.Docking
NavigateForwardCommand = new RelayCommand(NavigateForward, () => history.CanNavigateForward);
NavigateToHistoryCommand = new RelayCommand<NavigationEntry>(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 @@ -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<ILSpy.Search.SearchPaneModel>();
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<IDockable> GetAllDockables(IDockable? root)
{
if (root == null)

24
ILSpy/Search/SearchPane.axaml.cs

@ -16,8 +16,11 @@ @@ -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 @@ -25,6 +28,8 @@ namespace ILSpy.Search
{
public partial class SearchPane : UserControl
{
SearchPaneModel? boundModel;
public SearchPane()
{
InitializeComponent();
@ -32,6 +37,25 @@ namespace ILSpy.Search @@ -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)

11
ILSpy/Search/SearchPaneModel.cs

@ -117,6 +117,17 @@ namespace ILSpy.Search @@ -117,6 +117,17 @@ namespace ILSpy.Search
[ObservableProperty]
public partial bool IsSearching { get; set; }
/// <summary>
/// Raised when the pane should hand keyboard focus to its search input. The view's
/// code-behind subscribes and calls <c>SearchInput.Focus()</c> on the dispatcher;
/// the VM stays UI-framework-agnostic. Fires from <see cref="RequestFocus"/>, which
/// <see cref="Docking.DockWorkspace.ShowSearchCommand"/> calls after bringing the
/// pane to active.
/// </summary>
public event Action? FocusRequested;
public void RequestFocus() => FocusRequested?.Invoke();
/// <summary>
/// User clicked (or double-tapped) a result row. Walks the result's <c>Reference</c>
/// to the matching assembly-tree node via <see cref="AssemblyTreeModel.FindTreeNode"/>

Loading…
Cancel
Save