Code
·
308 lines
·
12090 bytes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308// https://github.com/mattj23/InteractiveReadLine
using System.Text;
using InteractiveReadLine.Formatting;
using InteractiveReadLine.KeyBehaviors;
using InteractiveReadLine.Tokenizing;
#nullable disable
namespace InteractiveReadLine
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Gets the current LineState representation of the text and the cursor position
/// </summary>
public LineState LineState => new(TextBuffer.ToString(), cursorPos);
/// <inheritdoc />
public StringBuilder TextBuffer { get; } = new StringBuilder();
/// <inheritdoc />
public int CursorPosition
{
get => cursorPos;
set
{
cursorPos = value;
if (cursorPos > TextBuffer.Length)
cursorPos = TextBuffer.Length;
if (cursorPos < 0)
cursorPos = 0;
}
}
/// <inheritdoc />
public ConsoleKeyInfo ReceivedKey { get; private set; }
/// <inheritdoc />
public void AutoCompleteNext()
{
if (autoCompleteIndex >= 0)
{
// Next index
autoCompleteIndex++;
if (autoCompleteIndex >= autoCompleteSuggestions.Length)
autoCompleteIndex = 0;
this.SetAutoCompleteText();
}
else
this.StartAutoComplete();
}
/// <inheritdoc />
public void AutoCompletePrevious()
{
if (autoCompleteIndex >= 0)
{
// Previous index
autoCompleteIndex--;
if (autoCompleteIndex < 0)
autoCompleteIndex = autoCompleteSuggestions.Length - 1;
this.SetAutoCompleteText();
}
else
this.StartAutoComplete();
}
/// <inheritdoc />
public void InsertText(FormattedText text)
{
provider.InsertText(text);
}
/// <inheritdoc />
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;
}
/// <summary>
/// Interactively manage the user input of a line of text at the console, returning the contents
/// of the text when finished.
/// </summary>
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();
}
/// <summary>
/// Updates the display on the underlying provider. This is where any formatter is called, immediately
/// prior to the display being set.
/// </summary>
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);
}
/// <summary>
/// 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
/// </summary>
private Action<IKeyBehaviorTarget> GetKeyAction(ConsoleKeyInfo info)
{
var charKey = new KeyId(info.KeyChar);
if (config.KeyBehaviors.TryGetValue(charKey, out Action<IKeyBehaviorTarget> 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<IKeyBehaviorTarget> value2))
return value2;
return null;
}
/// <summary>
/// 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.
/// </summary>
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();
}
}
/// <summary>
/// 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.
/// </summary>
private void InvalidateAutoComplete()
{
autoCompleteIndex = Int32.MinValue;
autoCompleteTokens = null;
autoCompleteSuggestions = null;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Causes the ReadLine handler to finish, returning the contents of the text buffer
/// </summary>
public void Finish() => finishTrigger = true;
}
}