// 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.Collections.ObjectModel; using System.Linq; using NStack; namespace Terminal.Gui { /// /// 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"); } } /// /// 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 IReadOnlyCollection> 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.Count) { // Render the line map.ElementAt (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.Count; i++) { if (map.ElementAt (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.Count == 0) { return 0; } if (visible) { //Somehow we managed to scroll off the end of the control if (ScrollOffsetVertical >= map.Count) { 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 IReadOnlyCollection> BuildLineMap () { if (cachedLineMap != null) { return cachedLineMap; } List> toReturn = new List> (); foreach (var root in roots.Values) { toReturn.AddRange (AddToLineMap (root)); } return cachedLineMap = new ReadOnlyCollection>(toReturn); } 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 it is not an event we care about if (!me.Flags.HasFlag (MouseFlags.Button1Clicked) && !me.Flags.HasFlag (MouseFlags.Button1DoubleClicked) && !me.Flags.HasFlag (MouseFlags.WheeledDown) && !me.Flags.HasFlag (MouseFlags.WheeledUp) && !me.Flags.HasFlag (MouseFlags.WheeledRight) && !me.Flags.HasFlag (MouseFlags.WheeledLeft)) { // do nothing 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.Count) { return false; } // The line they clicked on var clickedBranch = map.ElementAt (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 (); InvalidateLineMap (); } else if (clickedBranch.CanExpand ()) { clickedBranch.Expand (); InvalidateLineMap (); } 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 = map.IndexOf(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.Count - Bounds.Height + 1); SelectedObject = map.Last ().Model; SetNeedsDisplay (); } /// /// Changes the to and scrolls to ensure /// it is visible. Has no effect if is not exposed in the tree (e.g. /// its parents are collapsed) /// /// public void GoTo (T toSelect) { if (ObjectToBranch (toSelect) == null) { return; } SelectedObject = toSelect; EnsureVisible (toSelect); SetNeedsDisplay (); } /// /// The number of screen lines to move the currently selected object by. Supports negative /// . Each branch occupies 1 line on screen /// /// If nothing is currently selected or the selected object is no longer in the tree /// then the first object in the tree is selected instead /// Positive to move the selection down the screen, negative to move it up /// 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 = map.IndexOf(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.Count - 1); var newBranch = map.ElementAt(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.ElementAt(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 = map.IndexOf(b => Equals (b.Model, o)); if (currentIdx == -1) { return; } var currentBranch = map.ElementAt(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.ElementAt(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 = map.IndexOf(b => Equals (b.Model, o)); if (currentIdx == -1) { return; } var currentBranch = map.ElementAt(currentIdx); var next = currentBranch; for (; currentIdx < map.Count; 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.ElementAt(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.Count == 0) { return; } // Start searching from the first element in the map var idxStart = 0; // or the current selected branch if (SelectedObject != null) { idxStart = map.IndexOf(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.Count; idxCur != idxStart; idxCur = (idxCur + 1) % map.Count) { if (predicate (map.ElementAt(idxCur))) { SelectedObject = map.ElementAt(idxCur).Model; EnsureVisible (map.ElementAt(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 = map.IndexOf(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.Count == 0) { return; } multiSelectedRegions.Push (new TreeSelection (map.ElementAt(0), map.Count, 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, IReadOnlyCollection> map) { Origin = from; included.Add (Origin.Model); var oldIdx = map.IndexOf(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 /// /// The object that is being rendered /// public delegate string AspectGetterDelegate (T toRender) 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; } } static class ReadOnlyCollectionExtensions { public static int IndexOf (this IReadOnlyCollection self, Func predicate) { int i = 0; foreach (T element in self) { if (predicate(element)) return i; i++; } return -1; } public static int IndexOf (this IReadOnlyCollection self, T toFind) { int i = 0; foreach (T element in self) { if (Equals(element,toFind)) return i; i++; } return -1; } } }