Browse Source

Fix #4027: mouse back/forward buttons navigate the history again

WPF translated XButton1/XButton2 into BrowseBack/BrowseForward
commands by itself, so the WPF frontend got the behaviour for free
and the buttons never reached the control under the pointer as a
click. Avalonia has no such translation and KeyBinding cannot express
pointer buttons, so only Alt+Left / Alt+Right survived the migration.
MainWindow now swallows the X-button press while it tunnels (Dock
would otherwise activate the pane under the pointer, AvaloniaEdit
would focus the editor or toggle a folding marker) and routes the
release to the existing DockWorkspace navigation commands.

Navigating also no longer moves the active pane to the editor: the
history target is usually the already-active tab, and Dock's
ActiveDockable setter re-runs InitActiveDockable -> SetFocusedDockable
even for an unchanged value, so re-activating it only moved the
focus. WPF's ActiveTabPage setter was a no-op for the same value.

Assisted-by: Claude:claude-fable-5:Claude Code
pull/4039/head
Christoph Wille 4 weeks ago
parent
commit
dfca502681
  1. 117
      ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs
  2. 6
      ILSpy/Docking/DockWorkspace.cs
  3. 4
      ILSpy/Views/MainWindow.axaml
  4. 37
      ILSpy/Views/MainWindow.axaml.cs

117
ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs

