// 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; using NStack; namespace Terminal.Gui { /// /// Interface to implement when you want the regular (non generic) to automatically determine children for your class (without having to specify a ) /// public interface ITreeNode { /// /// The children of your class which should be rendered underneath it when expanded /// /// IList Children {get;} } /// /// Simple class for representing nodes, use with regular (non generic) . /// 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 ?? "Unamed Node"; } /// /// 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) /// /// Only implement this method if you have a very fast way of determining whether an object can have children e.g. checking a Type (directories can always be expanded) /// /// bool CanExpand(T model); /// /// Returns all children of a given which should be added to the tree as new branches underneath it /// /// /// IEnumerable GetChildren(T 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. If you are implementing this method ensure you passed true in base constructor or set /// /// /// public virtual bool CanExpand (T model){ return GetChildren(model).Any(); } /// public abstract IEnumerable GetChildren (T 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 (ITreeNode model) { return model.Children; } } /// /// 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 (T model) { return canExpand?.Invoke(model) ?? base.CanExpand (model); } /// /// Returns children using the delegate method passed during construction /// /// /// public override IEnumerable GetChildren (T model) { return childGetter.Invoke(model); } } /// /// Interface for all non generic members of /// public interface ITreeView { /// /// Contains options for changing how the tree is rendered /// TreeStyle Style{get;set;} /// /// Removes all objects from the tree and clears selection /// void ClearObjects (); /// /// Sets a flag indicating this view needs to be redisplayed because its state has changed. /// void SetNeedsDisplay (); } /// /// Convenience implementation of generic for any tree were all nodes implement /// public class TreeView : TreeView { /// /// Creates a new instance of the tree control with absolute positioning and initialises with default based builder /// public TreeView () { TreeBuilder = new TreeNodeBuilder(); } } /// /// Defines rendering options that affect how the tree is displayed /// public class TreeStyle { /// /// 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; /// /// Symbol to use for branch nodes that can be expanded to indicate this to the user. Defaults to '+'. Set to null to hide /// public Rune? ExpandableSymbol {get;set;} = '+'; /// /// Symbol to use for branch nodes that can be collapsed (are currently expanded). Defaults to '-'. Set to null to hide /// public Rune? CollapseableSymbol {get;set;} = '-'; /// /// Set to true to highlight expand/collapse symbols in hot key color /// public bool ColorExpandSymbol {get;set;} /// /// Invert console colours used to render the expand symbol /// public bool InvertExpandSymbolColors {get;set;} /// /// True to leave the last row of the control free for overwritting (e.g. by a scrollbar). When True scrolling will be triggered on the second last row of the control rather than the last. /// /// public bool LeaveLastRow {get;set;} } /// /// Hierarchical tree view with expandable branches. Branch objects are dynamically determined when expanded using a user defined /// public class TreeView : View, ITreeView where T:class { private int scrollOffsetVertical; private int scrollOffsetHorizontal; /// /// 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 /// T selectedObject; /// /// Contains options for changing how the tree is rendered /// public TreeStyle Style {get;set;} = new TreeStyle(); /// /// True to allow multiple objects to be selected at once /// /// public bool MultiSelect {get;set;} = true; /// /// The currently selected object in the tree. When is true this is the object at which the cursor is at /// public T SelectedObject { get => selectedObject; set { var oldValue = selectedObject; selectedObject = value; if(!ReferenceEquals(oldValue,value)) SelectionChanged?.Invoke(this,new SelectionChangedEventArgs(this,oldValue,value)); } } /// /// Secondary selected regions of tree when is true /// private Stack> _multiSelectedRegions = new Stack>(); /// /// Error message to display when the control is not properly initialized at draw time (nodes added but no tree builder set) /// public static ustring NoBuilderError = "ERROR: Builder Not Set"; /// /// Called when the changes /// public event EventHandler> SelectionChanged; /// /// 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 offset of 0. To see changes in the UI call public int ScrollOffsetVertical { get => scrollOffsetVertical; set { scrollOffsetVertical = Math.Max(0,value); } } /// /// The amount of tree view that has been scrolled to the right (horizontally) /// /// Setting a value of less than 0 will result in a offset of 0. To see changes in the UI call public int ScrollOffsetHorizontal { get => scrollOffsetHorizontal; set { scrollOffsetHorizontal = Math.Max(0,value); } } /// /// The current number of rows in the tree (ignoring the controls bounds) /// public int ContentHeight => BuildLineMap().Count(); /// /// Returns the string representation of model objects hosted in the tree. Default implementation is to call /// /// public AspectGetterDelegate AspectGetter {get;set;} = (o)=>o.ToString() ?? ""; /// /// Creates a new tree view with absolute positioning. Use to set set root objects for the tree. Children will not be rendered until you set /// public TreeView():base() { CanFocus = true; } /// /// 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(T 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 = default(T); _multiSelectedRegions.Clear(); roots = new Dictionary>(); SetNeedsDisplay(); } /// /// Removes the given root object from the tree /// /// If is the currently then the selection is cleared /// public void Remove(T o) { if(roots.ContainsKey(o)) { roots.Remove(o); SetNeedsDisplay(); if(Equals(SelectedObject,o)) SelectedObject = default(T); } } /// /// 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(); } /// /// 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 (T 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(); } /// /// 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 (T o) { var branch = ObjectToBranch(o); if(branch == null || !branch.IsExpanded) return new T[0]; return branch.ChildBranches?.Values?.Select(b=>b.Model)?.ToArray() ?? new T[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 T GetParent (T o) { return ObjectToBranch(o)?.Parent?.Model; } /// public override void Redraw (Rect bounds) { if(roots == null) return; if(TreeBuilder == null) { Move(0,0); Driver.AddStr(NoBuilderError); return; } var map = BuildLineMap(); for(int line = 0 ; line < bounds.Height; line++){ var idxToRender = ScrollOffsetVertical + 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(T 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; } /// /// Returns the maximum width line in the tree including prefix and expansion symbols /// /// True to consider only rows currently visible (based on window bounds and . False to calculate the width of every exposed branch in the tree /// public int GetContentWidth(bool visible){ var map = BuildLineMap(); if(map.Length == 0) return 0; if(visible){ //Somehow we managed to scroll off the end of the control if(ScrollOffsetVertical >= map.Length) return 0; // If control has no height to it then there is no visible area for content if(Bounds.Height == 0) return 0; return map.Skip(ScrollOffsetVertical).Take(Bounds.Height).Max(b=>b.GetWidth(Driver)); } else{ return map.Max(b=>b.GetWidth(Driver)); } } /// /// 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; } } } } /// public override bool ProcessKey (KeyEvent keyEvent) { switch (keyEvent.Key) { case Key.CursorRight: Expand(SelectedObject); break; case Key.CursorLeft: CursorLeft(); break; case Key.CursorUp: case Key.CursorUp | Key.ShiftMask: AdjustSelection(-1,keyEvent.Key.HasFlag(Key.ShiftMask)); break; case Key.CursorDown: case Key.CursorDown | Key.ShiftMask: AdjustSelection(1,keyEvent.Key.HasFlag(Key.ShiftMask)); break; case Key.PageUp: case Key.PageUp | Key.ShiftMask: AdjustSelection(-Bounds.Height,keyEvent.Key.HasFlag(Key.ShiftMask)); break; case Key.PageDown: case Key.PageDown | Key.ShiftMask: AdjustSelection(Bounds.Height,keyEvent.Key.HasFlag(Key.ShiftMask)); break; case Key.Home: GoToFirst(); break; case Key.End: GoToEnd(); break; default: // we don't care about this keystroke return false; } PositionCursor (); return true; } /// public override bool MouseEvent (MouseEvent me) { if (!me.Flags.HasFlag (MouseFlags.Button1Clicked) && !me.Flags.HasFlag (MouseFlags.Button1DoubleClicked) && me.Flags != MouseFlags.WheeledDown && me.Flags != MouseFlags.WheeledUp && me.Flags != MouseFlags.WheeledRight&& me.Flags != MouseFlags.WheeledLeft) return false; if (!HasFocus && CanFocus) { SetFocus (); } if (me.Flags == MouseFlags.WheeledDown) { ScrollOffsetVertical++; SetNeedsDisplay(); return true; } else if (me.Flags == MouseFlags.WheeledUp) { ScrollOffsetVertical--; SetNeedsDisplay(); return true; } if (me.Flags == MouseFlags.WheeledRight) { ScrollOffsetHorizontal++; SetNeedsDisplay(); return true; } else if (me.Flags == MouseFlags.WheeledLeft) { ScrollOffsetHorizontal--; SetNeedsDisplay(); return true; } if(me.Flags.HasFlag(MouseFlags.Button1Clicked)) { var map = BuildLineMap(); var idx = me.Y + ScrollOffsetVertical; // click is outside any visible nodes if(idx < 0 || idx >= map.Length) { return false; } // The line they clicked on var clickedBranch = map[idx]; bool isExpandToggleAttempt = clickedBranch.IsHitOnExpandableSymbol(Driver,me.X); // If we are already selected (double click) if(Equals(SelectedObject,clickedBranch.Model)) isExpandToggleAttempt = true; // if they clicked on the +/- expansion symbol if( isExpandToggleAttempt) { if (clickedBranch.IsExpanded) { clickedBranch.Collapse(); } else if(clickedBranch.CanExpand()) clickedBranch.Expand(); else { SelectedObject = clickedBranch.Model; // It is a leaf node _multiSelectedRegions.Clear(); } } else { // It is a first click somewhere in the current line that doesn't look like an expansion/collapse attempt SelectedObject = clickedBranch.Model; _multiSelectedRegions.Clear(); } SetNeedsDisplay(); return true; } return false; } /// /// Positions the cursor at the start of the selected objects line (if visible) /// public override void PositionCursor() { if (CanFocus && HasFocus && Visible && SelectedObject != null) { var map = BuildLineMap(); var idx = Array.FindIndex(map,b=>b.Model.Equals(SelectedObject)); // if currently selected line is visible if(idx - ScrollOffsetVertical >= 0 && idx - ScrollOffsetVertical < Bounds.Height) Move(0,idx - ScrollOffsetVertical); else base.PositionCursor(); } else { base.PositionCursor(); } } /// /// 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; AdjustSelection(0); SetNeedsDisplay(); } } } /// /// Changes the to the first root object and resets the to 0 /// public void GoToFirst() { ScrollOffsetVertical = 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(); ScrollOffsetVertical = 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 /// /// True to expand the selection (assuming is enabled). False to replace public void AdjustSelection (int offset, bool expandSelection = false) { // if it is not a shift click or we don't allow multi select if(!expandSelection || !MultiSelect) _multiSelectedRegions.Clear(); 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); var newBranch = map[newIdx]; // If it is a multi selection if(expandSelection && MultiSelect) { if(_multiSelectedRegions.Any()) { // expand the existing head selection var head = _multiSelectedRegions.Pop(); _multiSelectedRegions.Push(new TreeSelection(head.Origin,newIdx,map)); } else { // or start a new multi selection region _multiSelectedRegions.Push(new TreeSelection(map[idx],newIdx,map)); } } SelectedObject = newBranch.Model; /*this -1 allows for possible horizontal scroll bar in the last row of the control*/ int leaveSpace = Style.LeaveLastRow ? 1 :0; if(newIdx < ScrollOffsetVertical) { //if user has scrolled up too far to see their selection ScrollOffsetVertical = newIdx; } else if(newIdx >= ScrollOffsetVertical + Bounds.Height - leaveSpace){ //if user has scrolled off bottom of visible tree ScrollOffsetVertical = Math.Max(0,(newIdx+1) - (Bounds.Height-leaveSpace)); } } } 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(T toExpand) { if(toExpand == null) return; ObjectToBranch(toExpand)?.Expand(); SetNeedsDisplay(); } /// /// Returns true if the given object is exposed in the tree and can be expanded otherwise false /// /// /// public bool CanExpand(T o) { return ObjectToBranch(o)?.CanExpand() ?? false; } /// /// Returns true if the given object is exposed in the tree and expanded otherwise false /// /// /// public bool IsExpanded(T o) { return ObjectToBranch(o)?.IsExpanded ?? false; } /// /// Collapses the supplied object if it is currently expanded /// /// The object to collapse public void Collapse(T toCollapse) { if(toCollapse == null) return; var branch = ObjectToBranch(toCollapse); // Nothing to collapse if(branch == null) return; branch.Collapse(); if(SelectedObject != null && ObjectToBranch(SelectedObject) == null) { // If the old selection suddenly became invalid then clear it SelectedObject = null; } 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(T toFind) { return BuildLineMap().FirstOrDefault(o=>o.Model.Equals(toFind)); } /// /// Returns true if the is either the or part of a /// /// /// public bool IsSelected (T model) { return Equals(SelectedObject , model) || (MultiSelect && _multiSelectedRegions.Any(s=>s.Contains(model))); } /// /// Returns (if not null) and all multi selected objects if is true /// /// public IEnumerable GetAllSelectedObjects() { var map = BuildLineMap(); if(SelectedObject != null) yield return SelectedObject; // To determine multi selected objects, start with the line map, that avoids yielding hidden nodes that were selected then the parent collapsed e.g. programmatically or with mouse click if(MultiSelect){ foreach(var m in map.Select(b=>b.Model).Where(IsSelected)){ if(m != SelectedObject){ yield return m; } } } } } class TreeSelection where T : class { public Branch Origin {get;} private HashSet> alsoIncluded = new HashSet>(); /// /// Creates a new selection between two branches in the tree /// /// /// /// public TreeSelection(Branch from, int toIndex, Branch[] map ) { Origin = from; var oldIdx = Array.IndexOf(map,from); var lowIndex = Math.Min(oldIdx,toIndex); var highIndex = Math.Max(oldIdx,toIndex); // Select everything between the old and new indexes foreach(var alsoInclude in map.Skip(lowIndex).Take(highIndex-lowIndex)){ alsoIncluded.Add(alsoInclude); } } public bool Contains(T model) { return Equals(Origin.Model,model) || alsoIncluded.Any(b=>Equals(b.Model,model)); } } class Branch where T:class { /// /// 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 T 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,T 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)); } /// /// Returns the width of the line including prefix and the results of (the line body). /// /// public virtual int GetWidth (ConsoleDriver driver) { return GetLinePrefix(driver).Sum(Rune.ColumnWidth) + Rune.ColumnWidth(GetExpandableSymbol(driver)) + (tree.AspectGetter(Model) ?? "").Length; } /// /// Renders the current on the specified line /// /// /// /// /// public virtual void Draw(ConsoleDriver driver,ColorScheme colorScheme, int y, int availableWidth) { // true if the current line of the tree is the selected one and control has focus bool isSelected = tree.IsSelected(Model) && tree.HasFocus; Attribute lineColor = isSelected? colorScheme.Focus : colorScheme.Normal; driver.SetAttribute(lineColor); // Everything on line before the expansion run and branch text Rune[] prefix = GetLinePrefix(driver).ToArray(); Rune expansion = GetExpandableSymbol(driver); string lineBody = tree.AspectGetter(Model) ?? ""; tree.Move(0,y); // if we have scrolled to the right then bits of the prefix will have dispeared off the screen int toSkip = tree.ScrollOffsetHorizontal; // Draw the line prefix (all paralell lanes or whitespace and an expand/collapse/leaf symbol) foreach(Rune r in prefix){ if(toSkip > 0){ toSkip--; } else{ driver.AddRune(r); availableWidth -= Rune.ColumnWidth(r); } } // pick color for expanded symbol if(tree.Style.ColorExpandSymbol || tree.Style.InvertExpandSymbolColors) { Attribute color; if(tree.Style.ColorExpandSymbol) color = isSelected ? tree.ColorScheme.HotFocus : tree.ColorScheme.HotNormal; else color = lineColor; if(tree.Style.InvertExpandSymbolColors) color = new Attribute(color.Background,color.Foreground); driver.SetAttribute(color); } if(toSkip > 0){ toSkip--; } else{ driver.AddRune(expansion); availableWidth -= Rune.ColumnWidth(expansion); } // horizontal scrolling has already skipped the prefix but now must also skip some of the line body if(toSkip > 0) { if(toSkip > lineBody.Length){ lineBody = ""; } else{ lineBody = lineBody.Substring(toSkip); } } // If body of line is too long if(lineBody.Sum(l=>Rune.ColumnWidth(l)) > availableWidth) { // remaining space is zero and truncate the line lineBody = new string(lineBody.TakeWhile(c=>(availableWidth -= Rune.ColumnWidth(c)) > 0).ToArray()); availableWidth = 0; } else{ // line is short so remaining width will be whatever comes after the line body availableWidth -= lineBody.Length; } //reset the line color if it was changed for rendering expansion symbol driver.SetAttribute(lineColor); driver.AddStr(lineBody); if(availableWidth > 0) driver.AddStr(new string(' ',availableWidth)); driver.SetAttribute(colorScheme.Normal); } /// /// 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.Style.ShowBranchLines) { for(int i = 0; i < Depth; i++) { yield return new Rune(' '); } yield break; } // 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; yield return new Rune(' '); } 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 GetExpandableSymbol(ConsoleDriver driver) { var leafSymbol = tree.Style.ShowBranchLines ? driver.HLine : ' '; if(IsExpanded) return tree.Style.CollapseableSymbol ?? leafSymbol; if(CanExpand()) return tree.Style.ExpandableSymbol ?? leafSymbol; return leafSymbol; } /// /// Returns true if the current branch can be expanded according to the or cached children already fetched /// /// public bool CanExpand () { // if we do not know the children yet if(ChildBranches == null) { //if there is a rapid method for determining whether there are children if(tree.TreeBuilder.SupportsCanExpand) { return tree.TreeBuilder.CanExpand(Model); } //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 have any return ChildBranches.Any(); } /// /// 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.Rebuild(); } } 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; } /// /// Returns true if the given x offset on the branch line is the +/- symbol. Returns false if not showing expansion symbols or leaf node etc /// /// /// /// internal bool IsHitOnExpandableSymbol (ConsoleDriver driver, int x) { // if leaf node then we cannot expand if(!CanExpand()) return false; // if we could theoretically expand if(!IsExpanded && tree.Style.ExpandableSymbol != null) { return x == GetLinePrefix(driver).Count(); } // if we could theoretically collapse if(IsExpanded && tree.Style.CollapseableSymbol != null) { return x == GetLinePrefix(driver).Count(); } return false; } } /// /// Delegates of this type are used to fetch string representations of user's model objects /// /// /// public delegate string AspectGetterDelegate(T model) where T:class; /// /// Event arguments describing a change in selected object in a tree view /// public class SelectionChangedEventArgs : EventArgs where T:class { /// /// The view in which the change occurred /// public TreeView Tree { get; } /// /// The previously selected value (can be null) /// public T OldValue { get; } /// /// The newly selected value in the (can be null) /// public T NewValue { get; } /// /// Creates a new instance of event args describing a change of selection in /// /// /// /// public SelectionChangedEventArgs(TreeView tree, T oldValue, T newValue) { Tree = tree; OldValue = oldValue; NewValue = newValue; } } }