Browse Source

Extract shared TreeSelectionBinder; add drag-reorder insert marker

Both tree panes had near-identical model<->tree selection sync; that's now one reusable TreeSelectionBinder(tree, model.SelectedItems) wiring both directions (tree->model mirror + model->tree reveal/focus, with the off-list guard) -- the analyzer and assembly panes just construct and dispose one. Adds the drag-reorder drop indicator: an accent line at the target row's Before/After edge, positioned during a reorder DragOver.
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
b606d4b814
  1. 71
      ILSpy/Analyzers/AnalyzerTreeView.axaml.cs
  2. 10
      ILSpy/AssemblyTree/AssemblyListPane.axaml
  3. 105
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
  4. 124
      ILSpy/Controls/TreeView/TreeSelectionBinder.cs

71
ILSpy/Analyzers/AnalyzerTreeView.axaml.cs

@ -31,14 +31,13 @@ namespace ILSpy.Analyzers @@ -31,14 +31,13 @@ namespace ILSpy.Analyzers
{
public partial class AnalyzerTreeView : UserControl
{
bool syncingSelection;
AnalyzerTreeViewModel? boundModel;
ILSpy.Controls.TreeView.TreeSelectionBinder? selectionBinder;
IReadOnlyList<IContextMenuEntryExport> contextMenuEntries = Array.Empty<IContextMenuEntryExport>();
public AnalyzerTreeView()
{
InitializeComponent();
Tree.SelectionChanged += OnTreeSelectionChanged;
var registry = TryGetContextMenuRegistry();
AttachContextMenu(registry?.Entries ?? Array.Empty<IContextMenuEntryExport>());
}
@ -105,76 +104,14 @@ namespace ILSpy.Analyzers @@ -105,76 +104,14 @@ namespace ILSpy.Analyzers
{
boundModel = model;
Tree.Root = model.Root;
model.SelectedItems.CollectionChanged += OnModelSelectionChanged;
// The model may already carry a selection (analysed before the view was realised).
if (model.SelectedItems.Count > 0)
SyncModelSelectionToTree();
selectionBinder = new ILSpy.Controls.TreeView.TreeSelectionBinder(Tree, model.SelectedItems);
}
void DetachFromModel()
{
if (boundModel == null)
return;
boundModel.SelectedItems.CollectionChanged -= OnModelSelectionChanged;
selectionBinder?.Dispose();
selectionBinder = null;
boundModel = null;
}
// Tree -> model: mirror the ListBox selection (already SharpTreeNodes) into the view-model.
void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (syncingSelection || boundModel == null)
return;
syncingSelection = true;
try
{
var current = Tree.SelectedItems!.OfType<SharpTreeNode>().ToHashSet();
for (int i = boundModel.SelectedItems.Count - 1; i >= 0; i--)
{
if (!current.Contains(boundModel.SelectedItems[i]))
boundModel.SelectedItems.RemoveAt(i);
}
foreach (var node in current)
{
if (!boundModel.SelectedItems.Contains(node))
boundModel.SelectedItems.Add(node);
}
}
finally
{
syncingSelection = false;
}
}
// Model -> tree: a programmatic selection (e.g. a freshly analysed node) is shown and revealed.
void OnModelSelectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (syncingSelection || boundModel == null)
return;
SyncModelSelectionToTree();
}
void SyncModelSelectionToTree()
{
if (boundModel == null)
return;
syncingSelection = true;
try
{
Tree.SelectedItems!.Clear();
SharpTreeNode? primary = null;
foreach (var node in boundModel.SelectedItems)
{
Tree.ScrollIntoNodeView(node);
Tree.SelectedItems.Add(node);
primary = node;
}
if (primary != null)
Tree.FocusNode(primary);
}
finally
{
syncingSelection = false;
}
}
}
}

10
ILSpy/AssemblyTree/AssemblyListPane.axaml

@ -25,5 +25,13 @@ @@ -25,5 +25,13 @@
</Style>
</UserControl.Styles>
<tv:SharpTreeView Name="Tree" ShowRoot="False" ShowLines="True" />
<Panel>
<tv:SharpTreeView Name="Tree" ShowRoot="False" ShowLines="True" />
<!-- Drag-reorder drop indicator: a thin accent line at the Before/After edge of the target
row, positioned from code in UpdateInsertMarker during a reorder DragOver. -->
<Border Name="InsertMarker" IsVisible="False" IsHitTestVisible="False"
Height="2" Width="0"
HorizontalAlignment="Left" VerticalAlignment="Top"
Background="#FF0078D7" />
</Panel>
</UserControl>

105
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

