Kaynağa Gözat

Fixes #4027. Add collection search matcher (#4029)

* Add collection search matcher

* Fix naming

* fix naming

* Move FileDialogCollectionNavigator to its own file (no longer private class)
Add class diagram for collectionNavigation

* Add ICollectionNavigator interface

* Move to separate file IListCollectionNavigator

* Update class diagram

* update class diagram

* Add tests for overriding ICollectionNavigatorMatcher

* xmldoc and nullability warning fixes

* Code Cleanup

* Make requested changes to naming and terminology

* Move to seperate namespace

* Update class diagram and change TreeView to reference the interface not concrete class

* Switch to implicit new

* highlight that this class also works with tree view

* Apply tig patch to ensure keybindings get priority over navigator

See: https://github.com/gui-cs/Terminal.Gui/issues/4027#issuecomment-2810020893

* Apply 'keybinding has priority' fix to TreeView too

* Apply 'keybindngs priority over navigation' fix to TableView

* Remove entire branch for selectively returning false now that it is default when there is a keybinding collision

* Make classes internal and remove 'custom' navigator that was configured in UICatlaogToplevel

* Change logging in collection navigator from Trace to Debug

* Switch to NewKeyDownEvent and directly setting HasFocus

* Remove application top dependency

* Remove references to application

* Remove Application

* Move new tests to parallel

---------

Co-authored-by: Tig <[email protected]>
Thomas Nind 4 ay önce
ebeveyn
işleme
3f38d8104e

+ 1 - 1
Examples/UICatalog/Scenarios/CollectionNavigatorTester.cs

@@ -58,7 +58,7 @@ public class CollectionNavigatorTester : Scenario
         "$200.00",
         "$210.99",
         "$$",
-        "appricot",
+        "apricot",
         "arm",
         "丗丙业丞",
         "丗丙丛",

+ 0 - 15
Examples/UICatalog/UICatalogTop.cs

@@ -380,11 +380,6 @@ public class UICatalogTop : Toplevel
 
     public static ObservableCollection<Scenario>? CachedScenarios { get; set; }
 
-    // UI Catalog uses TableView for the scenario list instead of a ListView to demonstrate how
-    // TableView works. There's no real reason not to use ListView. Because we use TableView, and TableView
-    // doesn't (currently) have CollectionNavigator support built in, we implement it here, within the app.
-    private readonly CollectionNavigator _scenarioCollectionNav = new ();
-
     // If set, holds the scenario the user selected to run
     public static Scenario? CachedSelectedScenario { get; set; }
 
@@ -561,16 +556,6 @@ public class UICatalogTop : Toplevel
                                                                    }
                                                                   );
 
-        // Create a collection of just the scenario names (the 1st column in our TableView)
-        // for CollectionNavigator. 
-        List<object> firstColumnList = [];
-
-        for (var i = 0; i < _scenarioList.Table.Rows; i++)
-        {
-            firstColumnList.Add (_scenarioList.Table [i, 0]);
-        }
-
-        _scenarioCollectionNav.Collection = firstColumnList;
     }
 
     #endregion Category List

+ 124 - 0
Terminal.Gui/Views/CollectionNavigation/CollectionNavigation.cd

@@ -0,0 +1,124 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ClassDiagram MajorVersion="1" MinorVersion="1">
+  <Comment CommentText="Views that use the CollectionNavigation system">
+    <Position X="0.5" Y="0.5" Height="0.458" Width="1.856" />
+  </Comment>
+  <Comment CommentText="Specialized navigators for each collection type (e.g. list, tree etc)">
+    <Position X="4.646" Y="0.5" Height="0.5" Width="3.169" />
+  </Comment>
+  <Comment CommentText="Shared matching component (users should provide alternative implementations of this class if they want to modify collection navigation behaviour)">
+    <Position X="9.448" Y="0.5" Height="0.708" Width="3.169" />
+  </Comment>
+  <Class Name="Terminal.Gui.CollectionNavigatorBase" Collapsed="true">
+    <Position X="6.25" Y="1.5" Width="2" />
+    <TypeIdentifier>
+      <HashCode>AAgEAAAAAAAQAAAIAAEAAgAAAAAABAAEAAAAACwAAAA=</HashCode>
+      <FileName>Views\CollectionNavigation\CollectionNavigatorBase.cs</FileName>
+    </TypeIdentifier>
+    <ShowAsAssociation>
+      <Property Name="Matcher" />
+    </ShowAsAssociation>
+    <Lollipop Position="0.2" />
+  </Class>
+  <Class Name="Terminal.Gui.CollectionNavigator" Collapsed="true">
+    <Position X="4.5" Y="3.5" Width="2" />
+    <TypeIdentifier>
+      <HashCode>AAAAAAAAAAAAQAAAAAAAAgAAAAAAAAAEAAAAAAAAAAA=</HashCode>
+      <FileName>Views\CollectionNavigation\CollectionNavigator.cs</FileName>
+    </TypeIdentifier>
+    <Lollipop Position="0.2" />
+  </Class>
+  <Class Name="Terminal.Gui.DefaultCollectionNavigatorMatcher">
+    <Position X="9.5" Y="2.5" Width="2.75" />
+    <TypeIdentifier>
+      <HashCode>AAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAQA=</HashCode>
+      <FileName>Views\CollectionNavigation\DefaultCollectionNavigatorMatcher.cs</FileName>
+    </TypeIdentifier>
+    <Lollipop Position="0.2" />
+  </Class>
+  <Class Name="Terminal.Gui.TableCollectionNavigator" Collapsed="true">
+    <Position X="4.75" Y="6.5" Width="2.25" />
+    <TypeIdentifier>
+      <HashCode>AAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAEAAAAIAAAAAA=</HashCode>
+      <FileName>Views\CollectionNavigation\TableCollectionNavigator.cs</FileName>
+    </TypeIdentifier>
+  </Class>
+  <Class Name="Terminal.Gui.ListView" Collapsed="true">
+    <Position X="0.5" Y="4.25" Width="1.5" />
+    <TypeIdentifier>
+      <HashCode>AAE+ASAkEnAAABAAKGAggYAZJAIAABEAcBAaAwAQIAA=</HashCode>
+      <FileName>Views\ListView.cs</FileName>
+    </TypeIdentifier>
+    <ShowAsAssociation>
+      <Property Name="KeystrokeNavigator" />
+    </ShowAsAssociation>
+    <Lollipop Position="0.2" />
+  </Class>
+  <Class Name="Terminal.Gui.FileDialog" Collapsed="true">
+    <Position X="0.5" Y="5.5" Width="1.75" />
+    <Compartments>
+      <Compartment Name="Nested Types" Collapsed="false" />
+    </Compartments>
+    <TypeIdentifier>
+      <HashCode>iIY4LQFUHDKVIHIESBgigQcFT6GxhBDABGJItBQAwAQ=</HashCode>
+      <FileName>Views\FileDialog.cs</FileName>
+    </TypeIdentifier>
+    <Lollipop Position="0.2" />
+  </Class>
+  <Class Name="Terminal.Gui.FileDialogCollectionNavigator" Collapsed="true">
+    <Position X="4.75" Y="5.5" Width="2.25" />
+    <TypeIdentifier>
+      <HashCode>AAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAEAAAAAAAAAAA=</HashCode>
+      <FileName>Views\FileDialogCollectionNavigator.cs</FileName>
+    </TypeIdentifier>
+  </Class>
+  <Class Name="Terminal.Gui.TableView" Collapsed="true" BaseTypeListCollapsed="true">
+    <Position X="0.5" Y="6.5" Width="1.5" />
+    <TypeIdentifier>
+      <HashCode>QwUeAxwgICIAcABIABeR0oBAkhoFGGOBDABgAN3oPEI=</HashCode>
+      <FileName>Views\TableView\TableView.cs</FileName>
+    </TypeIdentifier>
+    <Lollipop Position="0.2" />
+  </Class>
+  <Class Name="Terminal.Gui.TreeView" Collapsed="true">
+    <Position X="0.5" Y="3" Width="1.5" />
+    <TypeIdentifier>
+      <HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAA=</HashCode>
+      <FileName>Views\TreeView\TreeView.cs</FileName>
+    </TypeIdentifier>
+    <Lollipop Position="0.2" />
+  </Class>
+  <Class Name="Terminal.Gui.TreeView&lt;T&gt;" Collapsed="true">
+    <Position X="0.5" Y="2" Width="1.5" />
+    <TypeIdentifier>
+      <HashCode>UwAGySBgBSBGMAQgIiCaBDUItJIBSAWwRMQOSgQCwJI=</HashCode>
+      <FileName>Views\TreeView\TreeView.cs</FileName>
+    </TypeIdentifier>
+    <ShowAsAssociation>
+      <Property Name="KeystrokeNavigator" />
+    </ShowAsAssociation>
+    <Lollipop Position="0.2" />
+  </Class>
+  <Interface Name="Terminal.Gui.ICollectionNavigatorMatcher" Collapsed="true">
+    <Position X="9.5" Y="1.5" Width="2.75" />
+    <TypeIdentifier>
+      <HashCode>AAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAA=</HashCode>
+      <FileName>Views\CollectionNavigation\ICollectionNavigatorMatcher.cs</FileName>
+    </TypeIdentifier>
+  </Interface>
+  <Interface Name="Terminal.Gui.IListCollectionNavigator" Collapsed="true">
+    <Position X="3.75" Y="2.25" Width="2" />
+    <TypeIdentifier>
+      <HashCode>AAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</HashCode>
+      <FileName>Views\CollectionNavigation\IListCollectionNavigator.cs</FileName>
+    </TypeIdentifier>
+  </Interface>
+  <Interface Name="Terminal.Gui.ICollectionNavigator" Collapsed="true">
+    <Position X="3.75" Y="1.5" Width="2" />
+    <TypeIdentifier>
+      <HashCode>AAgAAAAAAAAAAAAIAAAAAAAAAAAABAAAAAAAACgAAAA=</HashCode>
+      <FileName>Views\CollectionNavigation\ICollectionNavigator.cs</FileName>
+    </TypeIdentifier>
+  </Interface>
+  <Font Name="Segoe UI" Size="9" />
+</ClassDiagram>

