From b606d4b81478c4916cc675f78b3577406717388a Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 5 Jun 2026 08:42:21 +0200 Subject: [PATCH] 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. --- ILSpy/Analyzers/AnalyzerTreeView.axaml.cs | 71 +--------- ILSpy/AssemblyTree/AssemblyListPane.axaml | 10 +- ILSpy/AssemblyTree/AssemblyListPane.axaml.cs | 105 +++++---------- .../Controls/TreeView/TreeSelectionBinder.cs | 124 ++++++++++++++++++ 4 files changed, 168 insertions(+), 142 deletions(-) create mode 100644 ILSpy/Controls/TreeView/TreeSelectionBinder.cs diff --git a/ILSpy/Analyzers/AnalyzerTreeView.axaml.cs b/ILSpy/Analyzers/AnalyzerTreeView.axaml.cs index 5a8cde7be..48a06e25b 100644 --- a/ILSpy/Analyzers/AnalyzerTreeView.axaml.cs +++ b/ILSpy/Analyzers/AnalyzerTreeView.axaml.cs @@ -31,14 +31,13 @@ namespace ILSpy.Analyzers { public partial class AnalyzerTreeView : UserControl { - bool syncingSelection; AnalyzerTreeViewModel? boundModel; + ILSpy.Controls.TreeView.TreeSelectionBinder? selectionBinder; IReadOnlyList contextMenuEntries = Array.Empty(); public AnalyzerTreeView() { InitializeComponent(); - Tree.SelectionChanged += OnTreeSelectionChanged; var registry = TryGetContextMenuRegistry(); AttachContextMenu(registry?.Entries ?? Array.Empty()); } @@ -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().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; - } - } } } diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml b/ILSpy/AssemblyTree/AssemblyListPane.axaml index e24621816..d1fecd052 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml @@ -25,5 +25,13 @@ - + + + + + diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index b7917d308..4ca0fc523 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -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 contextMenuEntries = Array.Empty(); @@ -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 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()); @@ -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().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 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 { 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 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(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; + /// /// Whether can be reordered to land Before/After /// . Only top-level (non-package) assemblies reorder, never onto a diff --git a/ILSpy/Controls/TreeView/TreeSelectionBinder.cs b/ILSpy/Controls/TreeView/TreeSelectionBinder.cs new file mode 100644 index 000000000..7eb8a6e25 --- /dev/null +++ b/ILSpy/Controls/TreeView/TreeSelectionBinder.cs @@ -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 +{ + /// + /// Two-way binds a 's selection to a view-model's + /// : 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. + /// + public sealed class TreeSelectionBinder : IDisposable + { + readonly SharpTreeView tree; + readonly ObservableCollection modelSelection; + bool syncing; + + public TreeSelectionBinder(SharpTreeView tree, ObservableCollection 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; + } + + /// Re-applies the model selection to the tree (e.g. after the tree's Root rebinds). + 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().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; + } + } + } +}