diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs index 0ae73f527..4a4f8b462 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs @@ -921,6 +921,77 @@ public class AssemblyTreeTests await Waiters.WaitForAsync(() => assembly.IsExpanded, description: "Numpad * expands"); } + [AvaloniaTest] + public async Task Shift_Up_After_Shift_Down_Shrinks_The_Selection_Toward_The_Anchor() + { + // Anchor at A, Shift+Down twice -> A,B,C. Shift+Up must SHRINK back to A,B (not keep C). + var (window, vm) = await TestHarness.BootAsync(3); + var model = vm.AssemblyTreeModel; + var pane = await window.WaitForComponent(); + var grid = await pane.WaitForComponent(); + grid.Focus(); + Dispatcher.UIThread.RunJobs(); + + var hm = (global::Avalonia.Controls.DataGridHierarchical.IHierarchicalModel)grid.HierarchicalModel!; + var a = (SharpTreeNode)hm.Flattened[0].Item; + var b = (SharpTreeNode)hm.Flattened[1].Item; + var c = (SharpTreeNode)hm.Flattened[2].Item; + + model.SelectNode(a); + await Waiters.WaitForAsync(() => ReferenceEquals(model.SelectedItem, a)); + + window.KeyPress(Key.Down, RawInputModifiers.Shift, PhysicalKey.ArrowDown, null); + Dispatcher.UIThread.RunJobs(); + window.KeyPress(Key.Down, RawInputModifiers.Shift, PhysicalKey.ArrowDown, null); + Dispatcher.UIThread.RunJobs(); + model.SelectedItems.Should().BeEquivalentTo(new[] { a, b, c }, "precondition: A,B,C selected"); + + window.KeyPress(Key.Up, RawInputModifiers.Shift, PhysicalKey.ArrowUp, null); + Dispatcher.UIThread.RunJobs(); + model.SelectedItems.Should().BeEquivalentTo(new[] { a, b }, + "Shift+Up must shrink the range back toward the anchor (A,B), dropping C"); + } + + [AvaloniaTest] + public async Task Shift_Down_Then_Shift_Up_In_The_Middle_Shrinks_Without_Drifting_The_Anchor() + { + // Start mid-list, Shift+Down x3 then Shift+Up x2. The anchor must stay put, so the net is + // the [anchor..anchor+1] range -- not a range that drifted up past the anchor. + var (window, vm) = await TestHarness.BootAsync(3); + var model = vm.AssemblyTreeModel; + var pane = await window.WaitForComponent(); + var grid = await pane.WaitForComponent(); + grid.Focus(); + Dispatcher.UIThread.RunJobs(); + + var hm = (global::Avalonia.Controls.DataGridHierarchical.IHierarchicalModel)grid.HierarchicalModel!; + // Expand the first assembly so there are plenty of rows and a real "middle". + var firstAsm = (SharpTreeNode)hm.Flattened[0].Item; + firstAsm.EnsureLazyChildren(); + firstAsm.IsExpanded = true; + Dispatcher.UIThread.RunJobs(); + await Waiters.WaitForAsync(() => hm.Flattened.Count >= 7); + + var start = (SharpTreeNode)hm.Flattened[3].Item; + var below = (SharpTreeNode)hm.Flattened[4].Item; + model.SelectNode(start); + await Waiters.WaitForAsync(() => ReferenceEquals(model.SelectedItem, start)); + + for (int d = 0; d < 3; d++) + { + window.KeyPress(Key.Down, RawInputModifiers.Shift, PhysicalKey.ArrowDown, null); + Dispatcher.UIThread.RunJobs(); + } + for (int u = 0; u < 2; u++) + { + window.KeyPress(Key.Up, RawInputModifiers.Shift, PhysicalKey.ArrowUp, null); + Dispatcher.UIThread.RunJobs(); + } + + model.SelectedItems.Should().BeEquivalentTo(new[] { start, below }, + "net of +3/-2 from a fixed anchor is the anchor plus one row below it"); + } + [AvaloniaTest] public async Task Type_Ahead_Jumps_To_The_Node_Matching_The_Typed_Text() { diff --git a/ILSpy/Controls/TreeKeyboardController.cs b/ILSpy/Controls/TreeKeyboardController.cs index fc9d7aa63..09e51e15a 100644 --- a/ILSpy/Controls/TreeKeyboardController.cs +++ b/ILSpy/Controls/TreeKeyboardController.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; +using System.Reflection; using Avalonia.Controls; using Avalonia.Controls.DataGridHierarchical; @@ -54,6 +55,10 @@ namespace ILSpy.Controls readonly DispatcherTimer searchResetTimer; string searchBuffer = string.Empty; + // ProDataGrid internals reached reflectively to fix shift-range shrinking (see OnShiftRangeKey). + static readonly MethodInfo? setRowSelectionMethod = + typeof(DataGrid).GetMethod("SetRowSelection", BindingFlags.NonPublic | BindingFlags.Instance); + public TreeKeyboardController(DataGrid grid) { this.grid = grid ?? throw new ArgumentNullException(nameof(grid)); @@ -61,6 +66,8 @@ namespace ILSpy.Controls searchResetTimer.Tick += (_, _) => { searchResetTimer.Stop(); searchBuffer = string.Empty; }; grid.AddHandler(InputElement.KeyDownEvent, OnKeyDown, RoutingStrategies.Bubble); grid.AddHandler(InputElement.TextInputEvent, OnTextInput, RoutingStrategies.Bubble); + // Runs even after the DataGrid marks the key handled, so we can repair its selection. + grid.AddHandler(InputElement.KeyDownEvent, OnShiftRangeKey, RoutingStrategies.Bubble, handledEventsToo: true); } // The model is the grid's DataContext (the tree's UserControl sets it). Resolved per @@ -174,6 +181,68 @@ namespace ILSpy.Controls return null; } + // Workaround for a ProDataGrid bug: a shift+nav key moves the grid's current row and anchor + // correctly, but SelectFromAnchorToCurrent only ADDS the [anchor..current] range and never + // deselects rows outside it -- so shrinking a shift-selection (Shift+Up after extending down) + // is a no-op and the dropped rows stay selected. After the grid has processed the key, prune + // the selection back to exactly the [anchor..current] range. + // + // The prune uses the internal SetRowSelection(slot, isSelected:false, setAnchorSlot:false), + // NOT SelectedItems.Remove: removing an item makes the grid re-derive its current row (which + // corrupts the anchor for the next key), whereas SetRowSelection deselects a slot in place + // and leaves current/anchor untouched. + void OnShiftRangeKey(object? sender, KeyEventArgs e) + { + if ((e.KeyModifiers & KeyModifiers.Shift) == 0 + || grid.SelectionMode != DataGridSelectionMode.Extended + || setRowSelectionMethod is null + || e.Key is not (Key.Up or Key.Down or Key.Home or Key.End or Key.PageUp or Key.PageDown)) + return; + Dispatcher.UIThread.Post(PruneToShiftRange); + } + + void PruneToShiftRange() + { + if (Model is not { } hm || setRowSelectionMethod is null) + return; + int anchor = ReadGridSlot("AnchorSlot"); + int current = ReadGridSlot("CurrentSlot"); + if (anchor < 0 || current < 0) + return; + int lo = Math.Min(anchor, current), hi = Math.Max(anchor, current); + var flattened = hm.Flattened; + if (hi >= flattened.Count) + return; + // Collect first (don't mutate the selection while iterating it), then deselect. + var slotsToDrop = new List(); + foreach (var item in grid.SelectedItems) + { + var node = item is HierarchicalNode hn ? hn.Item as SharpTreeNode : item as SharpTreeNode; + if (node is null) + continue; + int slot = IndexOf(flattened, node); + if (slot >= 0 && (slot < lo || slot > hi)) + slotsToDrop.Add(slot); + } + foreach (int slot in slotsToDrop) + setRowSelectionMethod.Invoke(grid, new object[] { slot, false, false }); + } + + // Reads a DataGrid slot property/field by name; -1 if unavailable (defensive against a + // future ProDataGrid that renames or removes it -- the worst case is the pre-fix behaviour). + int ReadGridSlot(string name) + { + var type = grid.GetType(); + var member = (object?)type.GetProperty(name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance) + ?? type.GetField(name, BindingFlags.NonPublic | BindingFlags.Instance); + object? value = member switch { + PropertyInfo pi => pi.GetValue(grid), + FieldInfo fi => fi.GetValue(grid), + _ => null, + }; + return value is int slot ? slot : -1; + } + // Numpad-* recursive expand. Recurses only into children that opt in via // SharpTreeNode.CanExpandRecursively (false for lazy-loading nodes), so it stays bounded. static void ExpandRecursively(IHierarchicalModel hm, HierarchicalNode node)