Public Access
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.
482 lines
16 KiB
C#
482 lines
16 KiB
C#
using System.Text;
|
|
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Shell.Keyboard;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Terminal;
|
|
|
|
namespace DodoSSH.Client.App.Tests;
|
|
|
|
/// <summary>
|
|
/// The phone's terminal keys: what each sends, what a latch does to the next one, the cursor gesture's
|
|
/// speeds, the history's refusal to keep what was not echoed, and the arrangement surviving a relaunch.
|
|
/// </summary>
|
|
public sealed class TerminalKeyboardTests : IDisposable
|
|
{
|
|
private readonly string directory =
|
|
Path.Combine(Path.GetTempPath(), $"dodossh-keys-{Guid.CreateVersion7():N}");
|
|
|
|
private readonly List<byte[]> sent = [];
|
|
|
|
/// <summary>What the remote has drawn so far, as the history's echo test reads it.</summary>
|
|
private string screen = "stan@box:~$ ";
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(directory))
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text);
|
|
|
|
private static string Shown(byte[] bytes) => Encoding.ASCII.GetString(bytes);
|
|
|
|
// ── notation ─────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData(@"\e:wq\r", "\x1b:wq\r")]
|
|
[InlineData("^C", "\x03")]
|
|
[InlineData("^c", "\x03")]
|
|
[InlineData("^[", "\x1b")]
|
|
[InlineData("^?", "\x7f")]
|
|
[InlineData(@"\x1b[A", "\x1b[A")]
|
|
[InlineData(@"\^C", "^C")]
|
|
[InlineData("^1", "^1")]
|
|
[InlineData("git status", "git status")]
|
|
[InlineData(@"a\\b", @"a\b")]
|
|
public void Notation_IsReadTheWayATerminalUserWritesIt(string notation, string expected)
|
|
{
|
|
KeySequence.TryParse(notation, out var bytes).ShouldBeTrue();
|
|
|
|
Shown(bytes).ShouldBe(expected);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(@"oops\")]
|
|
[InlineData(@"\x1")]
|
|
[InlineData(@"\xZZ")]
|
|
public void MalformedNotation_IsRefusedRatherThanGuessedAt(string notation) =>
|
|
KeySequence.TryParse(notation, out _).ShouldBeFalse();
|
|
|
|
// ── latches ──────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData("c", true, false, "\x03")]
|
|
[InlineData("C", true, false, "\x03")]
|
|
[InlineData(" ", true, false, "\0")]
|
|
[InlineData("b", false, true, "\u001bb")]
|
|
[InlineData("\x1b[D", true, false, "\x1b[1;5D")]
|
|
[InlineData("\x1b[C", true, true, "\x1b[1;7C")]
|
|
[InlineData("\x1b[A", false, true, "\x1b[1;3A")]
|
|
[InlineData("ls", true, false, "ls")]
|
|
[InlineData("\x1b[15~", true, false, "\x1b[15~")]
|
|
public void ALatchedModifier_EncodesTheNextKeyTheWayXtermDoes(string key, bool control, bool alt, string expected) =>
|
|
Shown(TerminalModifiers.Apply(Ascii(key), control, alt)).ShouldBe(expected);
|
|
|
|
// ── the cursor gesture ───────────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void ADragInsideTheDeadZone_SendsNothing()
|
|
{
|
|
var gesture = new ArrowGesture();
|
|
|
|
gesture.Move(10, -8, 0).ShouldBe(0);
|
|
gesture.Advance(2_000).ShouldBe(0);
|
|
gesture.Direction.ShouldBe(ArrowDirection.None);
|
|
}
|
|
|
|
[Fact]
|
|
public void LeavingTheDeadZone_SendsOneKeyAtOnce_AndTheSlowSpeedWaitsBeforeRepeating()
|
|
{
|
|
var gesture = new ArrowGesture();
|
|
|
|
gesture.Move(-30, 0, 1_000).ShouldBe(1);
|
|
gesture.Direction.ShouldBe(ArrowDirection.Left);
|
|
gesture.Speed.ShouldBe(1);
|
|
|
|
// Held: nothing more until the first-repeat delay is up, then one per interval.
|
|
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs - 1).ShouldBe(0);
|
|
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs).ShouldBe(1);
|
|
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs + ArrowGesture.IntervalMs(1)).ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void DraggingFurtherTheSameWay_ShiftsThroughTheThreeSpeeds()
|
|
{
|
|
var gesture = new ArrowGesture();
|
|
|
|
gesture.Move(0, -30, 0);
|
|
gesture.Speed.ShouldBe(1);
|
|
gesture.Direction.ShouldBe(ArrowDirection.Up);
|
|
|
|
gesture.Move(0, -ArrowGesture.SecondSpeedAt, 10);
|
|
gesture.Speed.ShouldBe(2);
|
|
|
|
gesture.Move(0, -ArrowGesture.ThirdSpeedAt, 20);
|
|
gesture.Speed.ShouldBe(3);
|
|
|
|
// A second at the fastest speed is many keys; the same second at the slowest is a handful.
|
|
var fast = 0;
|
|
|
|
for (var t = 20L; t <= 1_020; t += 10)
|
|
{
|
|
fast += gesture.Advance(t);
|
|
}
|
|
|
|
var slow = new ArrowGesture();
|
|
var slowCount = slow.Move(0, -30, 0);
|
|
|
|
for (var t = 0L; t <= 1_000; t += 10)
|
|
{
|
|
slowCount += slow.Advance(t);
|
|
}
|
|
|
|
fast.ShouldBeGreaterThan(slowCount * 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void ASmallDriftOffTheAxis_DoesNotTurnLeftIntoUp()
|
|
{
|
|
var gesture = new ArrowGesture();
|
|
|
|
gesture.Move(-40, 0, 0);
|
|
gesture.Move(-40, -45, 10);
|
|
|
|
gesture.Direction.ShouldBe(ArrowDirection.Left);
|
|
|
|
// Clearly vertical now, past the hysteresis.
|
|
gesture.Move(-20, -60, 20).ShouldBe(1);
|
|
gesture.Direction.ShouldBe(ArrowDirection.Up);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComingBackToTheCentre_Stops()
|
|
{
|
|
var gesture = new ArrowGesture();
|
|
|
|
gesture.Move(50, 0, 0);
|
|
gesture.Move(3, 2, 10).ShouldBe(0);
|
|
|
|
gesture.Direction.ShouldBe(ArrowDirection.None);
|
|
gesture.Advance(5_000).ShouldBe(0);
|
|
}
|
|
|
|
// ── reading back the echo ────────────────────────────────────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData("\x1b[32mstan@box\x1b[0m:~$ ls -la", "stan@box:~$ ls -la")]
|
|
[InlineData("old output\r\n$ git statsu\b \b\b \bus", "$ git status")]
|
|
[InlineData("$ rm -rf build\r$ ls\x1b[K", "$ ls")]
|
|
[InlineData("\x1b]0;stan@box: ~\a$ uptime", "$ uptime")]
|
|
[InlineData("$ ech\x1b[1D\x1b[1@o", "$ ecoh")]
|
|
public void TheCursorsLine_IsReadAsATerminalWouldDrawIt(string output, string expected) =>
|
|
EchoedLine.LastLine(Ascii(output)).ShouldBe(expected);
|
|
|
|
// ── the history ──────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
private RecentOutput Recent() => new(Ascii(screen), AlternateScreen: false);
|
|
|
|
/// <summary>Types text one key at a time, echoing each key the way an ordinary shell prompt does.</summary>
|
|
private void TypeEchoed(TypedCommandHistory history, string text)
|
|
{
|
|
foreach (var c in text)
|
|
{
|
|
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
|
screen += c;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ACommandTheShellEchoed_IsKept()
|
|
{
|
|
var history = new TypedCommandHistory();
|
|
|
|
TypeEchoed(history, "uptime");
|
|
history.Observe(1, "box", "\r"u8, Recent);
|
|
|
|
history.Entries.ShouldHaveSingleItem().ShouldBe(new HistoryEntry("uptime", "box"));
|
|
}
|
|
|
|
[Fact]
|
|
public void APasswordTypedWithEchoOff_IsNeverKept()
|
|
{
|
|
var history = new TypedCommandHistory();
|
|
screen = "[sudo] password for stan: ";
|
|
|
|
// Typed, but nothing comes back: the prompt line is exactly what it was.
|
|
foreach (var c in "hunter2")
|
|
{
|
|
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
|
}
|
|
|
|
history.Observe(1, "box", "\r"u8, Recent);
|
|
|
|
history.Entries.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void APasswordTypedAfterAnArrowKey_IsStillNeverKept()
|
|
{
|
|
var history = new TypedCommandHistory();
|
|
screen = "Password: ";
|
|
|
|
history.Observe(1, "box", "\x1b[D"u8, Recent);
|
|
|
|
foreach (var c in "s3cret")
|
|
{
|
|
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
|
}
|
|
|
|
history.Observe(1, "box", "\r"u8, Recent);
|
|
|
|
history.Entries.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void APromptThatEchoesStars_IsNotACommand()
|
|
{
|
|
var history = new TypedCommandHistory();
|
|
screen = "Passphrase: ";
|
|
|
|
foreach (var c in "s3cret")
|
|
{
|
|
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
|
screen += '*';
|
|
}
|
|
|
|
history.Observe(1, "box", "\r"u8, Recent);
|
|
|
|
history.Entries.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void ALineFinishedWithTab_IsKeptAsTheShellCompletedIt()
|
|
{
|
|
var history = new TypedCommandHistory();
|
|
|
|
TypeEchoed(history, "cd /var/lo");
|
|
history.Observe(1, "box", "\t"u8, Recent);
|
|
screen += "g/";
|
|
history.Observe(1, "box", "\r"u8, Recent);
|
|
|
|
history.Entries.ShouldHaveSingleItem().Command.ShouldBe("cd /var/log/");
|
|
}
|
|
|
|
[Fact]
|
|
public void NothingTypedIntoAFullScreenProgram_IsKept()
|
|
{
|
|
var history = new TypedCommandHistory();
|
|
|
|
RecentOutput InVi() => new(Ascii("~\r\n~\r\nhello"), AlternateScreen: true);
|
|
|
|
foreach (var c in "hello")
|
|
{
|
|
history.Observe(1, "box", Ascii(c.ToString()), InVi);
|
|
}
|
|
|
|
history.Observe(1, "box", "\r"u8, InVi);
|
|
|
|
history.Entries.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void RunningACommandAgain_MovesItToTheTopRatherThanListingItTwice()
|
|
{
|
|
var history = new TypedCommandHistory();
|
|
|
|
foreach (var command in new[] { "ls", "df -h", "ls" })
|
|
{
|
|
screen = "$ ";
|
|
TypeEchoed(history, command);
|
|
history.Observe(1, "box", "\r"u8, Recent);
|
|
}
|
|
|
|
history.Entries.Select(e => e.Command).ShouldBe(["ls", "df -h"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void CtrlC_AbandonsTheLine()
|
|
{
|
|
var history = new TypedCommandHistory();
|
|
|
|
TypeEchoed(history, "rm -rf /tmp/x");
|
|
history.Observe(1, "box", "\x03"u8, Recent);
|
|
screen = "stan@box:~$ ";
|
|
history.Observe(1, "box", "\r"u8, Recent);
|
|
|
|
history.Entries.ShouldBeEmpty();
|
|
}
|
|
|
|
// ── the view model ───────────────────────────────────────────────────────────────────────────────────
|
|
|
|
private ClientSettingsStore Settings() => new(new ClientPaths(directory));
|
|
|
|
private TerminalKeyboardViewModel Keyboard(ClientSettingsStore? store = null) =>
|
|
new(
|
|
store ?? Settings(),
|
|
() => new TypingTarget(1, "box"),
|
|
(_, bytes) =>
|
|
{
|
|
sent.Add(bytes.ToArray());
|
|
return ValueTask.CompletedTask;
|
|
},
|
|
_ => Recent(),
|
|
(_, _, _, _) => Task.FromResult(true),
|
|
_ => Task.CompletedTask);
|
|
|
|
private static KeyButtonViewModel Button(TerminalKeyboardViewModel keyboard, string id) =>
|
|
keyboard.Groups.SelectMany(g => g.Keys).First(k => k.Key.Id == id);
|
|
|
|
[Fact]
|
|
public void CtrlOnTheBar_AppliesToTheNextLetterFromTheSoftKeyboard_AndThenLetsGo()
|
|
{
|
|
var keyboard = Keyboard();
|
|
|
|
Button(keyboard, "ctrl").PressCommand.Execute(null);
|
|
keyboard.IsControlLatched.ShouldBeTrue();
|
|
Button(keyboard, "ctrl").IsLatched.ShouldBeTrue();
|
|
|
|
keyboard.Send(Ascii("c"));
|
|
keyboard.Send(Ascii("c"));
|
|
|
|
sent.Select(Shown).ShouldBe(["\x03", "c"]);
|
|
keyboard.IsControlLatched.ShouldBeFalse();
|
|
Button(keyboard, "ctrl").IsLatched.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void TheBarAsShipped_IsTwoRows_ModifiersAboveCursorKeys()
|
|
{
|
|
var keyboard = Keyboard();
|
|
|
|
keyboard.BarRows.Count.ShouldBe(2);
|
|
keyboard.BarRows[0].Keys.Select(k => k.Label).ShouldBe(["esc", "tab", "ctrl", "alt", "|", "-", "/", "~"]);
|
|
keyboard.BarRows[1].Keys.Select(k => k.Label).ShouldBe(["←", "↓", "↑", "→", "paste"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ACustomisedBar_SurvivesARelaunch()
|
|
{
|
|
var store = Settings();
|
|
var keyboard = Keyboard(store);
|
|
|
|
keyboard.OpenCustomizeCommand.Execute(null);
|
|
keyboard.BarEditor[1].MoveUpCommand.Execute(null); // tab before esc
|
|
keyboard.BarEditor.Single(r => r.Label == "paste").RemoveCommand.Execute(null);
|
|
keyboard.NewKeyLabel = "wq";
|
|
keyboard.NewKeySequence = @"\e:wq\r";
|
|
keyboard.AddCustomKeyCommand.Execute(null);
|
|
|
|
var relaunched = Keyboard(store);
|
|
var labels = relaunched.BarRows.SelectMany(r => r.Keys).Select(k => k.Label).ToList();
|
|
|
|
labels[0].ShouldBe("tab");
|
|
labels[1].ShouldBe("esc");
|
|
labels.ShouldNotContain("paste");
|
|
labels[^1].ShouldBe("wq");
|
|
relaunched.Groups.Last().Title.ShouldBe("YOUR KEYS");
|
|
|
|
relaunched.BarRows.SelectMany(r => r.Keys).Last().PressCommand.Execute(null);
|
|
Shown(sent.Last()).ShouldBe("\x1b:wq\r");
|
|
}
|
|
|
|
[Fact]
|
|
public void AHiddenGroup_IsLeftOffTheKeysTab_AndStaysHidden()
|
|
{
|
|
var store = Settings();
|
|
var keyboard = Keyboard(store);
|
|
|
|
keyboard.GroupEditor.Single(g => g.Title == "TMUX").IsShown = false;
|
|
|
|
keyboard.Groups.Select(g => g.Title).ShouldNotContain("TMUX");
|
|
Keyboard(store).Groups.Select(g => g.Title).ShouldNotContain("TMUX");
|
|
}
|
|
|
|
[Fact]
|
|
public void Reset_PutsTheBarBack_ButKeepsKeysSomebodyWrote()
|
|
{
|
|
var keyboard = Keyboard();
|
|
|
|
keyboard.NewKeyLabel = "deploy";
|
|
keyboard.NewKeySequence = "make deploy";
|
|
keyboard.AddCustomKeyCommand.Execute(null);
|
|
|
|
keyboard.ResetLayoutCommand.Execute(null);
|
|
|
|
keyboard.BarRows.SelectMany(r => r.Keys).Select(k => k.Key.Id).ShouldBe(TerminalKeyCatalog.DefaultBar);
|
|
keyboard.CustomKeyEditor.ShouldHaveSingleItem().Label.ShouldBe("deploy");
|
|
}
|
|
|
|
[Fact]
|
|
public void AKeyWithANonsenseSequence_IsRefusedWithAReason()
|
|
{
|
|
var keyboard = Keyboard();
|
|
|
|
keyboard.NewKeyLabel = "bad";
|
|
keyboard.NewKeySequence = @"\x1";
|
|
keyboard.AddCustomKeyCommand.Execute(null);
|
|
|
|
keyboard.CustomKeyEditor.ShouldBeEmpty();
|
|
keyboard.CustomizeStatus.ShouldNotBeNullOrEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void TheBarHoldsSixteenKeys_AndSaysSoWhenFull()
|
|
{
|
|
var keyboard = Keyboard();
|
|
|
|
foreach (var choice in keyboard.GroupEditor.Single(g => g.Title == "FUNCTION").Keys.ToList())
|
|
{
|
|
// Rebuilt after every toggle, so look the chip up again each time.
|
|
keyboard.GroupEditor.Single(g => g.Title == "FUNCTION").Keys
|
|
.Single(k => k.Label == choice.Label).ToggleCommand.Execute(null);
|
|
}
|
|
|
|
keyboard.BarRows.Sum(r => r.Keys.Count).ShouldBe(TerminalKeyCatalog.MaximumBarKeys);
|
|
keyboard.CanAddToBar.ShouldBeFalse();
|
|
keyboard.CustomizeStatus.ShouldContain("16");
|
|
}
|
|
|
|
[Fact]
|
|
public void TheQuickAccessKeyboard_ReopensToTheTabLastUsed()
|
|
{
|
|
var store = Settings();
|
|
|
|
Keyboard(store).SelectTabCommand.Execute(QuickAccessTab.History);
|
|
|
|
var relaunched = Keyboard(store);
|
|
|
|
relaunched.SelectedTab.ShouldBe(QuickAccessTab.History);
|
|
relaunched.IsHistoryTab.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void AnIdThisBuildDoesNotKnow_IsSkippedRatherThanRefused()
|
|
{
|
|
var store = Settings();
|
|
store.Write(new ClientSettings(TerminalKeyboard: new TerminalKeyboardSettings(
|
|
Bar: ["esc", "from-the-future", "up"],
|
|
GroupOrder: ["vim", "nonsense"])));
|
|
|
|
var keyboard = Keyboard(store);
|
|
|
|
keyboard.BarRows.ShouldHaveSingleItem().Keys.Select(k => k.Key.Id).ShouldBe(["esc", "up"]);
|
|
keyboard.Groups[0].Title.ShouldBe("VIM");
|
|
keyboard.Groups.Count.ShouldBe(TerminalKeyCatalog.Groups.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheGesturesArrows_IgnoreALatchedCtrl()
|
|
{
|
|
var keyboard = Keyboard();
|
|
|
|
Button(keyboard, "ctrl").PressCommand.Execute(null);
|
|
keyboard.SendArrow(ArrowDirection.Up, 2);
|
|
|
|
Shown(sent.Single()).ShouldBe("\x1b[A\x1b[A");
|
|
keyboard.IsControlLatched.ShouldBeTrue();
|
|
}
|
|
}
|