@ -42,7 +42,7 @@ namespace ILSpy.AssemblyTree @@ -42,7 +42,7 @@ namespace ILSpy.AssemblyTree
// Where a dropped set of assemblies lands relative to the target row.
internal enum DropPosition { Before, After, Append }
bool syncingSelection;
ILSpy.Controls.TreeView.TreeSelectionBinder? selectionBinder;
LanguageSettings? languageSettings;
IReadOnlyList<IContextMenuEntryExport> contextMenuEntries = Array.Empty<IContextMenuEntryExport>();
@ -69,7 +69,6 @@ namespace ILSpy.AssemblyTree @@ -69,7 +69,6 @@ namespace ILSpy.AssemblyTree
if (DataContext is AssemblyTreeModel m)
m.MarkTreeReady();
};
Tree.SelectionChanged += OnTreeSelectionChanged;
// Right-press marks the context target without moving selection; MMB opens a new tab.
Tree.AddHandler(PointerPressedEvent, OnTreePointerPressed, RoutingStrategies.Tunnel);
Tree.AddHandler(ContextRequestedEvent, OnTreeContextRequested, RoutingStrategies.Bubble, handledEventsToo: true);
@ -82,6 +81,7 @@ namespace ILSpy.AssemblyTree @@ -82,6 +81,7 @@ namespace ILSpy.AssemblyTree
DragDrop.SetAllowDrop(Tree, true);
Tree.AddHandler(DragDrop.DragOverEvent, OnTreeDragOver);
Tree.AddHandler(DragDrop.DropEvent, OnTreeDrop);
Tree.AddHandler(DragDrop.DragLeaveEvent, (_, _) => HideInsertMarker());
var registry = TryGetContextMenuRegistry();
AttachContextMenu(registry?.Entries ?? Array.Empty<IContextMenuEntryExport>());
@ -370,87 +370,25 @@ namespace ILSpy.AssemblyTree @@ -370,87 +370,25 @@ namespace ILSpy.AssemblyTree
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
selectionBinder?.Dispose();
selectionBinder = null;
if (DataContext is AssemblyTreeModel model)
{
model.PropertyChanged += Model_PropertyChanged;
if (model.Root != null)
{
Tree.Root = model.Root;
SyncModelSelectionToTree();
}
selectionBinder = new ILSpy.Controls.TreeView.TreeSelectionBinder(Tree, model.SelectedItems);
}
}
void Model_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is not AssemblyTreeModel model)
return;
if (e.PropertyName == nameof(AssemblyTreeModel.Root) && model.Root != null)
if (sender is AssemblyTreeModel model
&& e.PropertyName == nameof(AssemblyTreeModel.Root) && model.Root != null)
{
Tree.Root = model.Root;
SyncModelSelectionToTree();
}
else if (e.PropertyName == nameof(AssemblyTreeModel.SelectedItem))
{
if (syncingSelection)
return;
SyncModelSelectionToTree();
}
}
// Tree -> model: mirror the ListBox selection (already SharpTreeNodes) into the model.
void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (syncingSelection || DataContext is not AssemblyTreeModel model)
return;
syncingSelection = true;
try
{
var current = Tree.SelectedItems!.OfType<SharpTreeNode>().ToHashSet();
for (int i = model.SelectedItems.Count - 1; i >= 0; i--)
{
if (!current.Contains(model.SelectedItems[i]))
model.SelectedItems.RemoveAt(i);
}
foreach (var node in current)
{
if (!model.SelectedItems.Contains(node))
model.SelectedItems.Add(node);
}
}
finally
{
syncingSelection = false;
}
}
// Model -> tree: reveal + select the model's nodes (e.g. restored / navigated selection).
void SyncModelSelectionToTree()
{
if (DataContext is not AssemblyTreeModel model)
return;
syncingSelection = true;
try
{
Tree.SelectedItems!.Clear();
SharpTreeNode? primary = null;
foreach (var node in model.SelectedItems)
{
Tree.ScrollIntoNodeView(node);
// Only add rows the flattener actually contains; adding an off-list node
// corrupts the ListBox SelectionModel (it throws on later enumeration).
if (Flattened is { } items && items.Contains(node))
{
Tree.SelectedItems.Add(node);
primary = node;
}
}
if (primary != null)
Tree.FocusNode(primary);
}
finally
{
syncingSelection = false;
// The flattener rebuilt; re-apply the model selection to the new rows.
selectionBinder?.Refresh();
}
}
@ -463,9 +401,9 @@ namespace ILSpy.AssemblyTree @@ -463,9 +401,9 @@ namespace ILSpy.AssemblyTree
if (draggingAssemblies is { } dragged && e.DataTransfer.Contains(AssemblyReorderFormat))
{
var (target, position) = HitTestTopLevelRow(e);
e.DragEffects = target != null && CanReorder(dragged, target, position)
? DragDropEffects.Move
: DragDropEffects.None;
bool valid = target != null && CanReorder(dragged, target, position);
e.DragEffects = valid ? DragDropEffects.Move : DragDropEffects.None;
UpdateInsertMarker(e, valid ? position : null);
e.Handled = true;
return;
}
@ -479,6 +417,7 @@ namespace ILSpy.AssemblyTree @@ -479,6 +417,7 @@ namespace ILSpy.AssemblyTree
{
if (draggingAssemblies is { } dragged && e.DataTransfer.Contains(AssemblyReorderFormat))
{
HideInsertMarker();
var (reorderTarget, reorderPosition) = HitTestTopLevelRow(e);
if (reorderTarget != null)
ReorderAssemblies(dragged, reorderTarget, reorderPosition);
@ -516,6 +455,24 @@ namespace ILSpy.AssemblyTree @@ -516,6 +455,24 @@ namespace ILSpy.AssemblyTree
return (atn, position);
}
// Positions the drop indicator at the top (Before) or bottom (After) edge of the target row.
void UpdateInsertMarker(DragEventArgs e, DropPosition? position)
{
if (position is not { } pos || pos == DropPosition.Append
|| (e.Source as Visual)?.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true) is not { } item)
{
HideInsertMarker();
return;
}
var topLeft = item.TranslatePoint(new Point(0, 0), this) ?? default;
double y = topLeft.Y + (pos == DropPosition.After ? item.Bounds.Height : 0);
InsertMarker.Margin = new Thickness(topLeft.X, y - 1, 0, 0);
InsertMarker.Width = item.Bounds.Width;
InsertMarker.IsVisible = true;
}
void HideInsertMarker() => InsertMarker.IsVisible = false;
/// <summary>
/// Whether <paramref name="dragged"/> can be reordered to land Before/After
/// <paramref name="target"/>. Only top-level (non-package) assemblies reorder, never onto a