+ 4 - 4
Terminal.Gui/Text/CollectionNavigator.cs → Terminal.Gui/Views/CollectionNavigation/CollectionNavigator.cs

@@ -2,9 +2,9 @@
 
 namespace Terminal.Gui;
 
-/// <inheritdoc/>
+/// <inheritdoc cref="CollectionNavigatorBase"/>
 /// <remarks>This implementation is based on a static <see cref="Collection"/> of objects.</remarks>
-public class CollectionNavigator : CollectionNavigatorBase
+internal class CollectionNavigator : CollectionNavigatorBase, IListCollectionNavigator
 {
     /// <summary>Constructs a new CollectionNavigator.</summary>
     public CollectionNavigator () { }
@@ -13,7 +13,7 @@ public class CollectionNavigator : CollectionNavigatorBase
     /// <param name="collection"></param>
     public CollectionNavigator (IList collection) { Collection = collection; }
 
-    /// <summary>The collection of objects to search. <see cref="object.ToString()"/> is used to search the collection.</summary>
+    /// <inheritdoc/>
     public IList Collection { get; set; }
 
     /// <inheritdoc/>
@@ -21,4 +21,4 @@ public class CollectionNavigator : CollectionNavigatorBase
 
     /// <inheritdoc/>
     protected override int GetCollectionLength () { return Collection.Count; }
-}
+}

+ 28 - 66
Terminal.Gui/Text/CollectionNavigatorBase.cs → Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs

@@ -1,55 +1,31 @@
-using Microsoft.Extensions.Logging;
+#nullable enable
 
 namespace Terminal.Gui;
 
