Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ac58a77
Enables sarching ListView with keyboard
tig Oct 23, 2022
7c8180d
Add SearchCollectionNavigator
Oct 24, 2022
c38af80
Merge pull request #1 from tznind/navigator
tig Oct 24, 2022
18ec9a2
integrated tznind's stuff
tig Oct 25, 2022
b09b3ad
Refactored UI Catalog Scenario class to support ToString()
tig Oct 25, 2022
71b00e9
Nuked ScenarioListDataSource
tig Oct 25, 2022
77ae856
Added SetNeedsDisplay to AllowsMultipleSelection per bdisp
tig Oct 25, 2022
03f69b1
Merge branch 'develop' into listview_keyboard_search
tig Oct 29, 2022
40514fb
more progress
tig Oct 29, 2022
77a4dbb
Merge branch 'uicat_refactor' into listview_keyboard_search
tig Oct 29, 2022
ebd01fc
TreeView example written; not wired up yet
tig Oct 30, 2022
1e17cf0
tweaks
tig Oct 30, 2022
79f82d1
Add SearchCollectionNavigator to TreeView
Oct 30, 2022
a624080
Add EnsureVisible call
Oct 30, 2022
c2a8d01
Added tests for 'bad' indexes being passed to SearchCollectionNavigator
Oct 30, 2022
938ea16
Merge pull request #2 from tznind/listview_keyboard_search_tv
tig Oct 30, 2022
b713d6a
Integrating tznid's latest
tig Oct 30, 2022
14591a0
Merge branch 'develop' into listview_keyboard_search
tig Oct 30, 2022
9edde39
Merge branch 'develop' into listview_keyboard_search
tig Oct 30, 2022
4480051
Merge branch 'develop' into listview_keyboard_search
tig Oct 30, 2022
9d8fead
Merge branch 'listview_keyboard_search' of tig:tig/Terminal.Gui into …
tig Oct 30, 2022
1b2dc40
merge
tig Oct 31, 2022
f1630a9
merge
tig Oct 31, 2022
c7862b7
Merge branch 'develop' into listview_keyboard_search
tig Oct 31, 2022
06bc892
Merge branch 'develop' into listview_keyboard_search
tig Oct 31, 2022
60d1166
Near final fixes? Refactored/renamed stuff
tig Nov 1, 2022
3ee2485
fixed scenario categories
tig Nov 1, 2022
859b8de
fixed merge error
tig Nov 1, 2022
271e309
Merge branch 'develop' into listview_keyboard_search
tig Nov 1, 2022
e94cd4b
renamed ClearState
tig Nov 1, 2022
66398eb
Renamed classes; fixed rendering bug in ListView
tig Nov 1, 2022
a2f04ed
Delay is now 500ms and TypingDelay is public
tig Nov 1, 2022
079a2e0
removed local nstack ref
tig Nov 1, 2022
bc846bf
added unit test that proves 'wrong' key behavior is broken
tig Nov 1, 2022
c7f4392
fixed unit test that proves 'wrong' key behavior is broken
tig Nov 1, 2022
2200dc0
fixed (for real?) unit test that proves 'wrong' key behavior is broken
tig Nov 1, 2022
1247a12
updated unit tests for new 'wrong' key behavior
tig Nov 1, 2022
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
244 changes: 244 additions & 0 deletions Terminal.Gui/Core/CollectionNavigator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
using System;
using System.Collections.Generic;
using System.Linq;

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 class CollectionNavigator {
/// <summary>
/// Constructs a new CollectionNavigator.
/// </summary>
public CollectionNavigator () { }

/// <summary>
/// Constructs a new CollectionNavigator for the given collection.
/// </summary>
/// <param name="collection"></param>
public CollectionNavigator (IEnumerable<object> collection) => Collection = collection;

DateTime lastKeystroke = DateTime.Now;
/// <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; } = 500;

/// <summary>
/// The compararer function to use when searching the collection.
/// </summary>
public StringComparer Comparer { get; set; } = StringComparer.InvariantCultureIgnoreCase;

/// <summary>
/// The collection of objects to search. <see cref="object.ToString()"/> is used to search the collection.
/// </summary>
public IEnumerable<object> Collection { get; set; }

/// <summary>
/// Event arguments for the <see cref="CollectionNavigator.SearchStringChanged"/> event.
/// </summary>
public class KeystrokeNavigatorEventArgs {
/// <summary>
/// he current <see cref="SearchString"/>.
/// </summary>
public string SearchString { get; }

/// <summary>
/// Initializes a new instance of <see cref="KeystrokeNavigatorEventArgs"/>
/// </summary>
/// <param name="searchString">The current <see cref="SearchString"/>.</param>
public KeystrokeNavigatorEventArgs (string searchString)
{
SearchString = searchString;
}
}

/// <summary>
/// This event is invoked when <see cref="SearchString"/> changes. Useful for debugging.
/// </summary>
public event Action<KeystrokeNavigatorEventArgs> SearchStringChanged;

private string _searchString = "";
/// <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>
public string SearchString {
get => _searchString;
private set {
_searchString = value;
OnSearchStringChanged (new KeystrokeNavigatorEventArgs (value));
}
}

/// <summary>
/// Invoked when the <see cref="SearchString"/> changes. Useful for debugging. Invokes the <see cref="SearchStringChanged"/> event.
/// </summary>
/// <param name="e"></param>
public virtual void OnSearchStringChanged (KeystrokeNavigatorEventArgs e)
{
SearchStringChanged?.Invoke (e);
}

/// <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>
public int GetNextMatchingItem (int currentIndex, char keyStruck)
{
AssertCollectionIsNotNull ();
if (!char.IsControl (keyStruck)) {

// maybe user pressed 'd' and now presses 'd' again.
// a candidate search is things that begin with "dd"
// but if we find none then we must fallback on cycling
// d instead and discard the candidate state
string candidateState = "";

// is it a second or third (etc) keystroke within a short time
if (SearchString.Length > 0 && DateTime.Now - lastKeystroke < TimeSpan.FromMilliseconds (TypingDelay)) {
// "dd" is a candidate
candidateState = SearchString + keyStruck;
} else {
// its a fresh keystroke after some time
// or its first ever key press
SearchString = new string (keyStruck, 1);
}

var idxCandidate = GetNextMatchingItem (currentIndex, candidateState,
// prefer not to move if there are multiple characters e.g. "ca" + 'r' should stay on "car" and not jump to "cart"
candidateState.Length > 1);

if (idxCandidate != -1) {
// found "dd" so candidate searchstring is accepted
lastKeystroke = DateTime.Now;
SearchString = candidateState;
return idxCandidate;
}

//// nothing matches "dd" so discard it as a candidate
//// and just cycle "d" instead
lastKeystroke = DateTime.Now;
idxCandidate = GetNextMatchingItem (currentIndex, candidateState);

// 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) {
// ignore it since we're still within the typing delay
// don't add it to SearchString either
return currentIndex;
}

// if no changes to current state manifested
if (idxCandidate == currentIndex || idxCandidate == -1) {
// clear history and treat as a fresh letter
ClearSearchString ();

// match on the fresh letter alone
SearchString = new string (keyStruck, 1);
idxCandidate = GetNextMatchingItem (currentIndex, SearchString);
return idxCandidate == -1 ? currentIndex : idxCandidate;
}

// Found another "d" or just leave index as it was
return idxCandidate;

} else {
// clear state because keypress was a control char
ClearSearchString ();

// control char indicates no selection
return -1;
}
}

