// This code is based on http://objectlistview.sourceforge.net (GPLv3 tree/list controls by phillip.piper@gmail.com). Phillip has explicitly granted permission for his design and code to be used in this library under the MIT license. using System; using System.Collections.Generic; using System.Linq; namespace Terminal.Gui { /// /// Interface to implement when you want to automatically determine children for your class /// public interface ITreeNode { /// /// The children of your class which should be rendered underneath it when expanded /// /// IList Children {get;} /// /// The textual representation to be rendered when your class is visible in the tree /// /// string Text {get;} } /// /// Simple class for representing nodes of a /// public class TreeNode : ITreeNode { /// /// Children of the current node /// /// public IList Children {get;set;} = new List(); /// /// Text to display in tree node for current entry /// /// public string Text {get;set;} /// /// returns /// /// public override string ToString() { return Text; } /// /// Initialises a new instance with no /// public TreeNode() { } /// /// Initialises a new instance and sets starting /// public TreeNode(string text) { Text = text; } } /// /// Interface for supplying data to a on demand as root level nodes are expanded by the user /// public interface ITreeBuilder { /// /// Returns true if is implemented by this class /// /// bool SupportsCanExpand {get;} /// /// Returns true/false for whether a model has children. This method should be implemented when is an expensive operation otherwise should return false (in which case this method will not be called) /// /// /// bool CanExpand(object model); /// /// Returns all children of a given which should be added to the tree as new branches underneath it /// /// /// IEnumerable GetChildren(object model); } /// /// Abstract implementation of /// public abstract class TreeBuilder : ITreeBuilder { /// public bool SupportsCanExpand { get; protected set;} = false; /// /// Override this method to return a rapid answer as to whether returns results. /// /// /// public virtual bool CanExpand (object model){ return GetChildren(model).Any(); } /// public abstract IEnumerable GetChildren (object model); /// /// Constructs base and initializes /// /// Pass true if you intend to implement otherwise false public TreeBuilder(bool supportsCanExpand) { SupportsCanExpand = supportsCanExpand; } } /// /// implementation for objects /// public class TreeNodeBuilder : TreeBuilder { /// /// Initialises a new instance of builder for any model objects of Type /// public TreeNodeBuilder():base(false) { } /// /// Returns from /// /// /// public override IEnumerable GetChildren (object model) { return model is ITreeNode n ? n.Children : Enumerable.Empty(); } } /// /// Implementation of that uses user defined functions /// public class DelegateTreeBuilder : TreeBuilder { private Func> childGetter; private Func canExpand; /// /// Constructs an implementation of that calls the user defined method to determine children /// /// /// public DelegateTreeBuilder(Func> childGetter) : base(false) { this.childGetter = childGetter; } /// /// Constructs an implementation of that calls the user defined method to determine children and to determine expandability /// /// /// /// public DelegateTreeBuilder(Func> childGetter, Func canExpand) : base(true) { this.childGetter = childGetter; this.canExpand = canExpand; } /// /// Returns whether a node can be expanded based on the delegate passed during construction /// /// /// public override bool CanExpand (object model) { return canExpand?.Invoke(model) ?? base.CanExpand (model); } /// /// Returns children using the delegate method passed during construction /// /// /// public override IEnumerable GetChildren (object model) { return childGetter.Invoke(model); } } /// /// Hierarchical tree view with expandable branches. Branch objects are dynamically determined when expanded using a user defined /// public class TreeView : View { private int scrollOffset; /// /// True to render vertical lines under expanded nodes to show which node belongs to which parent. False to use only whitespace /// /// public bool ShowBranchLines {get;set;} = true; /// /// Determines how sub branches of the tree are dynamically built at runtime as the user expands root nodes /// /// public ITreeBuilder TreeBuilder { get;set;} /// /// private variable for /// object selectedObject; /// /// The currently selected object in the tree /// public object SelectedObject { get => selectedObject; set { var oldValue = selectedObject; selectedObject = value; if(!ReferenceEquals(oldValue,value)) SelectionChanged?.Invoke(this,new SelectionChangedEventArgs(this,oldValue,value)); } } /// /// Called when the changes /// 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(); } } /// /// Rebuilds the tree structure for all exposed objects starting with the root objects. Call this method when you know there are changes to the tree but don't know which objects have changed (otherwise use ) /// public void RebuildTree() { foreach(var branch in roots.Values) branch.Rebuild(); SetNeedsDisplay(); } /// /// The root objects in the tree, note that this collection is of root objects only /// public IEnumerable Objects {get=>roots.Keys;} /// /// Map of root objects to the branches under them. All objects have a even if that branch has no children /// internal Dictionary roots {get; set;} = new Dictionary(); /// /// The amount of tree view that has been scrolled off the top of the screen (by the user scrolling down) /// /// Setting a value of less than 0 will result in a ScrollOffset of 0. To see changes in the UI call public int ScrollOffset { get => scrollOffset; set { scrollOffset = Math.Max(0,value); } } /// /// Creates a new tree view with absolute positioning. Use to set set root objects for the tree. /// public TreeView():base() { CanFocus = true; TreeBuilder = new TreeNodeBuilder(); } /// /// Initialises .Creates a new tree view with absolute positioning. Use to set set root objects for the tree. /// public TreeView(ITreeBuilder builder) : this() { TreeBuilder = builder; } /// /// Adds a new root level object unless it is already a root of the tree /// /// public void AddObject(object o) { if(!roots.ContainsKey(o)) { roots.Add(o,new Branch(this,null,o)); SetNeedsDisplay(); } } /// /// Removes all objects from the tree and clears /// public void ClearObjects() { SelectedObject = null; roots = new Dictionary(); SetNeedsDisplay(); } /// /// Removes the given root object from the tree /// /// If is the currently then the selection is cleared /// public void Remove(object o) { if(roots.ContainsKey(o)) { roots.Remove(o); SetNeedsDisplay(); if(Equals(SelectedObject,o)) SelectedObject = null; } } /// /// Adds many new root level objects. Objects that are already root objects are ignored /// /// Objects to add as new root level objects public void AddObjects(IEnumerable collection) { bool objectsAdded = false; foreach(var o in collection) { if (!roots.ContainsKey (o)) { roots.Add(o,new Branch(this,null,o)); objectsAdded = true; } } if(objectsAdded) SetNeedsDisplay(); } /// /// Returns the currently expanded children of the passed object. Returns an empty collection if the branch is not exposed or not expanded /// /// An object in the tree /// public IEnumerable GetChildren (object o) { var branch = ObjectToBranch(o); if(branch == null || !branch.IsExpanded) return new object[0]; return branch.ChildBranches?.Values?.Select(b=>b.Model)?.ToArray() ?? new object[0]; } /// /// Returns the parent object of in the tree. Returns null if the object is not exposed in the tree /// /// An object in the tree /// public object GetParent (object o) { return ObjectToBranch(o)?.Parent?.Model; } /// /// Returns the string representation of model objects hosted in the tree. Default implementation is to call /// /// public AspectGetterDelegate AspectGetter {get;set;} = (o)=>o.ToString(); /// public override void Redraw (Rect bounds) { if(roots == null) return; var map = BuildLineMap(); for(int line = 0 ; line < bounds.Height; line++){ var idxToRender = ScrollOffset + line; // Is there part of the tree view to render? if(idxToRender < map.Length) { // Render the line map[idxToRender].Draw(Driver,ColorScheme,line,bounds.Width); } else { // Else clear the line to prevent stale symbols due to scrolling etc Move(0,line); Driver.SetAttribute(ColorScheme.Normal); Driver.AddStr(new string(' ',bounds.Width)); } } } /// /// Returns the index of the object if it is currently exposed (it's parent(s) have been expanded). This can be used with and to scroll to a specific object /// /// Uses the Equals method and returns the first index at which the object is found or -1 if it is not found /// An object that appears in your tree and is currently exposed /// The index the object was found at or -1 if it is not currently revealed or not in the tree at all public int GetScrollOffsetOf(object o) { var map = BuildLineMap(); for (int i = 0; i < map.Length; i++) { if (map[i].Model.Equals(o)) return i; } //object not found return -1; } /// /// Calculates all currently visible/expanded branches (including leafs) and outputs them by index from the top of the screen /// /// Index 0 of the returned array is the first item that should be visible in the top of the control, index 1 is the next etc. /// private Branch[] BuildLineMap() { List toReturn = new List(); foreach(var root in roots.Values) { toReturn.AddRange(AddToLineMap(root)); } return toReturn.ToArray(); } private IEnumerable AddToLineMap (Branch currentBranch) { yield return currentBranch; if(currentBranch.IsExpanded){ foreach(var subBranch in currentBranch.ChildBranches.Values){ foreach(var sub in AddToLineMap(subBranch)) { yield return sub; } } } } /// /// Symbol to use for expanded branch nodes to indicate to the user that they can be collapsed. Defaults to '-' /// public Rune ExpandedSymbol {get;set;} = '-'; /// /// Symbol to use for branch nodes that can be expanded to indicate this to the user. Defaults to '+' /// public Rune ExpandableSymbol {get;set;} = '+'; /// public override bool ProcessKey (KeyEvent keyEvent) { switch (keyEvent.Key) { case Key.CursorRight: Expand(SelectedObject); break; case Key.CursorLeft: CursorLeft(); break; case Key.CursorUp: AdjustSelection(-1); break; case Key.CursorDown: AdjustSelection(1); break; case Key.PageUp: AdjustSelection(-Bounds.Height); break; case Key.PageDown: AdjustSelection(Bounds.Height); break; case Key.Home: GoToFirst(); break; case Key.End: GoToEnd(); break; default: // we don't care about this keystroke return false; } PositionCursor (); return true; } /// /// Determines systems behaviour when the left arrow key is pressed. Default behaviour is to collapse the current tree node if possible otherwise changes selection to current branches parent /// protected virtual void CursorLeft() { if(IsExpanded(SelectedObject)) Collapse(SelectedObject); else { var parent = GetParent(SelectedObject); if(parent != null){ SelectedObject = parent; SetNeedsDisplay(); } } } /// /// Changes the to the first root object and resets the to 0 /// public void GoToFirst() { ScrollOffset = 0; SelectedObject = roots.Keys.FirstOrDefault(); SetNeedsDisplay(); } /// /// Changes the to the last object in the tree and scrolls so that it is visible /// public void GoToEnd () { var map = BuildLineMap(); ScrollOffset = Math.Max(0,map.Length - Bounds.Height +1); SelectedObject = map.Last().Model; SetNeedsDisplay(); } /// /// Changes the selected object by a number of screen lines /// /// If nothing is currently selected the first root is selected. If the selected object is no longer in the tree the first object is selected /// private void AdjustSelection (int offset) { if(SelectedObject == null){ SelectedObject = roots.Keys.FirstOrDefault(); } else { var map = BuildLineMap(); var idx = Array.FindIndex(map,b=>b.Model.Equals(SelectedObject)); if(idx == -1) { // The current selection has disapeared! SelectedObject = roots.Keys.FirstOrDefault(); } else { var newIdx = Math.Min(Math.Max(0,idx+offset),map.Length-1); SelectedObject = map[newIdx].Model; if(newIdx < ScrollOffset) { //if user has scrolled up too far to see their selection ScrollOffset = newIdx; } else if(newIdx >= ScrollOffset + Bounds.Height){ //if user has scrolled off bottom of visible tree ScrollOffset = Math.Max(0,(newIdx+1) - Bounds.Height); } } } SetNeedsDisplay(); } /// /// Expands the supplied object if it is contained in the tree (either as a root object or as an exposed branch object) /// /// The object to expand public void Expand(object toExpand) { if(toExpand == null) return; ObjectToBranch(toExpand)?.Expand(); 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 /// /// The object to collapse public void Collapse(object toCollapse) { if(toCollapse == null) return; ObjectToBranch(toCollapse)?.Collapse(); SetNeedsDisplay(); } /// /// Returns the corresponding in the tree for . This will not work for objects hidden by their parent being collapsed /// /// /// The branch for or null if it is not currently exposed in the tree private Branch ObjectToBranch(object toFind) { return BuildLineMap().FirstOrDefault(o=>o.Model.Equals(toFind)); } } class Branch { /// /// True if the branch is expanded to reveal child branches /// public bool IsExpanded {get;set;} /// /// The users object that is being displayed by this branch of the tree /// public object Model {get;private set;} /// /// The depth of the current branch. Depth of 0 indicates root level branches /// 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; /// /// Declares a new branch of in which the users object is presented /// /// The UI control in which the branch resides /// Pass null for root level branches, otherwise pass the parent /// The user's object that should be displayed public Branch(TreeView tree,Branch parentBranchIfAny,object model) { this.tree = tree; this.Model = model; if(parentBranchIfAny != null) { Depth = parentBranchIfAny.Depth +1; Parent = parentBranchIfAny; } } /// /// Fetch the children of this branch. This method populates /// public virtual void FetchChildren() { if (tree.TreeBuilder == null) return; var children = tree.TreeBuilder.GetChildren(this.Model) ?? Enumerable.Empty(); this.ChildBranches = children.ToDictionary(k=>k,val=>new Branch(tree,this,val)); } /// /// Renders the current on the specified line /// /// /// /// /// public virtual void Draw(ConsoleDriver driver,ColorScheme colorScheme, int y, int availableWidth) { // Everything on line before the expansion run and branch text Rune[] prefix = GetLinePrefix(driver).ToArray(); Rune expansion = GetExpandableIcon(driver); string lineBody = tree.AspectGetter(Model); var remainingWidth = availableWidth - (prefix.Length + 1 + lineBody.Length); tree.Move(0,y); driver.SetAttribute(colorScheme.Normal); foreach(Rune r in prefix) driver.AddRune(r); driver.AddRune(expansion); driver.SetAttribute(tree.SelectedObject == Model ? colorScheme.HotFocus : colorScheme.Normal); driver.AddStr(lineBody); driver.SetAttribute(colorScheme.Normal); if(remainingWidth > 0) driver.AddStr(new string(' ',remainingWidth)); } /// /// Gets all characters to render prior to the current branches line. This includes indentation whitespace and any tree branches (if enabled) /// /// /// private IEnumerable GetLinePrefix (ConsoleDriver driver) { // If not showing line branches or this is a root object if (!tree.ShowBranchLines) { for(int i = 0; i < Depth; i++) { yield return new Rune(' '); } } // yield indentations with runes appropriate to the state of the parents foreach(var cur in GetParentBranches().Reverse()) { if(cur.IsLast()) yield return new Rune(' '); else yield return driver.VLine; } if(IsLast()) yield return driver.LLCorner; else yield return driver.LeftTee; } /// /// Returns all parents starting with the immediate parent and ending at the root /// /// private IEnumerable GetParentBranches() { var cur = Parent; while(cur != null) { yield return cur; cur = cur.Parent; } } /// /// Returns an appropriate symbol for displaying next to the string representation of the object to indicate whether it or not (or it is a leaf) /// /// /// public Rune GetExpandableIcon(ConsoleDriver driver) { if(IsExpanded) return tree.ExpandedSymbol; var leafSymbol = tree.ShowBranchLines ? driver.HLine : ' '; if(ChildBranches == null) { //if there is a rapid method for determining whether there are children if(tree.TreeBuilder.SupportsCanExpand) { return tree.TreeBuilder.CanExpand(Model) ? tree.ExpandableSymbol : leafSymbol; } //there is no way of knowing whether we can expand without fetching the children FetchChildren(); } //we fetched or already know the children, so return whether we are a leaf or a expandable branch return ChildBranches.Any() ? tree.ExpandableSymbol : leafSymbol; } /// /// Expands the current branch if possible /// public void Expand() { if(ChildBranches == null) { FetchChildren(); } if (ChildBranches.Any ()) { IsExpanded = true; } } /// /// 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.TreeBuilder?.GetChildren(this.Model) ?? Enumerable.Empty(); // 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 newChild in newChildren) { // If we don't know about the child yet we need a new branch if (!ChildBranches.ContainsKey (newChild)) { ChildBranches.Add(newChild,new Branch(tree,this,newChild)); } else{ //we already have this object but update the reference anyway incase Equality match but the references are new ChildBranches[newChild].Model = newChild; } } } } /// /// Calls on the current branch and all expanded children /// internal void Rebuild() { Refresh(false); // if we know about our children if(ChildBranches != null) { if(IsExpanded) { //if we are expanded we need to updatethe visible children foreach(var child in ChildBranches) { child.Value.Refresh(false); } } else { // we are not expanded so should forget about children because they may not exist anymore ChildBranches = null; } } } /// /// Returns true if this branch has parents and it is the last node of it's parents branches (or last root of the tree) /// /// private bool IsLast() { if(Parent == null) return this == tree.roots.Values.LastOrDefault(); return Parent.ChildBranches.Values.LastOrDefault() == this; } } /// /// Delegates of this type are used to fetch string representations of user's model objects /// /// /// public delegate string AspectGetterDelegate(object model); /// /// Event arguments describing a change in selected object in a tree view /// public class SelectionChangedEventArgs : EventArgs { /// /// The view in which the change occurred /// public TreeView Tree { get; } /// /// The previously selected value (can be null) /// public object OldValue { get; } /// /// The newly selected value in the (can be null) /// public object NewValue { get; } /// /// Creates a new instance of event args describing a change of selection in /// /// /// /// public SelectionChangedEventArgs(TreeView tree, object oldValue, object newValue) { Tree = tree; OldValue = oldValue; NewValue = newValue; } } }