Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
39c0e4b
Add collection search matcher
tznind Apr 13, 2025
ee0748e
Fix naming
tznind Apr 13, 2025
4509780
fix naming
tznind Apr 13, 2025
b76212e
Move FileDialogCollectionNavigator to its own file (no longer private…
tznind Apr 13, 2025
bd364f7
Add ICollectionNavigator interface
tznind Apr 13, 2025
98383b4
Move to separate file IListCollectionNavigator
tznind Apr 13, 2025
9db1071
Update class diagram
tznind Apr 13, 2025
4b29940
update class diagram
tznind Apr 13, 2025
135d3c5
Add tests for overriding ICollectionNavigatorMatcher
tznind Apr 15, 2025
d1d5534
xmldoc and nullability warning fixes
tznind Apr 15, 2025
be56a8e
Code Cleanup
tznind Apr 15, 2025
26ff23d
Make requested changes to naming and terminology
tznind Apr 16, 2025
1f686f9
Move to seperate namespace
tznind Apr 16, 2025
dbeba5d
Update class diagram and change TreeView to reference the interface n…
tznind Apr 16, 2025
94219c6
Switch to implicit new
tznind Apr 16, 2025
18c3d90
Merge branch 'v2_develop' into collection-navigator-matcher
tig Apr 16, 2025
0511ad9
highlight that this class also works with tree view
tznind Apr 16, 2025
932c6aa
Merge branch 'collection-navigator-matcher' of https://github.com/tzn…
tznind Apr 16, 2025
58b0178
Apply tig patch to ensure keybindings get priority over navigator
tznind Apr 17, 2025
3ec5cc6
Apply 'keybinding has priority' fix to TreeView too
tznind Apr 17, 2025
41c9134
Apply 'keybindngs priority over navigation' fix to TableView
tznind Apr 17, 2025
0e29e04
Remove entire branch for selectively returning false now that it is d…
tznind Apr 17, 2025
d142ff1
Make classes internal and remove 'custom' navigator that was configur…
tznind Apr 17, 2025
35e402d
Fix merged conflicts
tznind Apr 24, 2025
ebecbc8
Change logging in collection navigator from Trace to Debug
tznind Apr 24, 2025
ab8c830
Switch to NewKeyDownEvent and directly setting HasFocus
tznind Apr 24, 2025
65a12da
Remove application top dependency
tznind Apr 26, 2025
d15bd12
Remove references to application
tznind Apr 27, 2025
e803d37
Remove Application
tznind Apr 27, 2025
1b8ab97
Merge branch 'v2_develop' into collection-navigator-matcher
tznind Apr 27, 2025
639b1d1
Move new tests to parallel
tznind Apr 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions Terminal.Gui/Views/CollectionNavigation/CollectionNavigation.cd
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -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 () { }
Expand All @@ -13,12 +13,12 @@ public CollectionNavigator () { }
/// <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/>
protected override object ElementAt (int idx) { return Collection [idx]; }

/// <inheritdoc/>
protected override int GetCollectionLength () { return Collection.Count; }
}
}
Original file line number Diff line number Diff line change
@@ -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))
Expand All @@ -61,21 +37,21 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck)
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 (
Expand All @@ -86,14 +62,14 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck)
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;
}

Expand All @@ -102,13 +78,13 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck)
_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;
Expand All @@ -117,7 +93,7 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck)
// 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 ();
Expand All @@ -126,17 +102,17 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck)
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 ();
Expand All @@ -145,29 +121,17 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck)
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>
Expand Down Expand Up @@ -195,7 +159,7 @@ internal int GetNextMatchingItem (int currentIndex, string search, bool minimize

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)
Expand All @@ -209,7 +173,7 @@ internal int GetNextMatchingItem (int currentIndex, string search, bool minimize
//circular
int idxCandidate = (i + currentIndex) % collectionLength;

if (IsMatch (search, ElementAt (idxCandidate)))
if (Matcher.IsMatch (search, ElementAt (idxCandidate)))
{
return idxCandidate;
}
Expand All @@ -222,7 +186,7 @@ internal int GetNextMatchingItem (int currentIndex, string search, bool minimize
// 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;
}
Expand All @@ -237,6 +201,4 @@ private void ClearSearchString ()
SearchString = "";
_lastKeystroke = DateTime.Now;
}

private bool IsMatch (string search, object value) { return value?.ToString ().StartsWith (search, StringComparison.InvariantCultureIgnoreCase) ?? false; }
}
}
Loading