using System;
using System.Collections.Generic;
using System.Linq;
namespace Terminal.Gui {
///
/// Navigates a collection of items using keystrokes. The keystrokes are used to build a search string.
/// The is used to find the next item in the collection that matches the search string
/// when is called.
///
/// 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.
///
///
/// If the user pauses keystrokes for a short time (see ), the search string is cleared.
///
///
public abstract class CollectionNavigatorBase {
DateTime lastKeystroke = DateTime.Now;
///
/// Gets or sets the number of milliseconds to delay before clearing the search string. The delay is
/// reset on each call to . The default is 500ms.
///
public int TypingDelay { get; set; } = 500;
///
/// The compararer function to use when searching the collection.
///
public StringComparer Comparer { get; set; } = StringComparer.InvariantCultureIgnoreCase;
///
/// This event is invoked when changes. Useful for debugging.
///
public event EventHandler SearchStringChanged;
private string _searchString = "";
///
/// Gets the current search string. This includes the set of keystrokes that have been pressed
/// since the last unsuccessful match or after ) milliseconds. Useful for debugging.
///
public string SearchString {
get => _searchString;
private set {
_searchString = value;
OnSearchStringChanged (new KeystrokeNavigatorEventArgs (value));
}
}
///
/// Invoked when the changes. Useful for debugging. Invokes the event.
///
///
public virtual void OnSearchStringChanged (KeystrokeNavigatorEventArgs e)
{
SearchStringChanged?.Invoke (this, e);
}
///
/// Gets the index of the next item in the collection that matches the current plus the provided character (typically
/// from a key press).
///
/// The index in the collection to start the search from.
/// The character of the key the user pressed.
/// The index of the item that matches what the user has typed.
/// Returns if no item in the collection matched.
public int GetNextMatchingItem (int currentIndex, char keyStruck)
{
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;
}
}
///
/// Gets the index of the next item in the collection that matches .
///
/// The index in the collection to start the search from.
/// The search string to use.
/// Set to to stop the search on the first match
/// if there are multiple matches for .
/// e.g. "ca" + 'r' should stay on "car" and not jump to "cart". If (the default),
/// the next matching item will be returned, even if it is above in the collection.
///
/// The index of the next matching item or if no match was found.
internal int GetNextMatchingItem (int currentIndex, string search, bool minimizeMovement = false)
{
if (string.IsNullOrEmpty (search)) {
return -1;
}
var collectionLength = GetCollectionLength ();
if (currentIndex != -1 && currentIndex < collectionLength && IsMatch (search, ElementAt (currentIndex))) {
// we are already at a match
if (minimizeMovement) {
// if we would rather not jump around (e.g. user is typing lots of text to get this match)
return currentIndex;
}
for (int i = 1; i < collectionLength; i++) {
//circular
var idxCandidate = (i + currentIndex) % collectionLength;
if (IsMatch (search, ElementAt (idxCandidate))) {
return idxCandidate;
}
}
// nothing else starts with the search term
return currentIndex;
} else {
// search terms no longer match the current selection or there is none
for (int i = 0; i < collectionLength; i++) {
if (IsMatch (search, ElementAt (i))) {
return i;
}
}
// Nothing matches
return -1;
}
}
///
/// Return the number of elements in the collection
///
protected abstract int GetCollectionLength ();
private bool IsMatch (string search, object value)
{
return value?.ToString ().StartsWith (search, StringComparison.InvariantCultureIgnoreCase) ?? false;
}
///
/// Returns the collection being navigated element at .
///
///
protected abstract object ElementAt (int idx);
private void ClearSearchString ()
{
SearchString = "";
lastKeystroke = DateTime.Now;
}
///
/// Returns true if is a searchable key
/// (e.g. letters, numbers, etc) that are valid to pass to this
/// class for search filtering.
///
///
///
public static bool IsCompatibleKey (KeyEvent kb)
{
return !kb.IsAlt && !kb.IsCtrl;
}
}
}