-/// <summary>
-///     Navigates a collection of items using keystrokes. The keystrokes are used to build a search string. The
-///     <see cref="SearchString"/> is used to find the next item in the collection that matches the search string when
-///     <see cref="GetNextMatchingItem(int, char)"/> is called.
-///     <para>
-///         If the user types keystrokes that can't be found in the collection, the search string is cleared and the next
-///         item is found that starts with the last keystroke.
-///     </para>
-///     <para>If the user pauses keystrokes for a short time (see <see cref="TypingDelay"/>), the search string is cleared.</para>
-/// </summary>
-public abstract class CollectionNavigatorBase
+/// <inheritdoc/>
+internal abstract class CollectionNavigatorBase : ICollectionNavigator
 {
     private DateTime _lastKeystroke = DateTime.Now;
     private string _searchString = "";
 
-    /// <summary>The comparer function to use when searching the collection.</summary>
-    public StringComparer Comparer { get; set; } = StringComparer.InvariantCultureIgnoreCase;
+    /// <inheritdoc/>
+    public ICollectionNavigatorMatcher Matcher { get; set; } = new DefaultCollectionNavigatorMatcher ();
 
-    /// <summary>
-    ///     Gets the current search string. This includes the set of keystrokes that have been pressed since the last
-    ///     unsuccessful match or after <see cref="TypingDelay"/>) milliseconds. Useful for debugging.
-    /// </summary>
+    /// <inheritdoc/>
     public string SearchString
     {
         get => _searchString;
         private set
         {
             _searchString = value;
-            OnSearchStringChanged (new KeystrokeNavigatorEventArgs (value));
+            OnSearchStringChanged (new (value));
         }
     }
 
-    /// <summary>
-    ///     Gets or sets the number of milliseconds to delay before clearing the search string. The delay is reset on each
-    ///     call to <see cref="GetNextMatchingItem(int, char)"/>. The default is 500ms.
-    /// </summary>
+    /// <inheritdoc/>
     public int TypingDelay { get; set; } = 500;
 
-    /// <summary>
-    ///     Gets the index of the next item in the collection that matches the current <see cref="SearchString"/> plus the
-    ///     provided character (typically from a key press).
-    /// </summary>
-    /// <param name="currentIndex">The index in the collection to start the search from.</param>
-    /// <param name="keyStruck">The character of the key the user pressed.</param>
-    /// <returns>
-    ///     The index of the item that matches what the user has typed. Returns <see langword="-1"/> if no item in the
-    ///     collection matched.
-    /// </returns>
+    /// <inheritdoc/>
     public int GetNextMatchingItem (int currentIndex, char keyStruck)
     {
         if (!char.IsControl (keyStruck))
@@ -61,21 +37,21 @@ public abstract class CollectionNavigatorBase
             var candidateState = "";
             var elapsedTime = DateTime.Now - _lastKeystroke;
 
-            Logging.Trace($"CollectionNavigator began processing '{keyStruck}', it has been {elapsedTime} since last keystroke");
+            Logging.Debug ($"CollectionNavigator began processing '{keyStruck}', it has been {elapsedTime} since last keystroke");
 
             // is it a second or third (etc) keystroke within a short time
             if (SearchString.Length > 0 && elapsedTime < TimeSpan.FromMilliseconds (TypingDelay))
             {
                 // "dd" is a candidate
                 candidateState = SearchString + keyStruck;
-                Logging.Trace($"Appending, search is now for '{candidateState}'");
+                Logging.Debug ($"Appending, search is now for '{candidateState}'");
             }
             else
             {
                 // its a fresh keystroke after some time
                 // or its first ever key press
                 SearchString = new string (keyStruck, 1);
-                Logging.Trace($"It has been too long since last key press so beginning new search");
+                Logging.Debug ($"It has been too long since last key press so beginning new search");
             }
 
             int idxCandidate = GetNextMatchingItem (
@@ -86,14 +62,14 @@ public abstract class CollectionNavigatorBase
                                                     candidateState.Length > 1
                                                    );
 
-            Logging.Trace($"CollectionNavigator searching (preferring minimum movement) matched:{idxCandidate}");
+            Logging.Debug ($"CollectionNavigator searching (preferring minimum movement) matched:{idxCandidate}");
             if (idxCandidate != -1)
             {
                 // found "dd" so candidate search string is accepted
                 _lastKeystroke = DateTime.Now;
                 SearchString = candidateState;
 
-                Logging.Trace($"Found collection item that matched search:{idxCandidate}");
+                Logging.Debug ($"Found collection item that matched search:{idxCandidate}");
                 return idxCandidate;
             }
 
@@ -102,13 +78,13 @@ public abstract class CollectionNavigatorBase
             _lastKeystroke = DateTime.Now;
             idxCandidate = GetNextMatchingItem (currentIndex, candidateState);
 
-            Logging.Trace($"CollectionNavigator searching (any match) matched:{idxCandidate}");
+            Logging.Debug ($"CollectionNavigator searching (any match) matched:{idxCandidate}");
 
             // if a match wasn't found, the user typed a 'wrong' key in their search ("can" + 'z'
             // instead of "can" + 'd').
             if (SearchString.Length > 1 && idxCandidate == -1)
             {
-                Logging.Trace("CollectionNavigator ignored key and returned existing index");
+                Logging.Debug ("CollectionNavigator ignored key and returned existing index");
                 // ignore it since we're still within the typing delay
                 // don't add it to SearchString either
                 return currentIndex;
@@ -117,7 +93,7 @@ public abstract class CollectionNavigatorBase
             // if no changes to current state manifested
             if (idxCandidate == currentIndex || idxCandidate == -1)
             {
-                Logging.Trace("CollectionNavigator found no changes to current index, so clearing search");
+                Logging.Debug ("CollectionNavigator found no changes to current index, so clearing search");
 
                 // clear history and treat as a fresh letter
                 ClearSearchString ();
@@ -126,17 +102,17 @@ public abstract class CollectionNavigatorBase
                 SearchString = new string (keyStruck, 1);
                 idxCandidate = GetNextMatchingItem (currentIndex, SearchString);
 
-                Logging.Trace($"CollectionNavigator new SearchString {SearchString} matched index:{idxCandidate}" );
+                Logging.Debug ($"CollectionNavigator new SearchString {SearchString} matched index:{idxCandidate}");
 
                 return idxCandidate == -1 ? currentIndex : idxCandidate;
             }
 
-            Logging.Trace($"CollectionNavigator final answer was:{idxCandidate}" );
+            Logging.Debug ($"CollectionNavigator final answer was:{idxCandidate}");
             // Found another "d" or just leave index as it was
             return idxCandidate;
         }
 
-        Logging.Trace("CollectionNavigator found key press was not actionable so clearing search and returning -1");
+        Logging.Debug ("CollectionNavigator found key press was not actionable so clearing search and returning -1");
 
         // clear state because keypress was a control char
         ClearSearchString ();
@@ -145,29 +121,17 @@ public abstract class CollectionNavigatorBase
         return -1;
     }
 
-    /// <summary>
-    ///     Returns true if <paramref name="a"/> is a searchable key (e.g. letters, numbers, etc) that are valid to pass
-    ///     to this class for search filtering.
-    /// </summary>
-    /// <param name="a"></param>
-    /// <returns></returns>
-    public static bool IsCompatibleKey (Key a)
-    {
-        Rune rune = a.AsRune;
 
-        return rune != default (Rune) && !Rune.IsControl (rune);
-    }
 
     /// <summary>
-    ///     Invoked when the <see cref="SearchString"/> changes. Useful for debugging. Invokes the
+    ///     Raised when the <see cref="SearchString"/> is changed. Useful for debugging. Raises the
     ///     <see cref="SearchStringChanged"/> event.
     /// </summary>
     /// <param name="e"></param>
-    public virtual void OnSearchStringChanged (KeystrokeNavigatorEventArgs e) { SearchStringChanged?.Invoke (this, e); }
+    protected virtual void OnSearchStringChanged (KeystrokeNavigatorEventArgs e) { SearchStringChanged?.Invoke (this, e); }
 
-    /// <summary>This event is invoked when <see cref="SearchString"/>  changes. Useful for debugging.</summary>
-    [CanBeNull]
-    public event EventHandler<KeystrokeNavigatorEventArgs> SearchStringChanged;
+    /// <summary>This event is raised when <see cref="SearchString"/> is changed. Useful for debugging.</summary>
+    public event EventHandler<KeystrokeNavigatorEventArgs>? SearchStringChanged;
 
     /// <summary>Returns the collection being navigated element at <paramref name="idx"/>.</summary>
     /// <returns></returns>
@@ -195,7 +159,7 @@ public abstract class CollectionNavigatorBase
 
         int collectionLength = GetCollectionLength ();
 
-        if (currentIndex != -1 && currentIndex < collectionLength && IsMatch (search, ElementAt (currentIndex)))
+        if (currentIndex != -1 && currentIndex < collectionLength && Matcher.IsMatch (search, ElementAt (currentIndex)))
         {
             // we are already at a match
             if (minimizeMovement)
@@ -209,7 +173,7 @@ public abstract class CollectionNavigatorBase
                 //circular
                 int idxCandidate = (i + currentIndex) % collectionLength;
 
-                if (IsMatch (search, ElementAt (idxCandidate)))
+                if (Matcher.IsMatch (search, ElementAt (idxCandidate)))
                 {
                     return idxCandidate;
                 }
@@ -222,7 +186,7 @@ public abstract class CollectionNavigatorBase
         // search terms no longer match the current selection or there is none
         for (var i = 0; i < collectionLength; i++)
         {
-            if (IsMatch (search, ElementAt (i)))
+            if (Matcher.IsMatch (search, ElementAt (i)))
             {
                 return i;
             }
@@ -237,6 +201,4 @@ public abstract class CollectionNavigatorBase
         SearchString = "";
         _lastKeystroke = DateTime.Now;
     }
-
-    private bool IsMatch (string search, object value) { return value?.ToString ().StartsWith (search, StringComparison.InvariantCultureIgnoreCase) ?? false; }
-}
+}

