From 38e5ff83300de458bf8f7c850df932413b1d01b5 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 18 Nov 2024 13:24:47 -0700 Subject: [PATCH] #nullable enable, code cleanup, etc... --- .../Views/TreeView/AspectGetterDelegate.cs | 3 +- Terminal.Gui/Views/TreeView/Branch.cs | 114 +- .../Views/TreeView/DelegateTreeBuilder.cs | 14 +- .../TreeView/DrawTreeViewLineEventArgs.cs | 9 +- Terminal.Gui/Views/TreeView/ITreeView.cs | 17 + .../TreeView/SelectionChangedEventArgs.cs | 13 +- Terminal.Gui/Views/TreeView/TreeSelection.cs | 46 + Terminal.Gui/Views/TreeView/TreeStyle.cs | 2 +- Terminal.Gui/Views/TreeView/TreeView.cs | 1039 +++++++++-------- UICatalog/Scenarios/TreeViewFileSystem.cs | 2 +- 10 files changed, 674 insertions(+), 585 deletions(-) create mode 100644 Terminal.Gui/Views/TreeView/ITreeView.cs create mode 100644 Terminal.Gui/Views/TreeView/TreeSelection.cs diff --git a/Terminal.Gui/Views/TreeView/AspectGetterDelegate.cs b/Terminal.Gui/Views/TreeView/AspectGetterDelegate.cs index 5865be9a35..14ab2f648a 100644 --- a/Terminal.Gui/Views/TreeView/AspectGetterDelegate.cs +++ b/Terminal.Gui/Views/TreeView/AspectGetterDelegate.cs @@ -1,4 +1,5 @@ -namespace Terminal.Gui; +#nullable enable +namespace Terminal.Gui; /// Delegates of this type are used to fetch string representations of user's model objects /// The object that is being rendered diff --git a/Terminal.Gui/Views/TreeView/Branch.cs b/Terminal.Gui/Views/TreeView/Branch.cs index e2d220aceb..e37ec1fdb3 100644 --- a/Terminal.Gui/Views/TreeView/Branch.cs +++ b/Terminal.Gui/Views/TreeView/Branch.cs @@ -1,8 +1,9 @@ -namespace Terminal.Gui; +#nullable enable +namespace Terminal.Gui; internal class Branch where T : class { - private readonly TreeView tree; + private readonly TreeView? _tree; /// /// Declares a new branch of in which the users object is @@ -11,9 +12,9 @@ internal class Branch where T : class /// 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) + public Branch (TreeView? tree, Branch? parentBranchIfAny, T? model) { - this.tree = tree; + _tree = tree; Model = model; if (parentBranchIfAny is { }) @@ -27,7 +28,7 @@ public Branch (TreeView tree, Branch parentBranchIfAny, T model) /// 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; } + public Dictionary>? ChildBranches { get; set; } /// The depth of the current branch. Depth of 0 indicates root level branches. public int Depth { get; } @@ -36,10 +37,10 @@ public Branch (TreeView tree, Branch parentBranchIfAny, T model) public bool IsExpanded { get; set; } /// The users object that is being displayed by this branch of the tree. - public T Model { get; private set; } + public T? Model { get; private set; } /// The parent or null if it is a root. - public Branch Parent { get; } + public Branch? Parent { get; } /// /// Returns true if the current branch can be expanded according to the or cached @@ -52,9 +53,9 @@ public bool CanExpand () if (ChildBranches is null) { //if there is a rapid method for determining whether there are children - if (tree.TreeBuilder.SupportsCanExpand) + if (_tree is { TreeBuilder.SupportsCanExpand: true }) { - return tree.TreeBuilder.CanExpand (Model); + return Model is { } && _tree.TreeBuilder.CanExpand (Model); } //there is no way of knowing whether we can expand without fetching the children @@ -62,7 +63,7 @@ public bool CanExpand () } //we fetched or already know the children, so return whether we have any - return ChildBranches.Any (); + return ChildBranches is { } && ChildBranches.Any (); } /// Marks the branch as collapsed ( false). @@ -73,28 +74,28 @@ public bool CanExpand () /// /// /// - public virtual void Draw (ConsoleDriver driver, ColorScheme colorScheme, int y, int availableWidth) + public virtual void Draw (ConsoleDriver? driver, ColorScheme colorScheme, int y, int availableWidth) { List cells = new (); int? indexOfExpandCollapseSymbol = null; int indexOfModelText; // true if the current line of the tree is the selected one and control has focus - bool isSelected = tree.IsSelected (Model); + bool isSelected = _tree!.IsSelected (Model); Attribute textColor = - isSelected ? tree.HasFocus ? colorScheme.Focus : colorScheme.HotNormal : colorScheme.Normal; - Attribute symbolColor = tree.Style.HighlightModelTextOnly ? colorScheme.Normal : textColor; + isSelected ? _tree.HasFocus ? colorScheme.Focus : colorScheme.HotNormal : colorScheme.Normal; + Attribute symbolColor = _tree.Style.HighlightModelTextOnly ? colorScheme.Normal : textColor; // Everything on line before the expansion run and branch text Rune [] prefix = GetLinePrefix (driver).ToArray (); Rune expansion = GetExpandableSymbol (driver); - string lineBody = tree.AspectGetter (Model) ?? ""; + string lineBody = _tree.AspectGetter?.Invoke (Model!) ?? ""; - tree.Move (0, y); + _tree.Move (0, y); // if we have scrolled to the right then bits of the prefix will have disappeared off the screen - int toSkip = tree.ScrollOffsetHorizontal; + int toSkip = _tree.ScrollOffsetHorizontal; Attribute attr = symbolColor; // Draw the line prefix (all parallel lanes or whitespace and an expand/collapse/leaf symbol) @@ -112,20 +113,20 @@ public virtual void Draw (ConsoleDriver driver, ColorScheme colorScheme, int y, } // pick color for expanded symbol - if (tree.Style.ColorExpandSymbol || tree.Style.InvertExpandSymbolColors) + if (_tree.Style.ColorExpandSymbol || _tree.Style.InvertExpandSymbolColors) { Attribute color = symbolColor; - if (tree.Style.ColorExpandSymbol) + if (_tree.Style.ColorExpandSymbol) { if (isSelected) { - color = tree.Style.HighlightModelTextOnly ? colorScheme.HotNormal : - tree.HasFocus ? tree.ColorScheme.HotFocus : tree.ColorScheme.HotNormal; + color = _tree.Style.HighlightModelTextOnly ? colorScheme.HotNormal : + _tree.HasFocus ? _tree.GetHotFocusColor() : _tree.GetHotNormalColor(); } else { - color = tree.ColorScheme.HotNormal; + color = _tree.GetHotNormalColor(); } } else @@ -133,7 +134,7 @@ public virtual void Draw (ConsoleDriver driver, ColorScheme colorScheme, int y, color = symbolColor; } - if (tree.Style.InvertExpandSymbolColors) + if (_tree.Style.InvertExpandSymbolColors) { color = new Attribute (color.Background, color.Foreground); } @@ -194,9 +195,9 @@ public virtual void Draw (ConsoleDriver driver, ColorScheme colorScheme, int y, Attribute modelColor = textColor; // if custom color delegate invoke it - if (tree.ColorGetter is { }) + if (_tree.ColorGetter is { }) { - ColorScheme modelScheme = tree.ColorGetter (Model); + ColorScheme? modelScheme = _tree.ColorGetter (Model!); // if custom color scheme is defined for this Model if (modelScheme is { }) @@ -230,19 +231,19 @@ public virtual void Draw (ConsoleDriver driver, ColorScheme colorScheme, int y, Model = Model, Y = y, Cells = cells, - Tree = tree, + Tree = _tree, IndexOfExpandCollapseSymbol = indexOfExpandCollapseSymbol, IndexOfModelText = indexOfModelText }; - tree.OnDrawLine (e); + _tree.OnDrawLine (e); - if (!e.Handled && driver != null) + if (!e.Handled) { foreach (Cell cell in cells) { - driver.SetAttribute ((Attribute)cell.Attribute!); - driver.AddRune (cell.Rune); + driver?.SetAttribute ((Attribute)cell.Attribute!); + driver?.AddRune (cell.Rune); } } @@ -257,7 +258,7 @@ public void Expand () FetchChildren (); } - if (ChildBranches.Any ()) + if (ChildBranches is { } && ChildBranches.Any ()) { IsExpanded = true; } @@ -266,23 +267,23 @@ public void Expand () /// Fetch the children of this branch. This method populates . public virtual void FetchChildren () { - if (tree.TreeBuilder is null) + if (_tree?.TreeBuilder is null) { return; } IEnumerable children; - if (Depth >= tree.MaxDepth) + if (Depth >= _tree.MaxDepth) { children = Enumerable.Empty (); } else { - children = tree.TreeBuilder.GetChildren (Model) ?? Enumerable.Empty (); + children = _tree.TreeBuilder.GetChildren (Model!) ?? Enumerable.Empty (); } - ChildBranches = children.ToDictionary (k => k, val => new Branch (tree, this, val)); + ChildBranches = children.ToDictionary (k => k, val => new Branch (_tree, this, val)); } /// @@ -291,18 +292,18 @@ public virtual void FetchChildren () /// /// /// - public Rune GetExpandableSymbol (ConsoleDriver driver) + public Rune GetExpandableSymbol (ConsoleDriver? driver) { - Rune leafSymbol = tree.Style.ShowBranchLines ? Glyphs.HLine : (Rune)' '; + Rune leafSymbol = _tree is { Style.ShowBranchLines: true } ? Glyphs.HLine : (Rune)' '; if (IsExpanded) { - return tree.Style.CollapseableSymbol ?? leafSymbol; + return _tree!.Style.CollapsableSymbol ?? leafSymbol; } if (CanExpand ()) { - return tree.Style.ExpandableSymbol ?? leafSymbol; + return _tree!.Style.ExpandableSymbol ?? leafSymbol; } return leafSymbol; @@ -313,10 +314,15 @@ public Rune GetExpandableSymbol (ConsoleDriver driver) /// line body). /// /// - public virtual int GetWidth (ConsoleDriver driver) + public virtual int GetWidth (ConsoleDriver? driver) { - return - GetLinePrefix (driver).Sum (r => r.GetColumns ()) + GetExpandableSymbol (driver).GetColumns () + (tree.AspectGetter (Model) ?? "").Length; + if (_tree is { }) + { + return + GetLinePrefix (driver).Sum (r => r.GetColumns ()) + GetExpandableSymbol (driver).GetColumns () + (_tree.AspectGetter?.Invoke (Model!) ?? "").Length; + } + + return 0; } /// Refreshes cached knowledge in this branch e.g. what children an object has. @@ -340,7 +346,7 @@ public void Refresh (bool startAtTop) // we already knew about some children so preserve the state of the old children // first gather the new Children - IEnumerable newChildren = tree.TreeBuilder?.GetChildren (Model) ?? Enumerable.Empty (); + IEnumerable newChildren = _tree?.TreeBuilder?.GetChildren (Model!) ?? Enumerable.Empty (); // Children who no longer appear need to go foreach (T toRemove in ChildBranches.Keys.Except (newChildren).ToArray ()) @@ -348,9 +354,9 @@ public void Refresh (bool startAtTop) ChildBranches.Remove (toRemove); //also if the user has this node selected (its disappearing) so lets change selection to us (the parent object) to be helpful - if (Equals (tree.SelectedObject, toRemove)) + if (Equals (_tree?.SelectedObject, toRemove)) { - tree.SelectedObject = Model; + _tree.SelectedObject = Model; } } @@ -360,7 +366,7 @@ public void Refresh (bool startAtTop) // 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)); + ChildBranches.Add (newChild, new Branch (_tree, this, newChild)); } else { @@ -408,10 +414,10 @@ internal void ExpandAll () /// /// /// - internal IEnumerable GetLinePrefix (ConsoleDriver driver) + internal IEnumerable GetLinePrefix (ConsoleDriver? driver) { // If not showing line branches or this is a root object. - if (!tree.Style.ShowBranchLines) + if (_tree is { Style.ShowBranchLines: false }) { for (var i = 0; i < Depth; i++) { @@ -453,7 +459,7 @@ internal IEnumerable GetLinePrefix (ConsoleDriver driver) /// /// /// - internal bool IsHitOnExpandableSymbol (ConsoleDriver driver, int x) + internal bool IsHitOnExpandableSymbol (ConsoleDriver? driver, int x) { // if leaf node then we cannot expand if (!CanExpand ()) @@ -462,13 +468,13 @@ internal bool IsHitOnExpandableSymbol (ConsoleDriver driver, int x) } // if we could theoretically expand - if (!IsExpanded && tree.Style.ExpandableSymbol != default (Rune?)) + if (!IsExpanded && _tree?.Style.ExpandableSymbol != default (Rune?)) { return x == GetLinePrefix (driver).Count (); } // if we could theoretically collapse - if (IsExpanded && tree.Style.CollapseableSymbol != default (Rune?)) + if (IsExpanded && _tree!.Style.CollapsableSymbol != default (Rune?)) { return x == GetLinePrefix (driver).Count (); } @@ -504,7 +510,7 @@ internal void Rebuild () /// private IEnumerable> GetParentBranches () { - Branch cur = Parent; + Branch? cur = Parent; while (cur is { }) { @@ -523,10 +529,10 @@ private bool IsLast () { if (Parent is null) { - return this == tree.roots.Values.LastOrDefault (); + return this == _tree?.Roots?.Values.LastOrDefault (); } - return Parent.ChildBranches.Values.LastOrDefault () == this; + return Parent.ChildBranches?.Values.LastOrDefault () == this; } private static Cell NewCell (Attribute attr, Rune r) { return new Cell { Rune = r, Attribute = new (attr) }; } diff --git a/Terminal.Gui/Views/TreeView/DelegateTreeBuilder.cs b/Terminal.Gui/Views/TreeView/DelegateTreeBuilder.cs index f3edef285a..358b72054d 100644 --- a/Terminal.Gui/Views/TreeView/DelegateTreeBuilder.cs +++ b/Terminal.Gui/Views/TreeView/DelegateTreeBuilder.cs @@ -3,8 +3,8 @@ /// Implementation of that uses user defined functions public class DelegateTreeBuilder : TreeBuilder { - private readonly Func canExpand; - private readonly Func> childGetter; + private readonly Func _canExpand; + private readonly Func> _childGetter; /// /// Constructs an implementation of that calls the user defined method @@ -12,7 +12,7 @@ public class DelegateTreeBuilder : TreeBuilder /// /// /// - public DelegateTreeBuilder (Func> childGetter) : base (false) { this.childGetter = childGetter; } + public DelegateTreeBuilder (Func> childGetter) : base (false) { this._childGetter = childGetter; } /// /// Constructs an implementation of that calls the user defined method @@ -23,17 +23,17 @@ public class DelegateTreeBuilder : TreeBuilder /// public DelegateTreeBuilder (Func> childGetter, Func canExpand) : base (true) { - this.childGetter = childGetter; - this.canExpand = canExpand; + this._childGetter = childGetter; + this._canExpand = canExpand; } /// Returns whether a node can be expanded based on the delegate passed during construction /// /// - public override bool CanExpand (T toExpand) { return canExpand?.Invoke (toExpand) ?? base.CanExpand (toExpand); } + public override bool CanExpand (T toExpand) { return _canExpand?.Invoke (toExpand) ?? base.CanExpand (toExpand); } /// Returns children using the delegate method passed during construction /// /// - public override IEnumerable GetChildren (T forObject) { return childGetter.Invoke (forObject); } + public override IEnumerable GetChildren (T forObject) { return _childGetter.Invoke (forObject); } } diff --git a/Terminal.Gui/Views/TreeView/DrawTreeViewLineEventArgs.cs b/Terminal.Gui/Views/TreeView/DrawTreeViewLineEventArgs.cs index 6cd03747d1..ca947a1fd7 100644 --- a/Terminal.Gui/Views/TreeView/DrawTreeViewLineEventArgs.cs +++ b/Terminal.Gui/Views/TreeView/DrawTreeViewLineEventArgs.cs @@ -1,4 +1,5 @@ -// This code is based on http://objectlistview.sourceforge.net (GPLv3 tree/list controls +#nullable enable +// 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. @@ -25,17 +26,17 @@ public class DrawTreeViewLineEventArgs where T : class public int IndexOfModelText { get; init; } /// The object at this line in the tree - public T Model { get; init; } + public T? Model { get; init; } /// /// The rune and color of each symbol that will be rendered. Note that only is /// respected. You can modify these to change what is rendered. /// /// Changing the length of this collection may result in corrupt rendering - public List Cells { get; init; } + public List? Cells { get; init; } /// The that is performing the rendering. - public TreeView Tree { get; init; } + public TreeView? Tree { get; init; } /// The line within tree view bounds that is being rendered public int Y { get; init; } diff --git a/Terminal.Gui/Views/TreeView/ITreeView.cs b/Terminal.Gui/Views/TreeView/ITreeView.cs new file mode 100644 index 0000000000..91e169e5ff --- /dev/null +++ b/Terminal.Gui/Views/TreeView/ITreeView.cs @@ -0,0 +1,17 @@ +namespace Terminal.Gui; + +/// +/// Interface for all non-generic members of . +/// See TreeView Deep Dive for more information. +/// +public interface ITreeView +{ + /// Removes all objects from the tree and clears selection. + void ClearObjects (); + + /// Sets a flag indicating this view needs to be drawn because its state has changed. + void SetNeedsDraw (); + + /// Contains options for changing how the tree is rendered. + TreeStyle Style { get; set; } +} diff --git a/Terminal.Gui/Views/TreeView/SelectionChangedEventArgs.cs b/Terminal.Gui/Views/TreeView/SelectionChangedEventArgs.cs index 5253f3d404..622ebd7b28 100644 --- a/Terminal.Gui/Views/TreeView/SelectionChangedEventArgs.cs +++ b/Terminal.Gui/Views/TreeView/SelectionChangedEventArgs.cs @@ -1,13 +1,14 @@ -namespace Terminal.Gui; +#nullable enable +namespace Terminal.Gui; /// Event arguments describing a change in selected object in a tree view -public class SelectionChangedEventArgs : EventArgs where T : class +public class SelectionChangedEventArgs : EventArgs where T : class? { /// Creates a new instance of event args describing a change of selection in /// /// /// - public SelectionChangedEventArgs (TreeView tree, T oldValue, T newValue) + public SelectionChangedEventArgs (TreeView? tree, T? oldValue, T? newValue) { Tree = tree; OldValue = oldValue; @@ -15,11 +16,11 @@ public SelectionChangedEventArgs (TreeView tree, T oldValue, T newValue) } /// The newly selected value in the (can be null) - public T NewValue { get; } + public T? NewValue { get; } /// The previously selected value (can be null) - public T OldValue { get; } + public T? OldValue { get; } /// The view in which the change occurred - public TreeView Tree { get; } + public TreeView? Tree { get; } } diff --git a/Terminal.Gui/Views/TreeView/TreeSelection.cs b/Terminal.Gui/Views/TreeView/TreeSelection.cs new file mode 100644 index 0000000000..2d918f0785 --- /dev/null +++ b/Terminal.Gui/Views/TreeView/TreeSelection.cs @@ -0,0 +1,46 @@ +#nullable enable +namespace Terminal.Gui; + +internal class TreeSelection where T : class +{ + /// Creates a new selection between two branches in the tree + /// + /// + /// + public TreeSelection (Branch? from, int toIndex, IReadOnlyCollection>? map) + { + Origin = from; + + if (Origin?.Model is null) + { + return; + } + + if (map is null) + { + return; + } + + _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)) + { + if (alsoInclude.Model is { }) + { + _included.Add (alsoInclude.Model); + } + } + } + + private readonly HashSet _included = new (); + public bool Contains (T? model) { return _included.Contains (model); } + + public Branch? Origin { get; } +} diff --git a/Terminal.Gui/Views/TreeView/TreeStyle.cs b/Terminal.Gui/Views/TreeView/TreeStyle.cs index 0b560f2a9e..1c7a19e947 100644 --- a/Terminal.Gui/Views/TreeView/TreeStyle.cs +++ b/Terminal.Gui/Views/TreeView/TreeStyle.cs @@ -7,7 +7,7 @@ public class TreeStyle /// Symbol to use for branch nodes that can be collapsed (are currently expanded). Defaults to '-'. Set to null to /// hide. /// - public Rune? CollapseableSymbol { get; set; } = Glyphs.Collapse; + public Rune? CollapsableSymbol { get; set; } = Glyphs.Collapse; /// Set to to highlight expand/collapse symbols in hot key color. public bool ColorExpandSymbol { get; set; } diff --git a/Terminal.Gui/Views/TreeView/TreeView.cs b/Terminal.Gui/Views/TreeView/TreeView.cs index 728a411bdb..cb7f32fc32 100644 --- a/Terminal.Gui/Views/TreeView/TreeView.cs +++ b/Terminal.Gui/Views/TreeView/TreeView.cs @@ -1,28 +1,12 @@ +#nullable enable // 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; -using static Terminal.Gui.SpinnerStyle; 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 drawn because its state has changed. - void SetNeedsDraw (); -} - /// /// Convenience implementation of generic for any tree were all nodes implement /// . See TreeView Deep Dive for more information. @@ -41,7 +25,6 @@ public TreeView () AspectGetter = o => o is null ? "Null" : o.Text ?? o?.ToString () ?? "Unnamed Node"; } - bool IDesignable.EnableForDesign () { var root1 = new TreeNode ("Root1"); @@ -56,6 +39,7 @@ bool IDesignable.EnableForDesign () AddObject (root2); ExpandAll (); + return true; } } @@ -73,25 +57,6 @@ public class TreeView : View, ITreeView where T : class /// 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 . @@ -324,125 +289,26 @@ public TreeView () /// 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; + public TreeView (ITreeBuilder? builder) : this () { TreeBuilder = builder; } /// - /// Mouse event to trigger . Defaults to double click ( - /// ). Set to null to disable this feature. + /// Interface for filtering which lines of the tree are displayed e.g. to provide text searching. Defaults to + /// (no filtering). /// - /// - 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.ReplaceKey (ObjectActivationKey, value); - objectActivationKey = value; - SetNeedsDraw (); - } - } - } - - /// 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); - SetNeedsDraw (); - } - } - - /// 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); - SetNeedsDraw (); - } - } + public ITreeViewFilter? Filter { get; set; } = null; - /// - /// 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; + /// Secondary selected regions of tree when is true. + private readonly Stack> _multiSelectedRegions = new (); - if (!ReferenceEquals (oldValue, value)) - { - OnSelectionChanged (new SelectionChangedEventArgs (this, oldValue, value)); - } - } - } + /// Cached result of + private IReadOnlyCollection>? _cachedLineMap; - /// Determines how sub-branches of the tree are dynamically built at runtime as the user expands root nodes. - /// - public ITreeBuilder TreeBuilder { get; set; } + private KeyCode _objectActivationKey = KeyCode.Enter; + private int _scrollOffsetHorizontal; + private int _scrollOffsetVertical; - /// - /// 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 (); + /// private variable for + private T? _selectedObject; /// Contains options for changing how the tree is rendered. public TreeStyle Style { get; set; } = new (); @@ -451,8 +317,8 @@ public T SelectedObject public void ClearObjects () { SelectedObject = default (T); - multiSelectedRegions.Clear (); - roots = new Dictionary> (); + _multiSelectedRegions.Clear (); + Roots = new (); InvalidateLineMap (); SetNeedsDraw (); } @@ -470,15 +336,17 @@ public void ClearObjects () return true; } - T o = SelectedObject; + T? o = SelectedObject; if (o is { }) { // TODO: Should this be cancelable? ObjectActivatedEventArgs e = new (this, o); OnObjectActivated (e); + return true; } + return false; } @@ -486,9 +354,9 @@ public void ClearObjects () /// public void AddObject (T o) { - if (!roots.ContainsKey (o)) + if (Roots is { } && !Roots.ContainsKey (o)) { - roots.Add (o, new Branch (this, null, o)); + Roots.Add (o, new (this, null, o)); InvalidateLineMap (); SetNeedsDraw (); } @@ -503,9 +371,9 @@ public void AddObjects (IEnumerable collection) foreach (T o in collection) { - if (!roots.ContainsKey (o)) + if (Roots is { } && !Roots.ContainsKey (o)) { - roots.Add (o, new Branch (this, null, o)); + Roots.Add (o, new (this, null, o)); objectsAdded = true; } } @@ -535,47 +403,50 @@ 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 (); + _multiSelectedRegions.Clear (); } if (SelectedObject is null) { - SelectedObject = roots.Keys.FirstOrDefault (); + SelectedObject = Roots?.Keys.FirstOrDefault (); } else { - IReadOnlyCollection> map = BuildLineMap (); + IReadOnlyCollection>? map = BuildLineMap (); - int idx = map.IndexOf (b => b.Model.Equals (SelectedObject)); + int idx = map.IndexOf (b => b.Model!.Equals (SelectedObject)); if (idx == -1) { // The current selection has disappeared! - SelectedObject = roots.Keys.FirstOrDefault (); + SelectedObject = Roots?.Keys.FirstOrDefault (); } else { - int newIdx = Math.Min (Math.Max (0, idx + offset), map.Count - 1); + if (map is { }) + { + int newIdx = Math.Min (Math.Max (0, idx + offset), map.Count - 1); - Branch newBranch = map.ElementAt (newIdx); + 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 + // If it is a multi selection + if (expandSelection && MultiSelect) { - // or start a new multi selection region - multiSelectedRegions.Push (new TreeSelection (map.ElementAt (idx), newIdx, map)); + if (_multiSelectedRegions.Any ()) + { + // expand the existing head selection + TreeSelection head = _multiSelectedRegions.Pop (); + _multiSelectedRegions.Push (new (head.Origin, newIdx, map)); + } + else + { + // or start a new multi selection region + _multiSelectedRegions.Push (new (map.ElementAt (idx), newIdx, map)); + } } - } - SelectedObject = newBranch.Model; + SelectedObject = newBranch.Model; + } EnsureVisible (SelectedObject); } @@ -587,14 +458,18 @@ public void AdjustSelection (int offset, bool expandSelection = false) /// Moves the selection to the last child in the currently selected level. public void AdjustSelectionToBranchEnd () { - T o = SelectedObject; + T? o = SelectedObject; if (o is null) { return; } - IReadOnlyCollection> map = BuildLineMap (); + IReadOnlyCollection>? map = BuildLineMap (); + if (map is null) + { + return; + } int currentIdx = map.IndexOf (b => Equals (b.Model, o)); @@ -629,14 +504,19 @@ public void AdjustSelectionToBranchEnd () /// Moves the selection to the first child in the currently selected level. public void AdjustSelectionToBranchStart () { - T o = SelectedObject; + T? o = SelectedObject; if (o is null) { return; } - IReadOnlyCollection> map = BuildLineMap (); + IReadOnlyCollection>? map = BuildLineMap (); + + if (map is null) + { + return; + } int currentIdx = map.IndexOf (b => Equals (b.Model, o)); @@ -682,35 +562,51 @@ public void AdjustSelectionToNextItemBeginningWith ( { // search for next branch that begins with that letter var characterAsStr = character.ToString (); - AdjustSelectionToNext (b => AspectGetter (b.Model).StartsWith (characterAsStr, caseSensitivity)); + AdjustSelectionToNext (b => AspectGetter is { } && AspectGetter (b.Model!).StartsWith (characterAsStr, caseSensitivity)); } + /// 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 () ?? ""; + /// /// 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; } + public bool CanExpand (T? o) { return ObjectToBranch (o)?.CanExpand () ?? false; } /// Collapses the - public void Collapse () { Collapse (selectedObject); } + 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); } + 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); } + public void CollapseAll (T? toCollapse) { CollapseImpl (toCollapse, true); } /// Collapses all root nodes in the tree. public void CollapseAll () { - foreach (KeyValuePair> item in roots) + if (Roots is null) + { + return; + } + + foreach (KeyValuePair> item in Roots) { item.Value.Collapse (); } @@ -719,19 +615,28 @@ public void CollapseAll () SetNeedsDraw (); } + /// + /// 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 (); + /// /// 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; + public event EventHandler>? DrawLine; /// /// Adjusts the to ensure the given is visible. Has no /// effect if already visible. /// - public void EnsureVisible (T model) + public void EnsureVisible (T? model) { - IReadOnlyCollection> map = BuildLineMap (); + IReadOnlyCollection>? map = BuildLineMap (); int idx = map.IndexOf (b => Equals (b.Model, model)); @@ -763,7 +668,7 @@ public void EnsureVisible (T model) /// object). /// /// The object to expand. - public void Expand (T toExpand) + public void Expand (T? toExpand) { if (toExpand is null) { @@ -777,7 +682,7 @@ public void Expand (T toExpand) /// Expands the supplied object and all child objects. /// The object to expand. - public void ExpandAll (T toExpand) + public void ExpandAll (T? toExpand) { if (toExpand is null) { @@ -795,7 +700,12 @@ public void ExpandAll (T toExpand) /// public void ExpandAll () { - foreach (KeyValuePair> item in roots) + if (Roots is null) + { + return; + } + + foreach (KeyValuePair> item in Roots) { item.Value.ExpandAll (); } @@ -809,16 +719,16 @@ public void ExpandAll () /// is true /// /// - public IEnumerable GetAllSelectedObjects () + public IEnumerable GetAllSelectedObjects () { - IReadOnlyCollection> map = BuildLineMap (); + 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) + if (MultiSelect && map is {}) { - foreach (T m in map.Select (b => b.Model).Where (IsSelected)) + foreach (T? m in map.Select (b => b.Model).Where (IsSelected)) { yield return m; } @@ -838,13 +748,13 @@ public IEnumerable GetAllSelectedObjects () /// /// An object in the tree. /// - public IEnumerable GetChildren (T o) + public T? [] GetChildren (T? o) { - Branch branch = ObjectToBranch (o); + Branch? branch = ObjectToBranch (o); if (branch is null || !branch.IsExpanded) { - return new T [0]; + return Array.Empty (); } return branch.ChildBranches?.Values?.Select (b => b.Model)?.ToArray () ?? new T [0]; @@ -858,9 +768,9 @@ public IEnumerable GetChildren (T o) /// public int GetContentWidth (bool visible) { - IReadOnlyCollection> map = BuildLineMap (); + IReadOnlyCollection>? map = BuildLineMap (); - if (map.Count == 0) + if (map is null || map.Count == 0) { return 0; } @@ -879,10 +789,10 @@ public int GetContentWidth (bool visible) return 0; } - return map.Skip (ScrollOffsetVertical).Take (Viewport.Height).Max (b => b.GetWidth (Driver)); + return map.Skip (ScrollOffsetVertical).Take (Viewport.Height).Max (b => b.GetWidth (Driver!)); } - return map.Max (b => b.GetWidth (Driver)); + return map.Max (b => b.GetWidth (Driver!)); } /// @@ -894,7 +804,7 @@ public int GetContentWidth (bool visible) /// /// 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 T GetObjectOnRow (int row) { return HitTest (row)?.Model!; } /// /// @@ -910,7 +820,7 @@ public int GetContentWidth (bool visible) /// public int? GetObjectRow (T toFind) { - int idx = BuildLineMap ().IndexOf (o => o.Model.Equals (toFind)); + int idx = BuildLineMap ().IndexOf (o => o.Model!.Equals (toFind)); if (idx == -1) { @@ -926,7 +836,7 @@ public int GetContentWidth (bool visible) /// /// An object in the tree. /// - public T GetParent (T o) { return ObjectToBranch (o)?.Parent?.Model; } + 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 @@ -936,13 +846,17 @@ public int GetContentWidth (bool visible) /// 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) + public int GetScrollOffsetOf (T? o) { - IReadOnlyCollection> map = BuildLineMap (); + IReadOnlyCollection>? map = BuildLineMap (); + if (map is null) + { + return -1; + } for (var i = 0; i < map.Count; i++) { - if (map.ElementAt (i).Model.Equals (o)) + if (map!.ElementAt (i).Model!.Equals (o)) { return i; } @@ -972,8 +886,8 @@ public void GoTo (T toSelect) /// 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); + IReadOnlyCollection>? map = BuildLineMap (); + ScrollOffsetVertical = Math.Max (0, map!.Count - Viewport.Height + 1); SelectedObject = map.LastOrDefault ()?.Model; SetNeedsDraw (); @@ -986,18 +900,18 @@ public void GoToEnd () public void GoToFirst () { ScrollOffsetVertical = 0; - SelectedObject = roots.Keys.FirstOrDefault (); + SelectedObject = Roots?.Keys.FirstOrDefault (); SetNeedsDraw (); } /// Clears any cached results of the tree state. - public void InvalidateLineMap () { cachedLineMap = null; } + 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; } + public bool IsExpanded (T? o) { return ObjectToBranch (o)?.IsExpanded ?? false; } /// /// Returns true if the is either the or part of a @@ -1005,249 +919,82 @@ public void GoToFirst () /// /// /// - public bool IsSelected (T model) { return Equals (SelectedObject, model) || (MultiSelect && multiSelectedRegions.Any (s => s.Contains (model))); } - - // BUGBUG: OnMouseEvent is internal. TreeView should not be overriding. - /// - protected override bool OnMouseEvent (MouseEventArgs me) - { - // If it is not an event we care about - if (me is { IsSingleClicked: false, IsPressed: false, IsReleased: false, IsWheel: false } - && !me.Flags.HasFlag (ObjectActivationButton ?? MouseFlags.Button1DoubleClicked)) - { - // do nothing - return false; - } + public bool IsSelected (T? model) { return Equals (SelectedObject, model) || (MultiSelect && _multiSelectedRegions.Any (s => s.Contains (model))); } - if (!HasFocus && CanFocus) - { - SetFocus (); - } + /// + /// Gets the that searches the collection as the user + /// types. + /// + public CollectionNavigator KeystrokeNavigator { get; } = new (); - if (me.Flags == MouseFlags.WheeledDown) - { - ScrollDown (); + /// Maximum number of nodes that can be expanded in any given branch. + public int MaxDepth { get; set; } = 100; - return true; - } + /// 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); } - if (me.Flags == MouseFlags.WheeledUp) - { - ScrollUp (); + /// 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); } - return true; - } + /// True to allow multiple objects to be selected at once. + /// + public bool MultiSelect { get; set; } = true; - if (me.Flags == MouseFlags.WheeledRight) - { - ScrollOffsetHorizontal++; - SetNeedsDraw (); + /// + /// This event is raised when an object is activated e.g. by double clicking or pressing + /// . + /// + public event EventHandler>? ObjectActivated; - return true; - } + /// + /// Mouse event to trigger . Defaults to double click ( + /// ). Set to null to disable this feature. + /// + /// + public MouseFlags? ObjectActivationButton { get; set; } = MouseFlags.Button1DoubleClicked; - if (me.Flags == MouseFlags.WheeledLeft) + // TODO: Update to use Key instead of KeyCode + /// Key which when pressed triggers . Defaults to Enter. + public KeyCode ObjectActivationKey + { + get => _objectActivationKey; + set { - ScrollOffsetHorizontal--; - SetNeedsDraw (); - - return true; + if (_objectActivationKey != value) + { + KeyBindings.ReplaceKey (ObjectActivationKey, value); + _objectActivationKey = value; + SetNeedsDraw (); + } } + } - if (me.Flags.HasFlag (MouseFlags.Button1Clicked)) + /// The root objects in the tree, note that this collection is of root objects only. + public IEnumerable? Objects => Roots?.Keys; + + /// Positions the cursor at the start of the selected objects line (if visible). + public override Point? PositionCursor () + { + if (CanFocus && HasFocus && Visible && SelectedObject is { }) { - // The line they clicked on a branch - Branch clickedBranch = HitTest (me.Position.Y); + IReadOnlyCollection>? map = BuildLineMap (); + int idx = map!.IndexOf (b => b.Model!.Equals (SelectedObject)); - if (clickedBranch is null) + // if currently selected line is visible + if (idx - ScrollOffsetVertical >= 0 && idx - ScrollOffsetVertical < Viewport.Height) { - return false; + Move (0, idx - ScrollOffsetVertical); + + return MultiSelect ? new (0, idx - ScrollOffsetVertical) : null; } + } - 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 (); - } - - SetNeedsDraw (); - - 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; - SetNeedsDraw (); - - // 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; - - /// - protected override bool OnDrawingContent () - { - if (roots is null) - { - return true; - } - - if (TreeBuilder is null) - { - Move (0, 0); - Driver?.AddStr (NoBuilderError); - - return true; - } - - 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); - SetAttribute (GetNormalColor ()); - Driver?.AddStr (new string (' ', Viewport.Width)); - } - } - - return true; - } - - /// - protected override void OnHasFocusChanged (bool newHasFocus, [CanBeNull] View currentFocused, [CanBeNull] View newFocused) - { - if (newHasFocus) - { - // If there is no selected object and there are objects in the tree, select the first one - if (SelectedObject is null && Objects.Any ()) - { - SelectedObject = Objects.First (); - } - } - } - - /// - protected override bool OnKeyDown (Key key) - { - if (!Enabled) - { - return false; - } - - // If not a keybinding, is the key a searchable key press? - if (CollectionNavigatorBase.IsCompatibleKey (key) && AllowLetterBasedNavigation) - { - // If there has been a call to InvalidateMap since the last time - // we need a new one to reflect the new exposed tree state - IReadOnlyCollection> map = BuildLineMap (); - - // Find the current selected object within the tree - int current = map.IndexOf (b => b.Model == SelectedObject); - int? newIndex = KeystrokeNavigator?.GetNextMatchingItem (current, (char)key); - - if (newIndex is int && newIndex != -1) - { - SelectedObject = map.ElementAt ((int)newIndex).Model; - EnsureVisible (selectedObject); - SetNeedsDraw (); - - 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 (); - } + return null; + } /// /// Rebuilds the tree structure for all exposed objects starting with the root objects. Call this method when you @@ -1256,9 +1003,12 @@ protected override bool OnKeyDown (Key key) /// public void RebuildTree () { - foreach (Branch branch in roots.Values) + if (Roots is { }) { - branch.Rebuild (); + foreach (Branch branch in Roots.Values) + { + branch.Rebuild (); + } } InvalidateLineMap (); @@ -1275,9 +1025,9 @@ public void RebuildTree () /// 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) + public void RefreshObject (T? o, bool startAtTop = false) { - Branch branch = ObjectToBranch (o); + Branch? branch = ObjectToBranch (o); if (branch is { }) { @@ -1291,11 +1041,15 @@ public void RefreshObject (T o, bool startAtTop = false) /// If is the currently then the selection is cleared /// . /// - public void Remove (T o) + public void Remove (T? o) { - if (roots.ContainsKey (o)) + if (o is null) + { + return; + } + if (Roots is { } && Roots.ContainsKey (o)) { - roots.Remove (o); + Roots.Remove (o); InvalidateLineMap (); SetNeedsDraw (); @@ -1316,10 +1070,40 @@ public void ScrollDown () } } + /// 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); + SetNeedsDraw (); + } + } + + /// 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); + SetNeedsDraw (); + } + } + /// Scrolls the view area up a single line without changing the current selection. public void ScrollUp () { - if (scrollOffsetVertical > 0) + if (_scrollOffsetVertical > 0) { ScrollOffsetVertical--; SetNeedsDraw (); @@ -1334,23 +1118,46 @@ public void SelectAll () return; } - multiSelectedRegions.Clear (); + _multiSelectedRegions.Clear (); - IReadOnlyCollection> map = BuildLineMap (); + IReadOnlyCollection>? map = BuildLineMap (); - if (map.Count == 0) + if (map is null || map.Count == 0) { return; } - multiSelectedRegions.Push (new TreeSelection (map.ElementAt (0), map.Count, map)); + _multiSelectedRegions.Push (new (map.ElementAt (0), map.Count, map)); SetNeedsDraw (); - OnSelectionChanged (new SelectionChangedEventArgs (this, SelectedObject, SelectedObject)); + OnSelectionChanged (new (this!, SelectedObject, SelectedObject)); + } + + /// + /// 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 (this!, oldValue, value)); + } + } } /// Called when the changes. - public event EventHandler> SelectionChanged; + public event EventHandler>? SelectionChanged; + + /// Determines how sub-branches of the tree are dynamically built at runtime as the user expands root nodes. + /// + public ITreeBuilder? TreeBuilder { get; set; } /// /// Implementation of and . Performs operation and updates @@ -1358,14 +1165,14 @@ public void SelectAll () /// /// /// - protected void CollapseImpl (T toCollapse, bool all) + protected void CollapseImpl (T? toCollapse, bool all) { if (toCollapse is null) { return; } - Branch branch = ObjectToBranch (toCollapse); + Branch? branch = ObjectToBranch (toCollapse); // Nothing to collapse if (branch is null) @@ -1411,7 +1218,7 @@ protected virtual void CursorLeft (bool ctrl) } else { - T parent = GetParent (SelectedObject); + T? parent = GetParent (SelectedObject); if (parent is { }) { @@ -1430,13 +1237,233 @@ protected override void Dispose (bool disposing) ColorGetter = null; } + /// + protected override bool OnDrawingContent () + { + if (Roots is null) + { + return true; + } + + if (TreeBuilder is null) + { + Move (0, 0); + Driver?.AddStr (NoBuilderError); + + return true; + } + + IReadOnlyCollection>? map = BuildLineMap (); + + if (map is null) + { + return true; + } + + 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); + SetAttribute (GetNormalColor ()); + Driver?.AddStr (new (' ', Viewport.Width)); + } + } + + return true; + } + + /// + protected override void OnHasFocusChanged (bool newHasFocus, View? currentFocused, View? newFocused) + { + if (newHasFocus) + { + // If there is no selected object and there are objects in the tree, select the first one + if (SelectedObject is null && Objects is { } && Objects.Any ()) + { + SelectedObject = Objects.First (); + } + } + } + + /// + protected override bool OnKeyDown (Key key) + { + if (!Enabled) + { + return false; + } + + if (!CollectionNavigatorBase.IsCompatibleKey (key) || !AllowLetterBasedNavigation) + { + return false; + } + + // If not a keybinding, is the key a searchable key press? + + // If there has been a call to InvalidateMap since the last time + // we need a new one to reflect the new exposed tree state + IReadOnlyCollection>? map = BuildLineMap (); + + if (map == null) + { + return false; + } + + // Find the current selected object within the tree + int current = map.IndexOf (b => b.Model == SelectedObject); + int? newIndex = KeystrokeNavigator?.GetNextMatchingItem (current, (char)key); + + if (newIndex is { } && newIndex != -1) + { + SelectedObject = map.ElementAt ((int)newIndex).Model; + EnsureVisible (_selectedObject); + SetNeedsDraw (); + + return true; + } + + return false; + } + + // BUGBUG: OnMouseEvent is internal. TreeView should not be overriding. + /// + protected override bool OnMouseEvent (MouseEventArgs me) + { + // If it is not an event we care about + if (me is { IsSingleClicked: false, IsPressed: false, IsReleased: false, IsWheel: false } + && !me.Flags.HasFlag (ObjectActivationButton ?? MouseFlags.Button1DoubleClicked)) + { + // do nothing + return false; + } + + 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++; + SetNeedsDraw (); + + return true; + } + + if (me.Flags == MouseFlags.WheeledLeft) + { + ScrollOffsetHorizontal--; + SetNeedsDraw (); + + 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 (); + } + + SetNeedsDraw (); + + 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; + SetNeedsDraw (); + + // trigger activation event + OnObjectActivated (new (this, clickedBranch.Model!)); + + // mouse event is handled. + return true; + } + + return false; + } + /// 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); } + 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 @@ -1447,50 +1474,65 @@ protected override void Dispose (bool disposing) /// the next etc. /// /// - internal IReadOnlyCollection> BuildLineMap () + internal IReadOnlyCollection>? BuildLineMap () { - if (cachedLineMap is { }) + if (_cachedLineMap is { }) { - return cachedLineMap; + return _cachedLineMap; } List> toReturn = new (); - foreach (Branch root in roots.Values) + if (Roots is { }) { - IEnumerable> toAdd = AddToLineMap (root, false, out bool isMatch); - - if (isMatch) + foreach (Branch root in Roots.Values) { - toReturn.AddRange (toAdd); + IEnumerable>? toAdd = AddToLineMap (root, false, out bool isMatch); + + if (isMatch && toAdd is { }) + { + toReturn.AddRange (toAdd); + } } } - cachedLineMap = new ReadOnlyCollection> (toReturn); + _cachedLineMap = new ReadOnlyCollection> (toReturn); // Update the collection used for search-typing - KeystrokeNavigator.Collection = cachedLineMap.Select (b => AspectGetter (b.Model)).ToArray (); + KeystrokeNavigator.Collection = _cachedLineMap.Select (b => AspectGetter! (b.Model!)).ToArray (); - return cachedLineMap; + 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) + /// + /// 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 (); + + private IEnumerable>? AddToLineMap (Branch? currentBranch, bool parentMatches, out bool match) { + if (currentBranch is null) + { + match = false; + return Array.Empty> (); + } + bool weMatch = IsFilterMatch (currentBranch); var anyChildMatches = false; List> toReturn = new (); List> children = new (); - if (currentBranch.IsExpanded) + if (currentBranch is { IsExpanded: true, ChildBranches: { } }) { foreach (Branch subBranch in currentBranch.ChildBranches.Values) { - foreach (Branch sub in AddToLineMap (subBranch, weMatch, out bool childMatch)) + foreach (Branch sub in AddToLineMap (subBranch, weMatch, out bool childMatch)!) { if (childMatch) { @@ -1520,10 +1562,10 @@ private IEnumerable> AddToLineMap (Branch currentBranch, bool paren /// private void AdjustSelectionToNext (Func, bool> predicate) { - IReadOnlyCollection> map = BuildLineMap (); + IReadOnlyCollection>? map = BuildLineMap (); // empty map means we can't select anything anyway - if (map.Count == 0) + if (map is null || map.Count == 0) { return; } @@ -1560,9 +1602,14 @@ private void AdjustSelectionToNext (Func, bool> predicate) /// 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) + private Branch? HitTest (int y) { - IReadOnlyCollection> map = BuildLineMap (); + IReadOnlyCollection>? map = BuildLineMap (); + + if (map is null) + { + return null; + } int idx = y + ScrollOffsetVertical; @@ -1576,7 +1623,7 @@ private Branch HitTest (int y) return map.ElementAt (idx); } - private bool IsFilterMatch (Branch branch) { return Filter?.IsMatch (branch.Model) ?? true; } + private bool IsFilterMatch (Branch? branch) { return Filter?.IsMatch (branch?.Model) ?? true; } /// /// Returns the corresponding in the tree for . This will not @@ -1584,35 +1631,5 @@ private Branch HitTest (int y) /// /// /// 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); } - -} + private Branch? ObjectToBranch (T? toFind) { return BuildLineMap ()!.FirstOrDefault (o => o.Model is { } && o.Model.Equals (toFind)); } +} \ No newline at end of file diff --git a/UICatalog/Scenarios/TreeViewFileSystem.cs b/UICatalog/Scenarios/TreeViewFileSystem.cs index f371a18f84..ccafcffa64 100644 --- a/UICatalog/Scenarios/TreeViewFileSystem.cs +++ b/UICatalog/Scenarios/TreeViewFileSystem.cs @@ -299,7 +299,7 @@ private void SetExpandableSymbols (Rune expand, Rune? collapse) _miNoSymbols.Checked = expand.Value == default (int); _treeViewFiles.Style.ExpandableSymbol = expand; - _treeViewFiles.Style.CollapseableSymbol = collapse; + _treeViewFiles.Style.CollapsableSymbol = collapse; _treeViewFiles.SetNeedsDraw (); }