.NET Decompiler with support for PDB generation, ReadyToRun, Metadata (&more) - cross-platform!
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

615 lines
20 KiB

// 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.Collections.Specialized;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ICSharpCode.ILSpyX.TreeView;
using ICSharpCode.ILSpyX.TreeView.PlatformAbstractions;
namespace ILSpy.Controls.TreeView
{
/// <summary>
/// A virtualizing tree control built on <see cref="ListBox"/>, mirroring the WPF SharpTreeView.
/// The tree of <see cref="SharpTreeNode"/>s is flattened to the visible-node list by
/// <see cref="TreeFlattener"/> (an <see cref="System.Collections.IList"/> +
/// <see cref="INotifyCollectionChanged"/>) which is bound straight to the ListBox ItemsSource --
/// so the ListBox virtualizes it and provides anchor-based extend/shrink selection natively.
/// </summary>
public class SharpTreeView : ListBox
{
public static readonly StyledProperty<SharpTreeNode?> RootProperty =
AvaloniaProperty.Register<SharpTreeView, SharpTreeNode?>(nameof(Root));
public static readonly StyledProperty<bool> ShowRootProperty =
AvaloniaProperty.Register<SharpTreeView, bool>(nameof(ShowRoot), defaultValue: true);
public static readonly StyledProperty<bool> ShowRootExpanderProperty =
AvaloniaProperty.Register<SharpTreeView, bool>(nameof(ShowRootExpander), defaultValue: false);
public static readonly StyledProperty<bool> ShowLinesProperty =
AvaloniaProperty.Register<SharpTreeView, bool>(nameof(ShowLines), defaultValue: true);
TreeFlattener? flattener;
bool doNotScrollOnExpanding;
string searchBuffer = string.Empty;
DispatcherTimer? searchResetTimer;
static SharpTreeView()
{
// WPF "Extended": plain click selects one, Shift extends a range from the anchor (and
// shrinks it), Ctrl toggles. Avalonia's ListBox provides this with Multiple selection --
// the behaviour ProDataGrid could not do correctly.
SelectionModeProperty.OverrideDefaultValue<SharpTreeView>(SelectionMode.Multiple);
}
public SharpTreeView()
{
// Take keyboard focus directly so gestures like Ctrl+A work the moment the user tabs
// or clicks into the pane, before any row becomes the current item.
Focusable = true;
SelectionChanged += OnSelectionChanged;
DoubleTapped += OnDoubleTapped;
// Drag-drop is handled generically here and delegated to SharpTreeNode.CanDrop/Drop.
AddHandler(PointerPressedEvent, OnDragPointerPressed, RoutingStrategies.Tunnel);
AddHandler(PointerMovedEvent, OnDragPointerMoved, RoutingStrategies.Bubble, handledEventsToo: true);
AddHandler(PointerReleasedEvent, OnDragPointerReleased, RoutingStrategies.Bubble, handledEventsToo: true);
DragDrop.SetAllowDrop(this, true);
AddHandler(DragDrop.DragOverEvent, OnDragOver);
AddHandler(DragDrop.DropEvent, OnDrop);
AddHandler(DragDrop.DragLeaveEvent, (_, _) => HideInsertMarker());
}
public SharpTreeNode? Root {
get => GetValue(RootProperty);
set => SetValue(RootProperty, value);
}
public bool ShowRoot {
get => GetValue(ShowRootProperty);
set => SetValue(ShowRootProperty, value);
}
public bool ShowRootExpander {
get => GetValue(ShowRootExpanderProperty);
set => SetValue(ShowRootExpanderProperty, value);
}
public bool ShowLines {
get => GetValue(ShowLinesProperty);
set => SetValue(ShowLinesProperty, value);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == RootProperty || change.Property == ShowRootProperty || change.Property == ShowRootExpanderProperty)
Reload();
}
void Reload()
{
if (flattener != null)
{
flattener.Stop();
flattener = null;
}
if (Root != null)
{
if (!(ShowRoot && ShowRootExpander))
Root.IsExpanded = true;
flattener = new TreeFlattener(Root, ShowRoot);
ItemsSource = flattener;
}
else
{
ItemsSource = null;
}
// Avalonia's ListBox removes items from the selection automatically when they leave the
// source (a collapsed ancestor hides them), so no manual deselect-on-hide is needed.
}
protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey)
=> new SharpTreeViewItem();
protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey)
=> NeedsContainer<SharpTreeViewItem>(item, out recycleKey);
protected override void PrepareContainerForItemOverride(Control container, object? item, int index)
{
base.PrepareContainerForItemOverride(container, item, index);
if (container is SharpTreeViewItem treeItem)
treeItem.ParentTreeView = this;
}
void OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
foreach (SharpTreeNode node in e.RemovedItems)
node.IsSelected = false;
foreach (SharpTreeNode node in e.AddedItems)
node.IsSelected = true;
}
void OnDoubleTapped(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var node = (e.Source as Visual)?.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true)?.Node;
if (node == null)
return;
// Let the node act first (e.g. an analyzer entity row navigates); only when it doesn't
// handle the gesture do we fall back to toggling expansion.
var args = new AvaloniaPlatformRoutedEventArgs(e);
node.ActivateItem(args);
if (!e.Handled && node.ShowExpander)
{
node.IsExpanded = !node.IsExpanded;
e.Handled = true;
}
}
/// <summary>
/// Called when a visible node expands so its newly shown children are scrolled into view
/// (without scrolling the node itself off the top).
/// </summary>
internal void HandleExpanding(SharpTreeNode node)
{
if (doNotScrollOnExpanding)
return;
SharpTreeNode lastVisibleChild = node;
while (true)
{
var child = lastVisibleChild.Children.LastOrDefault(c => c.IsVisible);
if (child == null)
break;
lastVisibleChild = child;
}
if (lastVisibleChild != node)
{
ScrollIntoView(lastVisibleChild);
Dispatcher.UIThread.Post(() => ScrollIntoView(node), DispatcherPriority.Loaded);
}
}
/// <summary>Scrolls the node into view (unless <paramref name="scroll"/> is false) and gives it
/// keyboard focus. Pass <c>scroll: false</c> to focus a row that is already visible without
/// disturbing the scroll position.</summary>
public void FocusNode(SharpTreeNode node, bool scroll = true)
{
ArgumentNullException.ThrowIfNull(node);
if (scroll)
ScrollIntoNodeView(node);
if (ContainerFromItem(node) is { } container)
container.Focus();
else
Dispatcher.UIThread.Post(() => (ContainerFromItem(node))?.Focus(), DispatcherPriority.Loaded);
}
/// <summary>True when the node's row is realised and lies fully within the scroll viewport.
/// Used to decide, before a selection change scrolls the list, whether a reveal is needed at
/// all -- an already-visible row should not be pulled to the centre.</summary>
public bool IsNodeFullyVisible(SharpTreeNode node)
{
ArgumentNullException.ThrowIfNull(node);
var scrollViewer = this.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault();
if (scrollViewer is null)
return false;
if (ContainerFromItem(node) is Control row && row.IsVisible
&& row.TranslatePoint(new Point(0, 0), scrollViewer) is { } top)
return top.Y >= 0 && top.Y + row.Bounds.Height <= scrollViewer.Viewport.Height;
return false;
}
/// <summary>Moves the (single) selection to <paramref name="node"/> and focuses it.
/// Used by keyboard navigation where selection must follow the caret.</summary>
void SelectAndFocus(SharpTreeNode node)
{
SelectedItem = node;
FocusNode(node);
}
public void ScrollIntoNodeView(SharpTreeNode node)
{
ArgumentNullException.ThrowIfNull(node);
doNotScrollOnExpanding = true;
foreach (var ancestor in node.Ancestors())
ancestor.IsExpanded = true;
doNotScrollOnExpanding = false;
CenterNodeInView(node);
}
/// <summary>
/// Reveals <paramref name="node"/> centred in the viewport so the user's eye lands on a
/// newly selected row, rather than at the nearest edge (where <see cref="ListBox.ScrollIntoView"/>
/// leaves it). Skips the move when the row is already fully visible, so clicking a visible
/// row -- or selecting a freshly-loaded top-level entry that's already on screen -- never
/// yanks the viewport.
/// </summary>
void CenterNodeInView(SharpTreeNode node)
{
var scrollViewer = this.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault();
if (scrollViewer is null)
{
ScrollIntoView(node);
return;
}
// If the row is already realised and roughly centred, leave the viewport alone -- this
// keeps a re-selection of an already-centred row from twitching. We deliberately do NOT
// skip a merely-visible row sitting at an edge: the ListBox's AutoScrollToSelectedItem
// drags the selected row to the nearest edge first, and a reveal should still pull it to
// the centre from there. (Skipping an already-visible row is decided one level up, before
// AutoScroll runs, in the model->tree sync -- see TreeSelectionBinder.SyncModelToTree.)
if (ContainerFromItem(node) is Control visible && visible.IsVisible
&& visible.TranslatePoint(new Point(0, 0), scrollViewer) is { } top)
{
var rowMid = top.Y + visible.Bounds.Height / 2;
var viewportMid = scrollViewer.Viewport.Height / 2;
if (Math.Abs(rowMid - viewportMid) <= visible.Bounds.Height)
return;
}
// Bring it on screen (edge), force layout so the container realises, then offset so the
// row sits at the vertical centre.
ScrollIntoView(node);
UpdateLayout();
if (ContainerFromItem(node) is not Control row)
return;
if (row.TranslatePoint(new Point(0, 0), scrollViewer) is not { } rowTop)
return;
var desiredTop = (scrollViewer.Viewport.Height - row.Bounds.Height) / 2;
var newOffsetY = scrollViewer.Offset.Y + (rowTop.Y - desiredTop);
var maxOffset = Math.Max(0, scrollViewer.Extent.Height - scrollViewer.Viewport.Height);
newOffsetY = Math.Clamp(newOffsetY, 0, maxOffset);
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, newOffsetY);
}
protected override void OnKeyDown(KeyEventArgs e)
{
// Ctrl+A select-all must work on the first press even before a current item is
// established (right after the tree gains focus). The base ListBox only selects all
// when a focused item lives inside it, so drive it explicitly here.
if (e.Key == Key.A && e.KeyModifiers == KeyModifiers.Control
&& SelectionMode.HasFlag(SelectionMode.Multiple))
{
SelectAll();
e.Handled = true;
return;
}
var node = (e.Source as Visual)?.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true)?.Node
?? SelectedItem as SharpTreeNode;
if (node != null && e.KeyModifiers == KeyModifiers.None)
{
switch (e.Key)
{
case Key.Left:
if (node.IsExpanded)
node.IsExpanded = false;
else if (node.Parent != null && !node.Parent.IsRoot)
SelectAndFocus(node.Parent);
else
break;
e.Handled = true;
break;
case Key.Right:
if (!node.IsExpanded && node.ShowExpander)
node.IsExpanded = true;
else if (node.Children.Count > 0)
SelectAndFocus(node.Children.First(c => c.IsVisible));
else
break;
e.Handled = true;
break;
case Key.Add:
node.IsExpanded = true;
e.Handled = true;
break;
case Key.Subtract:
node.IsExpanded = false;
e.Handled = true;
break;
case Key.Multiply:
node.IsExpanded = true;
ExpandRecursively(node);
e.Handled = true;
break;
case Key.Enter:
case Key.Space:
if (SelectedItems!.Count == 1 && ReferenceEquals(SelectedItem, node))
{
if (e.Key == Key.Space && node.IsCheckable)
node.IsChecked = node.IsChecked == null ? true : !node.IsChecked;
else
node.ActivateItem(new AvaloniaPlatformRoutedEventArgs(e));
e.Handled = true;
}
break;
}
}
if (!e.Handled)
base.OnKeyDown(e);
}
static void ExpandRecursively(SharpTreeNode node)
{
if (!node.CanExpandRecursively)
return;
node.IsExpanded = true;
foreach (var child in node.Children)
ExpandRecursively(child);
}
protected override void OnTextInput(TextInputEventArgs e)
{
var text = e.Text;
if (!string.IsNullOrEmpty(text) && !char.IsControl(text[0]) && flattener is { Count: > 0 })
{
searchBuffer += text;
RestartSearchTimer();
// A fresh single keystroke advances past the current row (so repeating a letter
// cycles); a growing prefix re-matches from the current row so a settled match stays.
int anchor = SelectedItem is SharpTreeNode current ? flattener.IndexOf(current) : -1;
int from = searchBuffer.Length <= 1 ? anchor + 1 : anchor;
if (FindPrefixMatch(from) is { } match)
{
SelectedItem = match;
FocusNode(match);
}
e.Handled = true;
}
if (!e.Handled)
base.OnTextInput(e);
}
SharpTreeNode? FindPrefixMatch(int from)
{
if (flattener is null)
return null;
int count = flattener.Count;
if (from < 0)
from = 0;
for (int k = 0; k < count; k++)
{
if (flattener[(from + k) % count] is SharpTreeNode node
&& node.Text?.ToString() is { } text
&& text.StartsWith(searchBuffer, StringComparison.OrdinalIgnoreCase))
{
return node;
}
}
return null;
}
void RestartSearchTimer()
{
searchResetTimer ??= new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
searchResetTimer.Stop();
searchResetTimer.Tick -= OnSearchTimeout;
searchResetTimer.Tick += OnSearchTimeout;
searchResetTimer.Start();
}
void OnSearchTimeout(object? sender, EventArgs e)
{
searchResetTimer?.Stop();
searchBuffer = string.Empty;
}
/// <summary>Selected items with no selected ancestor (used by Delete).</summary>
public IEnumerable<SharpTreeNode> GetTopLevelSelection()
{
var selection = SelectedItems!.OfType<SharpTreeNode>().ToHashSet();
return selection.Where(item => item.Ancestors().All(a => !selection.Contains(a)));
}
#region Drag and drop
// External Explorer drops are presented to the node under this format (the internal reorder
// payload carries its own node-defined format from SharpTreeNode.Copy).
const string FileDropFormat = "FileDrop";
static readonly DataFormat<string> InternalDragFormat =
DataFormat.CreateStringApplicationFormat("sharptreeview-drag");
enum DropPlace { Before, Inside, After }
SharpTreeNode[]? draggedNodes;
IPlatformDataObject? dragData;
SharpTreeNode? pressedNode;
PointerPressedEventArgs? dragPress;
Point dragStartPoint;
Border? insertMarker;
void OnDragPointerReleased(object? sender, PointerReleasedEventArgs e)
{
pressedNode = null;
dragPress = null;
}
void OnDragPointerPressed(object? sender, PointerPressedEventArgs e)
{
pressedNode = null;
dragPress = null;
if (e.Source is not Visual hit || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
return;
if (hit.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true)?.Node is { } node)
{
pressedNode = node;
dragPress = e;
dragStartPoint = e.GetPosition(this);
}
}
async void OnDragPointerMoved(object? sender, PointerEventArgs e)
{
if (pressedNode is null || dragPress is not { } press)
return;
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
pressedNode = null;
dragPress = null;
return;
}
var delta = e.GetPosition(this) - dragStartPoint;
if (Math.Abs(delta.X) < 4 && Math.Abs(delta.Y) < 4)
return;
var nodes = ResolveDraggedSet(pressedNode);
pressedNode = null;
dragPress = null;
if (nodes.Length == 0 || !nodes[0].CanDrag(nodes))
return;
draggedNodes = nodes;
dragData = nodes[0].Copy(nodes);
try
{
var data = new DataTransfer();
data.Add(DataTransferItem.Create(InternalDragFormat, "1"));
await DragDrop.DoDragDropAsync(press, data, DragDropEffects.Move | DragDropEffects.Copy);
}
finally
{
draggedNodes = null;
dragData = null;
HideInsertMarker();
}
}
// The dragged set: the whole selection when the pressed row is part of it, else the pressed row.
SharpTreeNode[] ResolveDraggedSet(SharpTreeNode pressed)
{
var selection = SelectedItems!.OfType<SharpTreeNode>().ToArray();
return selection.Contains(pressed) ? selection : new[] { pressed };
}
void OnDragOver(object? sender, DragEventArgs e)
{
if (ResolveDropTarget(e) is not { } target)
{
e.DragEffects = DragDropEffects.None;
HideInsertMarker();
e.Handled = true;
return;
}
var args = new AvaloniaPlatformDragEventArgs(BuildPlatformData(e));
if (target.Node.CanDrop(args, target.Index))
{
e.DragEffects = args.Effects.ToAvalonia();
ShowInsertMarker(target.Item, target.Place);
}
else
{
e.DragEffects = DragDropEffects.None;
HideInsertMarker();
}
e.Handled = true;
}
void OnDrop(object? sender, DragEventArgs e)
{
HideInsertMarker();
if (ResolveDropTarget(e) is not { } target)
return;
var args = new AvaloniaPlatformDragEventArgs(BuildPlatformData(e));
if (target.Node.CanDrop(args, target.Index))
{
target.Node.InternalDrop(args, target.Index);
e.DragEffects = args.Effects.ToAvalonia();
}
e.Handled = true;
}
readonly record struct DropTarget(SharpTreeNode Node, int Index, DropPlace Place, SharpTreeViewItem Item);
DropTarget? ResolveDropTarget(DragEventArgs e)
{
if (e.Source is not Visual hit
|| hit.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true) is not { Node: { } node } item)
return null;
double h = item.Bounds.Height;
double y = e.GetPosition(item).Y;
DropPlace place = y < h * 0.25 ? DropPlace.Before : y > h * 0.75 ? DropPlace.After : DropPlace.Inside;
switch (place)
{
case DropPlace.Inside:
return new DropTarget(node, node.Children.Count, place, item);
default:
if (node.Parent is not { } parent)
return new DropTarget(node, node.Children.Count, DropPlace.Inside, item);
int idx = parent.Children.IndexOf(node);
return new DropTarget(parent, place == DropPlace.After ? idx + 1 : idx, place, item);
}
}
IPlatformDataObject BuildPlatformData(DragEventArgs e)
{
// Internal drag: the node-built payload from Copy. External: pack the dropped file paths.
if (dragData != null)
return dragData;
var data = new AvaloniaDataObject();
if (e.DataTransfer.Contains(DataFormat.File) && e.DataTransfer.TryGetFiles() is { } storageItems)
{
var files = storageItems
.Select(f => f.TryGetLocalPath())
.Where(p => !string.IsNullOrEmpty(p))
.Select(p => p!)
.ToArray();
if (files.Length > 0)
data.SetData(FileDropFormat, files);
}
return data;
}
void ShowInsertMarker(SharpTreeViewItem item, DropPlace place)
{
if (place == DropPlace.Inside || AdornerLayer.GetAdornerLayer(this) is not { } layer)
{
HideInsertMarker();
return;
}
if (insertMarker == null)
{
insertMarker = new Border { IsHitTestVisible = false, BorderBrush = Brushes.DodgerBlue };
layer.Children.Add(insertMarker);
}
AdornerLayer.SetAdornedElement(insertMarker, item);
insertMarker.BorderThickness = place == DropPlace.After
? new Thickness(0, 0, 0, 2)
: new Thickness(0, 2, 0, 0);
insertMarker.IsVisible = true;
}
void HideInsertMarker()
{
if (insertMarker != null)
insertMarker.IsVisible = false;
}
#endregion
}
}