+ 30 - 0
Terminal.Gui/Views/CollectionNavigation/DefaultCollectionNavigatorMatcher.cs

@@ -0,0 +1,30 @@
+#nullable enable
+
+namespace Terminal.Gui;
+
+/// <summary>
+///     Default implementation of <see cref="ICollectionNavigatorMatcher"/>, performs
+///     case-insensitive (see <see cref="Comparer"/>) matching of items based on
+///     <see cref="object.ToString()"/>.
+/// </summary>
+internal class DefaultCollectionNavigatorMatcher : ICollectionNavigatorMatcher
+{
+    /// <summary>The comparer function to use when searching the collection.</summary>
+    public StringComparison Comparer { get; set; } = StringComparison.InvariantCultureIgnoreCase;
+
+    /// <inheritdoc/>
+    public bool IsMatch (string search, object? value) { return value?.ToString ()?.StartsWith (search, Comparer) ?? false; }
+
+    /// <summary>
+    ///     Returns true if <paramref name="key"/> is key searchable key (e.g. letters, numbers, etc) that are valid to pass
+    ///     to this class for search filtering.
+    /// </summary>
+    /// <param name="key"></param>
+    /// <returns></returns>
+    public bool IsCompatibleKey (Key key)
+    {
+        Rune rune = key.AsRune;
+
+        return rune != default && !Rune.IsControl (rune);
+    }
+}

+ 49 - 0
Terminal.Gui/Views/CollectionNavigation/ICollectionNavigator.cs

@@ -0,0 +1,49 @@
+#nullable enable
+
+namespace Terminal.Gui;
+
+/// <summary>
+///     Navigates a collection of items using keystrokes. The keystrokes are used to build a search string. The
+///     <see cref="SearchString"/> is used to find the next item in the collection that matches the search string when
+///     <see cref="GetNextMatchingItem(int, char)"/> is called.
+///     <para>
+///         If the user types keystrokes that can't be found in the collection, the search string is cleared and the next
+///         item is found that starts with the last keystroke.
+///     </para>
+///     <para>If the user pauses keystrokes for a short time (see <see cref="TypingDelay"/>), the search string is cleared.</para>
+/// </summary>
+public interface ICollectionNavigator
+{
+    /// <summary>
+    ///     Gets or sets the number of milliseconds to delay before clearing the search string. The delay is reset on each
+    ///     call to <see cref="GetNextMatchingItem(int, char)"/>. The default is 500ms.
+    /// </summary>
+    public int TypingDelay { get; set; }
+
+    /// <summary>This event is invoked when <see cref="SearchString"/>  changes. Useful for debugging.</summary>
+    public event EventHandler<KeystrokeNavigatorEventArgs>? SearchStringChanged;
+
+    /// <summary>
+    ///     Gets the current search string. This includes the set of keystrokes that have been pressed since the last
+    ///     unsuccessful match or after <see cref="TypingDelay"/>) milliseconds. Useful for debugging.
+    /// </summary>
+    string SearchString { get; }
+
+    /// <summary>
+    ///     Class responsible for deciding whether given entries in the collection match
+    ///     the search term the user is typing.
+    /// </summary>
+    ICollectionNavigatorMatcher Matcher { get; set; }
+
+    /// <summary>
+    ///     Gets the index of the next item in the collection that matches the current <see cref="SearchString"/> plus the
+    ///     provided character (typically from a key press).
+    /// </summary>
+    /// <param name="currentIndex">The index in the collection to start the search from.</param>
+    /// <param name="keyStruck">The character of the key the user pressed.</param>
+    /// <returns>
+    ///     The index of the item that matches what the user has typed. Returns <see langword="-1"/> if no item in the
+    ///     collection matched.
+    /// </returns>
+    int GetNextMatchingItem (int currentIndex, char keyStruck);
+}

+ 26 - 0
Terminal.Gui/Views/CollectionNavigation/ICollectionNavigatorMatcher.cs

@@ -0,0 +1,26 @@
+namespace Terminal.Gui;
+
+/// <summary>
+///     Determines which keys trigger collection manager navigation
+///     and how to match typed strings to objects in the collection.
+///     Default implementation is <see cref="DefaultCollectionNavigatorMatcher"/>.
+/// </summary>
+public interface ICollectionNavigatorMatcher
+{
+    /// <summary>
+    ///     Returns true if <paramref name="key"/> is key searchable key (e.g. letters, numbers, etc) that are valid to pass
+    ///     to this class for search filtering.
+    /// </summary>
+    /// <param name="key"></param>
+    /// <returns></returns>
+    bool IsCompatibleKey (Key key);
+
+    /// <summary>
+    ///     Return true if the <paramref name="value"/> matches (e.g. starts with)
+    ///     the <paramref name="search"/> term.
+    /// </summary>
+    /// <param name="search"></param>
+    /// <param name="value"></param>
+    /// <returns></returns>
+    bool IsMatch (string search, object value);
+}

+ 13 - 0
Terminal.Gui/Views/CollectionNavigation/IListCollectionNavigator.cs

@@ -0,0 +1,13 @@
+using System.Collections;
+
+namespace Terminal.Gui;
+
+/// <summary>
+///     <see cref="ICollectionNavigator"/> sub-interface for <see cref="ListView"/> and <see cref="TreeView"/>. See also
+///     <see cref="ListView.KeystrokeNavigator"/> / <see cref="TreeView.KeystrokeNavigator"/>
+/// </summary>
+public interface IListCollectionNavigator : ICollectionNavigator
+{
+    /// <summary>The collection of objects to search. <see cref="object.ToString()"/> is used to search the collection.</summary>
+    IList Collection { get; set; }
+}

+ 7 - 7
Terminal.Gui/Text/TableCollectionNavigator.cs → Terminal.Gui/Views/CollectionNavigation/TableCollectionNavigator.cs

@@ -1,24 +1,24 @@
 namespace Terminal.Gui;
 
 /// <summary>Collection navigator for cycling selections in a <see cref="TableView"/>.</summary>
