mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
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 Codepull/3755/head
4 changed files with 5 additions and 280 deletions
@ -1,261 +0,0 @@
@@ -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 |
||||
{ |
||||
/// <summary>
|
||||
/// A tree whose keyboard gestures the <see cref="TreeKeyboardController"/> 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).
|
||||
/// </summary>
|
||||
public interface ITreeKeyboardTarget |
||||
{ |
||||
SharpTreeNode? PrimarySelectedNode { get; } |
||||
void SelectNode(SharpTreeNode? node); |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Adds the standard tree keyboard gestures to a hierarchical <see cref="DataGrid"/>:
|
||||
/// Left/Right collapse-expand + parent/child navigation, Numpad +/-/* (including recursive
|
||||
/// expand), and type-ahead incremental search. Expansion is driven through the grid's
|
||||
/// <see cref="IHierarchicalModel"/>; selection moves delegate to the
|
||||
/// <see cref="ITreeKeyboardTarget"/>. Attach one per tree (assembly tree + analyzer tree)
|
||||
/// for consistent behaviour.
|
||||
/// </summary>
|
||||
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<HierarchicalNode> 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<HierarchicalNode> 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<int>(); |
||||
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); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue