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);