-public class TableCollectionNavigator : CollectionNavigatorBase
+internal class TableCollectionNavigator : CollectionNavigatorBase
 {
-    private readonly TableView tableView;
+    private readonly TableView _tableView;
 
     /// <summary>Creates a new instance for navigating the data in the wrapped <paramref name="tableView"/>.</summary>
-    public TableCollectionNavigator (TableView tableView) { this.tableView = tableView; }
+    public TableCollectionNavigator (TableView tableView) { this._tableView = tableView; }
 
     /// <inheritdoc/>
     protected override object ElementAt (int idx)
     {
-        int col = tableView.FullRowSelect ? 0 : tableView.SelectedColumn;
-        object rawValue = tableView.Table [idx, col];
+        int col = _tableView.FullRowSelect ? 0 : _tableView.SelectedColumn;
+        object rawValue = _tableView.Table [idx, col];
 
-        ColumnStyle style = tableView.Style.GetColumnStyleIfAny (col);
+        ColumnStyle style = _tableView.Style.GetColumnStyleIfAny (col);
 
         return style?.RepresentationGetter?.Invoke (rawValue) ?? rawValue;
     }
 
     /// <inheritdoc/>
-    protected override int GetCollectionLength () { return tableView.Table.Rows; }
+    protected override int GetCollectionLength () { return _tableView.Table.Rows; }
 }

+ 2 - 25
Terminal.Gui/Views/FileDialog.cs

@@ -170,8 +170,8 @@ public class FileDialog : Dialog, IDesignable
             Width = Dim.Fill (),
             Height = Dim.Fill (),
             FullRowSelect = true,
-            CollectionNavigator = new FileDialogCollectionNavigator (this)
         };
+        _tableView.CollectionNavigator = new FileDialogCollectionNavigator (this, _tableView);
         _tableView.KeyBindings.ReplaceCommands (Key.Space, Command.Select);
         _tableView.MouseClick += OnTableViewMouseClick;
         _tableView.Style.InvertSelectedCellFirstCharacter = true;
@@ -1465,29 +1465,6 @@ public class FileDialog : Dialog, IDesignable
         _tableView.Update ();
     }
 
-    internal class FileDialogCollectionNavigator : CollectionNavigatorBase
-    {
-        private readonly FileDialog _fileDialog;
-        public FileDialogCollectionNavigator (FileDialog fileDialog) { _fileDialog = fileDialog; }
-
-        protected override object ElementAt (int idx)
-        {
-            object val = FileDialogTableSource.GetRawColumnValue (
-                                                                  _fileDialog._tableView.SelectedColumn,
-                                                                  _fileDialog.State?.Children [idx]
-                                                                 );
-
-            if (val is null)
-            {
-                return string.Empty;
-            }
-
-            return val.ToString ().Trim ('.');
-        }
-
-        protected override int GetCollectionLength () { return _fileDialog.State?.Children.Length ?? 0; }
-    }
-
     /// <summary>State representing a recursive search from <see cref="FileDialogState.Directory"/> downwards.</summary>
     internal class SearchState : FileDialogState
     {
@@ -1639,4 +1616,4 @@ public class FileDialog : Dialog, IDesignable
 
         return true;
     }
-}
+}

+ 21 - 0
Terminal.Gui/Views/FileDialogCollectionNavigator.cs

@@ -0,0 +1,21 @@
+namespace Terminal.Gui;
+
+internal class FileDialogCollectionNavigator (FileDialog fileDialog, TableView tableView) : CollectionNavigatorBase
+{
+    protected override object ElementAt (int idx)
+    {
+        object val = FileDialogTableSource.GetRawColumnValue (
+                                                              tableView.SelectedColumn,
+                                                              fileDialog.State?.Children [idx]
+                                                             );
+
+        if (val is null)
+        {
+            return string.Empty;
+        }
+
+        return val.ToString ().Trim ('.');
+    }
+
+    protected override int GetCollectionLength () { return fileDialog.State?.Children.Length ?? 0; }
+}

+ 3 - 23
Terminal.Gui/Views/ListView.cs

@@ -234,7 +234,7 @@ public class ListView : View, IDesignable
     ///     Gets the <see cref="CollectionNavigator"/> that searches the <see cref="ListView.Source"/> collection as the
     ///     user types.
     /// </summary>
-    public CollectionNavigator KeystrokeNavigator { get; } = new ();
+    public IListCollectionNavigator KeystrokeNavigator { get; } = new CollectionNavigator();
 
     /// <summary>Gets or sets the leftmost column that is currently visible (when scrolling horizontally).</summary>
     /// <value>The left position.</value>
@@ -809,27 +809,7 @@ public class ListView : View, IDesignable
     /// <inheritdoc/>
     protected override bool OnKeyDown (Key key)
     {
-        // If marking is enabled and the user presses the space key don't let CollectionNavigator
-        // at it
-        if (AllowsMarking)
-        {
-            IEnumerable<Key> keys = KeyBindings.GetAllFromCommands (Command.Select);
-
-            if (keys.Contains (key))
-            {
-                return false;
-            }
-
-            keys = KeyBindings.GetAllFromCommands ([Command.Select, Command.Down]);
-
-            if (keys.Contains (key))
-            {
-                return false;
-            }
-
-        }
-
-        // If the key was bound to a command, invoke the command. This enables overriding the default handling.
+        // If the key was bound to key command, let normal KeyDown processing happen. This enables overriding the default handling.
         // See: https://github.com/gui-cs/Terminal.Gui/issues/3950#issuecomment-2807350939
         if (KeyBindings.TryGet (key, out _))
         {
@@ -837,7 +817,7 @@ public class ListView : View, IDesignable
         }
 
         // Enable user to find & select an item by typing text
-        if (CollectionNavigatorBase.IsCompatibleKey (key))
+        if (KeystrokeNavigator.Matcher.IsCompatibleKey (key))
         {
             int? newItem = KeystrokeNavigator?.GetNextMatchingItem (SelectedItem, (char)key);
 

+ 9 - 2
Terminal.Gui/Views/TableView/TableView.cs

@@ -306,7 +306,7 @@ public class TableView : View, IDesignable
     }
 
     /// <summary>Navigator for cycling the selected item in the table by typing. Set to null to disable this feature.</summary>
-    public CollectionNavigatorBase CollectionNavigator { get; set; }
+    public ICollectionNavigator CollectionNavigator { get; set; }
 
     /// <summary>
     ///     Horizontal scroll offset.  The index of the first column in <see cref="Table"/> to display when when rendering
@@ -1010,12 +1010,19 @@ public class TableView : View, IDesignable
             return false;
         }
 
+        // If the key was bound to key command, let normal KeyDown processing happen. This enables overriding the default handling.
+        // See: https://github.com/gui-cs/Terminal.Gui/issues/3950#issuecomment-2807350939
+        if (KeyBindings.TryGet (key, out _))
+        {
+            return false;
+        }
+
         if (CollectionNavigator != null
             && HasFocus
             && Table.Rows != 0
             && key != KeyBindings.GetFirstFromCommands (Command.Accept)
             && key != CellActivationKey
-            && CollectionNavigatorBase.IsCompatibleKey (key)
+            && CollectionNavigator.Matcher.IsCompatibleKey (key)
             && !key.KeyCode.HasFlag (KeyCode.CtrlMask)
             && !key.KeyCode.HasFlag (KeyCode.AltMask)
             && Rune.IsLetterOrDigit ((Rune)key))

+ 9 - 2
Terminal.Gui/Views/TreeView/TreeView.cs

@@ -350,7 +350,7 @@ public class TreeView<T> : View, ITreeView where T : class
     ///     Gets the <see cref="CollectionNavigator"/> that searches the <see cref="Objects"/> collection as the user
     ///     types.
     /// </summary>
-    public CollectionNavigator KeystrokeNavigator { get; } = new ();
+    public IListCollectionNavigator KeystrokeNavigator { get; } = new CollectionNavigator();
 
     /// <summary>Maximum number of nodes that can be expanded in any given branch.</summary>
     public int MaxDepth { get; set; } = 100;
@@ -1206,8 +1206,15 @@ public class TreeView<T> : View, ITreeView where T : class
             return false;
         }
 
+        // If the key was bound to key command, let normal KeyDown processing happen. This enables overriding the default handling.
+        // See: https://github.com/gui-cs/Terminal.Gui/issues/3950#issuecomment-2807350939
+        if (KeyBindings.TryGet (key, out _))
+        {
+            return false;
+        }
+
         // If not a keybinding, is the key a searchable key press?
-        if (CollectionNavigatorBase.IsCompatibleKey (key) && AllowLetterBasedNavigation)
+        if (KeystrokeNavigator.Matcher.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

+ 1 - 30
Tests/UnitTests/Views/ListViewTests.cs

@@ -1,6 +1,7 @@
 using System.Collections;
 using System.Collections.ObjectModel;
 using System.Collections.Specialized;
+using Moq;
 using UnitTests;
 using Xunit.Abstractions;
 
@@ -1157,34 +1158,4 @@ Item 6",
             }
         }
     }
-
-    [Fact]
-    public void CollectionNavigatorMatcher_KeybindingsOverrideNavigator ()
-    {
-        Application.Top = new ();
-
-        ObservableCollection<string> source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" };
-        ListView lv = new ListView { Source = new ListWrapper<string> (source) };
-
-        Application.Top.Add (lv);
-        lv.SetFocus ();
-
-        lv.KeyBindings.Add (Key.B, Command.Down);
-
-        Assert.Equal (-1, lv.SelectedItem);
-
-        // Keys should be consumed to move down the navigation i.e. to apricot
-        Assert.True (Application.RaiseKeyDownEvent (Key.B));
-        Assert.Equal (0, lv.SelectedItem);
-
-        Assert.True (Application.RaiseKeyDownEvent (Key.B));
-        Assert.Equal (1, lv.SelectedItem);
-
-        // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle
-        Assert.True (Application.RaiseKeyDownEvent (Key.C));
-        Assert.Equal (5, lv.SelectedItem);
-
-        Application.Top.Dispose ();
-        Application.ResetState ();
-    }
 }

+ 3 - 6
Tests/UnitTests/Views/TableViewTests.cs

@@ -68,11 +68,7 @@ public class TableViewTests (ITestOutputHelper output)
 
         tv.Table = new DataTableSource (dt);
         tv.NullSymbol = string.Empty;
-
-        var top = new Toplevel ();
-        top.Add (tv);
-        Application.Begin (top);
-
+        tv.ColorScheme = new ColorScheme ();
         tv.Draw ();
 
         var expected =
@@ -105,6 +101,8 @@ public class TableViewTests (ITestOutputHelper output)
             style.ColorGetter = e => { return scheme; };
         }
 
+        // Required or won't draw properly
+        Application.Driver.Clip = new Region (tv.Frame);
         tv.SetNeedsDraw ();
         tv.Draw ();
 
@@ -116,7 +114,6 @@ public class TableViewTests (ITestOutputHelper output)
 01111101101111111110
 ";
         DriverAssert.AssertDriverAttributesAre (expected, output, Application.Driver, tv.ColorScheme.Normal, color);
-        top.Dispose ();
     }
 
     [Fact]

+ 42 - 22
Tests/UnitTestsParallelizable/Text/CollectionNavigatorTests.cs

@@ -1,4 +1,5 @@
-using Xunit.Abstractions;
+using Moq;
+using Xunit.Abstractions;
 
 namespace Terminal.Gui.TextTests;
 
@@ -6,7 +7,7 @@ public class CollectionNavigatorTests
 {
     private static readonly string [] simpleStrings =
     {
-        "appricot", // 0
+        "apricot", // 0
         "arm", // 1
         "bat", // 2
         "batman", // 3
@@ -20,7 +21,7 @@ public class CollectionNavigatorTests
     [Fact]
     public void AtSymbol ()
     {
-        var strings = new [] { "appricot", "arm", "ta", "@bob", "@bb", "text", "egg", "candle" };
+        var strings = new [] { "apricot", "arm", "ta", "@bob", "@bb", "text", "egg", "candle" };
 
         var n = new CollectionNavigator (strings);
         Assert.Equal (3, n.GetNextMatchingItem (0, '@'));
@@ -44,19 +45,19 @@ public class CollectionNavigatorTests
         Assert.Equal (0, n.GetNextMatchingItem (-1, 'a'));
         Assert.Equal (1, n.GetNextMatchingItem (0, 'a'));
 
-        // if 4 (candle) is selected it should loop back to appricot
+        // if 4 (candle) is selected it should loop back to apricot
         Assert.Equal (0, n.GetNextMatchingItem (4, 'a'));
     }
 
     [Fact]
     public void Delay ()
     {
-        var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "appricot" };
+        var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot" };
         var current = 0;
         var n = new CollectionNavigator (strings);
 
         // No delay
-        Assert.Equal (strings.IndexOf ("appricot"), current = n.GetNextMatchingItem (current, 'a'));
+        Assert.Equal (strings.IndexOf ("apricot"), current = n.GetNextMatchingItem (current, 'a'));
         Assert.Equal ("a", n.SearchString);
         Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, '$'));
         Assert.Equal ("$", n.SearchString);
@@ -65,7 +66,7 @@ public class CollectionNavigatorTests
 
         // Delay 
         Thread.Sleep (n.TypingDelay + 10);
-        Assert.Equal (strings.IndexOf ("appricot"), current = n.GetNextMatchingItem (current, 'a'));
+        Assert.Equal (strings.IndexOf ("apricot"), current = n.GetNextMatchingItem (current, 'a'));
         Assert.Equal ("a", n.SearchString);
 
         Thread.Sleep (n.TypingDelay + 10);
@@ -92,7 +93,7 @@ public class CollectionNavigatorTests
     [Fact]
     public void FullText ()
     {
-        var strings = new [] { "appricot", "arm", "ta", "target", "text", "egg", "candle" };
+        var strings = new [] { "apricot", "arm", "ta", "target", "text", "egg", "candle" };
 
         var n = new CollectionNavigator (strings);
         var current = 0;
@@ -104,13 +105,13 @@ public class CollectionNavigatorTests
         // still matches text
         Assert.Equal (strings.IndexOf ("text"), current = n.GetNextMatchingItem (current, 'x'));
 
-        // nothing starts texa so it should NOT jump to appricot
+        // nothing starts texa so it should NOT jump to apricot
         Assert.Equal (strings.IndexOf ("text"), current = n.GetNextMatchingItem (current, 'a'));
 
         Thread.Sleep (n.TypingDelay + 100);
 
-        // nothing starts "texa". Since were past timedelay we DO jump to appricot
-        Assert.Equal (strings.IndexOf ("appricot"), current = n.GetNextMatchingItem (current, 'a'));
+        // nothing starts "texa". Since were past timedelay we DO jump to apricot
+        Assert.Equal (strings.IndexOf ("apricot"), current = n.GetNextMatchingItem (current, 'a'));
     }
 
     [Theory]
@@ -128,13 +129,14 @@ public class CollectionNavigatorTests
     [InlineData (KeyCode.ShiftMask, false)]
     public void IsCompatibleKey_Does_Not_Allow_Alt_And_Ctrl_Keys (KeyCode keyCode, bool compatible)
     {
-        Assert.Equal (compatible, CollectionNavigatorBase.IsCompatibleKey (keyCode));
+        var m = new DefaultCollectionNavigatorMatcher ();
+        Assert.Equal (compatible, m.IsCompatibleKey (keyCode));
     }
 
     [Fact]
     public void MinimizeMovement_False_ShouldMoveIfMultipleMatches ()
     {
-        var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "appricot", "c", "car", "cart" };
+        var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot", "c", "car", "cart" };
         var current = 0;
         var n = new CollectionNavigator (strings);
         Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, "$$"));
@@ -146,7 +148,7 @@ public class CollectionNavigatorTests
         Assert.Equal (strings.IndexOf ("$200.00"), current = n.GetNextMatchingItem (current, "$"));
 
         Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, "$")); // back to top
-        Assert.Equal (strings.IndexOf ("appricot"), current = n.GetNextMatchingItem (current, "a"));
+        Assert.Equal (strings.IndexOf ("apricot"), current = n.GetNextMatchingItem (current, "a"));
         Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, "$")); // back to top
 
         Assert.Equal (strings.IndexOf ("$100.00"), current = n.GetNextMatchingItem (current, "$100.00"));
@@ -170,7 +172,7 @@ public class CollectionNavigatorTests
     [Fact]
     public void MinimizeMovement_True_ShouldStayOnCurrentIfMultipleMatches ()
     {
-        var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "appricot", "c", "car", "cart" };
+        var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot", "c", "car", "cart" };
         var current = 0;
         var n = new CollectionNavigator (strings);
         Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, "$$", true));
@@ -250,10 +252,10 @@ public class CollectionNavigatorTests
     [Fact]
     public void Symbols ()
     {
-        var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "appricot" };
+        var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot" };
         var current = 0;
         var n = new CollectionNavigator (strings);
-        Assert.Equal (strings.IndexOf ("appricot"), current = n.GetNextMatchingItem (current, 'a'));
+        Assert.Equal (strings.IndexOf ("apricot"), current = n.GetNextMatchingItem (current, 'a'));
         Assert.Equal ("a", n.SearchString);
 
         Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, '$'));
@@ -288,7 +290,7 @@ public class CollectionNavigatorTests
     [Fact]
     public void Unicode ()
     {
-        var strings = new [] { "appricot", "arm", "ta", "丗丙业丞", "丗丙丛", "text", "egg", "candle" };
+        var strings = new [] { "apricot", "arm", "ta", "丗丙业丞", "丗丙丛", "text", "egg", "candle" };
 
         var n = new CollectionNavigator (strings);
         var current = 0;
@@ -304,19 +306,19 @@ public class CollectionNavigatorTests
         // so we should move to the new match
         Assert.Equal (strings.IndexOf ("丗丙丛"), current = n.GetNextMatchingItem (current, '丛'));
 
-        // nothing starts "丗丙丛a". Since were still in the timedelay we do not jump to appricot
+        // nothing starts "丗丙丛a". Since were still in the timedelay we do not jump to apricot
         Assert.Equal (strings.IndexOf ("丗丙丛"), current = n.GetNextMatchingItem (current, 'a'));
 
         Thread.Sleep (n.TypingDelay + 100);
 
-        // nothing starts "丗丙丛a". Since were past timedelay we DO jump to appricot
-        Assert.Equal (strings.IndexOf ("appricot"), current = n.GetNextMatchingItem (current, 'a'));
+        // nothing starts "丗丙丛a". Since were past timedelay we DO jump to apricot
+        Assert.Equal (strings.IndexOf ("apricot"), current = n.GetNextMatchingItem (current, 'a'));
     }
 
     [Fact]
     public void Word ()
     {
-        var strings = new [] { "appricot", "arm", "bat", "batman", "bates hotel", "candle" };
+        var strings = new [] { "apricot", "arm", "bat", "batman", "bates hotel", "candle" };
         var current = 0;
         var n = new CollectionNavigator (strings);
         Assert.Equal (strings.IndexOf ("bat"), current = n.GetNextMatchingItem (current, 'b')); // match bat
@@ -338,4 +340,22 @@ public class CollectionNavigatorTests
                       current = n.GetNextMatchingItem (current, ' ')
                      ); // match bates hotel
     }
+    [Fact]
+    public void CustomMatcher_NeverMatches ()
+    {
+        var strings = new [] { "apricot", "arm", "bat", "batman", "bates hotel", "candle" };
+        var current = 0;
+        var n = new CollectionNavigator (strings);
+
+        var matchNone = new Mock<ICollectionNavigatorMatcher> ();
+
+        matchNone.Setup (m => m.IsMatch (It.IsAny<string> (), It.IsAny<object> ()))
+                 .Returns (false);
+
+        n.Matcher = matchNone.Object;
+
+        Assert.Equal (0, current = n.GetNextMatchingItem (current, 'b')); // no matches
+        Assert.Equal (0, current = n.GetNextMatchingItem (current, 'a')); // no matches
+        Assert.Equal (0, current = n.GetNextMatchingItem (current, 't')); // no matches
+    }
 }

+ 120 - 0
Tests/UnitTestsParallelizable/Views/ListViewTests.cs

