// 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 /// an ) /// public interface ITreeNode { /// /// Text to display when rendering the node /// string Text {get;set;} /// /// The children of your class which should be rendered underneath it when expanded /// /// IList Children {get;} /// /// Optionally allows you to store some custom data/class here. /// object Tag {get;set;} } /// /// Simple class for representing nodes, use with regular (non generic) . /// public class TreeNode : ITreeNode { /// /// Children of the current node /// /// public virtual IList Children {get;set;} = new List(); /// /// Text to display in tree node for current entry /// /// public virtual string Text {get;set;} /// /// Optionally allows you to store some custom data/class here. /// public object Tag {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(); AspectGetter = o=>o == null ? "Null" : (o.Text ?? o?.ToString() ?? "Unamed Node"); } } /// /// 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; /// /// True makes a letter key press navigate to the next visible branch that begins with /// that letter/digit /// /// public bool AllowLetterBasedNavigation {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)) OnSelectionChanged(new SelectionChangedEventArgs(this,oldValue,value)); } } /// /// This event is raised when an object is activated e.g. by double clicking or /// pressing /// public event Action> ObjectActivated; /// /// Key which when pressed triggers . /// Defaults to Enter /// public Key ObjectActivationKey {get;set;} = Key.Enter; /// /// Secondary selected regions of tree when is true /// private Stack> multiSelectedRegions = new Stack>(); /// /// Cached result of /// private Branch[] cachedLineMap; /// /// 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: TreeBuilder 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)); InvalidateLineMap(); SetNeedsDisplay(); } } /// /// Removes all objects from the tree and clears /// public void ClearObjects() { SelectedObject = default(T); multiSelectedRegions.Clear(); roots = new Dictionary>(); InvalidateLineMap(); 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); InvalidateLineMap(); 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) { InvalidateLineMap(); 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); InvalidateLineMap(); 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(); InvalidateLineMap(); 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() { if(cachedLineMap != null) return cachedLineMap; List> toReturn = new List>(); foreach(var root in roots.Values) { toReturn.AddRange(AddToLineMap(root)); } return cachedLineMap = 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) { if(keyEvent.Key == ObjectActivationKey) { var o = SelectedObject; if(o != null){ OnObjectActivated(new ObjectActivatedEventArgs(this,o)); PositionCursor (); return true; } } if(keyEvent.KeyValue >0 && keyEvent.KeyValue <0xFFFF){ var character = (char)keyEvent.KeyValue; // if it is a single character pressed without any control keys if(char.IsLetterOrDigit(character) && AllowLetterBasedNavigation && !keyEvent.IsShift && !keyEvent.IsAlt && !keyEvent.IsCtrl) { // search for next branch that begins with that letter var characterAsStr = character.ToString(); AdjustSelectionToNext(b=>AspectGetter(b.Model).StartsWith(characterAsStr,StringComparison.CurrentCultureIgnoreCase)); PositionCursor (); return true; } } switch (keyEvent.Key) { case Key.CursorRight: Expand(SelectedObject); break; case Key.CursorRight | Key.CtrlMask: ExpandAll(SelectedObject); break; case Key.CursorLeft: case Key.CursorLeft | Key.CtrlMask: CursorLeft(keyEvent.Key.HasFlag(Key.CtrlMask)); 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.CursorUp | Key.CtrlMask: AdjustSelectionToBranchStart(); break; case Key.CursorDown | Key.CtrlMask: AdjustSelectionToBranchEnd(); 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.A | Key.CtrlMask: SelectAll(); break; case Key.Home: GoToFirst(); break; case Key.End: GoToEnd(); break; default: // we don't care about this keystroke return false; } PositionCursor (); return true; } /// /// Raises the event /// /// protected virtual void OnObjectActivated(ObjectActivatedEventArgs e) { ObjectActivated?.Invoke(e); } /// 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(bool ctrl) { if(IsExpanded(SelectedObject)) { if(ctrl) CollapseAll(SelectedObject); else 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; EnsureVisible(SelectedObject); } } SetNeedsDisplay(); } /// /// Moves the selection to the first child in the currently selected level /// public void AdjustSelectionToBranchStart() { var o = SelectedObject; if(o == null) return; var map = BuildLineMap(); int currentIdx = Array.FindIndex(map,b=>Equals(b.Model,o)); if(currentIdx == -1) return; var currentBranch = map[currentIdx]; var next = currentBranch; for(;currentIdx >= 0;currentIdx--) { //if it is the beginning of the current depth of branch if(currentBranch.Depth != next.Depth){ SelectedObject = currentBranch.Model; EnsureVisible(currentBranch.Model); SetNeedsDisplay(); return; } // look at next branch up for consideration currentBranch = next; next = map[currentIdx]; } // We ran all the way to top of tree GoToFirst(); } /// /// Moves the selection to the last child in the currently selected level /// public void AdjustSelectionToBranchEnd() { var o = SelectedObject; if(o == null) return; var map = BuildLineMap(); int currentIdx = Array.FindIndex(map,b=>Equals(b.Model,o)); if(currentIdx == -1) return; var currentBranch = map[currentIdx]; var next = currentBranch; for(;currentIdx < map.Length;currentIdx++) { //if it is the end of the current depth of branch if(currentBranch.Depth != next.Depth){ SelectedObject = currentBranch.Model; EnsureVisible(currentBranch.Model); SetNeedsDisplay(); return; } // look at next branch for consideration currentBranch = next; next = map[currentIdx]; } GoToEnd(); } /// /// Sets the selection to the next branch that matches the /// /// private void AdjustSelectionToNext (Func, bool> predicate) { var map = BuildLineMap(); // empty map means we can't select anything anyway if(map.Length == 0) return; // Start searching from the first element in the map var idxStart = 0; // or the current selected branch if(SelectedObject !=null) idxStart = Array.FindIndex(map,b=>Equals(b.Model,SelectedObject)); // if currently selected object mysteriously vanished, search from beginning if(idxStart == -1) idxStart = 0; // loop around all indexes and back to first index for(int idxCur = (idxStart+1)%map.Length;idxCur != idxStart;idxCur = (idxCur+1) % map.Length) { if(predicate(map[idxCur])) { SelectedObject = map[idxCur].Model; EnsureVisible(map[idxCur].Model); SetNeedsDisplay(); return; } } } /// /// Adjusts the to ensure the given /// is visible. Has no effect if already visible /// public void EnsureVisible(T model) { var map = BuildLineMap(); var idx = Array.FindIndex(map,b=>Equals(b.Model,model)); if(idx == -1) return; /*this -1 allows for possible horizontal scroll bar in the last row of the control*/ int leaveSpace = Style.LeaveLastRow ? 1 :0; if(idx < ScrollOffsetVertical) { //if user has scrolled up too far to see their selection ScrollOffsetVertical = idx; } else if(idx >= ScrollOffsetVertical + Bounds.Height - leaveSpace){ //if user has scrolled off bottom of visible tree ScrollOffsetVertical = Math.Max(0,(idx+1) - (Bounds.Height-leaveSpace)); } } /// /// 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(); InvalidateLineMap(); SetNeedsDisplay(); } /// /// Expands the supplied object and all child objects /// /// The object to expand public void ExpandAll(T toExpand) { if(toExpand == null) return; ObjectToBranch(toExpand)?.ExpandAll(); InvalidateLineMap(); SetNeedsDisplay(); } /// /// Fully expands all nodes in the tree, if the tree is very big and built dynamically this /// may take a while (e.g. for file system) /// public void ExpandAll() { foreach (var item in roots) { item.Value.ExpandAll(); } InvalidateLineMap(); 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) { CollapseImpl(toCollapse,false); } /// /// Collapses the supplied object if it is currently expanded. Also collapses all children /// branches (this will only become apparent when/if the user expands it again) /// /// The object to collapse public void CollapseAll(T toCollapse) { CollapseImpl(toCollapse,true); } /// /// Collapses all root nodes in the tree /// public void CollapseAll() { foreach (var item in roots) { item.Value.Collapse(); } InvalidateLineMap(); SetNeedsDisplay(); } /// /// Implementation of and . Performs /// operation and updates selection if disapeared /// /// /// protected void CollapseImpl(T toCollapse, bool all) { if(toCollapse == null) return; var branch = ObjectToBranch(toCollapse); // Nothing to collapse if(branch == null) return; if (all) { branch.CollapseAll(); } else { branch.Collapse(); } if(SelectedObject != null && ObjectToBranch(SelectedObject) == null) { // If the old selection suddenly became invalid then clear it SelectedObject = null; } InvalidateLineMap(); SetNeedsDisplay(); } /// /// Clears any cached results of /// protected void InvalidateLineMap() { cachedLineMap = null; } /// /// 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(); // 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)){ yield return m; } } else { if(SelectedObject != null) yield return SelectedObject; } } /// /// Selects all objects in the tree when is enabled otherwise /// does nothing /// public void SelectAll() { if(!MultiSelect) return; multiSelectedRegions.Clear(); var map = BuildLineMap(); if(map.Length == 0) return; multiSelectedRegions.Push(new TreeSelection(map[0],map.Length,map)); SetNeedsDisplay(); OnSelectionChanged(new SelectionChangedEventArgs(this,SelectedObject,SelectedObject)); } /// /// Raises the SelectionChanged event /// /// protected virtual void OnSelectionChanged (SelectionChangedEventArgs e) { SelectionChanged?.Invoke(this,e); } } /// /// Event args for the event /// /// public class ObjectActivatedEventArgs where T : class { /// /// The tree in which the activation occurred /// /// public TreeView Tree {get;} /// /// The object that was selected at the time of activation /// /// public T ActivatedObject {get;} /// /// Creates a new instance documenting activation of the object /// /// Tree in which the activation is happening /// What object is being activated public ObjectActivatedEventArgs(TreeView tree, T activated) { Tree = tree; ActivatedObject = activated; } } class TreeSelection where T : class { public Branch Origin {get;} private HashSet included = new HashSet(); /// /// Creates a new selection between two branches in the tree /// /// /// /// public TreeSelection(Branch from, int toIndex, Branch[] map ) { Origin = from; included.Add(Origin.Model); 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)){ included.Add(alsoInclude.Model); } } public bool Contains(T model) { return included.Contains(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; } /// /// Expands the current branch and all children branches /// internal void ExpandAll () { Expand(); if(ChildBranches != null) foreach (var child in ChildBranches) { child.Value.ExpandAll(); } } /// /// Collapses the current branch and all children branches (even though those branches are /// no longer visible they retain collapse/expansion state) /// internal void CollapseAll () { Collapse(); if(ChildBranches != null) foreach (var child in ChildBranches) { child.Value.CollapseAll(); } } } /// /// 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; } } }