using System.Globalization; using System.Text; namespace DodoSSH.Client.Shell.Keyboard; /// /// The line the cursor is on, as a terminal would draw it from the last of a session's output. /// /// /// /// Just enough of a terminal to read back a line being edited, and deliberately no more. Line editors /// redraw with a handful of moves — backspace, carriage return, cursor left and right, erase to end of line, /// delete and insert characters — and those are modelled. Everything else that starts with an escape is /// skipped whole: colours, titles, mode switches. A redraw this does not follow produces a line that does /// not match, and the history then keeps nothing, which is the direction it is meant to fail in. /// /// /// The tail can start in the middle of a UTF-8 sequence or an escape. The first becomes a replacement /// character on a line long since scrolled past; the second is a few stray characters at the start of the /// same line. Neither reaches the last line unless it is the only line, and a prompt is never that short. /// /// internal static class EchoedLine { /// Reads the cursor's line out of a session's recent output. internal static string LastLine(ReadOnlySpan tail) { var text = Encoding.UTF8.GetString(tail); var line = new StringBuilder(); var cursor = 0; for (var i = 0; i < text.Length; i++) { var c = text[i]; switch (c) { case '\x1b': i = Escape(text, i, line, ref cursor); break; case '\r': cursor = 0; break; case '\n': line.Clear(); cursor = 0; break; case '\b': cursor = Math.Max(0, cursor - 1); break; case < ' ' or '\x7f': break; default: Put(line, ref cursor, c); break; } } return line.ToString(); } private static void Put(StringBuilder line, ref int cursor, char c) { while (line.Length < cursor) { line.Append(' '); } if (cursor < line.Length) { line[cursor] = c; } else { line.Append(c); } cursor++; } /// Follows one escape sequence starting at . /// The index of its last character. private static int Escape(string text, int at, StringBuilder line, ref int cursor) { if (at + 1 >= text.Length) { return at; } switch (text[at + 1]) { case '[': return ControlSequence(text, at + 2, line, ref cursor); case ']': // An operating system command — a window title, a hyperlink — ends at BEL or ST. for (var i = at + 2; i < text.Length; i++) { if (text[i] == '\a') { return i; } if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '\\') { return i + 1; } } return text.Length - 1; default: // Two-character escapes, and the charset selectors that take one more. return text[at + 1] is '(' or ')' or '*' or '+' ? Math.Min(at + 2, text.Length - 1) : at + 1; } } private static int ControlSequence(string text, int from, StringBuilder line, ref int cursor) { var end = from; while (end < text.Length && text[end] is < '@' or > '~') { end++; } if (end >= text.Length) { return text.Length - 1; } var parameters = text[from..end]; var n = int.TryParse(parameters, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) && parsed > 0 ? parsed : 1; switch (text[end]) { case 'C': cursor += n; break; case 'D': cursor = Math.Max(0, cursor - n); break; case 'G': cursor = n - 1; break; case 'K': EraseInLine(line, cursor, parameters); break; case 'P': if (cursor < line.Length) { line.Remove(cursor, Math.Min(n, line.Length - cursor)); } break; case '@': if (cursor < line.Length) { line.Insert(cursor, new string(' ', n)); } break; } return end; } private static void EraseInLine(StringBuilder line, int cursor, string parameters) { switch (parameters) { case "" or "0": if (cursor < line.Length) { line.Length = cursor; } break; case "1": for (var i = 0; i < Math.Min(cursor + 1, line.Length); i++) { line[i] = ' '; } break; case "2": line.Clear(); break; } } }