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 <tig@users.noreply.github.com>
This commit is contained in:
Thomas Nind
2025-04-28 13:39:11 +01:00
committed by Tig
parent 7fe6fd9453
commit 0a23df75da
21 changed files with 577 additions and 203 deletions

View File

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

View File

@@ -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

View File

@@ -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>

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}
}
}

View File

@@ -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; }
}

View File

@@ -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);

View File

@@ -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))

View File

@@ -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

View File

@@ -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 ();
}
}

View File

@@ -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]

View File

@@ -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
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}