Browse Source

Merge pull request #1 from tznind/navigator

Add SearchCollectionNavigator
Tig 2 years ago
parent
commit
c38af801b6

+ 121 - 0
Terminal.Gui/Core/SearchCollectionNavigator.cs

@@ -0,0 +1,121 @@
+using System;
+using System.Linq;
+
+namespace Terminal.Gui {
+	/// <summary>
+	/// Changes the index in a collection based on keys pressed
+	/// and the current state
+	/// </summary>
+	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;
+
+		}
+	}
+}

+ 126 - 0
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'));
+		}
+	}
+}