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
This commit is contained in:
tznind
2020-12-02 14:36:27 +00:00
parent f5875034f7
commit b75b79b068
2 changed files with 169 additions and 7 deletions

View File

@@ -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>

View File

@@ -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
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));
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);
}
}
}