diff --git a/Terminal.Gui/Core/SearchCollectionNavigator.cs b/Terminal.Gui/Core/SearchCollectionNavigator.cs new file mode 100644 index 0000000000..47d62b6613 --- /dev/null +++ b/Terminal.Gui/Core/SearchCollectionNavigator.cs @@ -0,0 +1,121 @@ +using System; +using System.Linq; + +namespace Terminal.Gui { + /// + /// Changes the index in a collection based on keys pressed + /// and the current state + /// + class SearchCollectionNavigator { + string state = ""; + DateTime lastKeystroke = DateTime.MinValue; + const int TypingDelay = 250; + public StringComparer Comparer { get; set; } = StringComparer.InvariantCultureIgnoreCase; + + public int CalculateNewIndex (string [] collection, int currentIndex, char keyStruck) + { + // if user presses a letter key + if (char.IsLetterOrDigit (keyStruck) || char.IsPunctuation (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 (state.Length > 0 && DateTime.Now - lastKeystroke < TimeSpan.FromMilliseconds (TypingDelay)) { + // "dd" is a candidate + candidateState = state + keyStruck; + } else { + // its a fresh keystroke after some time + // or its first ever key press + state = new string (keyStruck, 1); + } + + var idxCandidate = GetNextIndexMatching (collection, 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 state is accepted + lastKeystroke = DateTime.Now; + state = candidateState; + return idxCandidate; + } + + + // nothing matches "dd" so discard it as a candidate + // and just cycle "d" instead + lastKeystroke = DateTime.Now; + idxCandidate = GetNextIndexMatching (collection, currentIndex, state); + + // if no changes to current state manifested + if (idxCandidate == currentIndex || idxCandidate == -1) { + // clear history and treat as a fresh letter + ClearState (); + + // match on the fresh letter alone + state = new string (keyStruck, 1); + idxCandidate = GetNextIndexMatching (collection, currentIndex, state); + return idxCandidate == -1 ? currentIndex : idxCandidate; + } + + // Found another "d" or just leave index as it was + return idxCandidate; + + } else { + // clear state because keypress was non letter + ClearState (); + + // no change in index for non letter keystrokes + return currentIndex; + } + } + + private int GetNextIndexMatching (string [] collection, int currentIndex, string search, bool preferNotToMoveToNewIndexes = false) + { + if (string.IsNullOrEmpty (search)) { + return -1; + } + + // find indexes of items that start with the search text + int [] matchingIndexes = collection.Select ((item, idx) => (item, idx)) + .Where (k => k.Item1?.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 (preferNotToMoveToNewIndexes) { + // 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 ClearState () + { + state = ""; + lastKeystroke = DateTime.MinValue; + + } + } +} diff --git a/UnitTests/SearchCollectionNavigatorTests.cs b/UnitTests/SearchCollectionNavigatorTests.cs new file mode 100644 index 0000000000..ac39b88642 --- /dev/null +++ b/UnitTests/SearchCollectionNavigatorTests.cs @@ -0,0 +1,126 @@ +using Terminal.Gui; +using Xunit; + +namespace UnitTests { + public class SearchCollectionNavigatorTests { + + [Fact] + public void TestSearchCollectionNavigator_Cycling () + { + var s = new string []{ + "appricot", + "arm", + "bat", + "batman", + "candle" + }; + + var n = new SearchCollectionNavigator (); + Assert.Equal (2, n.CalculateNewIndex (s, 0, 'b')); + Assert.Equal (3, n.CalculateNewIndex (s, 2, 'b')); + + // if 4 (candle) is selected it should loop back to bat + Assert.Equal (2, n.CalculateNewIndex (s, 4, 'b')); + + } + + + [Fact] + public void TestSearchCollectionNavigator_ToSearchText () + { + var s = new string []{ + "appricot", + "arm", + "bat", + "batman", + "bbfish", + "candle" + }; + + var n = new SearchCollectionNavigator (); + Assert.Equal (2, n.CalculateNewIndex (s, 0, 'b')); + Assert.Equal (4, n.CalculateNewIndex (s, 2, 'b')); + + // another 'b' means searching for "bbb" which does not exist + // so we go back to looking for "b" as a fresh key strike + Assert.Equal (4, n.CalculateNewIndex (s, 2, 'b')); + } + + [Fact] + public void TestSearchCollectionNavigator_FullText () + { + var s = new string []{ + "appricot", + "arm", + "ta", + "target", + "text", + "egg", + "candle" + }; + + var n = new SearchCollectionNavigator (); + Assert.Equal (2, n.CalculateNewIndex (s, 0, 't')); + + // should match "te" in "text" + Assert.Equal (4, n.CalculateNewIndex (s, 2, 'e')); + + // still matches text + Assert.Equal (4, n.CalculateNewIndex (s, 4, 'x')); + + // nothing starts texa so it jumps to a for appricot + Assert.Equal (0, n.CalculateNewIndex (s, 4, 'a')); + } + + [Fact] + public void TestSearchCollectionNavigator_Unicode () + { + var s = new string []{ + "appricot", + "arm", + "ta", + "丗丙业丞", + "丗丙丛", + "text", + "egg", + "candle" + }; + + var n = new SearchCollectionNavigator (); + Assert.Equal (3, n.CalculateNewIndex (s, 0, '丗')); + + // 丗丙业丞 is as good a match as 丗丙丛 + // so when doing multi character searches we should + // prefer to stay on the same index unless we invalidate + // our typed text + Assert.Equal (3, n.CalculateNewIndex (s, 3, '丙')); + + // No longer matches 丗丙业丞 and now only matches 丗丙丛 + // so we should move to the new match + Assert.Equal (4, n.CalculateNewIndex (s, 3, '丛')); + + // nothing starts "丗丙丛a" so it jumps to a for appricot + Assert.Equal (0, n.CalculateNewIndex (s, 4, 'a')); + } + + [Fact] + public void TestSearchCollectionNavigator_AtSymbol () + { + var s = new string []{ + "appricot", + "arm", + "ta", + "@bob", + "@bb", + "text", + "egg", + "candle" + }; + + var n = new SearchCollectionNavigator (); + Assert.Equal (3, n.CalculateNewIndex (s, 0, '@')); + Assert.Equal (3, n.CalculateNewIndex (s, 3, 'b')); + Assert.Equal (4, n.CalculateNewIndex (s, 3, 'b')); + } + } +}