Autocomplete.cs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Collections.ObjectModel;
  4. using System.Linq;
  5. using System.Text;
  6. using Rune = System.Rune;
  7. namespace Terminal.Gui {
  8. /// <summary>
  9. /// Renders an overlay on another view at a given point that allows selecting
  10. /// from a range of 'autocomplete' options.
  11. /// </summary>
  12. public class Autocomplete {
  13. /// <summary>
  14. /// The maximum width of the autocomplete dropdown
  15. /// </summary>
  16. public int MaxWidth { get; set; } = 10;
  17. /// <summary>
  18. /// The maximum number of visible rows in the autocomplete dropdown to render
  19. /// </summary>
  20. public int MaxHeight { get; set; } = 6;
  21. /// <summary>
  22. /// True if the autocomplete should be considered open and visible
  23. /// </summary>
  24. protected bool Visible { get; set; } = true;
  25. /// <summary>
  26. /// The strings that form the current list of suggestions to render
  27. /// based on what the user has typed so far.
  28. /// </summary>
  29. public ReadOnlyCollection<string> Suggestions { get; protected set; } = new ReadOnlyCollection<string>(new string[0]);
  30. /// <summary>
  31. /// The full set of all strings that can be suggested.
  32. /// </summary>
  33. /// <returns></returns>
  34. public List<string> AllSuggestions { get; set; } = new List<string>();
  35. /// <summary>
  36. /// The currently selected index into <see cref="Suggestions"/> that the user has highlighted
  37. /// </summary>
  38. public int SelectedIdx { get; set; }
  39. /// <summary>
  40. /// When more suggestions are available than can be rendered the user
  41. /// can scroll down the dropdown list. This indicates how far down they
  42. /// have gone
  43. /// </summary>
  44. public int ScrollOffset {get;set;}
  45. /// <summary>
  46. /// The colors to use to render the overlay. Accessing this property before
  47. /// the Application has been initialised will cause an error
  48. /// </summary>
  49. public ColorScheme ColorScheme {
  50. get
  51. {
  52. if(colorScheme == null)
  53. {
  54. colorScheme = Colors.Menu;
  55. }
  56. return colorScheme;
  57. }
  58. set
  59. {
  60. ColorScheme = value;
  61. }
  62. }
  63. private ColorScheme colorScheme;
  64. /// <summary>
  65. /// The key that the user must press to accept the currently selected autocomplete suggestion
  66. /// </summary>
  67. public Key SelectionKey { get; set; } = Key.Enter;
  68. /// <summary>
  69. /// The key that the user can press to close the currently popped autocomplete menu
  70. /// </summary>
  71. public Key CloseKey {get;set;} = Key.Esc;
  72. /// <summary>
  73. /// Renders the autocomplete dialog inside the given <paramref name="view"/> at the
  74. /// given point.
  75. /// </summary>
  76. /// <param name="view">The view the overlay should be rendered into</param>
  77. /// <param name="renderAt"></param>
  78. public void RenderOverlay (View view, Point renderAt)
  79. {
  80. if (!Visible || !view.HasFocus || Suggestions.Count == 0) {
  81. return;
  82. }
  83. view.Move (renderAt.X, renderAt.Y);
  84. // don't overspill vertically
  85. var height = Math.Min(view.Bounds.Height - renderAt.Y,MaxHeight);
  86. var toRender = Suggestions.Skip(ScrollOffset).Take(height).ToArray();
  87. if(toRender.Length == 0)
  88. {
  89. return;
  90. }
  91. var width = Math.Min(MaxWidth,toRender.Max(s=>s.Length));
  92. // don't overspill horizontally
  93. width = Math.Min(view.Bounds.Width - renderAt.X ,width);
  94. for(int i=0;i<toRender.Length; i++) {
  95. if(i == SelectedIdx - ScrollOffset) {
  96. Application.Driver.SetAttribute (ColorScheme.Focus);
  97. }
  98. else {
  99. Application.Driver.SetAttribute (ColorScheme.Normal);
  100. }
  101. view.Move (renderAt.X, renderAt.Y+i);
  102. var text = TextFormatter.ClipOrPad(toRender[i],width);
  103. Application.Driver.AddStr (text );
  104. }
  105. }
  106. /// <summary>
  107. /// Updates <see cref="SelectedIdx"/> to be a valid index within <see cref="Suggestions"/>
  108. /// </summary>
  109. public void EnsureSelectedIdxIsValid()
  110. {
  111. SelectedIdx = Math.Max (0,Math.Min (Suggestions.Count - 1, SelectedIdx));
  112. // if user moved selection up off top of current scroll window
  113. if(SelectedIdx < ScrollOffset)
  114. {
  115. ScrollOffset = SelectedIdx;
  116. }
  117. // if user moved selection down past bottom of current scroll window
  118. while(SelectedIdx >= ScrollOffset + MaxHeight ){
  119. ScrollOffset++;
  120. }
  121. }
  122. /// <summary>
  123. /// Handle key events before <paramref name="hostControl"/> e.g. to make key events like
  124. /// up/down apply to the autocomplete control instead of changing the cursor position in
  125. /// the underlying text view.
  126. /// </summary>
  127. /// <param name="hostControl"></param>
  128. /// <param name="kb"></param>
  129. /// <returns></returns>
  130. public bool ProcessKey (TextView hostControl, KeyEvent kb)
  131. {
  132. if(IsWordChar((char)kb.Key))
  133. {
  134. Visible = true;
  135. }
  136. if(!Visible || Suggestions.Count == 0) {
  137. return false;
  138. }
  139. if (kb.Key == Key.CursorDown) {
  140. SelectedIdx++;
  141. EnsureSelectedIdxIsValid();
  142. hostControl.SetNeedsDisplay ();
  143. return true;
  144. }
  145. if (kb.Key == Key.CursorUp) {
  146. SelectedIdx--;
  147. EnsureSelectedIdxIsValid();
  148. hostControl.SetNeedsDisplay ();
  149. return true;
  150. }
  151. if(kb.Key == SelectionKey && SelectedIdx >=0 && SelectedIdx < Suggestions.Count) {
  152. var accepted = Suggestions [SelectedIdx];
  153. var typedSoFar = GetCurrentWord (hostControl) ?? "";
  154. if(typedSoFar.Length < accepted.Length) {
  155. // delete the text
  156. for(int i=0;i<typedSoFar.Length;i++)
  157. {
  158. hostControl.DeleteTextBackwards();
  159. }
  160. hostControl.InsertText (accepted);
  161. return true;
  162. }
  163. return false;
  164. }
  165. if(kb.Key == CloseKey)
  166. {
  167. ClearSuggestions ();
  168. Visible = false;
  169. hostControl.SetNeedsDisplay();
  170. return true;
  171. }
  172. return false;
  173. }
  174. /// <summary>
  175. /// Clears <see cref="Suggestions"/>
  176. /// </summary>
  177. public void ClearSuggestions ()
  178. {
  179. Suggestions = Enumerable.Empty<string> ().ToList ().AsReadOnly ();
  180. }
  181. /// <summary>
  182. /// Populates <see cref="Suggestions"/> with all strings in <see cref="AllSuggestions"/> that
  183. /// match with the current cursor position/text in the <paramref name="hostControl"/>
  184. /// </summary>
  185. /// <param name="hostControl">The text view that you want suggestions for</param>
  186. public void GenerateSuggestions (TextView hostControl)
  187. {
  188. // if there is nothing to pick from
  189. if(AllSuggestions.Count == 0) {
  190. ClearSuggestions ();
  191. return;
  192. }
  193. var currentWord = GetCurrentWord (hostControl);
  194. if(string.IsNullOrWhiteSpace(currentWord)) {
  195. ClearSuggestions ();
  196. }
  197. else {
  198. Suggestions = AllSuggestions.Where (o =>
  199. o.StartsWith (currentWord, StringComparison.CurrentCultureIgnoreCase) &&
  200. !o.Equals(currentWord,StringComparison.CurrentCultureIgnoreCase)
  201. ).ToList ().AsReadOnly();
  202. EnsureSelectedIdxIsValid();
  203. }
  204. }
  205. private string GetCurrentWord (TextView hostControl)
  206. {
  207. var currentLine = hostControl.GetCurrentLine ();
  208. var cursorPosition = Math.Min (hostControl.CurrentColumn, currentLine.Count);
  209. return IdxToWord (currentLine, cursorPosition);
  210. }
  211. private string IdxToWord (List<Rune> line, int idx)
  212. {
  213. StringBuilder sb = new StringBuilder ();
  214. // do not generate suggestions if the cursor is positioned in the middle of a word
  215. bool areMidWord;
  216. if(idx == line.Count) {
  217. // the cursor positioned at the very end of the line
  218. areMidWord = false;
  219. }
  220. else {
  221. // we are in the middle of a word if the cursor is over a letter/number
  222. areMidWord = IsWordChar (line [idx]);
  223. }
  224. // if we are in the middle of a word then there is no way to autocomplete that word
  225. if(areMidWord) {
  226. return null;
  227. }
  228. // we are at the end of a word. Work out what has been typed so far
  229. while(idx-- > 0) {
  230. if(IsWordChar(line [idx])) {
  231. sb.Insert(0,(char)line [idx]);
  232. }
  233. else {
  234. break;
  235. }
  236. }
  237. return sb.ToString ();
  238. }
  239. /// <summary>
  240. /// Return true if the given symbol should be considered part of a word
  241. /// and can be contained in matches. Base behaviour is to use <see cref="char.IsLetterOrDigit(char)"/>
  242. /// </summary>
  243. /// <param name="rune"></param>
  244. /// <returns></returns>
  245. public virtual bool IsWordChar (Rune rune)
  246. {
  247. return Char.IsLetterOrDigit ((char)rune);
  248. }
  249. }
  250. }