// 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.Collections.ObjectModel; 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 is null ? "Null" : o.Text ?? o?.ToString () ?? "Unnamed 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 { /// /// 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"; /// /// Interface for filtering which lines of the tree are displayed e.g. to provide text searching. Defaults to /// (no filtering). /// public ITreeViewFilter Filter = null; /// Secondary selected regions of tree when is true. private readonly Stack> multiSelectedRegions = new (); /// Cached result of private IReadOnlyCollection> cachedLineMap; private KeyCode objectActivationKey = KeyCode.Enter; private int scrollOffsetHorizontal; private int scrollOffsetVertical; /// private variable for private T selectedObject; /// /// Creates a new tree view with absolute positioning. Use to set /// root objects for the tree. Children will not be rendered until you set . /// public TreeView () { CanFocus = true; // Things this view knows how to do AddCommand ( Command.PageUp, () => { MovePageUp (); return true; } ); AddCommand ( Command.PageDown, () => { MovePageDown (); 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); return true; } ); AddCommand ( Command.LineUpExtend, () => { AdjustSelection (-1, true); return true; } ); AddCommand ( Command.LineUpToFirstBranch, () => { AdjustSelectionToBranchStart (); return true; } ); AddCommand ( Command.LineDown, () => { AdjustSelection (1); 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.Select, ActivateSelectedObjectIfAny); AddCommand (Command.Accept, ActivateSelectedObjectIfAny); // Default keybindings for this view KeyBindings.Add (Key.PageUp, Command.PageUp); KeyBindings.Add (Key.PageDown, Command.PageDown); KeyBindings.Add (Key.PageUp.WithShift, Command.PageUpExtend); KeyBindings.Add (Key.PageDown.WithShift, Command.PageDownExtend); KeyBindings.Add (Key.CursorRight, Command.Expand); KeyBindings.Add (Key.CursorRight.WithCtrl, Command.ExpandAll); KeyBindings.Add (Key.CursorLeft, Command.Collapse); KeyBindings.Add (Key.CursorLeft.WithCtrl, Command.CollapseAll); KeyBindings.Add (Key.CursorUp, Command.LineUp); KeyBindings.Add (Key.CursorUp.WithShift, Command.LineUpExtend); KeyBindings.Add (Key.CursorUp.WithCtrl, Command.LineUpToFirstBranch); KeyBindings.Add (Key.CursorDown, Command.LineDown); KeyBindings.Add (Key.CursorDown.WithShift, Command.LineDownExtend); KeyBindings.Add (Key.CursorDown.WithCtrl, Command.LineDownToLastBranch); KeyBindings.Add (Key.Home, Command.TopHome); KeyBindings.Add (Key.End, Command.BottomEnd); KeyBindings.Add (Key.A.WithCtrl, Command.SelectAll); KeyBindings.Add (ObjectActivationKey, Command.Select); } /// /// Initialises .Creates a new tree view with absolute positioning. Use /// to set root objects for the tree. /// public TreeView (ITreeBuilder builder) : this () { TreeBuilder = builder; } /// True makes a letter key press navigate to the next visible branch that begins with that letter/digit. /// public bool AllowLetterBasedNavigation { get; set; } = true; /// /// Returns the string representation of model objects hosted in the tree. Default implementation is to call /// . /// /// public AspectGetterDelegate AspectGetter { get; set; } = o => o.ToString () ?? ""; /// /// 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; } /// The current number of rows in the tree (ignoring the controls bounds). public int ContentHeight => BuildLineMap ().Count (); /// /// Gets the that searches the collection as the user /// types. /// public CollectionNavigator KeystrokeNavigator { get; } = new (); /// Maximum number of nodes that can be expanded in any given branch. public int MaxDepth { get; set; } = 100; /// True to allow multiple objects to be selected at once. /// public bool MultiSelect { get; set; } = true; /// /// Mouse event to trigger . Defaults to double click ( /// ). Set to null to disable this feature. /// /// public MouseFlags? ObjectActivationButton { get; set; } = MouseFlags.Button1DoubleClicked; // TODO: Update to use Key instead of KeyCode /// Key which when pressed triggers . Defaults to Enter. public KeyCode ObjectActivationKey { get => objectActivationKey; set { if (objectActivationKey != value) { KeyBindings.Replace (ObjectActivationKey, value); objectActivationKey = value; } } } /// The root objects in the tree, note that this collection is of root objects only. public IEnumerable Objects => roots.Keys; /// 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 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 an offset of 0. To see changes in the UI call /// . /// public int ScrollOffsetVertical { get => scrollOffsetVertical; set => scrollOffsetVertical = Math.Max (0, value); } /// /// 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 { T oldValue = selectedObject; selectedObject = value; if (!ReferenceEquals (oldValue, value)) { OnSelectionChanged (new SelectionChangedEventArgs (this, oldValue, value)); } } } /// Determines how sub-branches of the tree are dynamically built at runtime as the user expands root nodes. /// public ITreeBuilder TreeBuilder { get; set; } /// /// 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 (); /// Contains options for changing how the tree is rendered. public TreeStyle Style { get; set; } = new (); /// Removes all objects from the tree and clears . public void ClearObjects () { SelectedObject = default (T); multiSelectedRegions.Clear (); roots = new Dictionary> (); InvalidateLineMap (); SetNeedsDisplay (); } /// /// Triggers the event with the . /// This method also ensures that the selected object is visible. /// /// if was fired. public bool? ActivateSelectedObjectIfAny () { // By default, Command.Accept calls OnAccept, so we need to call it here to ensure that the event is fired. if (OnAccept () == true) { return true; } T o = SelectedObject; if (o is { }) { // TODO: Should this be cancelable? ObjectActivatedEventArgs e = new (this, o); OnObjectActivated (e); return true; } return false; } /// 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 (); } } /// 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) { var objectsAdded = false; foreach (T o in collection) { if (!roots.ContainsKey (o)) { roots.Add (o, new Branch (this, null, o)); objectsAdded = true; } } if (objectsAdded) { InvalidateLineMap (); 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 is null) { SelectedObject = roots.Keys.FirstOrDefault (); } else { IReadOnlyCollection> map = BuildLineMap (); int idx = map.IndexOf (b => b.Model.Equals (SelectedObject)); if (idx == -1) { // The current selection has disappeared! SelectedObject = roots.Keys.FirstOrDefault (); } else { int newIdx = Math.Min (Math.Max (0, idx + offset), map.Count - 1); Branch newBranch = map.ElementAt (newIdx); // If it is a multi selection if (expandSelection && MultiSelect) { if (multiSelectedRegions.Any ()) { // expand the existing head selection TreeSelection 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 last child in the currently selected level. public void AdjustSelectionToBranchEnd () { T o = SelectedObject; if (o is null) { return; } IReadOnlyCollection> map = BuildLineMap (); int currentIdx = map.IndexOf (b => Equals (b.Model, o)); if (currentIdx == -1) { return; } Branch currentBranch = map.ElementAt (currentIdx); Branch 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 (); } /// Moves the selection to the first child in the currently selected level. public void AdjustSelectionToBranchStart () { T o = SelectedObject; if (o is null) { return; } IReadOnlyCollection> map = BuildLineMap (); int currentIdx = map.IndexOf (b => Equals (b.Model, o)); if (currentIdx == -1) { return; } Branch currentBranch = map.ElementAt (currentIdx); Branch 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 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)); } /// /// 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; } /// 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 (KeyValuePair> item in roots) { item.Value.Collapse (); } InvalidateLineMap (); SetNeedsDisplay (); } /// /// Called once for each visible row during rendering. Can be used to make last minute changes to color or text /// rendered /// public event EventHandler> DrawLine; /// /// Adjusts the to ensure the given is visible. Has no /// effect if already visible. /// public void EnsureVisible (T model) { IReadOnlyCollection> map = BuildLineMap (); int 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 + Viewport.Height - leaveSpace) { //if user has scrolled off bottom of visible tree ScrollOffsetVertical = Math.Max (0, idx + 1 - (Viewport.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 is 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 is 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 (KeyValuePair> item in roots) { item.Value.ExpandAll (); } InvalidateLineMap (); SetNeedsDisplay (); } /// /// Returns (if not null) and all multi selected objects if /// is true /// /// public IEnumerable GetAllSelectedObjects () { IReadOnlyCollection> 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 (T m in map.Select (b => b.Model).Where (IsSelected)) { yield return m; } } else { if (SelectedObject is { }) { yield return SelectedObject; } } } /// /// 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) { Branch branch = ObjectToBranch (o); if (branch is null || !branch.IsExpanded) { return new T [0]; } return branch.ChildBranches?.Values?.Select (b => b.Model)?.ToArray () ?? new T [0]; } /// 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) { IReadOnlyCollection> 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 (Viewport.Height == 0) { return 0; } return map.Skip (ScrollOffsetVertical).Take (Viewport.Height).Max (b => b.GetWidth (Driver)); } return map.Max (b => b.GetWidth (Driver)); } /// /// 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; } /// /// /// 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) { int idx = BuildLineMap ().IndexOf (o => o.Model.Equals (toFind)); if (idx == -1) { return null; } return idx - ScrollOffsetVertical; } /// /// 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; } /// /// 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) { IReadOnlyCollection> map = BuildLineMap (); for (var i = 0; i < map.Count; i++) { if (map.ElementAt (i).Model.Equals (o)) { return i; } } //object not found return -1; } /// /// 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) is null) { return; } SelectedObject = toSelect; EnsureVisible (toSelect); SetNeedsDisplay (); } /// Changes the to the last object in the tree and scrolls so that it is visible. public void GoToEnd () { IReadOnlyCollection> map = BuildLineMap (); ScrollOffsetVertical = Math.Max (0, map.Count - Viewport.Height + 1); SelectedObject = map.LastOrDefault ()?.Model; SetNeedsDisplay (); } /// /// Changes the to the first root object and resets the /// to 0. /// public void GoToFirst () { ScrollOffsetVertical = 0; SelectedObject = roots.Keys.FirstOrDefault (); SetNeedsDisplay (); } /// Clears any cached results of the tree state. public void InvalidateLineMap () { cachedLineMap = null; } /// 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; } /// /// 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))); } /// protected internal override bool OnMouseEvent (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 base.OnMouseEvent (me); } if (!HasFocus && CanFocus) { SetFocus (); } if (me.Flags == MouseFlags.WheeledDown) { ScrollDown (); return true; } if (me.Flags == MouseFlags.WheeledUp) { ScrollUp (); return true; } if (me.Flags == MouseFlags.WheeledRight) { ScrollOffsetHorizontal++; SetNeedsDisplay (); return true; } if (me.Flags == MouseFlags.WheeledLeft) { ScrollOffsetHorizontal--; SetNeedsDisplay (); return true; } if (me.Flags.HasFlag (MouseFlags.Button1Clicked)) { // The line they clicked on a branch Branch clickedBranch = HitTest (me.Position.Y); if (clickedBranch is null) { return false; } bool isExpandToggleAttempt = clickedBranch.IsHitOnExpandableSymbol (Driver, me.Position.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 Branch clickedBranch = HitTest (me.Position.Y); if (clickedBranch is 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; } /// 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 (Viewport.Height, expandSelection); } /// 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 (-Viewport.Height, expandSelection); } /// /// This event is raised when an object is activated e.g. by double clicking or pressing /// . /// public event EventHandler> ObjectActivated; /// public override void OnDrawContent (Rectangle viewport) { if (roots is null) { return; } if (TreeBuilder is null) { Move (0, 0); Driver.AddStr (NoBuilderError); return; } IReadOnlyCollection> map = BuildLineMap (); for (var line = 0; line < Viewport.Height; line++) { int 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, Viewport.Width); } else { // Else clear the line to prevent stale symbols due to scrolling etc Move (0, line); Driver.SetAttribute (GetNormalColor ()); Driver.AddStr (new string (' ', Viewport.Width)); } } } /// public override bool OnEnter (View view) { if (SelectedObject is null && Objects.Any ()) { SelectedObject = Objects.First (); } return base.OnEnter (view); } /// public override bool OnProcessKeyDown (Key keyEvent) { if (!Enabled) { return false; } // BUGBUG: this should move to OnInvokingKeyBindings // If not a keybinding, is the key a searchable key press? if (CollectionNavigatorBase.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 int current = map.IndexOf (b => b.Model == SelectedObject); int? newIndex = KeystrokeNavigator?.GetNextMatchingItem (current, (char)keyEvent); if (newIndex is int && newIndex != -1) { SelectedObject = map.ElementAt ((int)newIndex).Model; EnsureVisible (selectedObject); SetNeedsDisplay (); return true; } } return false; } /// Positions the cursor at the start of the selected objects line (if visible). public override Point? PositionCursor () { if (CanFocus && HasFocus && Visible && SelectedObject is { }) { IReadOnlyCollection> map = BuildLineMap (); int idx = map.IndexOf (b => b.Model.Equals (SelectedObject)); // if currently selected line is visible if (idx - ScrollOffsetVertical >= 0 && idx - ScrollOffsetVertical < Viewport.Height) { Move (0, idx - ScrollOffsetVertical); return MultiSelect ? new (0, idx - ScrollOffsetVertical) : null ; } } return base.PositionCursor (); } /// /// 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 (Branch branch in roots.Values) { branch.Rebuild (); } 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) { Branch branch = ObjectToBranch (o); if (branch is { }) { branch.Refresh (startAtTop); 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); } } } /// 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 (); } } /// Selects all objects in the tree when is enabled otherwise does nothing. public void SelectAll () { if (!MultiSelect) { return; } multiSelectedRegions.Clear (); IReadOnlyCollection> map = BuildLineMap (); if (map.Count == 0) { return; } multiSelectedRegions.Push (new TreeSelection (map.ElementAt (0), map.Count, map)); SetNeedsDisplay (); OnSelectionChanged (new SelectionChangedEventArgs (this, SelectedObject, SelectedObject)); } /// Called when the changes. public event EventHandler> SelectionChanged; /// /// Implementation of and . Performs operation and updates /// selection if disappeared. /// /// /// protected void CollapseImpl (T toCollapse, bool all) { if (toCollapse is null) { return; } Branch branch = ObjectToBranch (toCollapse); // Nothing to collapse if (branch is null) { return; } if (all) { branch.CollapseAll (); } else { branch.Collapse (); } if (SelectedObject is { } && ObjectToBranch (SelectedObject) is null) { // If the old selection suddenly became invalid then clear it SelectedObject = null; } InvalidateLineMap (); SetNeedsDisplay (); } /// /// 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 { T parent = GetParent (SelectedObject); if (parent is { }) { SelectedObject = parent; AdjustSelection (0); SetNeedsDisplay (); } } } /// protected override void Dispose (bool disposing) { base.Dispose (disposing); ColorGetter = null; } /// Raises the event. /// protected virtual void OnObjectActivated (ObjectActivatedEventArgs e) { ObjectActivated?.Invoke (this, e); } /// Raises the SelectionChanged event. /// protected virtual void OnSelectionChanged (SelectionChangedEventArgs e) { SelectionChanged?.Invoke (this, e); } /// /// 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. /// /// internal IReadOnlyCollection> BuildLineMap () { if (cachedLineMap is { }) { return cachedLineMap; } List> toReturn = new (); foreach (Branch root in roots.Values) { IEnumerable> toAdd = AddToLineMap (root, false, out bool 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; } /// Raises the DrawLine event /// internal void OnDrawLine (DrawTreeViewLineEventArgs e) { DrawLine?.Invoke (this, e); } private IEnumerable> AddToLineMap (Branch currentBranch, bool parentMatches, out bool match) { bool weMatch = IsFilterMatch (currentBranch); var anyChildMatches = false; List> toReturn = new (); List> children = new (); if (currentBranch.IsExpanded) { foreach (Branch subBranch in currentBranch.ChildBranches.Values) { foreach (Branch sub in AddToLineMap (subBranch, weMatch, out bool 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; } /// Sets the selection to the next branch that matches the . /// private void AdjustSelectionToNext (Func, bool> predicate) { IReadOnlyCollection> 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 is { }) { 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; } } } /// 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) { IReadOnlyCollection> map = BuildLineMap (); int 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); } private bool IsFilterMatch (Branch branch) { return Filter?.IsMatch (branch.Model) ?? true; } /// /// 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)); } } internal class TreeSelection where T : class { private readonly HashSet included = new (); /// Creates a new selection between two branches in the tree /// /// /// public TreeSelection (Branch from, int toIndex, IReadOnlyCollection> map) { Origin = from; included.Add (Origin.Model); int oldIdx = map.IndexOf (from); int lowIndex = Math.Min (oldIdx, toIndex); int highIndex = Math.Max (oldIdx, toIndex); // Select everything between the old and new indexes foreach (Branch alsoInclude in map.Skip (lowIndex).Take (highIndex - lowIndex)) { included.Add (alsoInclude.Model); } } public Branch Origin { get; } public bool Contains (T model) { return included.Contains (model); } }