SearchCollectionNavigator.cs 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. namespace Terminal.Gui {
  5. /// <summary>
  6. /// Changes the index in a collection based on keys pressed
  7. /// and the current state
  8. /// </summary>
  9. class SearchCollectionNavigator {
  10. string state = "";
  11. DateTime lastKeystroke = DateTime.MinValue;
  12. const int TypingDelay = 250;
  13. public StringComparer Comparer { get; set; } = StringComparer.InvariantCultureIgnoreCase;
  14. private IEnumerable<object> Collection { get => _collection; set => _collection = value; }
  15. private IEnumerable<object> _collection;
  16. public SearchCollectionNavigator (IEnumerable<object> collection) { _collection = collection; }
  17. public int CalculateNewIndex (IEnumerable<object> collection, int currentIndex, char keyStruck)
  18. {
  19. // if user presses a key
  20. if (!char.IsControl(keyStruck)) {//char.IsLetterOrDigit (keyStruck) || char.IsPunctuation (keyStruck) || char.IsSymbol(keyStruck)) {
  21. // maybe user pressed 'd' and now presses 'd' again.
  22. // a candidate search is things that begin with "dd"
  23. // but if we find none then we must fallback on cycling
  24. // d instead and discard the candidate state
  25. string candidateState = "";
  26. // is it a second or third (etc) keystroke within a short time
  27. if (state.Length > 0 && DateTime.Now - lastKeystroke < TimeSpan.FromMilliseconds (TypingDelay)) {
  28. // "dd" is a candidate
  29. candidateState = state + keyStruck;
  30. } else {
  31. // its a fresh keystroke after some time
  32. // or its first ever key press
  33. state = new string (keyStruck, 1);
  34. }
  35. var idxCandidate = GetNextIndexMatching (collection, currentIndex, candidateState,
  36. // prefer not to move if there are multiple characters e.g. "ca" + 'r' should stay on "car" and not jump to "cart"
  37. candidateState.Length > 1);
  38. if (idxCandidate != -1) {
  39. // found "dd" so candidate state is accepted
  40. lastKeystroke = DateTime.Now;
  41. state = candidateState;
  42. return idxCandidate;
  43. }
  44. // nothing matches "dd" so discard it as a candidate
  45. // and just cycle "d" instead
  46. lastKeystroke = DateTime.Now;
  47. idxCandidate = GetNextIndexMatching (collection, currentIndex, state);
  48. // if no changes to current state manifested
  49. if (idxCandidate == currentIndex || idxCandidate == -1) {
  50. // clear history and treat as a fresh letter
  51. ClearState ();
  52. // match on the fresh letter alone
  53. state = new string (keyStruck, 1);
  54. idxCandidate = GetNextIndexMatching (collection, currentIndex, state);
  55. return idxCandidate == -1 ? currentIndex : idxCandidate;
  56. }
  57. // Found another "d" or just leave index as it was
  58. return idxCandidate;
  59. } else {
  60. // clear state because keypress was non letter
  61. ClearState ();
  62. // no change in index for non letter keystrokes
  63. return currentIndex;
  64. }
  65. }
  66. public int CalculateNewIndex (int currentIndex, char keyStruck)
  67. {
  68. return CalculateNewIndex (Collection, currentIndex, keyStruck);
  69. }
  70. private int GetNextIndexMatching (IEnumerable<object> collection, int currentIndex, string search, bool preferNotToMoveToNewIndexes = false)
  71. {
  72. if (string.IsNullOrEmpty (search)) {
  73. return -1;
  74. }
  75. // find indexes of items that start with the search text
  76. int [] matchingIndexes = collection.Select ((item, idx) => (item, idx))
  77. .Where (k => k.item?.ToString().StartsWith (search, StringComparison.InvariantCultureIgnoreCase) ?? false)
  78. .Select (k => k.idx)
  79. .ToArray ();
  80. // if there are items beginning with search
  81. if (matchingIndexes.Length > 0) {
  82. // is one of them currently selected?
  83. var currentlySelected = Array.IndexOf (matchingIndexes, currentIndex);
  84. if (currentlySelected == -1) {
  85. // we are not currently selecting any item beginning with the search
  86. // so jump to first item in list that begins with the letter
  87. return matchingIndexes [0];
  88. } else {
  89. // the current index is part of the matching collection
  90. if (preferNotToMoveToNewIndexes) {
  91. // if we would rather not jump around (e.g. user is typing lots of text to get this match)
  92. return matchingIndexes [currentlySelected];
  93. }
  94. // cycle to next (circular)
  95. return matchingIndexes [(currentlySelected + 1) % matchingIndexes.Length];
  96. }
  97. }
  98. // nothing starts with the search
  99. return -1;
  100. }
  101. private void ClearState ()
  102. {
  103. state = "";
  104. lastKeystroke = DateTime.MinValue;
  105. }
  106. /// <summary>
  107. /// Returns true if <paramref name="kb"/> is a searchable key
  108. /// (e.g. letters, numbers etc) that is valid to pass to to this
  109. /// class for search filtering
  110. /// </summary>
  111. /// <param name="kb"></param>
  112. /// <returns></returns>
  113. public static bool IsCompatibleKey (KeyEvent kb)
  114. {
  115. // For some reason, at least on Windows/Windows Terminal, `$` is coming through with `IsAlt == true`
  116. return !kb.IsAlt && !kb.IsCapslock && !kb.IsCtrl && !kb.IsScrolllock && !kb.IsNumlock;
  117. //return !kb.IsCapslock && !kb.IsCtrl && !kb.IsScrolllock && !kb.IsNumlock;
  118. }
  119. }
  120. }