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;
}
}
}