Browse Source

Added RefreshObject and IsExpanded
RefreshObject notifies tree of changes to a model (e.g. it's children) and clears cached knowledge but persists the branch expansion state

tznind 4 years ago
parent
commit
b75b79b068
2 changed files with 167 additions and 5 deletions
  1. 77 4
      Terminal.Gui/Views/TreeView.cs
  2. 90 1
      UnitTests/TreeViewTests.cs

+ 77 - 4
Terminal.Gui/Views/TreeView.cs

@@ -61,6 +61,22 @@ namespace Terminal.Gui {
 		/// </summary>
 		public event EventHandler<SelectionChangedEventArgs> SelectionChanged;
 
+		/// <summary>
+		/// Refreshes the state of the object <paramref name="o"/> in the tree.  This will recompute children, string representation etc
+		/// </summary>
+		/// <remarks>This has no effect if the object is not exposed in the tree.</remarks>
+		/// <param name="o"></param>
+		/// <param name="startAtTop">True to also refresh all ancestors of the objects branch (starting with the root).  False to refresh only the passed node</param>
+		public void RefreshObject (object o, bool startAtTop = false)
+		{
+			var branch = ObjectToBranch(o);
+			if(branch != null) {
+				branch.Refresh(startAtTop);
+				SetNeedsDisplay();
+			}
+
+		}
+
 
 		/// <summary>
 		/// The root objects in the tree, note that this collection is of root objects only
@@ -364,6 +380,16 @@ namespace Terminal.Gui {
 			SetNeedsDisplay();
 		}
 
+		/// <summary>
+		/// Returns true if the given object <paramref name="o"/> is exposed in the tree and expanded otherwise false
+		/// </summary>
+		/// <param name="o"></param>
+		/// <returns></returns>
+		public bool IsExpanded(object o)
+		{
+			return ObjectToBranch(o)?.IsExpanded ?? false;
+		}
+
 		/// <summary>
 		/// Collapses the supplied object if it is currently expanded 
 		/// </summary>
@@ -398,18 +424,22 @@ namespace Terminal.Gui {
 		/// <summary>
 		/// The users object that is being displayed by this branch of the tree
 		/// </summary>
-		public object Model {get;set;}
+		public object Model {get;private set;}
 		
 		/// <summary>
 		/// The depth of the current branch.  Depth of 0 indicates root level branches
 		/// </summary>
-		public int Depth {get;set;} = 0;
+		public int Depth {get;private set;} = 0;
 
 		/// <summary>
 		/// The children of the current branch.  This is null until the first call to <see cref="FetchChildren"/> to avoid enumerating the entire underlying hierarchy
 		/// </summary>
 		public Dictionary<object,Branch> ChildBranches {get;set;}
 
+		/// <summary>
+		/// The parent <see cref="Branch"/> or null if it is a root.
+		/// </summary>
+		public Branch Parent {get; private set;}
 
 		private TreeView tree;
 
@@ -426,6 +456,7 @@ namespace Terminal.Gui {
 			
 			if(parentBranchIfAny != null) {
 				Depth = parentBranchIfAny.Depth +1;
+				Parent = parentBranchIfAny;
 			}
 		}
 
@@ -438,7 +469,9 @@ namespace Terminal.Gui {
 			if (tree.ChildrenGetter == null)
 				return;
 
-			this.ChildBranches = tree.ChildrenGetter(this.Model).ToDictionary(k=>k,val=>new Branch(tree,this,val));
+			var children = tree.ChildrenGetter(this.Model) ?? new object[0];
+
+			this.ChildBranches = children.ToDictionary(k=>k,val=>new Branch(tree,this,val));
 		}
 
 		/// <summary>
@@ -499,10 +532,50 @@ namespace Terminal.Gui {
 			}
 		}
 
-		internal void Collapse ()
+		/// <summary>
+		/// Marks the branch as collapsed (<see cref="IsExpanded"/> false)
+		/// </summary>
+		public void Collapse ()
 		{
 			IsExpanded = false;
 		}
+
+		/// <summary>
+		/// Refreshes cached knowledge in this branch e.g. what children an object has
+		/// </summary>
+		/// <param name="startAtTop">True to also refresh all <see cref="Parent"/> branches (starting with the root)</param>
+		public void Refresh (bool startAtTop)
+		{
+			// if we must go up and refresh from the top down
+			if(startAtTop)
+				Parent?.Refresh(true);
+
+			// we don't want to loose the state of our children so lets be selective about how we refresh
+			//if we don't know about any children yet just use the normal method
+			if(ChildBranches == null)
+				FetchChildren();
+			else {
+				// we already knew about some children so preserve the state of the old children
+
+				// first gather the new Children
+				var newChildren = tree.ChildrenGetter(this.Model) ?? new object[0];
+
+				// Children who no longer appear need to go
+				foreach(var toRemove in ChildBranches.Keys.Except(newChildren).ToArray())
+				{
+					ChildBranches.Remove(toRemove);
+					
+					//also if the user has this node selected (its disapearing) so lets change selection to us (the parent object) to be helpful
+					if(Equals(tree.SelectedObject ,toRemove))
+						tree.SelectedObject = Model;
+				}
+				
+				// New children need to be added
+				foreach(var toAdd in newChildren.Except(ChildBranches.Keys).ToArray())
+					ChildBranches.Add(toAdd,new Branch(tree,this,toAdd));
+			}
+			
+		}
 	}
    
 	/// <summary>

+ 90 - 1
UnitTests/TreeViewTests.cs

@@ -40,7 +40,51 @@ namespace UnitTests {
 			return tree;
 		}
 		#endregion
+		
+		/// <summary>
+		/// Tests that <see cref="TreeView.Expand(object)"/> and <see cref="TreeView.IsExpanded(object)"/> are consistent
+		/// </summary>
+		[Fact]
+		public void IsExpanded_TrueAfterExpand()
+		{
+			var tree = CreateTree(out Factory f, out _, out _);
+			Assert.False(tree.IsExpanded(f));
+
+			tree.Expand(f);
+			Assert.True(tree.IsExpanded(f));
+
+			tree.Collapse(f);
+			Assert.False(tree.IsExpanded(f));
+		}
+
+		/// <summary>
+		/// Tests that <see cref="TreeView.IsExpanded(object)"/> and <see cref="TreeView.Expand(object)"/> behaves correctly when an object cannot be expanded (because it has no children)
+		/// </summary>
+		[Fact]
+		public void IsExpanded_FalseIfCannotExpand()
+		{
+			var tree = CreateTree(out Factory f, out Car c, out _);
+			
+			// expose the car by expanding the factory
+			tree.Expand(f);
+
+			// car is not expanded
+			Assert.False(tree.IsExpanded(c));
+
+			//try to expand the car (should have no effect because cars have no children)
+			tree.Expand(c);
+			
+			Assert.False(tree.IsExpanded(c));
+
+			// should also be ignored
+			tree.Collapse(c);
 
+			Assert.False(tree.IsExpanded(c));
+		}
+
+		/// <summary>
+		/// Tests illegal ranges for <see cref="TreeView.ScrollOffset"/>
+		/// </summary>
 		[Fact]
 		public void ScrollOffset_CannotBeNegative()
 		{
@@ -56,27 +100,72 @@ namespace UnitTests {
 		}
 
 
+		/// <summary>
+		/// Tests <see cref="TreeView.GetScrollOffsetOf(object)"/> for objects that are as yet undiscovered by the tree
+		/// </summary>
 		[Fact]
 		public void GetScrollOffsetOf_MinusOneForUnRevealed()
 		{
 			var tree = CreateTree(out Factory f, out Car c1, out Car c2);
 			
+			// to start with the tree is collapsed and only knows about the root object
 			Assert.Equal(0,tree.GetScrollOffsetOf(f));
 			Assert.Equal(-1,tree.GetScrollOffsetOf(c1));
 			Assert.Equal(-1,tree.GetScrollOffsetOf(c2));
 
-			//reveal it by expanding the root object
+			// reveal it by expanding the root object
 			tree.Expand(f);
 			
+			// tree now knows about children
 			Assert.Equal(0,tree.GetScrollOffsetOf(f));
 			Assert.Equal(1,tree.GetScrollOffsetOf(c1));
 			Assert.Equal(2,tree.GetScrollOffsetOf(c2));
 
+			// after collapsing the root node again
 			tree.Collapse(f);
 			
+			// tree no longer knows about the locations of these objects
 			Assert.Equal(0,tree.GetScrollOffsetOf(f));
 			Assert.Equal(-1,tree.GetScrollOffsetOf(c1));
 			Assert.Equal(-1,tree.GetScrollOffsetOf(c2));
 		}
+
+		/// <summary>
+		/// Simulates behind the scenes changes to an object (which children it has) and how to sync that into the tree using <see cref="TreeView.RefreshObject(object, bool)"/>
+		/// </summary>
+		[Fact]
+		public void RefreshObject_ChildRemoved()
+		{
+			var tree = CreateTree(out Factory f, out Car c1, out Car c2);
+			
+			//reveal it by expanding the root object
+			tree.Expand(f);
+			
+			Assert.Equal(0,tree.GetScrollOffsetOf(f));
+			Assert.Equal(1,tree.GetScrollOffsetOf(c1));
+			Assert.Equal(2,tree.GetScrollOffsetOf(c2));
+			
+			// Factory now no longer makes Car c1 (only c2)
+			f.Cars = new Car[]{c2};
+
+			// Tree does not know this yet
+			Assert.Equal(0,tree.GetScrollOffsetOf(f));
+			Assert.Equal(1,tree.GetScrollOffsetOf(c1));
+			Assert.Equal(2,tree.GetScrollOffsetOf(c2));
+
+			// If the user has selected the node c1
+			tree.SelectedObject = c1;
+
+			// When we refresh the tree
+			tree.RefreshObject(f);
+
+			// Now tree knows that factory has only one child node c2
+			Assert.Equal(0,tree.GetScrollOffsetOf(f));
+			Assert.Equal(-1,tree.GetScrollOffsetOf(c1));
+			Assert.Equal(1,tree.GetScrollOffsetOf(c2));
+
+			// The old selection was c1 which is now gone so selection should default to the parent of that branch (the factory)
+			Assert.Equal(f,tree.SelectedObject);
+		}
 	}
 }