/// <summary>
/// Gets the index of the next item in the collection that matches <paramref name="search"/>.
/// </summary>
/// <param name="currentIndex">The index in the collection to start the search from.</param>
/// <param name="search">The search string to use.</param>
/// <param name="minimizeMovement">Set to <see langword="true"/> to stop the search on the first match
/// if there are multiple matches for <paramref name="search"/>.
/// e.g. "ca" + 'r' should stay on "car" and not jump to "cart". If <see langword="false"/> (the default),
/// the next matching item will be returned, even if it is above in the collection.
/// </param>
/// <returns>The index of the next matching item or <see langword="-1"/> if no match was found.</returns>
internal int GetNextMatchingItem (int currentIndex, string search, bool minimizeMovement = false)
{
if (string.IsNullOrEmpty (search)) {
return -1;
}
AssertCollectionIsNotNull ();

// find indexes of items that start with the search text
int [] matchingIndexes = Collection.Select ((item, idx) => (item, idx))
.Where (k => k.item?.ToString ().StartsWith (search, StringComparison.InvariantCultureIgnoreCase) ?? false)
.Select (k => k.idx)
.ToArray ();

// if there are items beginning with search
if (matchingIndexes.Length > 0) {
// is one of them currently selected?
var currentlySelected = Array.IndexOf (matchingIndexes, currentIndex);

if (currentlySelected == -1) {
// we are not currently selecting any item beginning with the search
// so jump to first item in list that begins with the letter
return matchingIndexes [0];
} else {

// the current index is part of the matching collection
if (minimizeMovement) {
// if we would rather not jump around (e.g. user is typing lots of text to get this match)
return matchingIndexes [currentlySelected];
}

// cycle to next (circular)
return matchingIndexes [(currentlySelected + 1) % matchingIndexes.Length];
}
}

// nothing starts with the search
return -1;
}

private void AssertCollectionIsNotNull ()
{
if (Collection == null) {
throw new InvalidOperationException ("Collection is null");
}
}

private void ClearSearchString ()
{
SearchString = "";
lastKeystroke = DateTime.Now;
}

/// <summary>
/// Returns true if <paramref name="kb"/> is a searchable key
/// (e.g. letters, numbers etc) that is valid to pass to to this
/// class for search filtering.
/// </summary>
/// <param name="kb"></param>
/// <returns></returns>
public static bool IsCompatibleKey (KeyEvent kb)
{
return !kb.IsAlt && !kb.IsCapslock && !kb.IsCtrl && !kb.IsScrolllock && !kb.IsNumlock;
}
}
}
Loading