using System.Globalization; using System.Text; namespace DodoSSH.Client.Shell.Keyboard; /// /// Reads what a custom key should send, in the notation people already write it in. /// /// /// /// Two notations, both the ones a terminal user has seen before: backslash escapes as printf and /// bind write them (\e, \r, \t, \n, \\, \xHH), and caret /// notation as stty and every man page write control characters (^C, ^[, ^?). /// Anything else is typed as it stands, in UTF-8. /// /// /// A caret only means Ctrl in front of a character that has a control form — a letter, or one of /// @[\]^_?. Elsewhere, as in ^1 or a trailing ^, it is a caret. The cost is that /// grep ^root written here sends Ctrl-R; \^ is the way to say a literal caret before a /// letter, and the customise screen says so. /// /// /// Refusing is reserved for what cannot mean anything — a backslash at the very end, or \x without /// two hex digits — so a sequence that parses is one that sends exactly what it says. /// /// internal static class KeySequence { /// Turns notation into bytes. /// False when the notation is malformed; is then empty. internal static bool TryParse(string notation, out byte[] bytes) { ArgumentNullException.ThrowIfNull(notation); var output = new List(notation.Length); var literal = new StringBuilder(); for (var i = 0; i < notation.Length; i++) { var c = notation[i]; if (c == '\\') { if (!TryEscape(notation, ref i, output, literal)) { bytes = []; return false; } continue; } if (c == '^' && i + 1 < notation.Length && TryControl(notation[i + 1], out var control)) { FlushLiteral(output, literal); output.Add(control); i++; continue; } literal.Append(c); } FlushLiteral(output, literal); bytes = [.. output]; return true; } /// Reads the escape whose backslash is at , leaving it on its last character. private static bool TryEscape(string notation, ref int i, List output, StringBuilder literal) { if (i + 1 >= notation.Length) { return false; } var next = notation[++i]; if (next is 'x' or 'X') { // Exactly two digits, always. One digit followed by text would be a guess at where the number // stops. if (notation.Length - i - 1 < 2 || !byte.TryParse( notation.AsSpan(i + 1, 2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var hex)) { return false; } FlushLiteral(output, literal); output.Add(hex); i += 2; return true; } byte? escaped = next switch { 'e' or 'E' => 0x1B, 'r' => 0x0D, 'n' => 0x0A, 't' => 0x09, 'b' => 0x08, 'a' => 0x07, '0' => 0x00, _ => null, }; if (escaped is { } value) { FlushLiteral(output, literal); output.Add(value); } else { // \\, \^ and any other escaped character stand for themselves. literal.Append(next); } return true; } /// Moves the plain text gathered so far into the output, as UTF-8. private static void FlushLiteral(List output, StringBuilder literal) { if (literal.Length > 0) { output.AddRange(Encoding.UTF8.GetBytes(literal.ToString())); literal.Clear(); } } /// The control character caret notation names, where there is one. /// /// ^? is DEL rather than a masked byte — the one exception every table of caret notation makes, /// and the one people reach for as "backspace". /// internal static bool TryControl(char c, out byte control) { if (c == '?') { control = 0x7F; return true; } var upper = char.ToUpperInvariant(c); if (upper is >= '@' and <= '_') { control = (byte)(upper & 0x1F); return true; } control = 0; return false; } }