sources/Google.Solutions.Mvvm/Controls/BindableTreeView.cs (355 lines of code) (raw):

// // Copyright 2020 Google LLC // // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. // using Google.Solutions.Common.Runtime; using Google.Solutions.Mvvm.Binding; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Diagnostics; using System.Linq; using System.Linq.Expressions; using System.Reflection; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; #pragma warning disable CS1690 // Accessing a member on a field of a marshal-by-reference class may cause a runtime exception namespace Google.Solutions.Mvvm.Controls { public class BindableTreeView<TModelNode> : TreeView where TModelNode : class, INotifyPropertyChanged { private Expression<Func<TModelNode, bool>>? isExpandedExpression = null; private Expression<Func<TModelNode, string>>? textExpression = null; private Expression<Func<TModelNode, int>>? imageIndexExpression = null; private Expression<Func<TModelNode, int>>? selectedImageIndexExpression = null; private bool imageIndexExpressionReadonly = false; private bool selectedImageIndexExpressionReadonly = false; private Func<TModelNode, bool> isLeafFunc = _ => false; private Action<TModelNode, bool> setExpandedFunc = (n, state) => { }; private Func<TModelNode, bool> isExpandedFunc = _ => false; private Func<TModelNode, Task<ICollection<TModelNode>>> getChildrenAsyncFunc = _ => Task.FromResult<ICollection<TModelNode>>(new ObservableCollection<TModelNode>()); private readonly TaskScheduler taskScheduler; public event EventHandler? SelectedModelNodeChanged; public event EventHandler<ExceptionEventArgs>? LoadingChildrenFailed; public BindableTreeView() { this.taskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); this.BeforeExpand += (sender, args) => ((Node)args.Node).OnExpand(); this.BeforeCollapse += (sender, args) => ((Node)args.Node).OnCollapse(); // Consider right-click as a selection. this.NodeMouseClick += (sender, args) => { if (args.Button == MouseButtons.Right) { this.SelectedNode = args.Node; } }; this.AfterSelect += (sender, args) => { this.SelectedModelNodeChanged?.Invoke(this, EventArgs.Empty); }; } public TModelNode? SelectedModelNode { get { if (this.SelectedNode is Node node) { return node.Model; } else { return null; } } } //--------------------------------------------------------------------- // Data Binding. //--------------------------------------------------------------------- private static void DisposeAndClear(TreeNodeCollection nodes) { var disposables = nodes.OfType<Node>().ToList(); nodes.Clear(); foreach (var node in disposables) { node.Dispose(); } } public void Bind(TModelNode rootNode, IBindingContext bindingContext) { DisposeAndClear(this.Nodes); this.Nodes.Add(new Node(this, rootNode)); } public void BindIsLeaf(Func<TModelNode, bool> isLeafFunc) { this.isLeafFunc = isLeafFunc; } public void BindImageIndex( Expression<Func<TModelNode, int>> imageIndexExpression, bool readOnly = false) { this.imageIndexExpression = imageIndexExpression; this.imageIndexExpressionReadonly = readOnly; } public void BindSelectedImageIndex(Expression<Func<TModelNode, int>> selectedImageIndexExpression, bool readOnly = false) { this.selectedImageIndexExpression = selectedImageIndexExpression; this.selectedImageIndexExpressionReadonly = readOnly; } public void BindText(Expression<Func<TModelNode, string>> nameExpression) { this.textExpression = nameExpression; } public void BindIsExpanded(Expression<Func<TModelNode, bool>> propertyExpression) { if (propertyExpression.Body is MemberExpression memberExpression && memberExpression.Member is PropertyInfo propertyInfo) { this.isExpandedExpression = propertyExpression; this.isExpandedFunc = propertyExpression.Compile(); this.setExpandedFunc = (node, value) => propertyInfo.SetValue(node, value); } else { throw new ArgumentException("Expression does not resolve to a property"); } } public void BindChildren( Func<TModelNode, Task<ObservableCollection<TModelNode>>> getChildrenAsyncFunc) { this.getChildrenAsyncFunc = async n => await getChildrenAsyncFunc(n).ConfigureAwait(true); } public void BindChildren( Func<TModelNode, Task<ICollection<TModelNode>>> getChildrenAsyncFunc) { this.getChildrenAsyncFunc = getChildrenAsyncFunc; } //--------------------------------------------------------------------- // Node //--------------------------------------------------------------------- internal sealed class Node : TreeNode, IDisposable { private readonly BindableTreeView<TModelNode> treeView; public TModelNode Model { get; } // // Flag indicating whether a lazy load has been kicked off (it // might not be finished yet though). // private bool lazyLoadTriggered = false; private readonly TaskCompletionSource<ICollection<TModelNode>> lazyLoadResult = new TaskCompletionSource<ICollection<TModelNode>>(); private readonly DisposableContainer bindings = new DisposableContainer(); public Node(BindableTreeView<TModelNode> treeView, TModelNode modelNode) { this.treeView = treeView; this.Model = modelNode; // // Bind properties to keep TreeNode in sync with view model. // Note that binding is one-way (view model -> view) as TreeNodes // are not proper controls and do not provide the necessary events. // if (this.treeView.textExpression != null) { this.Name = this.Text = this.treeView.textExpression.Compile()(this.Model); this.bindings.Add(BindingExtensions.CreatePropertyChangeBinding( this.Model, this.treeView.textExpression, text => this.Text = text)); } if (this.treeView.imageIndexExpression != null) { this.ImageIndex = this.treeView.imageIndexExpression.Compile()(this.Model); if (!this.treeView.imageIndexExpressionReadonly) { this.bindings.Add(BindingExtensions.CreatePropertyChangeBinding( this.Model, this.treeView.imageIndexExpression, iconIndex => this.ImageIndex = iconIndex)); } } if (this.treeView.selectedImageIndexExpression != null) { this.SelectedImageIndex = this.treeView.selectedImageIndexExpression.Compile()(this.Model); if (!this.treeView.selectedImageIndexExpressionReadonly) { this.bindings.Add(BindingExtensions.CreatePropertyChangeBinding( this.Model, this.treeView.selectedImageIndexExpression, iconIndex => this.SelectedImageIndex = iconIndex)); } } if (this.treeView.isExpandedExpression != null) { this.bindings.Add(BindingExtensions.CreatePropertyChangeBinding( this.Model, this.treeView.isExpandedExpression, expanded => { if (expanded) { Expand(); } else { Collapse(); } })); } if (this.treeView.isLeafFunc(this.Model)) { // This node does not have children. } else { // // This node might have children. Add a dummy node // to ensure that the '+' control is being displayed. // this.Nodes.Add(new LoadingTreeNode()); if (this.treeView.isExpandedFunc(this.Model)) { // Eagerly load children. Expand(); LazyLoadChildren(); } else { // Lazy load children. Nothing to do for now. } } } internal void OnExpand() { if (!this.treeView.isExpandedFunc(this.Model)) { this.treeView.setExpandedFunc(this.Model, true); } LazyLoadChildren(); } internal void OnCollapse() { if (this.treeView.isExpandedFunc(this.Model)) { this.treeView.setExpandedFunc(this.Model, false); } } private void LazyLoadChildren() { if (this.lazyLoadTriggered) { return; } this.lazyLoadTriggered = true; this.treeView.getChildrenAsyncFunc(this.Model) .ContinueWith(t => { try { var children = t.Result; try { // // Suspend redrawing while updating nodes // to reduce flicker and vertical scrolling. // this.treeView.BeginUpdate(); // // Clear any dummy node if present. // Debug.Assert(!this.Nodes.OfType<Node>().Any()); DisposeAndClear(this.Nodes); // // Add nodes. // AddTreeNodesForModelNodes(children); } finally { this.treeView.EndUpdate(); } // // Observe for changes. // if (children is INotifyCollectionChanged observable) { observable.CollectionChanged += ModelChildrenChanged; } // // Notify waiters (but only the first time, not on retry). // if (!this.lazyLoadResult.Task.IsCompleted) { this.lazyLoadResult.SetResult(children); } } catch (Exception e) { // // Reset state so that the action can be retried. // Collapse(); this.treeView.setExpandedFunc(this.Model, false); this.lazyLoadTriggered = false; // // Report error. // this.treeView.LoadingChildrenFailed?.Invoke( this.Model, new ExceptionEventArgs(e)); // // Notify waiters (but only the first time, not on retry). // if (!this.lazyLoadResult.Task.IsCompleted) { this.lazyLoadResult.SetException(e); } } }, CancellationToken.None, TaskContinuationOptions.None, // Continue on UI thread. // Note that there's a bug in the CLR that can cause // TaskScheduler.FromCurrentSynchronizationContext() to become null. // Therefore, use a task scheduler object captured previously. // Cf. https://stackoverflow.com/questions/4659257/ this.treeView.taskScheduler); } public Task ExpandAsync() { Expand(); // // Return task indicating the lazy load result. If the // node was expanded already/before, then this task might // have been completed for a while. // return this.lazyLoadResult.Task; } internal void Reload() { // // Clear existing children as they might no longer be valid. // Collapse(); this.Nodes.Clear(); // // Force-reload children. // this.lazyLoadTriggered = false; this.Nodes.Add(new LoadingTreeNode()); LazyLoadChildren(); } private void AddTreeNodesForModelNodes(IEnumerable<TModelNode> children) { foreach (var child in children) { this.Nodes.Add(new Node(this.treeView, child)); } } internal Node FindTreeNodeByModelNode(TModelNode modelNode) { return this.Nodes .OfType<Node>() .FirstOrDefault(n => n.Model.Equals(modelNode)); } private void ModelChildrenChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) { case NotifyCollectionChangedAction.Add: AddTreeNodesForModelNodes(e.NewItems.OfType<TModelNode>()); break; case NotifyCollectionChangedAction.Remove: foreach (var oldModelItem in e.OldItems.OfType<TModelNode>()) { var oldTreeNode = FindTreeNodeByModelNode(oldModelItem); if (oldTreeNode != null) { this.Nodes.Remove(oldTreeNode); oldTreeNode.Dispose(); } } break; case NotifyCollectionChangedAction.Replace: if (e.OldItems.Count == e.NewItems.Count) { var count = e.OldItems.Count; for (var i = 0; i < count; i++) { var oldModelItem = (TModelNode)e.OldItems[i]; var newModelItem = (TModelNode)e.NewItems[i]; var treeNode = FindTreeNodeByModelNode(oldModelItem); if (treeNode != null) { this.Nodes.Remove(treeNode); treeNode.Dispose(); this.Nodes.Insert( e.NewStartingIndex, new Node(this.treeView, newModelItem)); } } } break; case NotifyCollectionChangedAction.Move: // Not supported. break; case NotifyCollectionChangedAction.Reset: // Reload everything. DisposeAndClear(this.Nodes); AddTreeNodesForModelNodes((ObservableCollection<TModelNode>)sender); break; default: break; } } public void Dispose() { this.bindings.Dispose(); } } private class LoadingTreeNode : TreeNode { public LoadingTreeNode() : base("Loading...") { } } } }