124
ILSpy/Controls/TreeView/TreeSelectionBinder.cs

@ -0,0 +1,124 @@ @@ -0,0 +1,124 @@
// 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;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
using Avalonia.Controls;
using ICSharpCode.ILSpyX.TreeView;
namespace ILSpy.Controls.TreeView
{
/// <summary>
/// Two-way binds a <see cref="SharpTreeView"/>'s selection to a view-model's
/// <see cref="ObservableCollection{SharpTreeNode}"/>: user selection flows into the model, and a
/// model-driven change (restore, navigate, freshly-opened nodes) reveals + focuses the primary in
/// the tree. One implementation shared by every tree pane, replacing the per-pane sync code.
/// </summary>
public sealed class TreeSelectionBinder : IDisposable
{
readonly SharpTreeView tree;
readonly ObservableCollection<SharpTreeNode> modelSelection;
bool syncing;
public TreeSelectionBinder(SharpTreeView tree, ObservableCollection<SharpTreeNode> modelSelection)
{
this.tree = tree ?? throw new ArgumentNullException(nameof(tree));
this.modelSelection = modelSelection ?? throw new ArgumentNullException(nameof(modelSelection));
tree.SelectionChanged += OnTreeSelectionChanged;
modelSelection.CollectionChanged += OnModelSelectionChanged;
// The model may already carry a selection (restored before the view was realised).
if (modelSelection.Count > 0)
SyncModelToTree();
}
public void Dispose()
{
tree.SelectionChanged -= OnTreeSelectionChanged;
modelSelection.CollectionChanged -= OnModelSelectionChanged;
}
/// <summary>Re-applies the model selection to the tree (e.g. after the tree's Root rebinds).</summary>
public void Refresh() => SyncModelToTree();
// Tree -> model: mirror the ListBox selection (already SharpTreeNodes) into the view-model.
void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (syncing)
return;
syncing = true;
try
{
var current = tree.SelectedItems!.OfType<SharpTreeNode>().ToHashSet();
for (int i = modelSelection.Count - 1; i >= 0; i--)
{
if (!current.Contains(modelSelection[i]))
modelSelection.RemoveAt(i);
}
foreach (var node in current)
{
if (!modelSelection.Contains(node))
modelSelection.Add(node);
}
}
finally
{
syncing = false;
}
}
void OnModelSelectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (syncing)
return;
SyncModelToTree();
}
void SyncModelToTree()
{
syncing = true;
try
{
tree.SelectedItems!.Clear();
SharpTreeNode? primary = null;
var items = tree.ItemsSource as IList;
foreach (var node in modelSelection)
{
tree.ScrollIntoNodeView(node);
// Only select rows the flattener actually contains; adding an off-list node
// corrupts the ListBox SelectionModel (it throws on later enumeration).
if (items != null && items.Contains(node))
{
tree.SelectedItems.Add(node);
primary = node;
}
}
if (primary != null)
tree.FocusNode(primary);
}
finally
{
syncing = false;
}
}
}
}
Loading…
Cancel
Save