From b75b79b06814d1038cfbb2a9c2e2993afd4aa815 Mon Sep 17 00:00:00 2001 From: tznind Date: Wed, 2 Dec 2020 14:36:27 +0000 Subject: [PATCH] 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 --- Terminal.Gui/Views/TreeView.cs | 81 +++++++++++++++++++++++++++-- UnitTests/TreeViewTests.cs | 95 ++++++++++++++++++++++++++++++++-- 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/Terminal.Gui/Views/TreeView.cs b/Terminal.Gui/Views/TreeView.cs index 2a4fc73f5..e5df98b06 100644 --- a/Terminal.Gui/Views/TreeView.cs +++ b/Terminal.Gui/Views/TreeView.cs @@ -61,6 +61,22 @@ namespace Terminal.Gui { /// public event EventHandler SelectionChanged; + /// + /// Refreshes the state of the object in the tree. This will recompute children, string representation etc + /// + /// This has no effect if the object is not exposed in the tree. + /// + /// True to also refresh all ancestors of the objects branch (starting with the root). False to refresh only the passed node + public void RefreshObject (object o, bool startAtTop = false) + { + var branch = ObjectToBranch(o); + if(branch != null) { + branch.Refresh(startAtTop); + SetNeedsDisplay(); + } + + } + /// /// The root objects in the tree, note that this collection is of root objects only @@ -364,6 +380,16 @@ namespace Terminal.Gui { SetNeedsDisplay(); } + /// + /// Returns true if the given object is exposed in the tree and expanded otherwise false + /// + /// + /// + public bool IsExpanded(object o) + { + return ObjectToBranch(o)?.IsExpanded ?? false; + } + /// /// Collapses the supplied object if it is currently expanded /// @@ -398,18 +424,22 @@ namespace Terminal.Gui { /// /// The users object that is being displayed by this branch of the tree /// - public object Model {get;set;} + public object Model {get;private set;} /// /// The depth of the current branch. Depth of 0 indicates root level branches /// - public int Depth {get;set;} = 0; + public int Depth {get;private set;} = 0; /// /// The children of the current branch. This is null until the first call to to avoid enumerating the entire underlying hierarchy /// public Dictionary ChildBranches {get;set;} + /// + /// The parent or null if it is a root. + /// + 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)); } /// @@ -499,10 +532,50 @@ namespace Terminal.Gui { } } - internal void Collapse () + /// + /// Marks the branch as collapsed ( false) + /// + public void Collapse () { IsExpanded = false; } + + /// + /// Refreshes cached knowledge in this branch e.g. what children an object has + /// + /// True to also refresh all branches (starting with the root) + 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)); + } + + } } /// diff --git a/UnitTests/TreeViewTests.cs b/UnitTests/TreeViewTests.cs index 53ff9f043..5e5958154 100644 --- a/UnitTests/TreeViewTests.cs +++ b/UnitTests/TreeViewTests.cs @@ -40,7 +40,51 @@ namespace UnitTests { return tree; } #endregion + + /// + /// Tests that and are consistent + /// + [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)); + } + + /// + /// Tests that and behaves correctly when an object cannot be expanded (because it has no children) + /// + [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)); + } + + /// + /// Tests illegal ranges for + /// [Fact] public void ScrollOffset_CannotBeNegative() { @@ -56,27 +100,72 @@ namespace UnitTests { } + /// + /// Tests for objects that are as yet undiscovered by the tree + /// [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 + 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)); + } + + /// + /// Simulates behind the scenes changes to an object (which children it has) and how to sync that into the tree using + /// + [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)); - - tree.Collapse(f); + // 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)); + 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); } } }