// 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.Text; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using Terminal.Gui; namespace Terminal.Gui { /// /// Interface for all non generic members of . /// /// See TreeView Deep Dive for more information. /// 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 . /// /// See TreeView Deep Dive for more information. /// 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 . /// /// See TreeView Deep Dive for more information. /// 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 EventHandler> ObjectActivated; /// /// Key which when pressed triggers . /// Defaults to Enter. /// public Key ObjectActivationKey { get => objectActivationKey; set { if (objectActivationKey != value) { ReplaceKeyBinding (ObjectActivationKey, value); objectActivationKey = value; } } } /// /// Mouse event to trigger . /// Defaults to double click (). /// Set to null to disable this feature. /// /// public MouseFlags? ObjectActivationButton { get; set; } = MouseFlags.Button1DoubleClicked; /// /// Delegate for multi colored tree views. Return the to use /// for each passed object or null to use the default. /// public Func ColorGetter { get; set; } /// /// 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 string NoBuilderError = "ERROR: TreeBuilder Not Set"; private Key objectActivationKey = Key.Enter; /// /// 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 () ?? ""; CursorVisibility desiredCursorVisibility = CursorVisibility.Invisible; /// /// Interface for filtering which lines of the tree are displayed /// e.g. to provide text searching. Defaults to /// (no filtering). /// public ITreeViewFilter Filter = null; /// /// Get / Set the wished cursor when the tree is focused. /// Only applies when is true. /// Defaults to . /// public CursorVisibility DesiredCursorVisibility { get { return MultiSelect ? desiredCursorVisibility : CursorVisibility.Invisible; } set { if (desiredCursorVisibility != value) { desiredCursorVisibility = value; if (HasFocus) { Application.Driver.SetCursorVisibility (DesiredCursorVisibility); } } } } /// /// 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; // Things this view knows how to do AddCommand (Command.PageUp, () => { MovePageUp (false); return true; }); AddCommand (Command.PageDown, () => { MovePageDown (false); return true; }); AddCommand (Command.PageUpExtend, () => { MovePageUp (true); return true; }); AddCommand (Command.PageDownExtend, () => { MovePageDown (true); return true; }); AddCommand (Command.Expand, () => { Expand (); return true; }); AddCommand (Command.ExpandAll, () => { ExpandAll (SelectedObject); return true; }); AddCommand (Command.Collapse, () => { CursorLeft (false); return true; }); AddCommand (Command.CollapseAll, () => { CursorLeft (true); return true; }); AddCommand (Command.LineUp, () => { AdjustSelection (-1, false); return true; }); AddCommand (Command.LineUpExtend, () => { AdjustSelection (-1, true); return true; }); AddCommand (Command.LineUpToFirstBranch, () => { AdjustSelectionToBranchStart (); return true; }); AddCommand (Command.LineDown, () => { AdjustSelection (1, false); return true; }); AddCommand (Command.LineDownExtend, () => { AdjustSelection (1, true); return true; }); AddCommand (Command.LineDownToLastBranch, () => { AdjustSelectionToBranchEnd (); return true; }); AddCommand (Command.TopHome, () => { GoToFirst (); return true; }); AddCommand (Command.BottomEnd, () => { GoToEnd (); return true; }); AddCommand (Command.SelectAll, () => { SelectAll (); return true; }); AddCommand (Command.ScrollUp, () => { ScrollUp (); return true; }); AddCommand (Command.ScrollDown, () => { ScrollDown (); return true; }); AddCommand (Command.Accept, () => { ActivateSelectedObjectIfAny (); return true; }); // Default keybindings for this view AddKeyBinding (Key.PageUp, Command.PageUp); AddKeyBinding (Key.PageDown, Command.PageDown); AddKeyBinding (Key.PageUp | Key.ShiftMask, Command.PageUpExtend); AddKeyBinding (Key.PageDown | Key.ShiftMask, Command.PageDownExtend); AddKeyBinding (Key.CursorRight, Command.Expand); AddKeyBinding (Key.CursorRight | Key.CtrlMask, Command.ExpandAll); AddKeyBinding (Key.CursorLeft, Command.Collapse); AddKeyBinding (Key.CursorLeft | Key.CtrlMask, Command.CollapseAll); AddKeyBinding (Key.CursorUp, Command.LineUp); AddKeyBinding (Key.CursorUp | Key.ShiftMask, Command.LineUpExtend); AddKeyBinding (Key.CursorUp | Key.CtrlMask, Command.LineUpToFirstBranch); AddKeyBinding (Key.CursorDown, Command.LineDown); AddKeyBinding (Key.CursorDown | Key.ShiftMask, Command.LineDownExtend); AddKeyBinding (Key.CursorDown | Key.CtrlMask, Command.LineDownToLastBranch); AddKeyBinding (Key.Home, Command.TopHome); AddKeyBinding (Key.End, Command.BottomEnd); AddKeyBinding (Key.A | Key.CtrlMask, Command.SelectAll); AddKeyBinding (ObjectActivationKey, Command.Accept); } /// /// 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; } /// public override bool OnEnter (View view) { Application.Driver.SetCursorVisibility (DesiredCursorVisibility); if (SelectedObject == null && Objects.Any ()) { SelectedObject = Objects.First (); } return base.OnEnter (view); } /// /// 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 OnDrawContent (Rect contentArea) { 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 (GetNormalColor ()); 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) { var toAdd = AddToLineMap (root, false, out var isMatch); if(isMatch) { toReturn.AddRange (toAdd); } } cachedLineMap = new ReadOnlyCollection> (toReturn); // Update the collection used for search-typing KeystrokeNavigator.Collection = cachedLineMap.Select (b => AspectGetter (b.Model)).ToArray (); return cachedLineMap; } private bool IsFilterMatch (Branch branch) { return Filter?.IsMatch(branch.Model) ?? true; } private IEnumerable> AddToLineMap (Branch currentBranch,bool parentMatches, out bool match) { bool weMatch = IsFilterMatch(currentBranch); bool anyChildMatches = false; var toReturn = new List>(); var children = new List>(); if (currentBranch.IsExpanded) { foreach (var subBranch in currentBranch.ChildBranches.Values) { foreach (var sub in AddToLineMap (subBranch, weMatch, out var childMatch)) { if(childMatch) { children.Add(sub); anyChildMatches = true; } } } } if(parentMatches || weMatch || anyChildMatches) { match = true; toReturn.Add(currentBranch); } else{ match = false; } toReturn.AddRange(children); return toReturn; } /// /// Gets the that searches the collection as /// the user types. /// public CollectionNavigator KeystrokeNavigator { get; private set; } = new CollectionNavigator (); /// public override bool ProcessKey (KeyEvent keyEvent) { if (!Enabled) { return false; } try { // First of all deal with any registered keybindings var result = InvokeKeybindings (keyEvent); if (result != null) { return (bool)result; } // If not a keybinding, is the key a searchable key press? if (CollectionNavigator.IsCompatibleKey (keyEvent) && AllowLetterBasedNavigation) { IReadOnlyCollection> map; // If there has been a call to InvalidateMap since the last time // we need a new one to reflect the new exposed tree state map = BuildLineMap (); // Find the current selected object within the tree var current = map.IndexOf (b => b.Model == SelectedObject); var newIndex = KeystrokeNavigator?.GetNextMatchingItem (current, (char)keyEvent.KeyValue); if (newIndex is int && newIndex != -1) { SelectedObject = map.ElementAt ((int)newIndex).Model; EnsureVisible (selectedObject); SetNeedsDisplay (); return true; } } } finally { PositionCursor (); } return base.ProcessKey (keyEvent); } /// /// Triggers the event with the . /// /// This method also ensures that the selected object is visible. /// public void ActivateSelectedObjectIfAny () { var o = SelectedObject; if (o != null) { OnObjectActivated (new ObjectActivatedEventArgs (this, o)); PositionCursor (); } } /// /// /// Returns the Y coordinate within the of the /// tree at which would be displayed or null if /// it is not currently exposed (e.g. its parent is collapsed). /// /// /// Note that the returned value can be negative if the TreeView is scrolled /// down and the object is off the top of the view. /// /// /// /// public int? GetObjectRow (T toFind) { var idx = BuildLineMap ().IndexOf (o => o.Model.Equals (toFind)); if (idx == -1) return null; return idx - ScrollOffsetVertical; } /// /// Moves the to the next item that begins with . /// This method will loop back to the start of the tree if reaching the end without finding a match. /// /// The first character of the next item you want selected. /// Case sensitivity of the search. public void AdjustSelectionToNextItemBeginningWith (char character, StringComparison caseSensitivity = StringComparison.CurrentCultureIgnoreCase) { // search for next branch that begins with that letter var characterAsStr = character.ToString (); AdjustSelectionToNext (b => AspectGetter (b.Model).StartsWith (characterAsStr, caseSensitivity)); PositionCursor (); } /// /// Moves the selection up by the height of the control (1 page). /// /// True if the navigation should add the covered nodes to the selected current selection. /// public void MovePageUp (bool expandSelection = false) { AdjustSelection (-Bounds.Height, expandSelection); } /// /// Moves the selection down by the height of the control (1 page). /// /// True if the navigation should add the covered nodes to the selected current selection. /// public void MovePageDown (bool expandSelection = false) { AdjustSelection (Bounds.Height, expandSelection); } /// /// Scrolls the view area down a single line without changing the current selection. /// public void ScrollDown () { if (ScrollOffsetVertical <= ContentHeight - 2) { ScrollOffsetVertical++; SetNeedsDisplay (); } } /// /// Scrolls the view area up a single line without changing the current selection. /// public void ScrollUp () { if (scrollOffsetVertical > 0) { ScrollOffsetVertical--; SetNeedsDisplay (); } } /// /// Raises the event. /// /// protected virtual void OnObjectActivated (ObjectActivatedEventArgs e) { ObjectActivated?.Invoke (this, e); } /// /// Returns the object in the tree list that is currently visible. /// at the provided row. Returns null if no object is at that location. /// /// /// If you have screen coordinates then use /// to translate these into the client area of the . /// /// The row of the of the . /// The object currently displayed on this row or null. public T GetObjectOnRow (int row) { return HitTest (row)?.Model; } /// public override bool MouseEvent (MouseEvent me) { // If it is not an event we care about if (!me.Flags.HasFlag (MouseFlags.Button1Clicked) && !me.Flags.HasFlag (ObjectActivationButton ?? 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) { ScrollDown (); return true; } else if (me.Flags == MouseFlags.WheeledUp) { ScrollUp (); 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)) { // The line they clicked on a branch var clickedBranch = HitTest (me.Y); if (clickedBranch == null) { return false; } 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; } // If it is activation via mouse (e.g. double click) if (ObjectActivationButton.HasValue && me.Flags.HasFlag (ObjectActivationButton.Value)) { // The line they clicked on a branch var clickedBranch = HitTest (me.Y); if (clickedBranch == null) { return false; } // Double click changes the selection to the clicked node as well as triggering // activation otherwise it feels wierd SelectedObject = clickedBranch.Model; SetNeedsDisplay (); // trigger activation event OnObjectActivated (new ObjectActivatedEventArgs (this, clickedBranch.Model)); // mouse event is handled. return true; } return false; } /// /// Returns the branch at the given client /// coordinate e.g. following a click event. /// /// Client Y position in the controls bounds. /// The clicked branch or null if outside of tree region. private Branch HitTest (int y) { var map = BuildLineMap (); var idx = y + ScrollOffsetVertical; // click is outside any visible nodes if (idx < 0 || idx >= map.Count) { return null; } // The line they clicked on return map.ElementAt (idx); } /// /// 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.LastOrDefault ()?.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 values. /// . 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 currently . /// public void Expand () { Expand (SelectedObject); } /// /// 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 /// public void Collapse () { Collapse (selectedObject); } /// /// 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 the tree state. /// public 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); } } 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); } } }