-
Notifications
You must be signed in to change notification settings - Fork 4.5k
/
KeyParser.cs
392 lines (346 loc) · 20.1 KB
/
KeyParser.cs
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
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Generic;
using System.Diagnostics;
namespace System.IO;
internal static class KeyParser
{
private const char Escape = '\u001B';
private const char Delete = '\u007F';
private const char VtSequenceEndTag = '~';
private const char ModifierSeparator = ';';
private const int MinimalSequenceLength = 3;
private const int SequencePrefixLength = 2; // ^[[ ("^[" stands for Escape)
internal static ConsoleKeyInfo Parse(char[] buffer, TerminalFormatStrings terminalFormatStrings, byte posixDisableValue, byte veraseCharacter, ref int startIndex, int endIndex)
{
int length = endIndex - startIndex;
Debug.Assert(length > 0);
// VERASE overrides anything from Terminfo. Both settings can be different for Linux and macOS.
if (buffer[startIndex] != posixDisableValue && buffer[startIndex] == veraseCharacter)
{
// the original char is preserved on purpose (backward compat + consistency)
return new ConsoleKeyInfo(buffer[startIndex++], ConsoleKey.Backspace, false, false, false);
}
// Escape Sequences start with Escape. But some terminals like PuTTY and rxvt prepend Escape to express that for given sequence Alt was pressed.
if (length >= MinimalSequenceLength + 1 && buffer[startIndex] == Escape && buffer[startIndex + 1] == Escape)
{
startIndex++;
if (TryParseTerminalInputSequence(buffer, terminalFormatStrings, out ConsoleKeyInfo parsed, ref startIndex, endIndex))
{
return new ConsoleKeyInfo(parsed.KeyChar, parsed.Key, (parsed.Modifiers & ConsoleModifiers.Shift) != 0, alt: true, (parsed.Modifiers & ConsoleModifiers.Control) != 0);
}
startIndex--;
}
else if (length >= MinimalSequenceLength && TryParseTerminalInputSequence(buffer, terminalFormatStrings, out ConsoleKeyInfo parsed, ref startIndex, endIndex))
{
return parsed;
}
if (length == 2 && buffer[startIndex] == Escape && buffer[startIndex + 1] != Escape)
{
startIndex++; // skip the Escape
return ParseFromSingleChar(buffer[startIndex++], isAlt: true);
}
return ParseFromSingleChar(buffer[startIndex++], isAlt: false);
}
private static bool TryParseTerminalInputSequence(char[] buffer, TerminalFormatStrings terminalFormatStrings, out ConsoleKeyInfo parsed, ref int startIndex, int endIndex)
{
ReadOnlySpan<char> input = buffer.AsSpan(startIndex, endIndex - startIndex);
parsed = default;
// sequences start with either "^[[" or "^[O". "^[" stands for Escape (27).
if (input.Length < MinimalSequenceLength || input[0] != Escape || (input[1] != '[' && input[1] != 'O'))
{
return false;
}
Dictionary<ReadOnlyMemory<char>, ConsoleKeyInfo> terminfoDb = terminalFormatStrings.KeyFormatToConsoleKey; // the most important source of truth
ConsoleModifiers modifiers = 0;
ConsoleKey key;
// Is it a three character sequence? (examples: '^[[H' (Home), '^[OP' (F1))
if (input[1] == 'O' || char.IsAsciiLetter(input[2]) || input.Length == MinimalSequenceLength)
{
if (!terminfoDb.TryGetValue(buffer.AsMemory(startIndex, MinimalSequenceLength), out parsed))
{
// All terminals which use "^[O{letter}" escape sequences don't define conflicting mappings.
// Example: ^[OH either means Home or simply is not used by given terminal.
// But with "^[[{character}" sequences, there are conflicts between rxvt and SCO.
// Example: "^[[a" is Shift+UpArrow for rxvt and Shift+F3 for SCO.
(key, modifiers) = input[1] == 'O' || terminalFormatStrings.IsRxvtTerm
? MapKeyIdOXterm(input[2], terminalFormatStrings.IsRxvtTerm)
: MapSCO(input[2]);
if (key == default)
{
return false; // it was not a known sequence
}
char keyChar = key switch
{
ConsoleKey.Enter => '\r', // "^[OM" should produce new line character (was not previously mapped this way)
ConsoleKey.Add => '+',
ConsoleKey.Subtract => '-',
ConsoleKey.Divide => '/',
ConsoleKey.Multiply => '*',
_ => default
};
parsed = Create(keyChar, key, modifiers);
}
startIndex += MinimalSequenceLength;
return true;
}
// Is it a four character sequence used by Linux Console or PuTTy configured to emulate it? (examples: '^[[[A' (F1), '^[[[B' (F2))
if (input[1] == '[' && input[2] == '[' && char.IsBetween(input[3], 'A', 'E'))
{
if (!terminfoDb.TryGetValue(buffer.AsMemory(startIndex, 4), out parsed))
{
parsed = new ConsoleKeyInfo(default, ConsoleKey.F1 + input[3] - 'A', false, false, false);
}
startIndex += 4;
return true;
}
// If sequence does not start with a letter, it must start with one or two digits that represent the Sequence Number
int digitCount = !char.IsBetween(input[SequencePrefixLength], '1', '9') // not using IsAsciiDigit as 0 is invalid
? 0
: char.IsDigit(input[SequencePrefixLength + 1]) ? 2 : 1;
if (digitCount == 0 // it does not start with a digit, it's not a sequence
|| SequencePrefixLength + digitCount >= input.Length) // it's too short to be a complete sequence
{
parsed = default;
return false;
}
if (IsSequenceEndTag(input[SequencePrefixLength + digitCount]))
{
// it's a VT Sequence like ^[[11~ or rxvt like ^[[11^
int sequenceLength = SequencePrefixLength + digitCount + 1;
if (!terminfoDb.TryGetValue(buffer.AsMemory(startIndex, sequenceLength), out parsed))
{
key = MapEscapeSequenceNumber(byte.Parse(input.Slice(SequencePrefixLength, digitCount)));
if (key == default)
{
return false; // it was not a known sequence
}
if (IsRxvtModifier(input[SequencePrefixLength + digitCount]))
{
modifiers = MapRxvtModifiers(input[SequencePrefixLength + digitCount]);
}
parsed = Create(default, key, modifiers);
}
startIndex += sequenceLength;
return true;
}
// If Sequence Number is not followed by the VT Seqence End Tag,
// it can be followed only by a Modifier Separator, Modifier (2-8) and Key ID or VT Sequence End Tag.
if (input[SequencePrefixLength + digitCount] is not ModifierSeparator
|| SequencePrefixLength + digitCount + 2 >= input.Length
|| !char.IsBetween(input[SequencePrefixLength + digitCount + 1], '2', '8')
|| (!char.IsAsciiLetterUpper(input[SequencePrefixLength + digitCount + 2]) && input[SequencePrefixLength + digitCount + 2] is not VtSequenceEndTag))
{
return false;
}
modifiers = MapXtermModifiers(input[SequencePrefixLength + digitCount + 1]);
key = input[SequencePrefixLength + digitCount + 2] is VtSequenceEndTag
? MapEscapeSequenceNumber(byte.Parse(input.Slice(SequencePrefixLength, digitCount)))
: MapKeyIdOXterm(input[SequencePrefixLength + digitCount + 2], terminalFormatStrings.IsRxvtTerm).key;
if (key == default)
{
return false;
}
startIndex += SequencePrefixLength + digitCount + 3; // 3 stands for separator, modifier and end tag or id
parsed = Create(default, key, modifiers);
return true;
// maps "^[O{character}" for all Terminals and "^[[{character}" for rxvt Terminals
static (ConsoleKey key, ConsoleModifiers modifiers) MapKeyIdOXterm(char character, bool isRxvt)
=> character switch
{
'A' or 'x' => (ConsoleKey.UpArrow, 0), // lowercase used by rxvt
'a' => (ConsoleKey.UpArrow, ConsoleModifiers.Shift), // rxvt
'B' or 'r' => (ConsoleKey.DownArrow, 0), // lowercase used by rxv
'b' => (ConsoleKey.DownArrow, ConsoleModifiers.Shift), // used by rxvt
'C' or 'v' => (ConsoleKey.RightArrow, 0), // lowercase used by rxv
'c' => (ConsoleKey.RightArrow, ConsoleModifiers.Shift), // used by rxvt
'D' or 't' => (ConsoleKey.LeftArrow, 0), // lowercase used by rxv
'd' => (ConsoleKey.LeftArrow, ConsoleModifiers.Shift), // used by rxvt
'E' => (ConsoleKey.NoName, 0), // ^[OE maps to Begin, but we don't have such Key. To reproduce press Num5.
'F' or 'q' => (ConsoleKey.End, 0),
'H' => (ConsoleKey.Home, 0),
'j' => (ConsoleKey.Multiply, 0), // used by both xterm and rxvt
'k' => (ConsoleKey.Add, 0), // used by both xterm and rxvt
'm' => (ConsoleKey.Subtract, 0), // used by both xterm and rxvt
'M' => (ConsoleKey.Enter, 0), // used by xterm, rxvt (they have it Terminfo) and tmux (no record in Terminfo)
'n' => (ConsoleKey.Delete, 0), // rxvt
'o' => (ConsoleKey.Divide, 0), // used by both xterm and rxvt
'P' => (ConsoleKey.F1, 0),
'p' => (ConsoleKey.Insert, 0), // rxvt
'Q' => (ConsoleKey.F2, 0),
'R' => (ConsoleKey.F3, 0),
'S' => (ConsoleKey.F4, 0),
's' => (ConsoleKey.PageDown, 0), // rxvt
'T' => (ConsoleKey.F5, 0), // VT 100+
'U' => (ConsoleKey.F6, 0), // VT 100+
'u' => (ConsoleKey.NoName, 0), // it should be Begin, but we don't have such (press Num5 in rxvt to reproduce)
'V' => (ConsoleKey.F7, 0), // VT 100+
'W' => (ConsoleKey.F8, 0), // VT 100+
'w' when isRxvt => (ConsoleKey.Home, 0),
'w' when !isRxvt => (ConsoleKey.End, 0),
'X' => (ConsoleKey.F9, 0), // VT 100+
'Y' => (ConsoleKey.F10, 0), // VT 100+
'y' => (ConsoleKey.PageUp, 0), // rxvt
'Z' => (ConsoleKey.F11, 0), // VT 100+
'[' => (ConsoleKey.F12, 0), // VT 100+
_ => default
};
// maps "^[[{character}" for SCO terminals, based on https://vt100.net/docs/vt510-rm/chapter6.html
static (ConsoleKey key, ConsoleModifiers modifiers) MapSCO(char character)
=> character switch
{
'A' => (ConsoleKey.UpArrow, 0),
'B' => (ConsoleKey.DownArrow, 0),
'C' => (ConsoleKey.RightArrow, 0),
'D' => (ConsoleKey.LeftArrow, 0),
'F' => (ConsoleKey.End, 0),
'G' => (ConsoleKey.PageDown, 0),
'H' => (ConsoleKey.Home, 0),
'I' => (ConsoleKey.PageUp, 0),
_ when char.IsBetween(character, 'M', 'X') => (ConsoleKey.F1 + character - 'M', 0),
_ when char.IsBetween(character, 'Y', 'Z') => (ConsoleKey.F1 + character - 'Y', ConsoleModifiers.Shift),
_ when char.IsBetween(character, 'a', 'j') => (ConsoleKey.F3 + character - 'a', ConsoleModifiers.Shift),
_ when char.IsBetween(character, 'k', 'v') => (ConsoleKey.F1 + character - 'k', ConsoleModifiers.Control),
_ when char.IsBetween(character, 'w', 'z') => (ConsoleKey.F1 + character - 'w', ConsoleModifiers.Control | ConsoleModifiers.Shift),
'@' => (ConsoleKey.F5, ConsoleModifiers.Control | ConsoleModifiers.Shift),
'[' => (ConsoleKey.F6, ConsoleModifiers.Control | ConsoleModifiers.Shift),
'<' or '\\' => (ConsoleKey.F7, ConsoleModifiers.Control | ConsoleModifiers.Shift), // the Spec says <, PuTTy uses \.
']' => (ConsoleKey.F8, ConsoleModifiers.Control | ConsoleModifiers.Shift),
'^' => (ConsoleKey.F9, ConsoleModifiers.Control | ConsoleModifiers.Shift),
'_' => (ConsoleKey.F10, ConsoleModifiers.Control | ConsoleModifiers.Shift),
'`' => (ConsoleKey.F11, ConsoleModifiers.Control | ConsoleModifiers.Shift),
'{' => (ConsoleKey.F12, ConsoleModifiers.Control | ConsoleModifiers.Shift),
_ => default
};
// based on https://en.wikipedia.org/wiki/ANSI_escape_code#Fe_Escape_sequences
static ConsoleKey MapEscapeSequenceNumber(byte number)
=> number switch
{
1 or 7 => ConsoleKey.Home,
2 => ConsoleKey.Insert,
3 => ConsoleKey.Delete,
4 or 8 => ConsoleKey.End,
5 => ConsoleKey.PageUp,
6 => ConsoleKey.PageDown,
// Limitation: 10 is mapped to F0, ConsoleKey does not define it so it's not supported.
11 => ConsoleKey.F1,
12 => ConsoleKey.F2,
13 => ConsoleKey.F3,
14 => ConsoleKey.F4,
15 => ConsoleKey.F5,
17 => ConsoleKey.F6,
18 => ConsoleKey.F7,
19 => ConsoleKey.F8,
20 => ConsoleKey.F9,
21 => ConsoleKey.F10,
23 => ConsoleKey.F11,
24 => ConsoleKey.F12,
25 => ConsoleKey.F13,
26 => ConsoleKey.F14,
28 => ConsoleKey.F15,
29 => ConsoleKey.F16,
31 => ConsoleKey.F17,
32 => ConsoleKey.F18,
33 => ConsoleKey.F19,
34 => ConsoleKey.F20,
// 9, 16, 22, 27, 30 and 35 have no mapping
_ => default
};
// based on https://www.xfree86.org/current/ctlseqs.html
static ConsoleModifiers MapXtermModifiers(char modifier)
=> modifier switch
{
'2' => ConsoleModifiers.Shift,
'3' => ConsoleModifiers.Alt,
'4' => ConsoleModifiers.Shift | ConsoleModifiers.Alt,
'5' => ConsoleModifiers.Control,
'6' => ConsoleModifiers.Shift | ConsoleModifiers.Control,
'7' => ConsoleModifiers.Alt | ConsoleModifiers.Control,
'8' => ConsoleModifiers.Shift | ConsoleModifiers.Alt | ConsoleModifiers.Control,
_ => default
};
static bool IsSequenceEndTag(char character) => character is VtSequenceEndTag || IsRxvtModifier(character);
static bool IsRxvtModifier(char character) => MapRxvtModifiers(character) != default;
static ConsoleModifiers MapRxvtModifiers(char modifier)
=> modifier switch
{
'^' => ConsoleModifiers.Control,
'$' => ConsoleModifiers.Shift,
'@' => ConsoleModifiers.Control | ConsoleModifiers.Shift,
_ => default
};
static ConsoleKeyInfo Create(char keyChar, ConsoleKey key, ConsoleModifiers modifiers)
=> new(keyChar, key, (modifiers & ConsoleModifiers.Shift) != 0, (modifiers & ConsoleModifiers.Alt) != 0, (modifiers & ConsoleModifiers.Control) != 0);
}
private static ConsoleKeyInfo ParseFromSingleChar(char single, bool isAlt)
{
bool isShift = false, isCtrl = false;
char keyChar = single;
ConsoleKey key = single switch
{
'\b' => ConsoleKey.Backspace,
'\t' => ConsoleKey.Tab,
'\r' or '\n' => ConsoleKey.Enter,
' ' => ConsoleKey.Spacebar,
Escape => ConsoleKey.Escape, // Ctrl+[ and Ctrl+3 are also mapped to 27. Limitation: Ctrl+[ and Ctrl+3 can't be mapped.
Delete => ConsoleKey.Backspace, // Ctrl+8 and Backspace are mapped to 127 (ASCII Delete key). Limitation: Ctrl+8 can't be mapped.
'*' => ConsoleKey.Multiply, // We can't distinguish Dx+Shift and Multiply (Numeric Keypad). Limitation: Shift+Dx can't be mapped.
'/' => ConsoleKey.Divide, // We can't distinguish OemX and Divide (Numeric Keypad). Limitation: OemX keys can't be mapped.
'-' => ConsoleKey.Subtract, // We can't distinguish OemMinus and Subtract (Numeric Keypad). Limitation: OemMinus can't be mapped.
'+' => ConsoleKey.Add, // We can't distinguish OemPlus and Add (Numeric Keypad). Limitation: OemPlus can't be mapped.
'=' => default, // '+' is not mapped to OemPlus, so `=` is not mapped to Shift+OemPlus. Limitation: Shift+OemPlus can't be mapped.
'!' or '@' or '#' or '$' or '%' or '^' or '&' or '&' or '*' or '(' or ')' => default, // We can't make assumptions about keyboard layout neither read it. Limitation: Shift+Dx keys can't be mapped.
',' => ConsoleKey.OemComma, // was not previously mapped this way
'.' => ConsoleKey.OemPeriod, // was not previously mapped this way
_ when char.IsAsciiLetterLower(single) => ConsoleKey.A + single - 'a',
_ when char.IsAsciiLetterUpper(single) => UppercaseCharacter(single, out isShift),
_ when char.IsAsciiDigit(single) => ConsoleKey.D0 + single - '0', // We can't distinguish DX and Ctrl+DX as they produce same values. Limitation: Ctrl+DX can't be mapped.
_ when char.IsBetween(single, (char)1, (char)26) => ControlAndLetterPressed(single, out keyChar, out isCtrl),
_ when char.IsBetween(single, (char)28, (char)31) => ControlAndDigitPressed(single, out keyChar, out isCtrl),
'\u0000' => ControlAndDigitPressed(single, out keyChar, out isCtrl),
_ => default
};
if (single is '\b' or '\n')
{
isCtrl = true; // Ctrl+Backspace is mapped to '\b' (8), Ctrl+Enter to '\n' (10)
}
if (isAlt)
{
isAlt = key != default; // two char sequences starting with Escape are Alt+$Key only when we can recognize the key
}
return new ConsoleKeyInfo(keyChar, key, isShift, isAlt, isCtrl);
static ConsoleKey UppercaseCharacter(char single, out bool isShift)
{
// Previous implementation assumed that all uppercase characters were typed using Shift.
// Limitation: Caps Lock+(a-z) is always mapped to Shift+(a-z).
isShift = true;
return ConsoleKey.A + single - 'A';
}
static ConsoleKey ControlAndLetterPressed(char single, out char keyChar, out bool isCtrl)
{
// Ctrl+(a-z) characters are mapped to values from 1 to 26.
// Ctrl+H is mapped to 8, which also maps to Ctrl+Backspace.
// Ctrl+I is mapped to 9, which also maps to Tab.
// Ctrl+J is mapped to 10, which also maps to Ctrl+Enter ('\n').
// Ctrl+M is mapped to 13, which also maps to Enter ('\r').
// Limitation: Ctrl+H, Ctrl+I, Ctrl+J and Crl+M can't be mapped. More: https://unix.stackexchange.com/questions/563469/conflict-ctrl-i-with-tab-in-normal-mode
Debug.Assert(single != 'b' && single != '\t' && single != '\n' && single != '\r');
isCtrl = true;
keyChar = default; // we could use the letter here, but it's impossible to distinguish upper vs lowercase (and Windows doesn't do it as well)
return ConsoleKey.A + single - 1;
}
static ConsoleKey ControlAndDigitPressed(char single, out char keyChar, out bool isCtrl)
{
// Ctrl+(D3-D7) characters are mapped to values from 27 to 31. Escape is also mapped to 27.
// Limitation: Ctrl+(D1, D3, D8, D9 and D0) can't be mapped.
Debug.Assert(single == default || char.IsBetween(single, (char)28, (char)31));
isCtrl = true;
keyChar = default; // consistent with Windows
return single switch
{
'\u0000' => ConsoleKey.D2, // was not previously mapped this way
_ => ConsoleKey.D4 + single - 28
};
}
}
}