CollectionNavigatorBase.cs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. #nullable enable
  2. namespace Terminal.Gui;
  3. /// <inheritdoc/>
  4. internal abstract class CollectionNavigatorBase : ICollectionNavigator
  5. {
  6. private DateTime _lastKeystroke = DateTime.Now;
  7. private string _searchString = "";
  8. /// <inheritdoc/>
  9. public ICollectionNavigatorMatcher Matcher { get; set; } = new DefaultCollectionNavigatorMatcher ();
  10. /// <inheritdoc/>
  11. public string SearchString
  12. {
  13. get => _searchString;
  14. private set
  15. {
  16. _searchString = value;
  17. OnSearchStringChanged (new (value));
  18. }
  19. }
  20. /// <inheritdoc/>
  21. public int TypingDelay { get; set; } = 500;
  22. /// <inheritdoc/>
  23. public int GetNextMatchingItem (int currentIndex, char keyStruck)
  24. {
  25. if (!char.IsControl (keyStruck))
  26. {
  27. // maybe user pressed 'd' and now presses 'd' again.
  28. // a candidate search is things that begin with "dd"
  29. // but if we find none then we must fallback on cycling
  30. // d instead and discard the candidate state
  31. var candidateState = "";
  32. var elapsedTime = DateTime.Now - _lastKeystroke;
  33. Logging.Debug ($"CollectionNavigator began processing '{keyStruck}', it has been {elapsedTime} since last keystroke");
  34. // is it a second or third (etc) keystroke within a short time
  35. if (SearchString.Length > 0 && elapsedTime < TimeSpan.FromMilliseconds (TypingDelay))
  36. {
  37. // "dd" is a candidate
  38. candidateState = SearchString + keyStruck;
  39. Logging.Debug ($"Appending, search is now for '{candidateState}'");
  40. }
  41. else
  42. {
  43. // its a fresh keystroke after some time
  44. // or its first ever key press
  45. SearchString = new string (keyStruck, 1);
  46. Logging.Debug ($"It has been too long since last key press so beginning new search");
  47. }
  48. int idxCandidate = GetNextMatchingItem (
  49. currentIndex,
  50. candidateState,
  51. // prefer not to move if there are multiple characters e.g. "ca" + 'r' should stay on "car" and not jump to "cart"
  52. candidateState.Length > 1
  53. );
  54. Logging.Debug ($"CollectionNavigator searching (preferring minimum movement) matched:{idxCandidate}");
  55. if (idxCandidate != -1)
  56. {
  57. // found "dd" so candidate search string is accepted
  58. _lastKeystroke = DateTime.Now;
  59. SearchString = candidateState;
  60. Logging.Debug ($"Found collection item that matched search:{idxCandidate}");
  61. return idxCandidate;
  62. }
  63. //// nothing matches "dd" so discard it as a candidate
  64. //// and just cycle "d" instead
  65. _lastKeystroke = DateTime.Now;
  66. idxCandidate = GetNextMatchingItem (currentIndex, candidateState);
  67. Logging.Debug ($"CollectionNavigator searching (any match) matched:{idxCandidate}");
  68. // if a match wasn't found, the user typed a 'wrong' key in their search ("can" + 'z'
  69. // instead of "can" + 'd').
  70. if (SearchString.Length > 1 && idxCandidate == -1)
  71. {
  72. Logging.Debug ("CollectionNavigator ignored key and returned existing index");
  73. // ignore it since we're still within the typing delay
  74. // don't add it to SearchString either
  75. return currentIndex;
  76. }
  77. // if no changes to current state manifested
  78. if (idxCandidate == currentIndex || idxCandidate == -1)
  79. {
  80. Logging.Debug ("CollectionNavigator found no changes to current index, so clearing search");
  81. // clear history and treat as a fresh letter
  82. ClearSearchString ();
  83. // match on the fresh letter alone
  84. SearchString = new string (keyStruck, 1);
  85. idxCandidate = GetNextMatchingItem (currentIndex, SearchString);
  86. Logging.Debug ($"CollectionNavigator new SearchString {SearchString} matched index:{idxCandidate}");
  87. return idxCandidate == -1 ? currentIndex : idxCandidate;
  88. }
  89. Logging.Debug ($"CollectionNavigator final answer was:{idxCandidate}");
  90. // Found another "d" or just leave index as it was
  91. return idxCandidate;
  92. }
  93. Logging.Debug ("CollectionNavigator found key press was not actionable so clearing search and returning -1");
  94. // clear state because keypress was a control char
  95. ClearSearchString ();
  96. // control char indicates no selection
  97. return -1;
  98. }
  99. /// <summary>
  100. /// Raised when the <see cref="SearchString"/> is changed. Useful for debugging. Raises the
  101. /// <see cref="SearchStringChanged"/> event.
  102. /// </summary>
  103. /// <param name="e"></param>
  104. protected virtual void OnSearchStringChanged (KeystrokeNavigatorEventArgs e) { SearchStringChanged?.Invoke (this, e); }
  105. /// <summary>This event is raised when <see cref="SearchString"/> is changed. Useful for debugging.</summary>
  106. public event EventHandler<KeystrokeNavigatorEventArgs>? SearchStringChanged;
  107. /// <summary>Returns the collection being navigated element at <paramref name="idx"/>.</summary>
  108. /// <returns></returns>
  109. protected abstract object ElementAt (int idx);
  110. /// <summary>Return the number of elements in the collection</summary>
  111. protected abstract int GetCollectionLength ();
  112. /// <summary>Gets the index of the next item in the collection that matches <paramref name="search"/>.</summary>
  113. /// <param name="currentIndex">The index in the collection to start the search from.</param>
  114. /// <param name="search">The search string to use.</param>
  115. /// <param name="minimizeMovement">
  116. /// Set to <see langword="true"/> to stop the search on the first match if there are
  117. /// multiple matches for <paramref name="search"/>. e.g. "ca" + 'r' should stay on "car" and not jump to "cart". If
  118. /// <see langword="false"/> (the default), the next matching item will be returned, even if it is above in the
  119. /// collection.
  120. /// </param>
  121. /// <returns>The index of the next matching item or <see langword="-1"/> if no match was found.</returns>
  122. internal int GetNextMatchingItem (int currentIndex, string search, bool minimizeMovement = false)
  123. {
  124. if (string.IsNullOrEmpty (search))
  125. {
  126. return -1;
  127. }
  128. int collectionLength = GetCollectionLength ();
  129. if (currentIndex != -1 && currentIndex < collectionLength && Matcher.IsMatch (search, ElementAt (currentIndex)))
  130. {
  131. // we are already at a match
  132. if (minimizeMovement)
  133. {
  134. // if we would rather not jump around (e.g. user is typing lots of text to get this match)
  135. return currentIndex;
  136. }
  137. for (var i = 1; i < collectionLength; i++)
  138. {
  139. //circular
  140. int idxCandidate = (i + currentIndex) % collectionLength;
  141. if (Matcher.IsMatch (search, ElementAt (idxCandidate)))
  142. {
  143. return idxCandidate;
  144. }
  145. }
  146. // nothing else starts with the search term
  147. return currentIndex;
  148. }
  149. // search terms no longer match the current selection or there is none
  150. for (var i = 0; i < collectionLength; i++)
  151. {
  152. if (Matcher.IsMatch (search, ElementAt (i)))
  153. {
  154. return i;
  155. }
  156. }
  157. // Nothing matches
  158. return -1;
  159. }
  160. private void ClearSearchString ()
  161. {
  162. SearchString = "";
  163. _lastKeystroke = DateTime.Now;
  164. }
  165. }