WIP: Start refactoring Autocomplete

- AutocompleteBase generates suggestions
  - PopupAutocomplete shows suggestions in popup
  - AppendAutocomplete will show suggestions inline
This commit is contained in:
tznind
2023-03-29 20:14:34 +01:00
parent 0733274ecc
commit 4eeff5e37d
5 changed files with 218 additions and 191 deletions

View File

@@ -8,43 +8,33 @@ namespace Terminal.Gui {
/// Autocomplete for a <see cref="TextField"/> which shows suggestions within the box.
/// Displayed suggestions can be completed using the tab key.
/// </summary>
public class AppendAutocomplete : IAutocomplete {
public class AppendAutocomplete : AutocompleteBase {
private int? currentFragment = null;
private string [] validFragments = new string [0];
private TextField textField;
public View HostControl { get => textField; set => textField = (TextField)value; }
public bool PopupInsideContainer { get; set; }
public int MaxWidth { get; set; }
public int MaxHeight { get; set; }
public bool Visible { get; set; }
public ReadOnlyCollection<string> Suggestions { get; set; }
public List<string> AllSuggestions { get; set; }
public int SelectedIdx { get; set; }
public ColorScheme ColorScheme { get; set; }
public Key SelectionKey { get; set; } = Key.Tab;
public Key CloseKey { get; set; }
public Key Reopen { get; set; }
public override View HostControl { get => textField; set => textField = (TextField)value; }
public override ColorScheme ColorScheme { get; set; }
public void ClearSuggestions ()
public override void ClearSuggestions ()
{
this.currentFragment = null;
this.validFragments = new string [0];
textField.SetNeedsDisplay ();
}
public void GenerateSuggestions (int columnOffset = 0)
public override void GenerateSuggestions (int columnOffset = 0)
{
validFragments = new string []{ "fish", "flipper", "fun" };
}
public bool MouseEvent (MouseEvent me, bool fromHost = false)
public override bool MouseEvent (MouseEvent me, bool fromHost = false)
{
return false;
}
public bool ProcessKey (KeyEvent kb)
public override bool ProcessKey (KeyEvent kb)
{
var key = kb.Key;
if (key == SelectionKey) {
@@ -60,7 +50,7 @@ namespace Terminal.Gui {
return false;
}
public void RenderOverlay (Point renderAt)
public override void RenderOverlay (Point renderAt)
{
if (!this.MakingSuggestion ()) {
return;
@@ -142,5 +132,10 @@ namespace Terminal.Gui {
textField.SetNeedsDisplay ();
return true;
}
protected override string GetCurrentWord (int columnOffset = 0)
{
throw new System.NotImplementedException ();
}
}
}

View File

@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using Rune = System.Rune;
namespace Terminal.Gui {
public abstract class AutocompleteBase : IAutocomplete
{
public abstract View HostControl { get; set; }
public bool PopupInsideContainer { get; set; }
/// <summary>
/// The maximum width of the autocomplete dropdown
/// </summary>
public virtual int MaxWidth { get; set; } = 10;
/// <summary>
/// The maximum number of visible rows in the autocomplete dropdown to render
/// </summary>
public virtual int MaxHeight { get; set; } = 6;
/// <inheritdoc/>
/// <summary>
/// True if the autocomplete should be considered open and visible
/// </summary>
public virtual bool Visible { get; set; }
/// <summary>
/// The strings that form the current list of suggestions to render
/// based on what the user has typed so far.
/// </summary>
public virtual ReadOnlyCollection<string> Suggestions { get; set; } = new ReadOnlyCollection<string> (new string [0]);
/// <summary>
/// The full set of all strings that can be suggested.
/// </summary>
/// <returns></returns>
public virtual List<string> AllSuggestions { get; set; } = new List<string> ();
/// <summary>
/// The currently selected index into <see cref="Suggestions"/> that the user has highlighted
/// </summary>
public virtual int SelectedIdx { get; set; }
/// <inheritdoc/>
public abstract ColorScheme ColorScheme { get; set; }
/// <inheritdoc/>
public virtual Key SelectionKey { get; set; } = Key.Enter;
/// <inheritdoc/>
public virtual Key CloseKey { get; set; } = Key.Esc;
/// <inheritdoc/>
public virtual Key Reopen { get; set; } = Key.Space | Key.CtrlMask | Key.AltMask;
/// <inheritdoc/>
public abstract bool MouseEvent (MouseEvent me, bool fromHost = false);
/// <inheritdoc/>
public abstract bool ProcessKey (KeyEvent kb);
/// <inheritdoc/>
public abstract void RenderOverlay (Point renderAt);
/// <summary>
/// Clears <see cref="Suggestions"/>
/// </summary>
public virtual void ClearSuggestions ()
{
Suggestions = Enumerable.Empty<string> ().ToList ().AsReadOnly ();
}
/// <summary>
/// Populates <see cref="Suggestions"/> with all strings in <see cref="AllSuggestions"/> that
/// match with the current cursor position/text in the <see cref="HostControl"/>
/// </summary>
/// <param name="columnOffset">The column offset.</param>
public virtual void GenerateSuggestions (int columnOffset = 0)
{
// if there is nothing to pick from
if (AllSuggestions.Count == 0) {
ClearSuggestions ();
return;
}
var currentWord = GetCurrentWord (columnOffset);
if (string.IsNullOrWhiteSpace (currentWord)) {
ClearSuggestions ();
} else {
Suggestions = AllSuggestions.Where (o =>
o.StartsWith (currentWord, StringComparison.CurrentCultureIgnoreCase) &&
!o.Equals (currentWord, StringComparison.CurrentCultureIgnoreCase)
).ToList ().AsReadOnly ();
EnsureSelectedIdxIsValid ();
}
}
/// <summary>
/// Updates <see cref="SelectedIdx"/> to be a valid index within <see cref="Suggestions"/>
/// </summary>
public virtual void EnsureSelectedIdxIsValid ()
{
SelectedIdx = Math.Max (0, Math.Min (Suggestions.Count - 1, SelectedIdx));
}
/// <summary>
/// Return true if the given symbol should be considered part of a word
/// and can be contained in matches. Base behavior is to use <see cref="char.IsLetterOrDigit(char)"/>
/// </summary>
/// <param name="rune"></param>
/// <returns></returns>
public virtual bool IsWordChar (Rune rune)
{
return Char.IsLetterOrDigit ((char)rune);
}
/// <summary>
/// Returns the currently selected word from the <see cref="HostControl"/>.
/// <para>
/// When overriding this method views can make use of <see cref="IdxToWord(List{Rune}, int, int)"/>
/// </para>
/// </summary>
/// <param name="columnOffset">The column offset.</param>
/// <returns></returns>
protected abstract string GetCurrentWord (int columnOffset = 0);
/// <summary>
/// <para>
/// Given a <paramref name="line"/> of characters, returns the word which ends at <paramref name="idx"/>
/// or null. Also returns null if the <paramref name="idx"/> is positioned in the middle of a word.
/// </para>
///
/// <para>
/// Use this method to determine whether autocomplete should be shown when the cursor is at
/// a given point in a line and to get the word from which suggestions should be generated.
/// Use the <paramref name="columnOffset"/> to indicate if search the word at left (negative),
/// at right (positive) or at the current column (zero) which is the default.
/// </para>
/// </summary>
/// <param name="line"></param>
/// <param name="idx"></param>
/// <param name="columnOffset"></param>
/// <returns></returns>
protected virtual string IdxToWord (List<Rune> line, int idx, int columnOffset = 0)
{
StringBuilder sb = new StringBuilder ();
var endIdx = idx;
// get the ending word index
while (endIdx < line.Count) {
if (IsWordChar (line [endIdx])) {
endIdx++;
} else {
break;
}
}
// It isn't a word char then there is no way to autocomplete that word
if (endIdx == idx && columnOffset != 0) {
return null;
}
// we are at the end of a word. Work out what has been typed so far
while (endIdx-- > 0) {
if (IsWordChar (line [endIdx])) {
sb.Insert (0, (char)line [endIdx]);
} else {
break;
}
}
return sb.ToString ();
}
}
}

View File

@@ -11,12 +11,12 @@ namespace Terminal.Gui {
/// Renders an overlay on another view at a given point that allows selecting
/// from a range of 'autocomplete' options.
/// </summary>
public abstract class Autocomplete : IAutocomplete {
public abstract class PopupAutocomplete : AutocompleteBase {
private class Popup : View {
Autocomplete autocomplete;
PopupAutocomplete autocomplete;
public Popup (Autocomplete autocomplete)
public Popup (PopupAutocomplete autocomplete)
{
this.autocomplete = autocomplete;
CanFocus = true;
@@ -61,7 +61,7 @@ namespace Terminal.Gui {
/// <summary>
/// The host control to handle.
/// </summary>
public virtual View HostControl {
public override View HostControl {
get => hostControl;
set {
hostControl = value;
@@ -74,6 +74,11 @@ namespace Terminal.Gui {
}
}
public PopupAutocomplete ()
{
PopupInsideContainer = true;
}
private void Top_Removed (object sender, SuperViewChangedEventArgs e)
{
Visible = false;
@@ -112,42 +117,6 @@ namespace Terminal.Gui {
}
}
/// <summary>
/// Gets or sets If the popup is displayed inside or outside the host limits.
/// </summary>
public bool PopupInsideContainer { get; set; } = true;
/// <summary>
/// The maximum width of the autocomplete dropdown
/// </summary>
public virtual int MaxWidth { get; set; } = 10;
/// <summary>
/// The maximum number of visible rows in the autocomplete dropdown to render
/// </summary>
public virtual int MaxHeight { get; set; } = 6;
/// <summary>
/// True if the autocomplete should be considered open and visible
/// </summary>
public virtual bool Visible { get; set; }
/// <summary>
/// The strings that form the current list of suggestions to render
/// based on what the user has typed so far.
/// </summary>
public virtual ReadOnlyCollection<string> Suggestions { get; set; } = new ReadOnlyCollection<string> (new string [0]);
/// <summary>
/// The full set of all strings that can be suggested.
/// </summary>
/// <returns></returns>
public virtual List<string> AllSuggestions { get; set; } = new List<string> ();
/// <summary>
/// The currently selected index into <see cref="Suggestions"/> that the user has highlighted
/// </summary>
public virtual int SelectedIdx { get; set; }
/// <summary>
/// When more suggestions are available than can be rendered the user
@@ -160,7 +129,7 @@ namespace Terminal.Gui {
/// The colors to use to render the overlay. Accessing this property before
/// the Application has been initialized will cause an error
/// </summary>
public virtual ColorScheme ColorScheme {
public override ColorScheme ColorScheme {
get {
if (colorScheme == null) {
colorScheme = Colors.Menu;
@@ -172,27 +141,12 @@ namespace Terminal.Gui {
}
}
/// <summary>
/// The key that the user must press to accept the currently selected autocomplete suggestion
/// </summary>
public virtual Key SelectionKey { get; set; } = Key.Enter;
/// <summary>
/// The key that the user can press to close the currently popped autocomplete menu
/// </summary>
public virtual Key CloseKey { get; set; } = Key.Esc;
/// <summary>
/// The key that the user can press to reopen the currently popped autocomplete menu
/// </summary>
public virtual Key Reopen { get; set; } = Key.Space | Key.CtrlMask | Key.AltMask;
/// <summary>
/// Renders the autocomplete dialog inside or outside the given <see cref="HostControl"/> at the
/// given point.
/// </summary>
/// <param name="renderAt"></param>
public virtual void RenderOverlay (Point renderAt)
public override void RenderOverlay (Point renderAt)
{
if (!Visible || HostControl?.HasFocus == false || Suggestions.Count == 0) {
LastPopupPos = null;
@@ -294,12 +248,10 @@ namespace Terminal.Gui {
}
}
/// <summary>
/// Updates <see cref="SelectedIdx"/> to be a valid index within <see cref="Suggestions"/>
/// </summary>
public virtual void EnsureSelectedIdxIsValid ()
public override void EnsureSelectedIdxIsValid ()
{
SelectedIdx = Math.Max (0, Math.Min (Suggestions.Count - 1, SelectedIdx));
base.EnsureSelectedIdxIsValid ();
// if user moved selection up off top of current scroll window
if (SelectedIdx < ScrollOffset) {
@@ -311,7 +263,6 @@ namespace Terminal.Gui {
ScrollOffset++;
}
}
/// <summary>
/// Handle key events before <see cref="HostControl"/> e.g. to make key events like
/// up/down apply to the autocomplete control instead of changing the cursor position in
@@ -319,7 +270,7 @@ namespace Terminal.Gui {
/// </summary>
/// <param name="kb">The key event.</param>
/// <returns><c>true</c>if the key can be handled <c>false</c>otherwise.</returns>
public virtual bool ProcessKey (KeyEvent kb)
public override bool ProcessKey (KeyEvent kb)
{
if (IsWordChar ((char)kb.Key)) {
Visible = true;
@@ -381,7 +332,7 @@ namespace Terminal.Gui {
/// <param name="me">The mouse event.</param>
/// <param name="fromHost">If was called from the popup or from the host.</param>
/// <returns><c>true</c>if the mouse can be handled <c>false</c>otherwise.</returns>
public virtual bool MouseEvent (MouseEvent me, bool fromHost = false)
public override bool MouseEvent (MouseEvent me, bool fromHost = false)
{
if (fromHost) {
if (!Visible) {
@@ -450,53 +401,7 @@ namespace Terminal.Gui {
}
}
/// <summary>
/// Clears <see cref="Suggestions"/>
/// </summary>
public virtual void ClearSuggestions ()
{
Suggestions = Enumerable.Empty<string> ().ToList ().AsReadOnly ();
}
/// <summary>
/// Populates <see cref="Suggestions"/> with all strings in <see cref="AllSuggestions"/> that
/// match with the current cursor position/text in the <see cref="HostControl"/>
/// </summary>
/// <param name="columnOffset">The column offset.</param>
public virtual void GenerateSuggestions (int columnOffset = 0)
{
// if there is nothing to pick from
if (AllSuggestions.Count == 0) {
ClearSuggestions ();
return;
}
var currentWord = GetCurrentWord (columnOffset);
if (string.IsNullOrWhiteSpace (currentWord)) {
ClearSuggestions ();
} else {
Suggestions = AllSuggestions.Where (o =>
o.StartsWith (currentWord, StringComparison.CurrentCultureIgnoreCase) &&
!o.Equals (currentWord, StringComparison.CurrentCultureIgnoreCase)
).ToList ().AsReadOnly ();
EnsureSelectedIdxIsValid ();
}
}
/// <summary>
/// Return true if the given symbol should be considered part of a word
/// and can be contained in matches. Base behavior is to use <see cref="char.IsLetterOrDigit(char)"/>
/// </summary>
/// <param name="rune"></param>
/// <returns></returns>
public virtual bool IsWordChar (Rune rune)
{
return Char.IsLetterOrDigit ((char)rune);
}
/// <summary>
/// Completes the autocomplete selection process. Called when user hits the <see cref="SelectionKey"/>.
@@ -541,62 +446,6 @@ namespace Terminal.Gui {
return false;
}
/// <summary>
/// Returns the currently selected word from the <see cref="HostControl"/>.
/// <para>
/// When overriding this method views can make use of <see cref="IdxToWord(List{Rune}, int, int)"/>
/// </para>
/// </summary>
/// <param name="columnOffset">The column offset.</param>
/// <returns></returns>
protected abstract string GetCurrentWord (int columnOffset = 0);
/// <summary>
/// <para>
/// Given a <paramref name="line"/> of characters, returns the word which ends at <paramref name="idx"/>
/// or null. Also returns null if the <paramref name="idx"/> is positioned in the middle of a word.
/// </para>
///
/// <para>
/// Use this method to determine whether autocomplete should be shown when the cursor is at
/// a given point in a line and to get the word from which suggestions should be generated.
/// Use the <paramref name="columnOffset"/> to indicate if search the word at left (negative),
/// at right (positive) or at the current column (zero) which is the default.
/// </para>
/// </summary>
/// <param name="line"></param>
/// <param name="idx"></param>
/// <param name="columnOffset"></param>
/// <returns></returns>
protected virtual string IdxToWord (List<Rune> line, int idx, int columnOffset = 0)
{
StringBuilder sb = new StringBuilder ();
var endIdx = idx;
// get the ending word index
while (endIdx < line.Count) {
if (IsWordChar (line [endIdx])) {
endIdx++;
} else {
break;
}
}
// It isn't a word char then there is no way to autocomplete that word
if (endIdx == idx && columnOffset != 0) {
return null;
}
// we are at the end of a word. Work out what has been typed so far
while (endIdx-- > 0) {
if (IsWordChar (line [endIdx])) {
sb.Insert (0, (char)line [endIdx]);
} else {
break;
}
}
return sb.ToString ();
}
/// <summary>
/// Deletes the text backwards before insert the selected text in the <see cref="HostControl"/>.
@@ -664,3 +513,4 @@ namespace Terminal.Gui {
}
}
}

View File

@@ -1315,7 +1315,7 @@ namespace Terminal.Gui {
/// from a range of 'autocomplete' options.
/// An implementation on a TextField.
/// </summary>
public class TextFieldAutocomplete : Autocomplete {
public class TextFieldAutocomplete : PopupAutocomplete {
/// <inheritdoc/>
protected override void DeleteTextBackwards ()

View File

@@ -1146,7 +1146,7 @@ namespace Terminal.Gui {
/// <summary>
/// Provides autocomplete context menu based on suggestions at the current cursor
/// position. Populate <see cref="Autocomplete.AllSuggestions"/> to enable this feature
/// position. Populate <see cref="IAutocomplete.AllSuggestions"/> to enable this feature
/// </summary>
public IAutocomplete Autocomplete { get; protected set; } = new TextViewAutocomplete ();
@@ -1734,7 +1734,6 @@ namespace Terminal.Gui {
}
Height = 1;
LayoutStyle = lyout;
Autocomplete.PopupInsideContainer = false;
SetNeedsDisplay ();
} else if (multiline && savedHeight != null) {
var lyout = LayoutStyle;
@@ -1743,7 +1742,6 @@ namespace Terminal.Gui {
}
Height = savedHeight;
LayoutStyle = lyout;
Autocomplete.PopupInsideContainer = true;
SetNeedsDisplay ();
}
}
@@ -4425,7 +4423,7 @@ namespace Terminal.Gui {
/// from a range of 'autocomplete' options.
/// An implementation on a TextView.
/// </summary>
public class TextViewAutocomplete : Autocomplete {
public class TextViewAutocomplete : PopupAutocomplete {
///<inheritdoc/>
protected override string GetCurrentWord (int columnOffset = 0)