mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
ProDataGrid derives its scroll extent from the current scroll offset, so an offset that is briefly too large inflates the extent, which permits a larger offset again. A trackpad reaches that state within a few hundred sub-row events: instrumented in the running app, a ~1500px list reported 8735px and kept growing, the thumb collapsed to its minimum, and the end of the list ran away from the user. A second defect slid the rows sideways by up to 10px - the star-sized column is measured against the width including the vertical scroll bar, leaving the grid convinced it has a scroll bar's worth of content to reach. Disabling the horizontal scroll bar, giving each grid its own DefaultRowHeightEstimator, pinning RowHeight, forcing the vertical scroll bar visible, and disabling scrolling on the template's inner ScrollViewer each removed a symptom at most; none can break a loop that runs through the scroll offset, and 12.0.4 is the newest release. A ListBox's virtualizing panel keeps the extent a function of the items alone. The price is laying out the columns here - so the header row and the item template have to be kept in step - and losing sortable, resizable headers. Assisted-by: Claude:claude-opus-5[1m]:Claude Codepull/3943/head
4 changed files with 272 additions and 73 deletions
@ -0,0 +1,156 @@
@@ -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; |
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<ListBox>("ProcessesList")!; |
||||
var item = await list.WaitForComponent<ListBoxItem>(); |
||||
Dispatcher.UIThread.RunJobs(); |
||||
|
||||
var scroller = list.GetVisualDescendants().OfType<ScrollViewer>().First(); |
||||
var horizontalScrollBar = list.GetVisualDescendants().OfType<ScrollBar>() |
||||
.Single(s => s.Name == "PART_HorizontalScrollBar"); |
||||
double rowHeight = item.Bounds.Height; |
||||
|
||||
var contentHeights = new HashSet<double>(); |
||||
var viewportHeights = new HashSet<double>(); |
||||
var horizontalOffsets = new HashSet<double>(); |
||||
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<ListBox>("ProcessesList")!; |
||||
await list.WaitForComponent<ListBoxItem>(); |
||||
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<ScrollViewer>().First(); |
||||
var lastRow = list.GetRealizedContainers().OfType<ListBoxItem>() |
||||
.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"); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue