Files
DodoSSH/src/DodoSSH.Client.Shell/Keyboard/EchoedLine.cs
T
jaap-jan 367cebae9b
ci / build and test (push) Successful in 4m52s
ci / desktop nightly (push) Failing after 58s
ci / android head (push) Successful in 5m59s
ci / api image (push) Successful in 8s
Android: a customisable key bar, a quick-access keyboard, a long-press cursor gesture, and a side panel on tablets
The key bar above the software keyboard is now the user's own: which keys, in what order, up to sixteen
in two rows that never scroll. Ctrl and Alt latch across the bar and the phone's keyboard, and Ctrl or Alt
with a cursor key sends the modified CSI form. Seven predefined groups (essentials, cursor, control,
symbols, tmux, vim, function) and keys of one's own, written in \e / ^C notation, are arranged on a
customise sheet that saves as it goes.

A grid button swaps the software keyboard for a quick-access keyboard of the same height, with KEYS,
SNIPPETS, HISTORY and APPEARANCE tabs; it reopens to the tab last used. Snippets go through the shell's
existing InsertSnippet. History keeps a typed command only once the remote has echoed it, so nothing
typed at a password prompt is ever recorded; it is memory-only and LOCK clears it. Each session pump now
keeps its last kilobyte of output and the alternate-screen state for that test (OutputTail).

Long-press and drag on the terminal sends arrow keys, as a joystick with three speeds by distance;
scrolling is unchanged and there is no mode switch. It takes the touch before Chromium's own long press,
so a held finger no longer pastes; paste is on the bar by default instead. A switch in APPEARANCE turns
the gesture off. The text-size buttons moved from the bar to APPEARANCE.

On wide screens the same tabs open as a side panel beside the terminal, toggled from the top bar, with
the bar's own keys repeated at the top of KEYS while the bar stands down.

The model, gesture arithmetic, echo reading and history are in DodoSSH.Client.Shell and covered by
TerminalKeyboardTests and OutputTailTests. Builds clean; not yet exercised on a device, because the
emulator's install is locked behind a passphrase that was not recorded.
2026-10-03 14:29:22 +02:00

206 lines
5.6 KiB
C#

using System.Globalization;
using System.Text;
namespace DodoSSH.Client.Shell.Keyboard;
/// <summary>
/// The line the cursor is on, as a terminal would draw it from the last of a session's output.
/// </summary>
/// <remarks>
/// <para>
/// <b>Just enough of a terminal to read back a line being edited</b>, 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal static class EchoedLine
{
/// <summary>Reads the cursor's line out of a session's recent output.</summary>
internal static string LastLine(ReadOnlySpan<byte> 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++;
}
/// <summary>Follows one escape sequence starting at <paramref name="at"/>.</summary>
/// <returns>The index of its last character.</returns>
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;
}
}
}