// https://github.com/mattj23/InteractiveReadLine
using System.Text;
using InteractiveReadLine.Formatting;
using InteractiveReadLine.KeyBehaviors;
using InteractiveReadLine.Tokenizing;
#nullable disable
namespace InteractiveReadLine
{
///
/// This class handles getting a single line of input from the underlying IReadLineProvider. It reads keys from the
/// provider, determines what the current line of text being edited should be and where the cursor should be positioned.
/// It pushes out the text to the provider, which also serves as the view.
///
public class ReadLineHandler(IReadLineProvider provider, ReadLineConfig config = null) : IKeyBehaviorTarget
{
private readonly ReadLineConfig config = config ?? ReadLineConfig.Basic;
private int cursorPos = 0;
private int autoCompleteIndex = int.MinValue;
private TokenizedLine autoCompleteTokens;
private bool autoCompleteCalled = false;
private string[] autoCompleteSuggestions = null;
private int historyIndex = config?.History?.Any() == true ? config.History.Count : 0;
private LineState preHistoryState;
private bool finishTrigger = false;
///
/// Gets the current LineState representation of the text and the cursor position
///
public LineState LineState => new(TextBuffer.ToString(), cursorPos);
///
public StringBuilder TextBuffer { get; } = new StringBuilder();
///
public int CursorPosition
{
get => cursorPos;
set
{
cursorPos = value;
if (cursorPos > TextBuffer.Length)
cursorPos = TextBuffer.Length;
if (cursorPos < 0)
cursorPos = 0;
}
}
///
public ConsoleKeyInfo ReceivedKey { get; private set; }
///
public void AutoCompleteNext()
{
if (autoCompleteIndex >= 0)
{
// Next index
autoCompleteIndex++;
if (autoCompleteIndex >= autoCompleteSuggestions.Length)
autoCompleteIndex = 0;
this.SetAutoCompleteText();
}
else
this.StartAutoComplete();
}
///
public void AutoCompletePrevious()
{
if (autoCompleteIndex >= 0)
{
// Previous index
autoCompleteIndex--;
if (autoCompleteIndex < 0)
autoCompleteIndex = autoCompleteSuggestions.Length - 1;
this.SetAutoCompleteText();
}
else
this.StartAutoComplete();
}
///
public void InsertText(FormattedText text)
{
provider.InsertText(text);
}
///
public TokenizedLine GetTextTokens()
{
return config.Lexer?.Invoke(this.LineState);
}
public void HistoryNext()
{
// If there is no history, we don't need to do anything
if (config.History?.Any() != true)
return;
// If we're at the end of the history (including the entered text) we do nothing
if (historyIndex == config.History.Count)
return;
// Otherwise we increment the history index and set the current text buffer based
// on whether or not we still have another history element
historyIndex++;
this.TextBuffer.Clear();
if (historyIndex == config.History.Count)
{
this.TextBuffer.Append(preHistoryState.Text);
this.CursorPosition = preHistoryState.Cursor;
}
else
{
this.TextBuffer.Append(config.History[historyIndex]);
this.CursorPosition = this.TextBuffer.Length;
}
}
public void HistoryPrevious()
{
// If there is no history, we don't need to do anything
if (config.History?.Any() != true)
return;
if (historyIndex == 0)
return;
// Check if we're about to leave entered text to go backwards in the history. If so
// we want to store it first.
if (historyIndex == config.History.Count)
{
preHistoryState = new LineState(this.LineState.Text, this.LineState.Cursor);
}
historyIndex--;
this.TextBuffer.Clear();
this.TextBuffer.Append(config.History[historyIndex]);
this.CursorPosition = this.TextBuffer.Length;
}
///
/// Interactively manage the user input of a line of text at the console, returning the contents
/// of the text when finished.
///
public string ReadLine()
{
// The display must be updated at the beginning if any prompts or other prefix/suffix text
// is to be displayed
this.UpdateDisplay();
// The main processing loop of the handler, this loop will block until it receives a single key from the
// console. It will then attempt to look up a key behavior for that key, and if it finds one it will
// invoke it, otherwise it will invoke the default behavior if there is one. After that it will check
// if the condition to finish the input has been set, and if not it will update the display and wait
// for the next key.
while (true)
{
this.ReceivedKey = provider.ReadKey();
// We will need to check if the line state (text & cursor position) is altered by the
// key behavior which will be run, so we store the current state now
var previousState = this.LineState;
autoCompleteCalled = false;
// See if there's a specific behavior which should be mapped to this key,
// and if so, run it instead of checking the insert/enter behaviors
var behavior = this.GetKeyAction(ReceivedKey);
if (behavior != null)
{
behavior.Invoke(this);
}
else
{
config.DefaultKeyBehavior?.Invoke(this);
}
// Check if the Finish behavior was called, indicating that we can exit this method
// and return the contents of the text buffer to the caller
if (finishTrigger)
break;
// If the text contents or the cursor have changed at all, and we weren't currently
// doing autocomplete, we need to invalidate the auto-completion information
if ((!previousState.Equals(this.LineState)) && !autoCompleteCalled)
this.InvalidateAutoComplete();
this.UpdateDisplay();
}
// If there is a delegate to update the history, invoke it now
config.UpdateHistory?.Invoke(TextBuffer.ToString());
return TextBuffer.ToString();
}
///
/// Updates the display on the underlying provider. This is where any formatter is called, immediately
/// prior to the display being set.
///
private void UpdateDisplay()
{
// Finally, if we have an available formatter, we can get a display format from here
var display = new LineDisplayState(string.Empty, TextBuffer.ToString(), string.Empty, cursorPos);
if (config.FormatterFromLine != null)
display = config.FormatterFromLine.Invoke(LineState);
else if (config.FormatterFromTokens != null && config.Lexer != null)
display = config.FormatterFromTokens(GetTextTokens());
provider.SetDisplay(display);
}
///
/// Check a ConsoleKeyInfo to see if the configuration object has a behavior registered
/// for that key. The character is checked first, and if that fails, the ConsoleKey and the modifier keys
/// are checked. If that fails, null is returned
///
private Action GetKeyAction(ConsoleKeyInfo info)
{
var charKey = new KeyId(info.KeyChar);
if (config.KeyBehaviors.TryGetValue(charKey, out Action value1))
return value1;
var key = new KeyId(info.Key, (info.Modifiers & ConsoleModifiers.Control) != 0,
(info.Modifiers & ConsoleModifiers.Alt) != 0, (info.Modifiers & ConsoleModifiers.Shift) != 0);
if (config.KeyBehaviors.TryGetValue(key, out Action value2))
return value2;
return null;
}
///
/// Initializes and begins the AutoComplete functionality. AutoComplete is its own special state of interaction,
/// and must be initialized before the next/previous functions will work. It then can continue until an action
/// other than next/previous occurs, after which it will have to be reinitialized to be used again. The
/// initialization process involves fetching the valid suggestions for the currently entered text and cursor
/// position.
///
private void StartAutoComplete()
{
if (!config.CanAutoComplete)
return;
autoCompleteTokens = config.Lexer(new LineState(TextBuffer.ToString(), cursorPos));
if (autoCompleteTokens.CursorToken == null)
return;
autoCompleteSuggestions = config.AutoCompletion(autoCompleteTokens) ?? [];
if (autoCompleteTokens.Text != TextBuffer.ToString())
{
TextBuffer.Clear();
TextBuffer.Append(autoCompleteTokens.Text);
CursorPosition = autoCompleteTokens.Cursor;
}
if (autoCompleteSuggestions.Length != 0)
{
autoCompleteIndex = 0;
SetAutoCompleteText();
}
}
///
/// If AutoComplete is currently active, the only allowable actions are next/previous. If any other
/// modification is made to the line, the current AutoComplete suggestions are invalidated and any attempt
/// to use the next/previous functionality will require a reinitialization of the AutoComplete mechanism.
///
private void InvalidateAutoComplete()
{
autoCompleteIndex = Int32.MinValue;
autoCompleteTokens = null;
autoCompleteSuggestions = null;
}
///
/// Inserts the text from the currently selected auto complete suggestion into the token under the cursor. Only
/// works if the system is currently in autocomplete mode.
///
private void SetAutoCompleteText()
{
if (!config.CanAutoComplete || autoCompleteTokens == null || autoCompleteIndex < 0)
return;
autoCompleteCalled = true;
autoCompleteTokens.CursorToken.Text = autoCompleteSuggestions[autoCompleteIndex];
autoCompleteTokens.CursorToken.Cursor = autoCompleteTokens.CursorToken.Text.Length;
TextBuffer.Clear();
TextBuffer.Append(autoCompleteTokens.Text);
CursorPosition = autoCompleteTokens.Cursor;
}
///
/// Causes the ReadLine handler to finish, returning the contents of the text buffer
///
public void Finish() => finishTrigger = true;
}
}