@ -19,9 +19,12 @@ @@ -19,9 +19,12 @@
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.NUnit;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using AwesomeAssertions;
@ -29,8 +32,10 @@ using AwesomeAssertions; @@ -29,8 +32,10 @@ using AwesomeAssertions;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Commands;
using ICSharpCode.ILSpy.Docking;
using ICSharpCode.ILSpy.TextView;
using ICSharpCode.ILSpy.TreeNodes;
using ICSharpCode.ILSpy.ViewModels;
using ICSharpCode.ILSpy.Views;
@ -121,6 +126,118 @@ public class BrowseBackForwardCommandTests @@ -121,6 +126,118 @@ public class BrowseBackForwardCommandTests
"after one back-step the forward stack should be non-empty");
}
[AvaloniaTest]
public async Task Mouse_Back_And_Forward_Buttons_Navigate_The_History()
{
// The extra mouse buttons (XButton1 = back, XButton2 = forward) drive the same history
// as Alt+Left / Alt+Right, matching browsers and the WPF version (where WPF itself
// translated the buttons into BrowseBack/BrowseForward commands). Avalonia has no such
// translation, so MainWindow routes the pointer events to the navigation commands.
// Arrange — build a two-entry history exactly like the menu-driven test above.
var (window, vm) = await TestHarness.BootAsync(3);
var (firstMethod, secondMethod) = await BuildTwoEntryHistoryAsync(vm);
// Act — click mouse-back anywhere in the window.
var point = new Point(100, 100);
window.MouseDown(point, MouseButton.XButton1);
window.MouseUp(point, MouseButton.XButton1);
// Assert — selection rewinds, then mouse-forward replays the step.
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod),
description: "XButton1 must navigate back one history entry");
await Waiters.WaitForAsync(() => vm.DockWorkspace.NavigateForwardCommand.CanExecute(null),
description: "after one back-step the forward stack should be non-empty");
window.MouseDown(point, MouseButton.XButton2);
window.MouseUp(point, MouseButton.XButton2);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, secondMethod),
description: "XButton2 must navigate forward one history entry");
}
[AvaloniaTest]
public async Task Mouse_Back_Button_Press_Does_Not_Reach_The_Control_Under_The_Pointer()
{
// The X buttons are navigation gestures, not clicks (WPF never delivered them to the
// control under the pointer). The press must not activate the pane under the pointer,
// move keyboard focus, or toggle a folding marker; only the release navigates, and the
// active pane stays where it was across the navigation.
// Arrange — two-entry history, assembly pane active, pointer over the editor.
var (window, vm) = await TestHarness.BootAsync(3);
var (firstMethod, _) = await BuildTwoEntryHistoryAsync(vm);
var view = await window.WaitForComponent<DecompilerTextView>();
vm.DockWorkspace.ShowToolPane(AssemblyTreeModel.PaneContentId);
var activePane = vm.DockWorkspace.Layout.FocusedDockable;
activePane.Should().NotBeNull("showing the assembly pane must make it the focused dockable");
var focusedElement = window.FocusManager?.GetFocusedElement();
int pressedInEditor = 0;
view.AddHandler(InputElement.PointerPressedEvent, (_, _) => pressedInEditor++,
RoutingStrategies.Tunnel | RoutingStrategies.Bubble);
var point = view.TranslatePoint(new Point(view.Bounds.Width / 2, view.Bounds.Height / 2), window);
point.Should().NotBeNull("the editor centre must map into the test window");
// Act / Assert — the press is swallowed at the window ...
window.MouseDown(point!.Value, MouseButton.XButton1);
pressedInEditor.Should().Be(0, "an X-button press must not reach the control under the pointer");
vm.DockWorkspace.Layout.FocusedDockable.Should().BeSameAs(activePane,
"pressing a mouse navigation button must not activate the pane under the pointer");
ReferenceEquals(window.FocusManager?.GetFocusedElement(), focusedElement).Should().BeTrue(
"pressing a mouse navigation button must not move keyboard focus");
// ... and the release navigates without moving the active pane to the editor.
window.MouseUp(point.Value, MouseButton.XButton1);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod),
description: "XButton1 must navigate back one history entry");
vm.DockWorkspace.Layout.FocusedDockable.Should().BeSameAs(activePane,
"navigating back must not move the active pane to the editor");
}
[AvaloniaTest]
public async Task Browse_Back_Keeps_The_Active_Pane()
{
// Back/Forward re-select a tree node and restore the tab's view state; the tab being
// navigated is already the active document, so the navigation must not move the active
// pane to it (WPF kept the current view focused). Exercises the command directly, which
// is what the Alt+Left key binding and the View menu invoke.
var (_, vm) = await TestHarness.BootAsync(3);
var (firstMethod, _) = await BuildTwoEntryHistoryAsync(vm);
vm.DockWorkspace.ShowToolPane(AssemblyTreeModel.PaneContentId);
var activePane = vm.DockWorkspace.Layout.FocusedDockable;
activePane.Should().NotBeNull("showing the assembly pane must make it the focused dockable");
vm.DockWorkspace.NavigateBackCommand.Execute(null);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod),
description: "BrowseBack must navigate back one history entry");
await vm.DockWorkspace.WaitForDecompiledTextAsync();
vm.DockWorkspace.Layout.FocusedDockable.Should().BeSameAs(activePane,
"navigating back must not move the active pane to the editor");
}
// Selects two methods of System.Linq.Enumerable with a pause in between so the history records
// them as two separate entries; returns them in selection order.
static async Task<(MethodTreeNode First, MethodTreeNode Second)> BuildTwoEntryHistoryAsync(MainWindowViewModel vm)
{
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var firstMethod = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable");
var secondMethod = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty");
vm.AssemblyTreeModel.SelectNode(firstMethod);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
await Task.Delay(600);
vm.AssemblyTreeModel.SelectNode(secondMethod);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
return (firstMethod, secondMethod);
}
[AvaloniaTest]
public void BrowseBack_MenuItem_Carries_The_Alt_Left_Gesture()
{

6
ILSpy/Docking/DockWorkspace.cs

@ -596,7 +596,11 @@ namespace ICSharpCode.ILSpy.Docking @@ -596,7 +596,11 @@ namespace ICSharpCode.ILSpy.Docking
suppressHistoryRecording = true;
try
{
if (factory.Documents?.VisibleDockables is { } docs && docs.Contains(target.Tab))
// Only activate a tab that is not already active: Dock's ActiveDockable setter re-runs
// InitActiveDockable -> SetFocusedDockable even for an unchanged value, which would
// move the active pane to the document on every navigation.
if (factory.Documents is { VisibleDockables: { } docs } documents
&& docs.Contains(target.Tab) && !ReferenceEquals(documents.ActiveDockable, target.Tab))
factory.SetActiveDockable(target.Tab);
if (target is TreeNodeEntry treeNode)
{

4
ILSpy/Views/MainWindow.axaml

@ -20,8 +20,8 @@ @@ -20,8 +20,8 @@
</Design.DataContext>
<Window.KeyBindings>
<!-- Browser-style navigation. XButton1/2 (mouse Back/Forward) aren't first-class in
Avalonia 12 yet, so we wire keyboard only for now. -->
<!-- Browser-style keyboard navigation. The mouse back/forward buttons drive the same
commands from code-behind (KeyBindings cannot express pointer buttons). -->
<KeyBinding Gesture="Alt+Left" Command="{Binding DockWorkspace.NavigateBackCommand}" />
<KeyBinding Gesture="Alt+Right" Command="{Binding DockWorkspace.NavigateForwardCommand}" />
<!-- Search pane shortcuts. Ctrl+Shift+F is the WPF default; Ctrl+E mirrors the

37
ILSpy/Views/MainWindow.axaml.cs

@ -21,6 +21,8 @@ using System.Composition; @@ -21,6 +21,8 @@ using System.Composition;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.ViewModels;
@ -60,6 +62,16 @@ namespace ICSharpCode.ILSpy.Views @@ -60,6 +62,16 @@ namespace ICSharpCode.ILSpy.Views
// gesture that triggered the DBus call. No-op unless that category is enabled.
InputDiagnostics.Attach(this);
ICSharpCode.ILSpy.MainMenu.Attach(this);
// Mouse back/forward buttons navigate the history, like Alt+Left / Alt+Right. There is
// no KeyBinding equivalent for pointer buttons, so listen window-wide. The press is
// swallowed while tunnelling (the window is the first stop) so the control under the
// pointer never sees it as a click: Dock would activate the pane, AvaloniaEdit would
// focus the editor or toggle a folding marker. The release then navigates;
// handledEventsToo because inner controls handle pointer events for their own gestures
// without ever using the X buttons.
AddHandler(PointerPressedEvent, OnBrowserNavigationPointerPressed, RoutingStrategies.Tunnel);
AddHandler(PointerReleasedEvent, OnBrowserNavigationPointerReleased,
RoutingStrategies.Bubble, handledEventsToo: true);
ApplySessionSettings(settingsService.SessionSettings);
Opened += async (_, _) => {
AppLog.Mark("MainWindow.Opened fired");
@ -77,6 +89,31 @@ namespace ICSharpCode.ILSpy.Views @@ -77,6 +89,31 @@ namespace ICSharpCode.ILSpy.Views
AppLog.Mark("MainWindow ctor exited");
}
void OnBrowserNavigationPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.PointerUpdateKind
is PointerUpdateKind.XButton1Pressed or PointerUpdateKind.XButton2Pressed)
{
e.Handled = true;
}
}
void OnBrowserNavigationPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (DataContext is not MainWindowViewModel viewModel)
return;
var command = e.InitialPressMouseButton switch {
MouseButton.XButton1 => viewModel.DockWorkspace.NavigateBackCommand,
MouseButton.XButton2 => viewModel.DockWorkspace.NavigateForwardCommand,
_ => null
};
if (command?.CanExecute(null) == true)
{
command.Execute(null);
e.Handled = true;
}
}
static void SurfaceCompositionErrors()
{
if (!AppEnv.CompositionErrors.Any)

Loading…
Cancel
Save