using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Text; using Rune = System.Rune; namespace Terminal.Gui { /// /// Renders an overlay on another view at a given point that allows selecting /// from a range of 'autocomplete' options. /// public class Autocomplete { /// /// The maximum width of the autocomplete dropdown /// public int MaxWidth { get; set; } = 10; /// /// The maximum number of visible rows in the autocomplete dropdown to render /// public int MaxHeight { get; set; } = 6; /// /// True if the autocomplete should be considered open and visible /// protected bool Visible { get; set; } = true; /// /// The strings that form the current list of suggestions to render /// based on what the user has typed so far. /// public ReadOnlyCollection Suggestions { get; protected set; } = new ReadOnlyCollection(new string[0]); /// /// The full set of all strings that can be suggested. /// /// public List AllSuggestions { get; set; } = new List(); /// /// The currently selected index into that the user has highlighted /// public int SelectedIdx { get; set; } /// /// When more suggestions are available than can be rendered the user /// can scroll down the dropdown list. This indicates how far down they /// have gone /// public int ScrollOffset {get;set;} /// /// The colors to use to render the overlay. Accessing this property before /// the Application has been initialised will cause an error /// public ColorScheme ColorScheme { get { if(colorScheme == null) { colorScheme = Colors.Menu; } return colorScheme; } set { colorScheme = value; } } private ColorScheme colorScheme; /// /// The key that the user must press to accept the currently selected autocomplete suggestion /// public Key SelectionKey { get; set; } = Key.Enter; /// /// The key that the user can press to close the currently popped autocomplete menu /// public Key CloseKey {get;set;} = Key.Esc; /// /// Renders the autocomplete dialog inside the given at the /// given point. /// /// The view the overlay should be rendered into /// public void RenderOverlay (View view, Point renderAt) { if (!Visible || !view.HasFocus || Suggestions.Count == 0) { return; } view.Move (renderAt.X, renderAt.Y); // don't overspill vertically var height = Math.Min(view.Bounds.Height - renderAt.Y,MaxHeight); var toRender = Suggestions.Skip(ScrollOffset).Take(height).ToArray(); if(toRender.Length == 0) { return; } var width = Math.Min(MaxWidth,toRender.Max(s=>s.Length)); // don't overspill horizontally width = Math.Min(view.Bounds.Width - renderAt.X ,width); for(int i=0;i /// Updates to be a valid index within /// public void EnsureSelectedIdxIsValid() { SelectedIdx = Math.Max (0,Math.Min (Suggestions.Count - 1, SelectedIdx)); // if user moved selection up off top of current scroll window if(SelectedIdx < ScrollOffset) { ScrollOffset = SelectedIdx; } // if user moved selection down past bottom of current scroll window while(SelectedIdx >= ScrollOffset + MaxHeight ){ ScrollOffset++; } } /// /// Handle key events before e.g. to make key events like /// up/down apply to the autocomplete control instead of changing the cursor position in /// the underlying text view. /// /// /// /// public bool ProcessKey (TextView hostControl, KeyEvent kb) { if(IsWordChar((char)kb.Key)) { Visible = true; } if(!Visible || Suggestions.Count == 0) { return false; } if (kb.Key == Key.CursorDown) { SelectedIdx++; EnsureSelectedIdxIsValid(); hostControl.SetNeedsDisplay (); return true; } if (kb.Key == Key.CursorUp) { SelectedIdx--; EnsureSelectedIdxIsValid(); hostControl.SetNeedsDisplay (); return true; } if(kb.Key == SelectionKey && SelectedIdx >=0 && SelectedIdx < Suggestions.Count) { var accepted = Suggestions [SelectedIdx]; var typedSoFar = GetCurrentWord (hostControl) ?? ""; if(typedSoFar.Length < accepted.Length) { // delete the text for(int i=0;i /// Clears /// public void ClearSuggestions () { Suggestions = Enumerable.Empty ().ToList ().AsReadOnly (); } /// /// Populates with all strings in that /// match with the current cursor position/text in the /// /// The text view that you want suggestions for public void GenerateSuggestions (TextView hostControl) { // if there is nothing to pick from if(AllSuggestions.Count == 0) { ClearSuggestions (); return; } var currentWord = GetCurrentWord (hostControl); if(string.IsNullOrWhiteSpace(currentWord)) { ClearSuggestions (); } else { Suggestions = AllSuggestions.Where (o => o.StartsWith (currentWord, StringComparison.CurrentCultureIgnoreCase) && !o.Equals(currentWord,StringComparison.CurrentCultureIgnoreCase) ).ToList ().AsReadOnly(); EnsureSelectedIdxIsValid(); } } private string GetCurrentWord (TextView hostControl) { var currentLine = hostControl.GetCurrentLine (); var cursorPosition = Math.Min (hostControl.CurrentColumn, currentLine.Count); return IdxToWord (currentLine, cursorPosition); } private string IdxToWord (List line, int idx) { StringBuilder sb = new StringBuilder (); // do not generate suggestions if the cursor is positioned in the middle of a word bool areMidWord; if(idx == line.Count) { // the cursor positioned at the very end of the line areMidWord = false; } else { // we are in the middle of a word if the cursor is over a letter/number areMidWord = IsWordChar (line [idx]); } // if we are in the middle of a word then there is no way to autocomplete that word if(areMidWord) { return null; } // we are at the end of a word. Work out what has been typed so far while(idx-- > 0) { if(IsWordChar(line [idx])) { sb.Insert(0,(char)line [idx]); } else { break; } } return sb.ToString (); } /// /// Return true if the given symbol should be considered part of a word /// and can be contained in matches. Base behaviour is to use /// /// /// public virtual bool IsWordChar (Rune rune) { return Char.IsLetterOrDigit ((char)rune); } } }