diff --git a/ILSpy.Tests/Views/OpenFromProcessDialogScrollingTests.cs b/ILSpy.Tests/Views/OpenFromProcessDialogScrollingTests.cs new file mode 100644 index 000000000..1d717ec1d --- /dev/null +++ b/ILSpy.Tests/Views/OpenFromProcessDialogScrollingTests.cs @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Tests.Processes; +using ICSharpCode.ILSpy.ViewModels; +using ICSharpCode.ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Views; + +/// +/// Scrolling the process list with a trackpad must be boring: the list may not resize +/// itself, drift sideways, or move its own end around. A DataGrid could not manage that - +/// its scroll extent was derived from the current scroll offset, so an offset that was +/// briefly too large inflated the extent, which permitted a larger offset still, and the +/// end of the list ran away from the user (measured in the running app: a 1500px list +/// reporting 8735px and still growing). Hence the plain ListBox, whose virtualizing panel +/// keeps the extent a function of the items alone. These tests pin the properties that made +/// the grid unusable, so a future switch back has to answer for them. +/// +[TestFixture] +public class OpenFromProcessDialogScrollingTests +{ + const int RowCount = 60; + + static OpenFromProcessDialog CreateDialogWithManyProcesses(int count = RowCount) + { + var explorer = new FakeProcessExplorer(); + for (int i = 0; i < count; i++) + { + explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process( + 1000 + i, "dotnet", i % 7 == 3 + ? $"Avalonia.BuildServices.486cb255276d45e9bb0ff8b0{i:D4}" + : "MSBuild")); + } + return new OpenFromProcessDialog(explorer); + } + + // The deltas a macOS trackpad actually sends: many tiny fractional events, each with a + // small sideways component next to the vertical one. Nothing here is a whole row, which + // is the point - whole-row steps hid every defect these tests cover. + static readonly double[] StepsY = { 0.02, 0.04, 0.06, 0.04, 0.02, 0.08, 0.12, 0.1, 0.06, 0.04 }; + static readonly double[] StepsX = { 0, 0, 0.02, 0, -0.02, 0, 0.04, 0, 0, -0.02 }; + + [AvaloniaTest] + public async Task Trackpad_Scrolling_The_Process_List_Keeps_The_List_Steady() + { + var dialog = CreateDialogWithManyProcesses(); + dialog.Show(); + var vm = (OpenFromProcessDialogViewModel)dialog.DataContext!; + await Waiters.WaitForAsync(() => vm.Processes.Count == RowCount); + + var list = dialog.FindControl("ProcessesList")!; + var item = await list.WaitForComponent(); + Dispatcher.UIThread.RunJobs(); + + var scroller = list.GetVisualDescendants().OfType().First(); + var horizontalScrollBar = list.GetVisualDescendants().OfType() + .Single(s => s.Name == "PART_HorizontalScrollBar"); + double rowHeight = item.Bounds.Height; + + var contentHeights = new HashSet(); + var viewportHeights = new HashSet(); + var horizontalOffsets = new HashSet(); + int horizontalScrollBarShown = 0; + double maxOffsetY = 0; + var point = list.Bounds.Center; + + // Far more events than the list is long, so it spends most of them pushed against + // the end - where an extent that follows the offset would run away. + for (int i = 0; i < 900; i++) + { + dialog.MouseWheel(point, new Vector(StepsX[i % StepsX.Length], -StepsY[i % StepsY.Length])); + Dispatcher.UIThread.RunJobs(); + + if (horizontalScrollBar.IsVisible) + horizontalScrollBarShown++; + contentHeights.Add(scroller.Extent.Height); + viewportHeights.Add(scroller.Viewport.Height); + horizontalOffsets.Add(scroller.Offset.X); + maxOffsetY = System.Math.Max(maxOffsetY, scroller.Offset.Y); + } + + horizontalScrollBarShown.Should().Be(0, + "the columns are laid out to fit the viewport, so there is nothing to reach sideways"); + horizontalOffsets.Should().Equal(new[] { 0.0 }, + "a vertical scroll must not shift the rows sideways, however much the fingers drift"); + viewportHeights.Should().HaveCount(1, + "a scroll bar appearing and disappearing resizes the rows area under the pointer"); + contentHeights.Should().HaveCount(1, + "the scrollable range must not move while the user is scrolling through it"); + contentHeights.Single().Should().BeApproximately(RowCount * rowHeight, 1.0, + "the list is exactly as tall as its rows - anything larger lets the view scroll " + + "past the last row and snap back"); + maxOffsetY.Should().BeApproximately(RowCount * rowHeight - scroller.Viewport.Height, 1.0, + "scrolling to the end must land on the last row and stop there"); + } + + [AvaloniaTest] + public async Task The_Last_Process_Is_Reachable_And_Fully_Visible() + { + var dialog = CreateDialogWithManyProcesses(); + dialog.Show(); + var vm = (OpenFromProcessDialogViewModel)dialog.DataContext!; + await Waiters.WaitForAsync(() => vm.Processes.Count == RowCount); + var list = dialog.FindControl("ProcessesList")!; + await list.WaitForComponent(); + Dispatcher.UIThread.RunJobs(); + + var point = list.Bounds.Center; + for (int i = 0; i < 900; i++) + { + dialog.MouseWheel(point, new Vector(StepsX[i % StepsX.Length], -StepsY[i % StepsY.Length])); + Dispatcher.UIThread.RunJobs(); + } + + var scroller = list.GetVisualDescendants().OfType().First(); + var lastRow = list.GetRealizedContainers().OfType() + .SingleOrDefault(c => ReferenceEquals(c.DataContext, vm.Processes[^1])); + + lastRow.Should().NotBeNull("the last process must be reachable by scrolling"); + var bottom = lastRow!.TranslatePoint(new Point(0, lastRow.Bounds.Height), scroller)!.Value.Y; + bottom.Should().BeLessThanOrEqualTo(scroller.Viewport.Height + 1, + "the last row must come to rest inside the viewport, not half under its edge"); + } +} diff --git a/ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs b/ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs index a357461ac..7c341b37b 100644 --- a/ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs +++ b/ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs @@ -35,9 +35,9 @@ using NUnit.Framework; namespace ICSharpCode.ILSpy.Tests.Views; /// -/// Pins the process-explorer dialog's shape: a filter box, a process grid above an -/// assembly grid, and the buttons that drive it. Everything is reachable by button - the -/// dialog carries no context menu - and the assembly grid allows multi-select so several +/// Pins the process-explorer dialog's shape: a filter box, a process list above an +/// assembly list, and the buttons that drive it. Everything is reachable by button - the +/// dialog carries no context menu - and the assembly list allows multi-select so several /// assemblies can be added in one go. /// [TestFixture] @@ -67,8 +67,8 @@ public class OpenFromProcessDialogStructureTests var dialog = CreateDialog(); dialog.FindControl("FilterBox").Should().NotBeNull("long process lists need filtering"); - dialog.FindControl("ProcessesGrid").Should().NotBeNull(); - dialog.FindControl("ModulesGrid").Should().NotBeNull("the selected process's assemblies are listed"); + dialog.FindControl("ProcessesList").Should().NotBeNull(); + dialog.FindControl("ModulesList").Should().NotBeNull("the selected process's assemblies are listed"); dialog.FindControl("VisibilityHint").Should().NotBeNull( "the dialog states which processes it cannot show"); @@ -80,11 +80,12 @@ public class OpenFromProcessDialogStructureTests [AvaloniaTest] public void Several_Assemblies_Can_Be_Selected_At_Once() { - var grid = CreateDialog().FindControl("ModulesGrid")!; + var dialog = CreateDialog(); - grid.SelectionMode.Should().Be(DataGridSelectionMode.Extended, + dialog.FindControl("ModulesList")!.SelectionMode.Should().Be(SelectionMode.Multiple, "adding several assemblies of one process in one go is the common case"); - grid.IsReadOnly.Should().BeTrue("the grid lists assemblies, it does not edit them"); + dialog.FindControl("ProcessesList")!.SelectionMode.Should().Be(SelectionMode.Single, + "one process at a time drives the assembly list below"); } [AvaloniaTest] @@ -99,7 +100,7 @@ public class OpenFromProcessDialogStructureTests await Waiters.WaitForAsync(() => explorer.ProcessCalls > 0); var vm = (OpenFromProcessDialogViewModel)dialog.DataContext!; await Waiters.WaitForAsync(() => vm.Processes.Count == 1); - dialog.FindControl("ProcessesGrid")!.ItemsSource.Should().BeSameAs(vm.Processes); + dialog.FindControl("ProcessesList")!.ItemsSource.Should().BeSameAs(vm.Processes); } [AvaloniaTest] @@ -119,19 +120,19 @@ public class OpenFromProcessDialogStructureTests vm.SelectedProcess = vm.Processes[0]; await Waiters.WaitForAsync(() => vm.Modules.Count == 2); - var grid = dialog.FindControl("ModulesGrid")!; - grid.SelectedItems.Add(vm.Modules[0]); + var list = dialog.FindControl("ModulesList")!; + list.SelectedItems!.Add(vm.Modules[0]); await Waiters.WaitForAsync(() => vm.SelectedModules.Count == 1); vm.SelectedModules.Single().Name.Should().Be("A.dll", - "the grid's selection is what the Add button acts on"); + "the list's selection is what the Add button acts on"); } [AvaloniaTest] public async Task Filtering_Keeps_A_Selected_Process_That_Still_Matches() { - // Only reproducible with the grid attached: the view model alone never sees the - // selection being written back, because it is the grid that pushes it. + // Only reproducible with the list attached: the view model alone never sees the + // selection being written back, because it is the list that pushes it. var explorer = new FakeProcessExplorer(); explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(100, "ILSpy", "ILSpy")); explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(200, "dotnet", "MyTool")); @@ -142,7 +143,7 @@ public class OpenFromProcessDialogStructureTests dialog.Show(); var vm = (OpenFromProcessDialogViewModel)dialog.DataContext!; await Waiters.WaitForAsync(() => vm.Processes.Count == 2); - dialog.FindControl("ProcessesGrid")!.SelectedItem = vm.Processes[0]; + dialog.FindControl("ProcessesList")!.SelectedItem = vm.Processes[0]; await Waiters.WaitForAsync(() => vm.Modules.Count == 1); // One keystroke that narrows the list without excluding the selected row. @@ -166,7 +167,7 @@ public class OpenFromProcessDialogStructureTests dialog.Show(); var vm = (OpenFromProcessDialogViewModel)dialog.DataContext!; await Waiters.WaitForAsync(() => vm.Processes.Count == 2); - dialog.FindControl("ProcessesGrid")!.SelectedItem = vm.Processes[0]; + dialog.FindControl("ProcessesList")!.SelectedItem = vm.Processes[0]; await Waiters.WaitForAsync(() => vm.Modules.Count == 1); vm.FilterText = "dotnet"; diff --git a/ILSpy/Views/OpenFromProcessDialog.axaml b/ILSpy/Views/OpenFromProcessDialog.axaml index 9a8fae4fe..3dddf19e6 100644 --- a/ILSpy/Views/OpenFromProcessDialog.axaml +++ b/ILSpy/Views/OpenFromProcessDialog.axaml @@ -6,6 +6,28 @@ Width="850" Height="560" MinWidth="500" MinHeight="360" WindowStartupLocation="CenterOwner" CanResize="True"> + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/ILSpy/Views/OpenFromProcessDialog.axaml.cs b/ILSpy/Views/OpenFromProcessDialog.axaml.cs index 195929091..76d071bb2 100644 --- a/ILSpy/Views/OpenFromProcessDialog.axaml.cs +++ b/ILSpy/Views/OpenFromProcessDialog.axaml.cs @@ -33,7 +33,7 @@ namespace ICSharpCode.ILSpy.Views /// /// "Open from Running Process" dialog: lists the running .NET processes, shows the /// assemblies loaded in the selected one, and closes with the paths to open - either the - /// assemblies picked in the grid or the process's entry assembly, which for a modern app + /// assemblies picked in the list or the process's entry assembly, which for a modern app /// is the dll behind its native host. Closes with null when cancelled; all behavior lives /// in . /// @@ -67,12 +67,13 @@ namespace ICSharpCode.ILSpy.Views SetColumnHeaders(); - // A DataGrid's multi-selection is not bindable, so the grid pushes it into the - // view model, which is what the Add button's command acts on. - var modulesGrid = this.FindControl("ModulesGrid")!; - modulesGrid.SelectionChanged += (_, _) => { + // A multi-selection is not bindable, so the list pushes it into the view model, + // which is what the Add button's command acts on. + var modulesList = this.FindControl("ModulesList")!; + modulesList.SelectionChanged += (_, _) => { viewModel.SelectedModules.Clear(); - foreach (var module in modulesGrid.SelectedItems.OfType()) + foreach (var module in modulesList.SelectedItems?.OfType() + ?? Enumerable.Empty()) viewModel.SelectedModules.Add(module); }; @@ -88,16 +89,14 @@ namespace ICSharpCode.ILSpy.Views void SetColumnHeaders() { - var processes = this.FindControl("ProcessesGrid")!; - processes.Columns[0].Header = Loc.OpenFromProcess_Process; - processes.Columns[1].Header = Loc.OpenFromProcess_Pid; - processes.Columns[2].Header = Loc.OpenFromProcess_Runtime; - processes.Columns[3].Header = Loc.OpenFromProcess_Architecture; - processes.Columns[4].Header = Loc.OpenFromProcess_EntryAssembly; - - var modules = this.FindControl("ModulesGrid")!; - modules.Columns[0].Header = Loc.Assembly; - modules.Columns[1].Header = Loc.OpenFromProcess_Location; + this.FindControl("ProcessColumnHeader")!.Text = Loc.OpenFromProcess_Process; + this.FindControl("PidColumnHeader")!.Text = Loc.OpenFromProcess_Pid; + this.FindControl("RuntimeColumnHeader")!.Text = Loc.OpenFromProcess_Runtime; + this.FindControl("ArchitectureColumnHeader")!.Text = Loc.OpenFromProcess_Architecture; + this.FindControl("EntryAssemblyColumnHeader")!.Text = Loc.OpenFromProcess_EntryAssembly; + + this.FindControl("AssemblyColumnHeader")!.Text = Loc.Assembly; + this.FindControl("LocationColumnHeader")!.Text = Loc.OpenFromProcess_Location; } void InitializeComponent() => AvaloniaXamlLoader.Load(this);