From 9d3b9eb57a2ed058953207ebcc61d822cc18d555 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 6 Jun 2026 10:59:52 +0200 Subject: [PATCH] Delete the unused ProDataGrid TreeKeyboardController TreeKeyboardController (and its ITreeKeyboardTarget interface) drove keyboard gestures on the old ProDataGrid trees, including a reflection workaround for the grid's shift-range selection that only added and never shrank a range. Both trees are SharpTreeView (ListBox) now -- which handles those gestures natively in SharpTreeView.OnKeyDown and gets anchor extend/shrink for free -- so nothing constructs the controller. Remove it and the now-vestigial interface implementations on the two tree view-models. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../Analyzers/AnalyzerTreeKeyboardTests.cs | 6 +- ILSpy/Analyzers/AnalyzerTreeViewModel.cs | 13 +- ILSpy/AssemblyTree/AssemblyTreeModel.cs | 5 +- ILSpy/Controls/TreeKeyboardController.cs | 261 ------------------ 4 files changed, 5 insertions(+), 280 deletions(-) delete mode 100644 ILSpy/Controls/TreeKeyboardController.cs diff --git a/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs b/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs index ccba92f1f..c5d9bbf9e 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs @@ -44,8 +44,8 @@ public class AnalyzerTreeKeyboardTests [AvaloniaTest] public async Task Right_Key_Expands_A_Node_In_The_Analyzer_Tree() { - // The analyzer tree shares the same TreeKeyboardController as the assembly tree, so the - // standard gestures work there too -- here, Right expands the focused node. + // The analyzer tree is a SharpTreeView like the assembly tree, so the standard tree gestures + // work there too via SharpTreeView.OnKeyDown -- here, Right expands the focused node. var (window, vm) = await TestHarness.BootAsync(3); var dockWorkspace = AppComposition.Current.GetExport(); var analyzerVm = AppComposition.Current.GetExport(); @@ -67,7 +67,7 @@ public class AnalyzerTreeKeyboardTests window.KeyPress(Key.Right, RawInputModifiers.None, PhysicalKey.ArrowRight, null); await Waiters.WaitForAsync(() => analyzed.IsExpanded, - description: "Right must expand the node via the shared TreeKeyboardController on the analyzer tree"); + description: "Right must expand the node via SharpTreeView.OnKeyDown on the analyzer tree"); } [AvaloniaTest] diff --git a/ILSpy/Analyzers/AnalyzerTreeViewModel.cs b/ILSpy/Analyzers/AnalyzerTreeViewModel.cs index d63520faa..f567768c1 100644 --- a/ILSpy/Analyzers/AnalyzerTreeViewModel.cs +++ b/ILSpy/Analyzers/AnalyzerTreeViewModel.cs @@ -33,21 +33,10 @@ namespace ILSpy.Analyzers [Export] [ExportToolPane(ContentId = PaneContentId, Alignment = ToolPaneAlignment.Bottom, Order = 0, IsVisibleByDefault = false)] [Shared] - public class AnalyzerTreeViewModel : ToolPaneModel, ILSpy.Controls.ITreeKeyboardTarget + public class AnalyzerTreeViewModel : ToolPaneModel { public const string PaneContentId = "Analyzer"; - // ITreeKeyboardTarget: lets the shared TreeKeyboardController drive the analyzer tree's - // keyboard navigation, the same as the assembly tree. - SharpTreeNode? ILSpy.Controls.ITreeKeyboardTarget.PrimarySelectedNode - => SelectedItems.Count > 0 ? SelectedItems[^1] : null; - - void ILSpy.Controls.ITreeKeyboardTarget.SelectNode(SharpTreeNode? node) - { - if (node != null) - SyncSelection(node); - } - public AnalyzerTreeViewModel() { Id = PaneContentId; diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs index 22e345c9f..e735b3f84 100644 --- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs +++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs @@ -49,11 +49,8 @@ namespace ILSpy.AssemblyTree [Export] [ExportToolPane(ContentId = PaneContentId, Alignment = ToolPaneAlignment.Left, Order = 0)] [Shared] - public partial class AssemblyTreeModel : ToolPaneModel, ILSpy.Controls.ITreeKeyboardTarget + public partial class AssemblyTreeModel : ToolPaneModel { - // ITreeKeyboardTarget: the primary (last) selected node drives TreeKeyboardController. - SharpTreeNode? ILSpy.Controls.ITreeKeyboardTarget.PrimarySelectedNode => SelectedItem; - public const string PaneContentId = "AssemblyTree"; readonly SettingsService settingsService; diff --git a/ILSpy/Controls/TreeKeyboardController.cs b/ILSpy/Controls/TreeKeyboardController.cs deleted file mode 100644 index 09e51e15a..000000000 --- a/ILSpy/Controls/TreeKeyboardController.cs +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team -// -// 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; -using System.Collections.Generic; -using System.Reflection; - -using Avalonia.Controls; -using Avalonia.Controls.DataGridHierarchical; -using Avalonia.Input; -using Avalonia.Interactivity; -using Avalonia.Threading; - -using ICSharpCode.ILSpyX.TreeView; - -namespace ILSpy.Controls -{ - /// - /// A tree whose keyboard gestures the drives. The - /// controller reads the focused node and asks the model to move the selection, so each tree - /// keeps its own select-and-reveal behaviour (decompile, navigate, scroll-into-view). - /// - public interface ITreeKeyboardTarget - { - SharpTreeNode? PrimarySelectedNode { get; } - void SelectNode(SharpTreeNode? node); - } - - /// - /// Adds the standard tree keyboard gestures to a hierarchical : - /// Left/Right collapse-expand + parent/child navigation, Numpad +/-/* (including recursive - /// expand), and type-ahead incremental search. Expansion is driven through the grid's - /// ; selection moves delegate to the - /// . Attach one per tree (assembly tree + analyzer tree) - /// for consistent behaviour. - /// - public sealed class TreeKeyboardController - { - readonly DataGrid grid; - 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)); - searchResetTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; - 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 - // gesture so the controller can be created before the DataContext is assigned. - ITreeKeyboardTarget? Target => grid.DataContext as ITreeKeyboardTarget; - - IHierarchicalModel? Model => grid.HierarchicalModel as IHierarchicalModel; - - void OnKeyDown(object? sender, KeyEventArgs e) - { - if (e.KeyModifiers != KeyModifiers.None) - return; - if (Target is not { } target - || target.PrimarySelectedNode is not { } current - || Model is not { } hm - || hm.FindNode(current) is not { } node) - return; - - switch (e.Key) - { - case Key.Left: - // Collapse if open; else step out to the parent (Parent is null on a root row). - if (node.IsExpanded) - hm.Collapse(node); - else if (node.Parent?.Item is SharpTreeNode parent) - target.SelectNode(parent); - else - return; - break; - case Key.Right: - // Expand if closed and not a leaf; else step into the first child. - if (!node.IsExpanded && !node.IsLeaf) - hm.Expand(node); - else if (node.IsExpanded && node.Children.Count > 0 && node.Children[0].Item is SharpTreeNode child) - target.SelectNode(child); - else - return; - break; - case Key.Add: - if (node.IsLeaf) - return; - hm.Expand(node); - break; - case Key.Subtract: - hm.Collapse(node); - break; - case Key.Multiply: - ExpandRecursively(hm, node); - break; - default: - return; - } - e.Handled = true; - } - - void OnTextInput(object? sender, TextInputEventArgs e) - { - var text = e.Text; - if (string.IsNullOrEmpty(text) || char.IsControl(text[0])) - return; - if (Target is not { } target || Model is not { } hm) - return; - var flattened = hm.Flattened; - if (flattened.Count == 0) - return; - - searchBuffer += text; - searchResetTimer.Stop(); - searchResetTimer.Start(); - - // Anchor the search at the current selection. A fresh single keystroke advances past it - // (so repeating a letter cycles through matches); accumulating a longer prefix re-matches - // from the current row so a settled selection that still matches stays put. - int anchor = IndexOf(flattened, target.PrimarySelectedNode); - int from = searchBuffer.Length <= 1 ? anchor + 1 : anchor; - - var match = FindPrefixMatch(flattened, searchBuffer, from); - if (match?.Item is SharpTreeNode node) - { - target.SelectNode(node); - e.Handled = true; - } - } - - static int IndexOf(IReadOnlyList flattened, SharpTreeNode? node) - { - if (node is null) - return -1; - for (int i = 0; i < flattened.Count; i++) - { - if (ReferenceEquals(flattened[i].Item, node)) - return i; - } - return -1; - } - - static HierarchicalNode? FindPrefixMatch(IReadOnlyList flattened, string prefix, int from) - { - if (from < 0) - from = 0; - for (int k = 0; k < flattened.Count; k++) - { - var candidate = flattened[(from + k) % flattened.Count]; - if (candidate.Item is SharpTreeNode stn - && stn.Text?.ToString() is { } textValue - && textValue.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - return candidate; - } - } - 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) - { - if (node.IsLeaf) - return; - hm.Expand(node); - foreach (var child in node.Children) - { - if (child.Item is SharpTreeNode { CanExpandRecursively: false }) - continue; - ExpandRecursively(hm, child); - } - } - } -}