@@ -0,0 +1,120 @@
+using System.Collections.ObjectModel;
+using Moq;
+
+namespace Terminal.Gui.ViewsTests;
+
+public class ListViewTests
+{
+    [Fact]
+    public void ListViewCollectionNavigatorMatcher_DefaultBehaviour ()
+    {
+        ObservableCollection<string> source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" };
+        ListView lv = new ListView { Source = new ListWrapper<string> (source) };
+
+        // Keys are consumed during navigation
+        Assert.True (lv.NewKeyDownEvent (Key.B));
+        Assert.True (lv.NewKeyDownEvent (Key.A));
+        Assert.True (lv.NewKeyDownEvent (Key.T));
+
+        Assert.Equal ("bat", (string)lv.Source.ToList () [lv.SelectedItem]);
+    }
+
+    [Fact]
+    public void ListViewCollectionNavigatorMatcher_IgnoreKeys ()
+    {
+        ObservableCollection<string> source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" };
+        ListView lv = new ListView { Source = new ListWrapper<string> (source) };
+
+
+        var matchNone = new Mock<ICollectionNavigatorMatcher> ();
+
+        matchNone.Setup (m => m.IsCompatibleKey (It.IsAny<Key> ()))
+                 .Returns (false);
+
+        lv.KeystrokeNavigator.Matcher = matchNone.Object;
+
+        // Keys are ignored because IsCompatibleKey returned false i.e. don't use these keys for navigation
+        Assert.False (lv.NewKeyDownEvent (Key.B));
+        Assert.False (lv.NewKeyDownEvent (Key.A));
+        Assert.False (lv.NewKeyDownEvent (Key.T));
+
+        // assert IsMatch never called
+        matchNone.Verify (m => m.IsMatch (It.IsAny<string> (), It.IsAny<object> ()), Times.Never ());
+    }
+
+    [Fact]
+    public void ListViewCollectionNavigatorMatcher_OverrideMatching ()
+    {
+        ObservableCollection<string> source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" };
+        ListView lv = new ListView { Source = new ListWrapper<string> (source) };
+
+
+        var matchNone = new Mock<ICollectionNavigatorMatcher> ();
+
+        matchNone.Setup (m => m.IsCompatibleKey (It.IsAny<Key> ()))
+                 .Returns (true);
+
+        // Match any string starting with b to "candle" (psych!)
+        matchNone.Setup (m => m.IsMatch (It.IsAny<string> (), It.IsAny<object> ()))
+                 .Returns ((string s, object key) => s.StartsWith ('B') && key?.ToString () == "candle");
+
+        lv.KeystrokeNavigator.Matcher = matchNone.Object;
+        // Keys are consumed during navigation
+        Assert.True (lv.NewKeyDownEvent (Key.B));
+        Assert.Equal (5, lv.SelectedItem);
+        Assert.True (lv.NewKeyDownEvent (Key.A));
+        Assert.Equal (5, lv.SelectedItem);
+        Assert.True (lv.NewKeyDownEvent (Key.T));
+        Assert.Equal (5, lv.SelectedItem);
+
+        Assert.Equal ("candle", (string)lv.Source.ToList () [lv.SelectedItem]);
+    }
+
+    [Fact]
+    public void ListView_CollectionNavigatorMatcher_KeybindingsOverrideNavigator ()
+    {
+        ObservableCollection<string> source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" };
+        ListView lv = new ListView { Source = new ListWrapper<string> (source) };
+
+        lv.SetFocus ();
+
+        lv.KeyBindings.Add (Key.B, Command.Down);
+
+        Assert.Equal (-1, lv.SelectedItem);
+
+        // Keys should be consumed to move down the navigation i.e. to apricot
+        Assert.True (lv.NewKeyDownEvent (Key.B));
+        Assert.Equal (0, lv.SelectedItem);
+
+        Assert.True (lv.NewKeyDownEvent (Key.B));
+        Assert.Equal (1, lv.SelectedItem);
+
+        // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle
+        Assert.True (lv.NewKeyDownEvent (Key.C));
+        Assert.Equal (5, lv.SelectedItem);
+    }
+
+    [Fact]
+    public void CollectionNavigatorMatcher_KeybindingsOverrideNavigator ()
+    {
+        ObservableCollection<string> source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" };
+        ListView lv = new ListView { Source = new ListWrapper<string> (source) };
+
+        lv.SetFocus ();
+
+        lv.KeyBindings.Add (Key.B, Command.Down);
+
+        Assert.Equal (-1, lv.SelectedItem);
+
+        // Keys should be consumed to move down the navigation i.e. to apricot
+        Assert.True (lv.NewKeyDownEvent (Key.B));
+        Assert.Equal (0, lv.SelectedItem);
+
+        Assert.True (lv.NewKeyDownEvent (Key.B));
+        Assert.Equal (1, lv.SelectedItem);
+
+        // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle
+        Assert.True (lv.NewKeyDownEvent (Key.C));
+        Assert.Equal (5, lv.SelectedItem);
+    }
+}

+ 45 - 0
Tests/UnitTestsParallelizable/Views/TableViewTests.cs

@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using JetBrains.Annotations;
+
+namespace Terminal.Gui.ViewsTests;
+
+[TestSubject (typeof (TableView))]
+public class TableViewTests
+{
+    [Fact]
+    public void TableView_CollectionNavigatorMatcher_KeybindingsOverrideNavigator ()
+    {
+        var dt = new DataTable ();
+        dt.Columns.Add ("blah");
+
+        dt.Rows.Add ("apricot");
+        dt.Rows.Add ("arm");
+        dt.Rows.Add ("bat");
+        dt.Rows.Add ("batman");
+        dt.Rows.Add ("bates hotel");
+        dt.Rows.Add ("candle");
+
+        var tableView = new TableView ();
+        tableView.Table = new DataTableSource (dt);
+        tableView.HasFocus = true;
+        tableView.KeyBindings.Add (Key.B, Command.Down);
+
+        Assert.Equal (0, tableView.SelectedRow);
+
+        // Keys should be consumed to move down the navigation i.e. to apricot
+        Assert.True (tableView.NewKeyDownEvent (Key.B));
+        Assert.Equal (1, tableView.SelectedRow);
+
+        Assert.True (tableView.NewKeyDownEvent (Key.B));
+        Assert.Equal (2, tableView.SelectedRow);
+
+        // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle
+        Assert.True (tableView.NewKeyDownEvent (Key.C));
+        Assert.Equal (5, tableView.SelectedRow);
+    }
+}

+ 40 - 0
Tests/UnitTestsParallelizable/Views/TreeViewTests.cs

@@ -0,0 +1,40 @@
+using JetBrains.Annotations;
+
+namespace Terminal.Gui.ViewsTests;
+
+[TestSubject (typeof (TreeView))]
+public class TreeViewTests
+{
+
+    [Fact]
+    public void TreeView_CollectionNavigatorMatcher_KeybindingsOverrideNavigator ()
+    {
+        var tree = new TreeView ();
+        tree.AddObjects ([
+                             new TreeNode(){ Text="apricot" },
+                             new TreeNode(){ Text="arm" },
+                             new TreeNode(){ Text="bat" },
+                             new TreeNode(){ Text="batman" },
+                             new TreeNode(){ Text="bates hotel" },
+                             new TreeNode(){ Text="candle" },
+                         ]);
+
+        tree.SetFocus ();
+
+        tree.KeyBindings.Add (Key.B, Command.Down);
+
+        Assert.Equal ("apricot", tree.SelectedObject.Text);
+
+        // Keys should be consumed to move down the navigation i.e. to apricot
+        Assert.True (tree.NewKeyDownEvent (Key.B));
+        Assert.NotNull (tree.SelectedObject);
+        Assert.Equal ("arm", tree.SelectedObject.Text);
+
+        Assert.True (tree.NewKeyDownEvent (Key.B));
+        Assert.Equal ("bat", tree.SelectedObject.Text);
+
+        // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle
+        Assert.True (tree.NewKeyDownEvent (Key.C));
+        Assert.Equal ("candle", tree.SelectedObject.Text);
+    }
+}