Android: a customisable key bar, a quick-access keyboard, a long-press cursor gesture, and a side panel on tablets
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

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.
This commit is contained in:
jaap-jan committed 2026-10-03 14:29:22 +02:00
1 parent c21de4ade4
commit 367cebae9b
22 files changed
+3949 -344

No files matched your search

@@ -0,0 +1,162 @@
using System.Globalization;
using System.Text;
namespace DodoSSH.Client.Shell.Keyboard;
/// <summary>
/// Reads what a custom key should send, in the notation people already write it in.
/// </summary>
/// <remarks>
/// <para>
/// Two notations, both the ones a terminal user has seen before: backslash escapes as <c>printf</c> and
/// <c>bind</c> write them (<c>\e</c>, <c>\r</c>, <c>\t</c>, <c>\n</c>, <c>\\</c>, <c>\xHH</c>), and caret
/// notation as <c>stty</c> and every man page write control characters (<c>^C</c>, <c>^[</c>, <c>^?</c>).
/// Anything else is typed as it stands, in UTF-8.
/// </para>
/// <para>
/// <b>A caret only means Ctrl in front of a character that has a control form</b> — a letter, or one of
/// <c>@[\]^_?</c>. Elsewhere, as in <c>^1</c> or a trailing <c>^</c>, it is a caret. The cost is that
/// <c>grep ^root</c> written here sends Ctrl-R; <c>\^</c> is the way to say a literal caret before a
/// letter, and the customise screen says so.
/// </para>
/// <para>
/// Refusing is reserved for what cannot mean anything — a backslash at the very end, or <c>\x</c> without
/// two hex digits — so a sequence that parses is one that sends exactly what it says.
/// </para>
/// </remarks>
internal static class KeySequence
{
/// <summary>Turns notation into bytes.</summary>
/// <returns>False when the notation is malformed; <paramref name="bytes"/> is then empty.</returns>
internal static bool TryParse(string notation, out byte[] bytes)
{
ArgumentNullException.ThrowIfNull(notation);
var output = new List<byte>(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;
}
/// <summary>Reads the escape whose backslash is at <paramref name="i"/>, leaving it on its last character.</summary>
private static bool TryEscape(string notation, ref int i, List<byte> 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;
}
/// <summary>Moves the plain text gathered so far into the output, as UTF-8.</summary>
private static void FlushLiteral(List<byte> output, StringBuilder literal)
{
if (literal.Length > 0)
{
output.AddRange(Encoding.UTF8.GetBytes(literal.ToString()));
literal.Clear();
}
}
/// <summary>The control character caret notation names, where there is one.</summary>
/// <remarks>
/// <c>^?</c> is DEL rather than a masked byte — the one exception every table of caret notation makes,
/// and the one people reach for as "backspace".
/// </remarks>
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;
}
}