From 53179509282e078723db3bd6da1fd77daddd66a4 Mon Sep 17 00:00:00 2001 From: Thomas Nind <31306100+tznind@users.noreply.github.com> Date: Wed, 10 May 2023 06:32:45 +0100 Subject: [PATCH] Fixes #2601 - Adds ability to filter TreeView branches/leaves (v2) (#2604) * Add ITreeViewFilter * Fix for v2 * Fix for new private variable name --- Terminal.Gui/Views/ITreeViewFilter.cs | 14 +++++ Terminal.Gui/Views/TreeView/TreeView.cs | 53 +++++++++++++++--- Terminal.Gui/Views/TreeViewTextFilter.cs | 65 ++++++++++++++++++++++ UICatalog/Scenarios/ClassExplorer.cs | 21 +++++++- UnitTests/Views/TreeViewTests.cs | 69 ++++++++++++++++++++++++ 5 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 Terminal.Gui/Views/ITreeViewFilter.cs create mode 100644 Terminal.Gui/Views/TreeViewTextFilter.cs diff --git a/Terminal.Gui/Views/ITreeViewFilter.cs b/Terminal.Gui/Views/ITreeViewFilter.cs new file mode 100644 index 000000000..6f9aa5afc --- /dev/null +++ b/Terminal.Gui/Views/ITreeViewFilter.cs @@ -0,0 +1,14 @@ +namespace Terminal.Gui { + + /// + /// Provides filtering for a . + /// + public interface ITreeViewFilter where T : class { + + /// + /// Return if the should + /// be included in the tree. + /// + bool IsMatch (T model); + } +} \ No newline at end of file diff --git a/Terminal.Gui/Views/TreeView/TreeView.cs b/Terminal.Gui/Views/TreeView/TreeView.cs index 5786315f9..3d5cbe889 100644 --- a/Terminal.Gui/Views/TreeView/TreeView.cs +++ b/Terminal.Gui/Views/TreeView/TreeView.cs @@ -214,6 +214,13 @@ namespace Terminal.Gui { CursorVisibility desiredCursorVisibility = CursorVisibility.Invisible; + /// + /// Interface for filtering which lines of the tree are displayed + /// e.g. to provide text searching. Defaults to + /// (no filtering). + /// + public ITreeViewFilter Filter = null; + /// /// Get / Set the wished cursor when the tree is focused. /// Only applies when is true. @@ -545,7 +552,12 @@ namespace Terminal.Gui { List> toReturn = new List> (); foreach (var root in roots.Values) { - toReturn.AddRange (AddToLineMap (root)); + + var toAdd = AddToLineMap (root, false, out var isMatch); + if(isMatch) + { + toReturn.AddRange (toAdd); + } } cachedLineMap = new ReadOnlyCollection> (toReturn); @@ -555,17 +567,44 @@ namespace Terminal.Gui { return cachedLineMap; } - private IEnumerable> AddToLineMap (Branch currentBranch) + private bool IsFilterMatch (Branch branch) { - yield return currentBranch; + return Filter?.IsMatch(branch.Model) ?? true; + } + + private IEnumerable> AddToLineMap (Branch currentBranch,bool parentMatches, out bool match) + { + bool weMatch = IsFilterMatch(currentBranch); + bool anyChildMatches = false; + + var toReturn = new List>(); + var children = new List>(); if (currentBranch.IsExpanded) { foreach (var subBranch in currentBranch.ChildBranches.Values) { - foreach (var sub in AddToLineMap (subBranch)) { - yield return sub; + + foreach (var sub in AddToLineMap (subBranch, weMatch, out var childMatch)) { + + if(childMatch) + { + children.Add(sub); + anyChildMatches = true; + } } } } + + if(parentMatches || weMatch || anyChildMatches) + { + match = true; + toReturn.Add(currentBranch); + } + else{ + match = false; + } + + toReturn.AddRange(children); + return toReturn; } /// @@ -1290,9 +1329,9 @@ namespace Terminal.Gui { } /// - /// Clears any cached results of + /// Clears any cached results of the tree state. /// - protected void InvalidateLineMap () + public void InvalidateLineMap () { cachedLineMap = null; } diff --git a/Terminal.Gui/Views/TreeViewTextFilter.cs b/Terminal.Gui/Views/TreeViewTextFilter.cs new file mode 100644 index 000000000..b861f08ae --- /dev/null +++ b/Terminal.Gui/Views/TreeViewTextFilter.cs @@ -0,0 +1,65 @@ +using System; + +namespace Terminal.Gui { + + /// + /// implementation which searches the + /// of the model for the given + /// . + /// + /// + public class TreeViewTextFilter : ITreeViewFilter where T : class { + readonly TreeView _forTree; + + /// + /// Creates a new instance of the filter for use with . + /// Set to begin filtering. + /// + /// + /// + public TreeViewTextFilter (TreeView forTree) + { + _forTree = forTree ?? throw new ArgumentNullException (nameof (forTree)); + } + + /// + /// The case sensitivity of the search match. + /// Defaults to . + /// + public StringComparison Comparer { get; set; } = StringComparison.OrdinalIgnoreCase; + private string text; + + /// + /// The text that will be searched for in the + /// + public string Text { + get { return text; } + set { + text = value; + RefreshTreeView (); + } + } + + private void RefreshTreeView () + { + _forTree.InvalidateLineMap (); + _forTree.SetNeedsDisplay (); + } + + /// + /// Returns if there is no or + /// the text matches the of the + /// . + /// + /// + /// + public bool IsMatch (T model) + { + if (string.IsNullOrWhiteSpace (Text)) { + return true; + } + + return _forTree.AspectGetter (model)?.IndexOf (Text, Comparer) != -1; + } + } +} \ No newline at end of file diff --git a/UICatalog/Scenarios/ClassExplorer.cs b/UICatalog/Scenarios/ClassExplorer.cs index d4d692f5b..79992b307 100644 --- a/UICatalog/Scenarios/ClassExplorer.cs +++ b/UICatalog/Scenarios/ClassExplorer.cs @@ -83,11 +83,30 @@ namespace UICatalog.Scenarios { _treeView = new TreeView () { X = 0, - Y = 0, + Y = 1, Width = Dim.Percent (50), Height = Dim.Fill (), }; + var lblSearch = new Label("Search"); + var tfSearch = new TextField(){ + Width = 20, + X = Pos.Right(lblSearch), + }; + + Win.Add(lblSearch); + Win.Add(tfSearch); + + var filter = new TreeViewTextFilter(_treeView); + _treeView.Filter = filter; + tfSearch.TextChanged += (s,e)=>{ + filter.Text = tfSearch.Text.ToString(); + if(_treeView.SelectedObject != null) + { + _treeView.EnsureVisible(_treeView.SelectedObject); + } + }; + _treeView.AddObjects (AppDomain.CurrentDomain.GetAssemblies ()); _treeView.AspectGetter = GetRepresentation; _treeView.TreeBuilder = new DelegateTreeBuilder (ChildGetter, CanExpand); diff --git a/UnitTests/Views/TreeViewTests.cs b/UnitTests/Views/TreeViewTests.cs index f08409a08..585b41b64 100644 --- a/UnitTests/Views/TreeViewTests.cs +++ b/UnitTests/Views/TreeViewTests.cs @@ -902,6 +902,75 @@ namespace Terminal.Gui.ViewsTests { new [] { tv.ColorScheme.Normal, pink }); } + [Fact, AutoInitShutdown] + public void TestTreeView_Filter () + { + var tv = new TreeView { Width = 20, Height = 10 }; + + var n1 = new TreeNode ("root one"); + var n1_1 = new TreeNode ("leaf 1"); + var n1_2 = new TreeNode ("leaf 2"); + n1.Children.Add (n1_1); + n1.Children.Add (n1_2); + + var n2 = new TreeNode ("root two"); + tv.AddObject (n1); + tv.AddObject (n2); + tv.Expand (n1); + + tv.ColorScheme = new ColorScheme (); + tv.LayoutSubviews (); + tv.Draw (); + + // Normal drawing of the tree view + TestHelpers.AssertDriverContentsAre ( +@" +├-root one +│ ├─leaf 1 +│ └─leaf 2 +└─root two +", output); + var filter = new TreeViewTextFilter (tv); + tv.Filter = filter; + + // matches nothing + filter.Text = "asdfjhasdf"; + tv.Draw (); + // Normal drawing of the tree view + TestHelpers.AssertDriverContentsAre ( +@"", output); + + + // Matches everything + filter.Text = "root"; + tv.Draw (); + TestHelpers.AssertDriverContentsAre ( +@" +├-root one +│ ├─leaf 1 +│ └─leaf 2 +└─root two +", output); + // Matches 2 leaf nodes + filter.Text = "leaf"; + tv.Draw (); + TestHelpers.AssertDriverContentsAre ( +@" +├-root one +│ ├─leaf 1 +│ └─leaf 2 +", output); + + // Matches 1 leaf nodes + filter.Text = "leaf 1"; + tv.Draw (); + TestHelpers.AssertDriverContentsAre ( +@" +├-root one +│ ├─leaf 1 +", output); + } + [Fact, AutoInitShutdown] public void DesiredCursorVisibility_MultiSelect () {