diff --git a/src/DodoSSH.Client.Android/Platform/RendererFocus.cs b/src/DodoSSH.Client.Android/Platform/RendererFocus.cs
index b41a40b..e3f9e77 100644
--- a/src/DodoSSH.Client.Android/Platform/RendererFocus.cs
+++ b/src/DodoSSH.Client.Android/Platform/RendererFocus.cs
@@ -1,10 +1,15 @@
+using global::Android.OS;
using global::Android.Views;
+using Avalonia.Threading;
+
+using DodoSSH.Client.Shell.Keyboard;
+
namespace DodoSSH.Client.Android.Platform;
///
-/// Takes the keyboard away from the terminal's WebView, and reports the taps that should bring it
-/// back.
+/// Takes the keyboard away from the terminal's WebView, reports the taps that should bring it back,
+/// and turns a long press and drag into arrow keys.
///
///
///
@@ -36,6 +41,23 @@ namespace DodoSSH.Client.Android.Platform;
/// keyboard over the very output being scrolled to.
///
///
+/// A long press and a drag is the cursor gesture, and scrolling is still everything else. There is no
+/// mode to switch: a finger that moves straight away is a scroll, exactly as before, and the page gets every
+/// event of it. A finger held still past becomes a joystick for the arrow
+/// keys — see for the speeds — and from that moment the rest of the touch is this
+/// class's: the WebView is sent a cancel, so it lets go of whatever it thought was starting, and is
+/// not shown another event until the finger lifts. A haptic tick says the gesture has begun, because nothing
+/// can be drawn over the terminal to say it instead.
+///
+///
+/// The delay is a little short of the platform's long-press timeout, and that is the point of it. Chromium
+/// starts its own long press — selection, and a contextmenu event the page answers with a paste — at
+/// the platform timeout, so taking the touch away first is what keeps a held finger from pasting the
+/// clipboard into a shell. Both timers run on the main looper and this one is posted first and shorter, so
+/// it wins without a race. With the gesture switched off nothing is taken, and a long press does what it
+/// did before.
+///
+///
/// Found by walking the decor view rather than asked of the NativeWebView control, for the reason
/// TerminalFocus gave and which has not changed: the control does not expose its platform child, and
/// this application only ever has the one WebView, so the walk's first match is necessarily the
@@ -52,17 +74,26 @@ internal static class RendererFocus
///
private static WeakReference? configured;
+ /// How long a finger has to stay still for the cursor gesture, as a share of the long-press timeout.
+ private const double CursorGestureDelay = 0.8;
+
+ /// How often a held gesture is asked whether another arrow has come due — about one frame.
+ private const long TickMs = 16;
+
///
- /// Makes the renderer unable to hold the keyboard, and calls when somebody
- /// taps it.
+ /// Makes the renderer unable to hold the keyboard, calls when somebody taps
+ /// it, and while somebody long-presses and drags it.
///
+ /// A tap: bring the keyboard back.
+ /// Asked at each press, so the setting applies from the next touch.
+ /// How many arrows to send, and which.
///
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
/// WebView does not exist until the surface it lives on has been shown — and because Android is
/// free to tear its renderer down and build a new one, which is a case this head already handles
/// elsewhere (see docs/android-port.md).
///
- public static void Suppress(Action onTapped)
+ public static void Suppress(Action onTapped, Func gesturesEnabled, Action onArrows)
{
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
{
@@ -83,57 +114,246 @@ internal static class RendererFocus
webView.FocusableInTouchMode = false;
webView.ClearFocus();
- WatchForTaps(webView, onTapped);
+ WatchTouches(webView, onTapped, gesturesEnabled, onArrows);
configured = new WeakReference(webView);
}
///
- /// Calls for a tap, and stays out of the way of everything else.
+ /// Calls for a tap, runs the cursor gesture for a long press, and stays out of
+ /// the way of everything else.
///
///
/// A tap is a press and a release that did not travel further than the platform's own
/// ScaledTouchSlop — the same test every Android view uses, and necessary rather than fastidious:
- /// without it, dragging the scrollback would raise the keyboard over the output being scrolled to.
+ /// without it, dragging the scrollback would raise the keyboard over the output being scrolled to. The
+ /// same slop is what cancels a pending long press, so a scroll can never turn into the gesture halfway.
///
- private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
+ private static void WatchTouches(
+ global::Android.Webkit.WebView webView,
+ Action onTapped,
+ Func gesturesEnabled,
+ Action onArrows)
{
- // The fallback is the platform's own historical default, for a context that has somehow gone.
- var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
- var downX = 0f;
- var downY = 0f;
- var moved = false;
+ var touch = new TouchState(webView, gesturesEnabled, onArrows);
webView.Touch += (_, e) =>
{
- // Observed, never consumed. The WebView's own OnTouchEvent still runs, so the page scrolls.
- e.Handled = false;
-
if (e.Event is not { } motion)
{
+ e.Handled = false;
return;
}
- switch (motion.Action)
+ // Observed and passed on, except while the cursor gesture holds the finger: then the page must not
+ // see it scroll.
+ e.Handled = touch.Handle(motion, onTapped);
+ };
+ }
+
+ /// One finger's worth of touch on the terminal, from press to release.
+ ///
+ ///
+ /// A class rather than locals captured by the handler, because two timers — the long press and the
+ /// repeat tick — read and write the same state from outside the touch callback.
+ ///
+ ///
+ /// Avalonia's rather than View.PostDelayed. On this head the
+ /// dispatcher is the main looper, so the timers land on the same thread and in the same queue as the
+ /// touches and as Chromium's own long press; and a dispatcher timer is a plain managed object, where a
+ /// posted Runnable is a Java peer that would want disposing for the life of a view that has none.
+ ///
+ ///
+ private sealed class TouchState
+ {
+ private readonly global::Android.Webkit.WebView webView;
+ private readonly Func gesturesEnabled;
+ private readonly Action onArrows;
+ private readonly ArrowGesture gesture = new();
+ private readonly int slop;
+ private readonly float density;
+ private readonly DispatcherTimer longPress;
+ private readonly DispatcherTimer tick;
+
+ private float downX;
+ private float downY;
+ private float lastX;
+ private float lastY;
+ private long downTime;
+ private bool moved;
+ private bool steering;
+ private int lastSpeed;
+
+ internal TouchState(
+ global::Android.Webkit.WebView webView,
+ Func gesturesEnabled,
+ Action onArrows)
+ {
+ this.webView = webView;
+ this.gesturesEnabled = gesturesEnabled;
+ this.onArrows = onArrows;
+
+ // The fallbacks are the platform's own historical defaults, for a context that has somehow gone.
+ slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
+ density = webView.Resources?.DisplayMetrics?.Density is > 0 and var d ? d : 1;
+
+ longPress = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromMilliseconds(ViewConfiguration.LongPressTimeout * CursorGestureDelay),
+ };
+ longPress.Tick += (_, _) => BeginSteering();
+
+ tick = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(TickMs) };
+ tick.Tick += (_, _) => Tick();
+ }
+
+ /// Whether the event is consumed rather than passed on to the page.
+ internal bool Handle(MotionEvent motion, Action onTapped)
+ {
+ switch (motion.ActionMasked)
{
case MotionEventActions.Down:
- downX = motion.GetX();
- downY = motion.GetY();
- moved = false;
+ Press(motion);
+ return false;
- break;
+ case MotionEventActions.PointerDown:
+ // A second finger is a pinch or a two-finger scroll, never the cursor gesture.
+ longPress.Stop();
+ moved = true;
+ return steering;
+
+ case MotionEventActions.Move when steering:
+ Steer(motion.GetX(), motion.GetY());
+ return true;
case MotionEventActions.Move:
- moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
+ Travel(motion.GetX(), motion.GetY());
+ return false;
- break;
+ case MotionEventActions.Up or MotionEventActions.Cancel:
+ return Release(motion.ActionMasked is MotionEventActions.Up, onTapped);
- case MotionEventActions.Up when !moved:
- onTapped();
-
- break;
+ default:
+ return steering;
}
- };
+ }
+
+ private void Press(MotionEvent motion)
+ {
+ downX = lastX = motion.GetX();
+ downY = lastY = motion.GetY();
+ downTime = motion.DownTime;
+ moved = false;
+ steering = false;
+
+ longPress.Stop();
+
+ if (gesturesEnabled())
+ {
+ longPress.Start();
+ }
+ }
+
+ /// A finger moving before the long press: past the slop it is a scroll, and stays one.
+ private void Travel(float x, float y)
+ {
+ lastX = x;
+ lastY = y;
+
+ if (!moved && (Math.Abs(x - downX) > slop || Math.Abs(y - downY) > slop))
+ {
+ moved = true;
+ longPress.Stop();
+ }
+ }
+
+ /// Whether the release is consumed — it is, when it ends the cursor gesture.
+ private bool Release(bool lifted, Action onTapped)
+ {
+ longPress.Stop();
+
+ if (steering)
+ {
+ EndSteering();
+ return true;
+ }
+
+ if (lifted && !moved)
+ {
+ onTapped();
+ }
+
+ return false;
+ }
+
+ /// The finger has been held still long enough: take the touch and start steering.
+ private void BeginSteering()
+ {
+ longPress.Stop();
+
+ steering = true;
+ lastSpeed = 0;
+ gesture.Reset();
+
+ // The page has seen a press and nothing since; a cancel tells it that press is over, so neither
+ // its scroller nor its own long press goes on to do anything with it. Parents are asked not to
+ // intercept either — the gesture now owns the finger until it lifts.
+ var now = SystemClock.UptimeMillis();
+
+ using (var cancel = MotionEvent.Obtain(downTime, now, MotionEventActions.Cancel, lastX, lastY, MetaKeyStates.None))
+ {
+ if (cancel is not null)
+ {
+ webView.OnTouchEvent(cancel);
+ cancel.Recycle();
+ }
+ }
+
+ webView.Parent?.RequestDisallowInterceptTouchEvent(true);
+ webView.PerformHapticFeedback(FeedbackConstants.LongPress);
+
+ tick.Start();
+ }
+
+ private void Steer(float x, float y)
+ {
+ var count = gesture.Move((x - downX) / density, (y - downY) / density, SystemClock.UptimeMillis());
+
+ Emit(count);
+ }
+
+ /// The repeat clock: whatever has come due since the last frame.
+ private void Tick()
+ {
+ if (steering)
+ {
+ Emit(gesture.Advance(SystemClock.UptimeMillis()));
+ }
+ }
+
+ private void Emit(int count)
+ {
+ if (count > 0)
+ {
+ onArrows(gesture.Direction, count);
+ }
+
+ // A tick under the thumb each time the speed shifts, which is the only way to feel the three
+ // levels without looking: the terminal under the finger cannot draw anything to show them.
+ if (gesture.Speed > lastSpeed && lastSpeed > 0)
+ {
+ webView.PerformHapticFeedback(FeedbackConstants.ClockTick);
+ }
+
+ lastSpeed = gesture.Speed;
+ }
+
+ private void EndSteering()
+ {
+ steering = false;
+ tick.Stop();
+ gesture.Reset();
+ }
}
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
index 466b052..7c85e27 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
+++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
@@ -647,15 +647,34 @@ internal sealed partial class PhoneShell : UserControl
}
///
- /// Lowers the connect menu or the forwards sheet, whichever is up over the terminal.
+ /// Lowers whatever is up over the terminal: the customise sheet, the quick-access keyboard, the connect
+ /// menu or the forwards sheet.
///
/// Whether one was closed, and so whether back has been spent.
///
- /// Both are raised from the terminal's bar and the scrim under each covers that bar, so at most one is
- /// ever open; the order below decides nothing.
+ /// The three sheets are raised from the terminal's bar or its keys and the scrim under each covers both,
+ /// so at most one is ever open. The quick-access keyboard comes second because it is the one that can be
+ /// up with nothing over it — opening the customise sheet lowers it, so the two never compete either.
///
private static bool TryCloseATerminalSheet(MainWindowViewModel current)
{
+ // The customise sheet, raised from the quick-access keyboard. DONE is the only other way off it, and
+ // every change on it has already been saved, so back loses nothing.
+ if (current.Keyboard.IsCustomizing)
+ {
+ current.Keyboard.CloseCustomizeCommand.Execute(null);
+ return true;
+ }
+
+ // The quick-access keyboard stands where the software keyboard stands, and back puts the software
+ // keyboard away without leaving the terminal — so it does the same for this. Only on the terminal
+ // surface: the flag outlives a trip to another screen, where there is no panel to lower.
+ if (current.Keyboard.IsQuickAccessOpen && current.IsTerminalShowing)
+ {
+ current.Keyboard.CloseQuickAccess();
+ return true;
+ }
+
if (current.IsConnectSheetOpen)
{
current.CloseConnectSheetCommand.Execute(null);
diff --git a/src/DodoSSH.Client.Android/Views/QuickAccessKeyboard.axaml b/src/DodoSSH.Client.Android/Views/QuickAccessKeyboard.axaml
new file mode 100644
index 0000000..c2120d3
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/QuickAccessKeyboard.axaml
@@ -0,0 +1,325 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/QuickAccessKeyboard.axaml.cs b/src/DodoSSH.Client.Android/Views/QuickAccessKeyboard.axaml.cs
new file mode 100644
index 0000000..aba231a
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/QuickAccessKeyboard.axaml.cs
@@ -0,0 +1,40 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// The quick-access keyboard: keys, snippets, history and appearance, in four tabs.
+internal sealed partial class QuickAccessKeyboard : UserControl
+{
+ /// Whether the KEYS tab repeats the bar's own keys at the top.
+ ///
+ /// On for the wide screen's side panel, where the bar is not drawn; off under a phone's terminal, where
+ /// the bar is directly above this and a second copy would be two rows of the same keys.
+ ///
+ public static readonly StyledProperty ShowsBarGroupProperty =
+ AvaloniaProperty.Register(nameof(ShowsBarGroup));
+
+ public QuickAccessKeyboard() => AvaloniaXamlLoader.Load(this);
+
+ ///
+ /// The keyboard button in the tab strip was pressed: put this panel away and bring the phone's back.
+ ///
+ ///
+ /// An event rather than a command, because both halves are platform calls the shared view model has no
+ /// business making — showing the software keyboard means focusing the terminal's input surface, which
+ /// only holds.
+ ///
+ public event EventHandler? KeyboardRequested;
+
+ ///
+ public bool ShowsBarGroup
+ {
+ get => GetValue(ShowsBarGroupProperty);
+ set => SetValue(ShowsBarGroupProperty, value);
+ }
+
+ private void OnKeyboardRequested(object? sender, RoutedEventArgs e) =>
+ KeyboardRequested?.Invoke(this, EventArgs.Empty);
+}
diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
index 1827425..ef1ce9d 100644
--- a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
@@ -167,7 +167,7 @@
job — see the remark on the header in PhoneShell.axaml. -->
-
+
+
-
-
+
+
+
+
-
-
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
@@ -866,6 +869,185 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs
index 1321fdf..70fef06 100644
--- a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs
+++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs
@@ -1,17 +1,16 @@
using System.ComponentModel;
-using System.Text;
using Avalonia;
using Avalonia.Controls;
-using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Platform;
using Avalonia.Interactivity;
-using Avalonia.Layout;
using Avalonia.Markup.Xaml;
-using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Threading;
+using Avalonia.VisualTree;
using DodoSSH.Client.Android.Platform;
+using DodoSSH.Client.Shell.Keyboard;
using DodoSSH.Client.Shell.ViewModels;
// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
@@ -22,85 +21,45 @@ using Button = Avalonia.Controls.Button;
namespace DodoSSH.Client.Android.Views;
-/// Design 03 — the terminal, and the accessory key row under it.
+/// Design 03 — the terminal, and the keys under or beside it.
internal sealed partial class TerminalScreen : UserControl
{
///
- /// The keys a software keyboard does not have.
+ /// The height the quick-access keyboard takes on a phone until the software keyboard has been measured.
///
///
- ///
- /// Chosen by what a shell session actually needs rather than by what a keyboard has: Esc leaves vi's
- /// insert mode, Tab completes a path, Ctrl makes ^C reachable, the arrows reach history, and the
- /// pipe and hyphen are two characters that are three taps deep on every Android keyboard and appear in
- /// almost every command worth typing on a phone.
- ///
- ///
- /// The order matches the design's row. It is scrollable rather than compressed, because shrinking ten
- /// keys to fit 360dp produces ten targets too small to hit.
- ///
+ /// About what Gboard and Samsung's keyboard take in portrait. It is only a starting point: the first time
+ /// the software keyboard is up, its own height replaces this, so the bar above the panel does not move
+ /// when one is swapped for the other.
///
- /// The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.
- ///
- /// Seven, which is what the row divides between — see the keypad's remark in TerminalScreen.axaml for
- /// why the set is fixed and flexed rather than scrollable. Adding an eighth is a matter of adding it
- /// here and changing the Columns on the UniformGrid; the two numbers have to agree, and the row
- /// simply gets narrower keys.
- ///
- private static readonly (string Label, byte[] Bytes, bool Latches)[] ModifierKeys =
- [
- ("esc", [0x1B], false),
- ("tab", [0x09], false),
- ("ctrl", [], true),
- ("|", [(byte)'|'], false),
- ("-", [(byte)'-'], false),
- ("~", [(byte)'~'], false),
- ("/", [(byte)'/'], false),
- ];
+ private const double DefaultQuickAccessHeight = 280;
- /// The lower row: the cursor keys, which is where they became reachable.
- ///
- ///
- /// The ANSI sequences a PTY expects, written out rather than composed, because the difference between
- /// CSI A and SS3 A is the difference between working in bash and not.
- ///
- ///
- /// ◆ Left, down, up, right — the order every other client on the platform puts them in, and not
- /// the order they were in here. On one flat row the pairs have to read as pairs: horizontal at the ends
- /// and vertical in the middle means the two keys that undo each other are always neighbours, and a
- /// thumb learns the row's shape rather than its labels. Up-down-left-right is the order they are named
- /// in, which is a different thing from the order they are used in.
- ///
- ///
- /// These four were the keys that used to live past the right-hand edge of the scroller — the most
- /// pressed keys on the keypad, and the ones you had to go looking for.
- ///
- ///
- private static readonly (string Label, byte[] Bytes, bool Latches)[] CursorKeys =
- [
- ("←", [0x1B, (byte)'[', (byte)'D'], false),
- ("↓", [0x1B, (byte)'[', (byte)'B'], false),
- ("↑", [0x1B, (byte)'[', (byte)'A'], false),
- ("→", [0x1B, (byte)'[', (byte)'C'], false),
- ];
+ public static readonly StyledProperty IsWideProperty =
+ AvaloniaProperty.Register(nameof(IsWide));
- ///
- /// Whether the next ordinary key should be sent as a control character.
- ///
- ///
- /// Latching rather than held. Holding a modifier while typing needs two hands and a keyboard that
- /// reports chords, and this row has neither — so Ctrl is pressed, then C, and releases itself. Every
- /// Android SSH client does this and users expect it.
- ///
- private bool controlLatched;
+ public static readonly StyledProperty ShowsKeysProperty =
+ AvaloniaProperty.Register(nameof(ShowsKeys));
- private Button? controlKey;
+ public static readonly StyledProperty ShowsQuickAccessPanelProperty =
+ AvaloniaProperty.Register(nameof(ShowsQuickAccessPanel));
+
+ public static readonly StyledProperty ShowsSidePanelProperty =
+ AvaloniaProperty.Register(nameof(ShowsSidePanel));
+
+ public static readonly StyledProperty QuickAccessHeightProperty =
+ AvaloniaProperty.Register(nameof(QuickAccessHeight), DefaultQuickAccessHeight);
private readonly TerminalInputSurface input;
/// The shell whose PropertyChanged this screen is subscribed to, so it can unsubscribe.
private MainWindowViewModel? subscribed;
+ /// The phone shell this screen sits in, for its width class.
+ private PhoneShell? phoneShell;
+
+ /// The software keyboard, while this control is attached. Null on a platform without one.
+ private IInputPane? softKeyboard;
+
///
/// What was last seen to be.
///
@@ -115,10 +74,14 @@ internal sealed partial class TerminalScreen : UserControl
{
AvaloniaXamlLoader.Load(this);
- BuildAccessoryRow();
-
input = this.FindControl("Input")!;
- input.Input += (_, e) => SendAsync(e.Bytes);
+
+ // Everything typed on the phone's own keyboard goes through the same path as the bar's keys, which is
+ // what lets Ctrl latched on the bar apply to a letter typed on Gboard. See TerminalKeyboardViewModel.
+ input.Input += (_, e) => Keyboard?.Send(e.Bytes);
+
+ this.FindControl("BottomPanel")!.KeyboardRequested += OnKeyboardRequested;
+ this.FindControl("SidePanel")!.KeyboardRequested += OnKeyboardRequested;
var renderer = this.FindControl("Renderer")!;
@@ -140,6 +103,7 @@ internal sealed partial class TerminalScreen : UserControl
{
previous.PropertyChanged -= OnShellPropertyChanged;
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
+ previous.Keyboard.PropertyChanged -= OnKeyboardPropertyChanged;
subscribed = null;
}
@@ -153,13 +117,250 @@ internal sealed partial class TerminalScreen : UserControl
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
// they look. See MainWindowViewModel.TerminalFocusRequested.
shell.TerminalFocusRequested += OnTerminalFocusRequested;
+ shell.Keyboard.PropertyChanged += OnKeyboardPropertyChanged;
subscribed = shell;
terminalWasShowing = shell.IsTerminalShowing;
}
+
+ RefreshKeys();
};
}
+ /// Whether the screen is wide enough for the side panel. , mirrored.
+ public bool IsWide
+ {
+ get => GetValue(IsWideProperty);
+ private set => SetValue(IsWideProperty, value);
+ }
+
+ /// Whether the bar under the terminal is drawn.
+ public bool ShowsKeys
+ {
+ get => GetValue(ShowsKeysProperty);
+ private set => SetValue(ShowsKeysProperty, value);
+ }
+
+ /// Whether the quick-access keyboard is drawn under the bar, in the software keyboard's place.
+ public bool ShowsQuickAccessPanel
+ {
+ get => GetValue(ShowsQuickAccessPanelProperty);
+ private set => SetValue(ShowsQuickAccessPanelProperty, value);
+ }
+
+ /// Whether the quick-access keyboard is drawn beside the terminal.
+ public bool ShowsSidePanel
+ {
+ get => GetValue(ShowsSidePanelProperty);
+ private set => SetValue(ShowsSidePanelProperty, value);
+ }
+
+ /// How tall the quick-access keyboard is on a phone: the software keyboard's last height.
+ public double QuickAccessHeight
+ {
+ get => GetValue(QuickAccessHeightProperty);
+ private set => SetValue(QuickAccessHeightProperty, value);
+ }
+
+ private TerminalKeyboardViewModel? Keyboard => (DataContext as MainWindowViewModel)?.Keyboard;
+
+ ///
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+
+ phoneShell = this.FindAncestorOfType();
+
+ if (phoneShell is not null)
+ {
+ phoneShell.PropertyChanged += OnPhoneShellPropertyChanged;
+ }
+
+ softKeyboard = TopLevel.GetTopLevel(this)?.InputPane;
+
+ if (softKeyboard is not null)
+ {
+ softKeyboard.StateChanged += OnSoftKeyboardChanged;
+ }
+
+ RefreshKeys();
+ }
+
+ ///
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ if (phoneShell is not null)
+ {
+ phoneShell.PropertyChanged -= OnPhoneShellPropertyChanged;
+ phoneShell = null;
+ }
+
+ if (softKeyboard is not null)
+ {
+ softKeyboard.StateChanged -= OnSoftKeyboardChanged;
+ softKeyboard = null;
+ }
+
+ base.OnDetachedFromVisualTree(e);
+ }
+
+ ///
+ /// Works out which of the three key surfaces is drawn: the bar, the panel under it, the panel beside.
+ ///
+ ///
+ ///
+ /// Computed here rather than written as conditions in the markup, for PhoneShell's reason: each is an
+ /// "and" of facts from three objects — the shell, the keyboard and the width — and a binding has no
+ /// "and". Recomputed on any notification from any of the three, which is cheaper than being wrong about
+ /// which of their properties are computed ones.
+ ///
+ ///
+ /// Wide, the side panel replaces the bar while it is open. Its KEYS tab repeats the bar's keys at
+ /// the top, so nothing somebody put on their bar goes out of reach, and a tablet keeps the height. Closed,
+ /// the bar comes back exactly as on a phone.
+ ///
+ ///
+ /// Wide, the bottom panel never shows. Rotating a phone into the wide class with the panel up would
+ /// otherwise leave it raised under a terminal that also has the side panel; it is lowered instead, and the
+ /// side panel is where the same tabs are.
+ ///
+ ///
+ private void RefreshKeys()
+ {
+ var wide = phoneShell?.IsWide == true;
+ var showing = subscribed?.IsTerminalShowing == true;
+ var keyboard = subscribed?.Keyboard;
+ var sidePanel = wide && keyboard?.IsSidePanelOpen == true;
+
+ if (wide && keyboard is { IsQuickAccessOpen: true })
+ {
+ keyboard.CloseQuickAccess();
+ }
+
+ IsWide = wide;
+ ShowsSidePanel = showing && sidePanel;
+ ShowsKeys = showing && !sidePanel;
+ ShowsQuickAccessPanel = showing && !wide && keyboard?.IsQuickAccessOpen == true;
+ }
+
+ private void OnPhoneShellPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == PhoneShell.IsWideProperty)
+ {
+ RefreshKeys();
+ }
+ }
+
+ private void OnKeyboardPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ switch (e.PropertyName)
+ {
+ case nameof(TerminalKeyboardViewModel.IsQuickAccessOpen)
+ or nameof(TerminalKeyboardViewModel.IsSidePanelOpen):
+ RefreshKeys();
+ break;
+
+ // The customise sheet has boxes of its own, and on a wide screen it can be opened while the
+ // terminal's keyboard is up. Put away here so the sheet is laid out into the whole display
+ // rather than the strip above a keyboard configured for a shell.
+ case nameof(TerminalKeyboardViewModel.IsCustomizing)
+ when sender is TerminalKeyboardViewModel { IsCustomizing: true }:
+ SoftKeyboard.Hide();
+ break;
+ }
+ }
+
+ ///
+ /// Notes the software keyboard's height, and lowers the quick-access keyboard when it comes back.
+ ///
+ ///
+ ///
+ /// The two cannot both be up on a phone — they are the same place on the screen — and the software
+ /// keyboard arriving is the one signal every way of bringing it back has in common: a tap on the
+ /// terminal, the panel's own keyboard button, a box elsewhere taking focus.
+ ///
+ ///
+ /// The height is kept only when it is plausibly a keyboard. Before Android 15 the window is resized for
+ /// the keyboard instead and the inset reported here is zero (see PhoneShell.ApplyKeyboardInset); a panel
+ /// sized from that would be no panel at all, so the default stands.
+ ///
+ ///
+ private void OnSoftKeyboardChanged(object? sender, InputPaneStateEventArgs e)
+ {
+ if (e.NewState is not InputPaneState.Open)
+ {
+ return;
+ }
+
+ RememberKeyboardHeight(e.EndRect.Height);
+ Keyboard?.CloseQuickAccess();
+ }
+
+ private void RememberKeyboardHeight(double height)
+ {
+ if (double.IsFinite(height) && height >= 160)
+ {
+ QuickAccessHeight = Math.Min(height, 480);
+ }
+ }
+
+ ///
+ /// The grid button: swaps the software keyboard for the quick-access keyboard, or back.
+ ///
+ ///
+ ///
+ /// A click handler rather than the view model's toggle alone, because both halves of the swap are
+ /// platform calls: the software keyboard is measured and put away on the way in, and brought back by
+ /// focusing the input surface on the way out. The view model only records which of the two is up.
+ ///
+ ///
+ /// The panel is raised before the keyboard is hidden. The two arrive as separate layout passes either
+ /// way; this order means the frame between them is the panel over a closing keyboard rather than a
+ /// terminal briefly the full height of the display.
+ ///
+ ///
+ private void OnQuickAccessToggled(object? sender, RoutedEventArgs e)
+ {
+ if (Keyboard is not { } keyboard)
+ {
+ return;
+ }
+
+ if (keyboard.IsQuickAccessOpen)
+ {
+ keyboard.CloseQuickAccess();
+ input.Acquire();
+
+ return;
+ }
+
+ if (softKeyboard is { State: InputPaneState.Open } pane)
+ {
+ RememberKeyboardHeight(pane.OccludedRect.Height);
+ }
+
+ keyboard.ToggleQuickAccessCommand.Execute(null);
+ SoftKeyboard.Hide();
+ }
+
+ /// The panel's keyboard button: put the panel away and bring the phone's keyboard back.
+ private void OnKeyboardRequested(object? sender, EventArgs e)
+ {
+ Keyboard?.CloseQuickAccess();
+ input.Acquire();
+ }
+
+ /// A tap on the terminal: the phone's keyboard, in place of the panel if that was up.
+ private void OnRendererTapped()
+ {
+ Keyboard?.CloseQuickAccess();
+ input.Acquire();
+ }
+
+ /// The cursor gesture's arrows, from .
+ private void OnCursorGesture(ArrowDirection direction, int count) =>
+ Keyboard?.SendArrow(direction, count);
+
///
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
///
@@ -171,6 +372,8 @@ internal sealed partial class TerminalScreen : UserControl
return;
}
+ RefreshKeys();
+
var showing = shell.IsTerminalShowing;
if (showing == terminalWasShowing)
@@ -207,7 +410,8 @@ internal sealed partial class TerminalScreen : UserControl
/// See for why a WebView that cannot hold focus still scrolls, and why
/// the tap has to be reported rather than left to the page.
///
- private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
+ private void AdoptRenderer() =>
+ RendererFocus.Suppress(OnRendererTapped, () => Keyboard?.CursorGestures == true, OnCursorGesture);
///
/// Puts the keyboard away on the way to the connect menu or the forwards sheet.
@@ -260,129 +464,4 @@ internal sealed partial class TerminalScreen : UserControl
android.BuiltInZoomControls = false;
}
}
-
- private void BuildAccessoryRow()
- {
- // ── nothing to hand back any more ──────────────────────────────────────────────────────────────
- // This row used to counter both halves of every press with a posted TerminalFocus.Return(), because
- // the platform requests Android's own focus for Avalonia's view after dispatching each handled
- // touch, and the keyboard belonged to the WebView's input connection. Under ADR 0015 it belongs to
- // TerminalInputSurface, which is an Avalonia control — so the focus the platform takes is the focus
- // the keyboard already wants, and the counter-move is not merely unnecessary but wrong. The keys
- // stay Focusable=false, which is now what keeps Avalonia's own focus on the input surface.
-
- Fill(this.FindControl("AccessoryKeys")!, ModifierKeys);
- Fill(this.FindControl("ArrowKeys")!, CursorKeys);
- }
-
- /// Puts one table's worth of keys into one row of the keypad.
- ///
- /// The rows differ in what they hold and in nothing else — same control, same class, same wiring — so
- /// they are filled by one method rather than by two that would drift.
- ///
- private void Fill(UniformGrid row, (string Label, byte[] Bytes, bool Latches)[] keys)
- {
- foreach (var (label, bytes, latches) in keys)
- {
- var key = CreateKey(label);
-
- if (latches)
- {
- controlKey = key;
- key.Click += (_, _) => ToggleControl();
- }
- else
- {
- key.Click += (_, _) => SendAsync(bytes);
- }
-
- row.Children.Add(key);
- }
- }
-
- /// One key of the accessory row, before its click is wired.
- /// Split from for length rather than for reuse.
- private static Button CreateKey(string label) =>
- new()
- {
- Content = label,
-
- // ◆ Every dimension and brush this used to set by hand is now Button.hotkey in Phone.axaml,
- // and the move is what lets the row have a pressed state at all: a style can say :pressed and
- // an assignment cannot. It also takes this file out of the business of naming the palette's
- // brushes, which is how a keypad ends up with a grey nothing else uses.
- //
- // Content is the string rather than a TextBlock built here for the same reason — the class
- // carries the font and the alignment, so a wrapper element would only be somewhere for them to
- // disagree.
- Classes = { "hotkey" },
-
- // ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
- //
- // These keys are an extension of the keyboard, not a place the keyboard should go. As
- // ordinary buttons they take Avalonia's focus on tap, which takes it off whatever the
- // terminal's input target is — and a modifier row that dismisses the keyboard every time it
- // is used is not a modifier row. Focusable=false is what a toolbar button is, and it means
- // the focused element never changes: TerminalInputSurface is still it, so the input
- // connection is never torn down and nothing has to be handed back.
- //
- // This used to be only half a fix, because the target was the NativeWebView and Android keeps
- // a focus of its own that the platform hands to Avalonia's view after every touch it
- // dispatches. That half is gone rather than countered: under ADR 0015 the input target is an
- // Avalonia control, so the focus Android moves is already where the keyboard should be.
- Focusable = false,
- };
-
- private void ToggleControl()
- {
- controlLatched = !controlLatched;
-
- if (controlKey is null)
- {
- return;
- }
-
- // Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
- // ^L to a database prompt believing they typed an l.
- //
- // A class rather than two brush assignments, so what "latched" looks like is stated once in
- // Phone.axaml beside what "pressed" looks like — which is where somebody changing either will be.
- controlKey.Classes.Set("latched", controlLatched);
- }
-
- ///
- /// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
- /// 0x1A, which is letter & 0x1F. Applied only to letters, because Ctrl with an arrow key is a
- /// different sequence entirely and silently mangling one into a control byte would be worse than
- /// ignoring the latch.
- ///
- private void SendAsync(byte[] bytes)
- {
- if (DataContext is not MainWindowViewModel { SelectedTab: { } tab } shell)
- {
- return;
- }
-
- var payload = bytes;
-
- if (controlLatched && bytes.Length == 1)
- {
- var c = bytes[0];
-
- if (c is >= (byte)'a' and <= (byte)'z' or >= (byte)'A' and <= (byte)'Z')
- {
- payload = [(byte)(c & 0x1F)];
- }
-
- ToggleControl();
- }
-
- // Discarded rather than awaited: this is a keystroke, the workspace ignores a session that has
- // gone, and a button handler that awaited would serialise the row behind a slow link.
- _ = shell.SendTerminalInputAsync(tab.SessionId, payload).AsTask();
- }
-
- /// Sends a literal string, for the keys that carry text rather than a control code.
- /// Kept because the snippet feature will type into a terminal exactly this way.
- internal void Send(string text) => SendAsync(Encoding.UTF8.GetBytes(text));
}
diff --git a/src/DodoSSH.Client.Session/ClientSettings.cs b/src/DodoSSH.Client.Session/ClientSettings.cs
index dcc2712..67b0369 100644
--- a/src/DodoSSH.Client.Session/ClientSettings.cs
+++ b/src/DodoSSH.Client.Session/ClientSettings.cs
@@ -26,10 +26,15 @@ namespace DodoSSH.Client.Session;
///
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
///
+///
+/// How the phone's keys above the keyboard are arranged, or null for the arrangement it ships with. See
+/// .
+///
public sealed record ClientSettings(
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
bool AutomaticUpdateChecks = true,
- bool SessionSidebarOpen = true)
+ bool SessionSidebarOpen = true,
+ TerminalKeyboardSettings? TerminalKeyboard = null)
{
/*
A positional record, and the defaults live on the parameters rather than on property initializers.
@@ -213,4 +218,5 @@ public sealed class ClientSettingsStore(ClientPaths paths)
WriteIndented = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(ClientSettings))]
+[JsonSerializable(typeof(TerminalKeyboardSettings))]
internal sealed partial class ClientSettingsJsonContext : JsonSerializerContext;
diff --git a/src/DodoSSH.Client.Session/TerminalKeyboardSettings.cs b/src/DodoSSH.Client.Session/TerminalKeyboardSettings.cs
new file mode 100644
index 0000000..8f23d34
--- /dev/null
+++ b/src/DodoSSH.Client.Session/TerminalKeyboardSettings.cs
@@ -0,0 +1,47 @@
+namespace DodoSSH.Client.Session;
+
+///
+/// How the phone's terminal keys are arranged on this device.
+///
+///
+///
+/// Ids, not keys. Every list here names keys and groups by the stable ids in the shell's key catalog
+/// (TerminalKeyCatalog) rather than storing what a key sends, so a fix to a predefined key's bytes
+/// reaches people who customised their bar before the fix. An id this build does not know — one written by
+/// a newer build, or one for a custom key since deleted — is skipped on the way in rather than refused.
+///
+///
+/// In , so on this device and in plain text. The arrangement suits one
+/// screen and one pair of thumbs, which is the same argument the font size makes for not following
+/// somebody onto a desktop. Custom keys are keystrokes such as :wq, and the customise screen says
+/// outright that this file is not a place for a password.
+///
+///
+/// Null lists mean "as shipped" rather than "empty". An empty is a choice somebody made,
+/// and has to survive a launch as one.
+///
+///
+/// The keys on the bar above the software keyboard, in order.
+/// The order of the groups on the quick-access keyboard's KEYS tab.
+/// Groups turned off on the KEYS tab.
+/// Keys somebody added for themselves.
+/// The quick-access keyboard's tab last used, which it reopens to.
+/// Whether a wide screen shows the quick-access keyboard beside the terminal.
+/// Whether long-press-and-drag on the terminal moves the cursor.
+public sealed record TerminalKeyboardSettings(
+ string[]? Bar = null,
+ string[]? GroupOrder = null,
+ string[]? HiddenGroups = null,
+ CustomTerminalKey[]? CustomKeys = null,
+ string? QuickAccessTab = null,
+ bool SidePanelOpen = true,
+ bool CursorGestures = true);
+
+/// A key somebody added: what it says on it, and what it types.
+/// Stable, so the bar can refer to it. Always starts custom:.
+/// What the key shows.
+///
+/// What it sends, in the notation the customise screen accepts — \e, \r, ^C and so on —
+/// rather than as bytes, so the file stays readable and the parser stays the one place escapes are decided.
+///
+public sealed record CustomTerminalKey(string Id, string Label, string Sequence);
diff --git a/src/DodoSSH.Client.Shell/Keyboard/ArrowGesture.cs b/src/DodoSSH.Client.Shell/Keyboard/ArrowGesture.cs
new file mode 100644
index 0000000..edebf66
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/Keyboard/ArrowGesture.cs
@@ -0,0 +1,171 @@
+namespace DodoSSH.Client.Shell.Keyboard;
+
+/// Which arrow key a cursor gesture is pressing, if any.
+internal enum ArrowDirection
+{
+ None,
+ Left,
+ Down,
+ Up,
+ Right,
+}
+
+///
+/// Long-press-and-drag on the terminal as a joystick for the arrow keys.
+///
+///
+///
+/// A joystick rather than a trackpad, and the difference is what the gesture is for. A trackpad would
+/// send one arrow per so many pixels of travel, which runs out of screen long before a long command line
+/// runs out of characters, and does nothing at all for the other half of the job — holding Up to walk back
+/// through history. Here the finger's distance from where the press landed is a speed, and the key repeats
+/// for as long as it is held there: a short way out steps slowly enough to stop on the character wanted, and
+/// further out in the same direction shifts up through the second and third speeds.
+///
+///
+/// The first step is immediate and the second waits. Crossing the dead zone sends one key at once, so
+/// a quick nudge is exactly one character, and the slowest speed then holds off for
+/// before repeating — the delay every hardware keyboard puts between a
+/// keypress and auto-repeat, for the same reason.
+///
+///
+/// The axis is sticky. A thumb dragging left does not travel in a straight line, and without some
+/// hysteresis a drift upwards of a few pixels would flip the gesture onto Up and walk into history mid-edit.
+/// The other axis has to win by to take over.
+///
+///
+/// Pure arithmetic over displacements in density-independent pixels and times in milliseconds, which is
+/// what lets it be tested without a touchscreen. The platform half — noticing the long press, taking the
+/// touch stream away from the page, running the clock — is in the Android head's RendererFocus.
+///
+///
+internal sealed class ArrowGesture
+{
+ /// How far from the press the finger can wander before anything is sent, in dp.
+ internal const double DeadZone = 16;
+
+ /// Where the second speed starts, in dp from the press.
+ internal const double SecondSpeedAt = 64;
+
+ /// Where the third speed starts, in dp from the press.
+ internal const double ThirdSpeedAt = 128;
+
+ /// How long the slowest speed waits after its first key before repeating.
+ internal const long FirstRepeatDelayMs = 400;
+
+ /// How much the other axis has to dominate before the direction changes axis.
+ internal const double AxisSwitchRatio = 1.4;
+
+ /// The most keys one call reports, so a stalled clock cannot come back as a burst.
+ private const int MaximumBurst = 3;
+
+ private long nextDue;
+
+ /// The key being held, or inside the dead zone.
+ internal ArrowDirection Direction { get; private set; }
+
+ /// The speed, from 1 to 3, or 0 inside the dead zone.
+ internal int Speed { get; private set; }
+
+ /// The interval between repeats at a speed.
+ internal static long IntervalMs(int speed) => speed switch
+ {
+ 1 => 220,
+ 2 => 90,
+ _ => 35,
+ };
+
+ /// Takes the finger's new displacement from where the press landed.
+ /// How many keys to send now, in .
+ internal int Move(double dx, double dy, long nowMs)
+ {
+ var direction = DirectionFor(dx, dy);
+
+ if (direction is ArrowDirection.None)
+ {
+ Direction = ArrowDirection.None;
+ Speed = 0;
+
+ return 0;
+ }
+
+ var along = direction is ArrowDirection.Left or ArrowDirection.Right ? Math.Abs(dx) : Math.Abs(dy);
+ var speed = along >= ThirdSpeedAt ? 3 : along >= SecondSpeedAt ? 2 : 1;
+
+ if (direction != Direction)
+ {
+ Direction = direction;
+ Speed = speed;
+ nextDue = nowMs + (speed == 1 ? FirstRepeatDelayMs : IntervalMs(speed));
+
+ return 1;
+ }
+
+ if (speed > Speed)
+ {
+ // Faster takes effect at once rather than after the slower interval already running out.
+ nextDue = Math.Min(nextDue, nowMs + IntervalMs(speed));
+ }
+
+ Speed = speed;
+
+ return Advance(nowMs);
+ }
+
+ /// Lets time pass with the finger held still.
+ /// How many keys have come due, in .
+ internal int Advance(long nowMs)
+ {
+ if (Direction is ArrowDirection.None)
+ {
+ return 0;
+ }
+
+ var interval = IntervalMs(Speed);
+ var count = 0;
+
+ while (nowMs >= nextDue && count < MaximumBurst)
+ {
+ count++;
+ nextDue += interval;
+ }
+
+ if (nowMs >= nextDue)
+ {
+ // Behind by more than a burst: drop the backlog rather than owe it.
+ nextDue = nowMs + interval;
+ }
+
+ return count;
+ }
+
+ /// Lets go.
+ internal void Reset()
+ {
+ Direction = ArrowDirection.None;
+ Speed = 0;
+ }
+
+ private ArrowDirection DirectionFor(double dx, double dy)
+ {
+ var ax = Math.Abs(dx);
+ var ay = Math.Abs(dy);
+
+ if (Math.Max(ax, ay) < DeadZone)
+ {
+ return ArrowDirection.None;
+ }
+
+ var horizontal = Direction switch
+ {
+ ArrowDirection.Left or ArrowDirection.Right => ay <= ax * AxisSwitchRatio,
+ ArrowDirection.Up or ArrowDirection.Down => ax > ay * AxisSwitchRatio,
+ _ => ax >= ay,
+ };
+
+ // Screen coordinates: y grows downwards, so a drag towards the top of the screen is Up.
+ return horizontal
+ ? dx < 0 ? ArrowDirection.Left : ArrowDirection.Right
+ : dy < 0 ? ArrowDirection.Up : ArrowDirection.Down;
+ }
+}
diff --git a/src/DodoSSH.Client.Shell/Keyboard/EchoedLine.cs b/src/DodoSSH.Client.Shell/Keyboard/EchoedLine.cs
new file mode 100644
index 0000000..e141c81
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/Keyboard/EchoedLine.cs
@@ -0,0 +1,205 @@
+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;
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Shell/Keyboard/KeySequence.cs b/src/DodoSSH.Client.Shell/Keyboard/KeySequence.cs
new file mode 100644
index 0000000..6688e81
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/Keyboard/KeySequence.cs
@@ -0,0 +1,162 @@
+using System.Globalization;
+using System.Text;
+
+namespace DodoSSH.Client.Shell.Keyboard;
+
+///
+/// Reads what a custom key should send, in the notation people already write it in.
+///
+///
+///
+/// Two notations, both the ones a terminal user has seen before: backslash escapes as printf and
+/// bind write them (\e, \r, \t, \n, \\, \xHH), and caret
+/// notation as stty and every man page write control characters (^C, ^[, ^?).
+/// Anything else is typed as it stands, in UTF-8.
+///
+///
+/// A caret only means Ctrl in front of a character that has a control form — a letter, or one of
+/// @[\]^_?. Elsewhere, as in ^1 or a trailing ^, it is a caret. The cost is that
+/// grep ^root written here sends Ctrl-R; \^ is the way to say a literal caret before a
+/// letter, and the customise screen says so.
+///
+///
+/// Refusing is reserved for what cannot mean anything — a backslash at the very end, or \x without
+/// two hex digits — so a sequence that parses is one that sends exactly what it says.
+///
+///
+internal static class KeySequence
+{
+ /// Turns notation into bytes.
+ /// False when the notation is malformed; is then empty.
+ internal static bool TryParse(string notation, out byte[] bytes)
+ {
+ ArgumentNullException.ThrowIfNull(notation);
+
+ var output = new List(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;
+ }
+
+ /// Reads the escape whose backslash is at , leaving it on its last character.
+ private static bool TryEscape(string notation, ref int i, List 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;
+ }
+
+ /// Moves the plain text gathered so far into the output, as UTF-8.
+ private static void FlushLiteral(List output, StringBuilder literal)
+ {
+ if (literal.Length > 0)
+ {
+ output.AddRange(Encoding.UTF8.GetBytes(literal.ToString()));
+ literal.Clear();
+ }
+ }
+
+ /// The control character caret notation names, where there is one.
+ ///
+ /// ^? is DEL rather than a masked byte — the one exception every table of caret notation makes,
+ /// and the one people reach for as "backspace".
+ ///
+ 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;
+ }
+}
diff --git a/src/DodoSSH.Client.Shell/Keyboard/TerminalKey.cs b/src/DodoSSH.Client.Shell/Keyboard/TerminalKey.cs
new file mode 100644
index 0000000..0ee7856
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/Keyboard/TerminalKey.cs
@@ -0,0 +1,55 @@
+namespace DodoSSH.Client.Shell.Keyboard;
+
+/// What pressing a terminal key does.
+internal enum TerminalKeyKind
+{
+ /// Sends its bytes, after any latched modifier has had its say.
+ Send,
+
+ /// Latches Ctrl for the next key, and releases itself after it.
+ Control,
+
+ /// Latches Alt for the next key, and releases itself after it.
+ Alt,
+
+ /// Pastes the clipboard, through the same path a right-click on the desktop takes.
+ Paste,
+}
+
+///
+/// One key the phone's own keyboard does not have.
+///
+///
+///
+/// A record for the convenience of construction and nothing else: two keys are the same key when their
+/// is, which is what the bar stores. The byte array would make record equality reference
+/// equality anyway, so nothing here leans on it.
+///
+///
+/// exists for the labels that are words — ctrl+B % — which want two cells on
+/// the quick-access keyboard where an arrow wants one. It is a fact about the label rather than a layout
+/// choice made per screen, so it lives here.
+///
+///
+/// Stable across releases; the bar and the group order are stored by it.
+/// What the key says.
+/// What pressing it does.
+/// What it sends, for . Empty otherwise.
+/// What it is for, for the customise screen and for accessibility.
+internal sealed record TerminalKey(string Id, string Label, TerminalKeyKind Kind, byte[] Bytes, string Hint)
+{
+ /// Whether the label is long enough to want a double-width cell.
+ internal bool IsWide => Label.Length > 4;
+
+ /// Whether this is one of the two latching modifiers.
+ internal bool IsModifier => Kind is TerminalKeyKind.Control or TerminalKeyKind.Alt;
+
+ /// Whether this key was added by the person using it rather than shipped.
+ internal bool IsCustom => Id.StartsWith(TerminalKeyCatalog.CustomPrefix, StringComparison.Ordinal);
+}
+
+/// A named set of keys on the quick-access keyboard.
+/// Stable across releases; the order and the hidden set are stored by it.
+/// The caption above the keys.
+/// The keys, in the order they are drawn.
+internal sealed record TerminalKeyGroup(string Id, string Title, IReadOnlyList Keys);
diff --git a/src/DodoSSH.Client.Shell/Keyboard/TerminalKeyCatalog.cs b/src/DodoSSH.Client.Shell/Keyboard/TerminalKeyCatalog.cs
new file mode 100644
index 0000000..f46abb1
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/Keyboard/TerminalKeyCatalog.cs
@@ -0,0 +1,199 @@
+using System.Collections.Frozen;
+using System.Text;
+
+namespace DodoSSH.Client.Shell.Keyboard;
+
+///
+/// Every key the phone ships with, in the groups the quick-access keyboard draws them in.
+///
+///
+///
+/// The ids are a stored format. The bar and the group order are written to the settings file by id,
+/// so renaming one silently drops it from every bar it was on. Labels, hints and bytes can change freely —
+/// that is the point of storing ids — but an id, once shipped, stays.
+///
+///
+/// Cursor and function keys are written as the sequences xterm sends in its default mode — CSI for
+/// the arrows, SS3 for F1 to F4 — for the reason TerminalInputSurface gives: nothing on this side
+/// tracks the remote's application-cursor mode, and CSI is what bash, zsh and every readline accept in
+/// both modes.
+///
+///
+internal static class TerminalKeyCatalog
+{
+ /// What every custom key's id starts with, so one can never collide with a shipped key.
+ internal const string CustomPrefix = "custom:";
+
+ /// The id of the group custom keys are drawn in.
+ internal const string CustomGroupId = "custom";
+
+ /// The most keys the bar holds.
+ ///
+ /// Two rows of . A third row would cost the terminal another five lines on a
+ /// phone, and anything used rarely enough not to make the first sixteen belongs on the quick-access
+ /// keyboard, one tap further away.
+ ///
+ internal const int MaximumBarKeys = 16;
+
+ /// The most keys one row of the bar divides its width between.
+ ///
+ /// Eight across 360dp is a 41dp target after margins — just under Android's 48 and roughly what every
+ /// terminal on the platform ships. Nine would be 36, which is where the old scrolling row's misses began.
+ ///
+ internal const int BarRowWidth = 8;
+
+ private static TerminalKey Send(string id, string label, string hint, params byte[] bytes) =>
+ new(id, label, TerminalKeyKind.Send, bytes, hint);
+
+ private static TerminalKey Text(string id, string label, string hint, string text) =>
+ new(id, label, TerminalKeyKind.Send, Encoding.UTF8.GetBytes(text), hint);
+
+ private static byte[] Csi(string tail) => [0x1B, (byte)'[', .. Encoding.ASCII.GetBytes(tail)];
+
+ private static byte[] Ss3(char final) => [0x1B, (byte)'O', (byte)final];
+
+ /// Ctrl-B and then a key: one press of a tmux binding under the default prefix.
+ private static TerminalKey Tmux(string id, string key, string hint) =>
+ new(id, $"ctrl+B {key}", TerminalKeyKind.Send, [0x02, .. Encoding.ASCII.GetBytes(key)], hint);
+
+ ///
+ /// Escape first, then the command: whatever mode vi was in, this is the one it is meant for.
+ ///
+ private static TerminalKey Vim(string id, string label, string command, string hint) =>
+ new(id, label, TerminalKeyKind.Send, [0x1B, .. Encoding.ASCII.GetBytes(command)], hint);
+
+ private static TerminalKey Control(char letter) =>
+ Send(
+ $"ctrl-{char.ToLowerInvariant(letter)}",
+ $"^{letter}",
+ ControlHints.GetValueOrDefault(letter, $"Ctrl+{letter}"),
+ (byte)(letter & 0x1F));
+
+ private static readonly Dictionary ControlHints = new()
+ {
+ ['C'] = "Interrupt the running command",
+ ['D'] = "End of input; logs out at an empty prompt",
+ ['Z'] = "Suspend the running command",
+ ['L'] = "Clear the screen",
+ ['R'] = "Search command history",
+ ['A'] = "Start of the line",
+ ['E'] = "End of the line",
+ ['U'] = "Delete to the start of the line",
+ ['W'] = "Delete the word before the cursor",
+ ['K'] = "Delete to the end of the line",
+ };
+
+ /// The groups as shipped, in their shipped order.
+ internal static IReadOnlyList Groups { get; } =
+ [
+ new("essentials", "ESSENTIALS",
+ [
+ Send("esc", "esc", "Escape", 0x1B),
+ Send("tab", "tab", "Complete a command or path", 0x09),
+ Send("shift-tab", "shift+tab", "Backwards through completions and forms", Csi("Z")),
+ new("ctrl", "ctrl", TerminalKeyKind.Control, [], "Hold Ctrl for the next key"),
+ new("alt", "alt", TerminalKeyKind.Alt, [], "Hold Alt for the next key"),
+ Send("enter", "⏎", "Enter", 0x0D),
+ new("paste", "paste", TerminalKeyKind.Paste, [], "Paste the clipboard"),
+ Send("ins", "ins", "Insert", Csi("2~")),
+ Send("del", "del", "Delete forwards", Csi("3~")),
+ ]),
+ new("cursor", "CURSOR",
+ [
+ Send("left", "←", "Left", Csi("D")),
+ Send("down", "↓", "Down; the next command in history", Csi("B")),
+ Send("up", "↑", "Up; the previous command in history", Csi("A")),
+ Send("right", "→", "Right", Csi("C")),
+ Send("home", "home", "Start of the line", Csi("H")),
+ Send("end", "end", "End of the line", Csi("F")),
+ Send("pgup", "pgup", "Page up", Csi("5~")),
+ Send("pgdn", "pgdn", "Page down", Csi("6~")),
+ ]),
+ new("control", "CONTROL",
+ [
+ Control('C'), Control('D'), Control('Z'), Control('L'), Control('R'),
+ Control('A'), Control('E'), Control('U'), Control('W'), Control('K'),
+ ]),
+ new("symbols", "SYMBOLS",
+ [
+ Text("pipe", "|", "Pipe", "|"),
+ Text("dash", "-", "Hyphen", "-"),
+ Text("underscore", "_", "Underscore", "_"),
+ Text("tilde", "~", "Home directory", "~"),
+ Text("slash", "/", "Slash", "/"),
+ Text("backslash", "\\", "Backslash", "\\"),
+ Text("backtick", "`", "Backtick", "`"),
+ Text("star", "*", "Asterisk", "*"),
+ Text("amp", "&", "Ampersand", "&"),
+ Text("dollar", "$", "Dollar", "$"),
+ Text("lt", "<", "Less than", "<"),
+ Text("gt", ">", "Greater than", ">"),
+ Text("lbrace", "{", "Left brace", "{"),
+ Text("rbrace", "}", "Right brace", "}"),
+ Text("lbracket", "[", "Left bracket", "["),
+ Text("rbracket", "]", "Right bracket", "]"),
+ ]),
+ new("tmux", "TMUX",
+ [
+ Send("tmux-prefix", "^B", "The tmux prefix", 0x02),
+ Tmux("tmux-new", "c", "New window"),
+ Tmux("tmux-next", "n", "Next window"),
+ Tmux("tmux-prev", "p", "Previous window"),
+ Tmux("tmux-split-v", "%", "Split side by side"),
+ Tmux("tmux-split-h", "\"", "Split top and bottom"),
+ Tmux("tmux-other", "o", "Next pane"),
+ Tmux("tmux-kill", "x", "Close the pane"),
+ Tmux("tmux-detach", "d", "Detach"),
+ Tmux("tmux-copy", "[", "Scroll and copy mode"),
+ ]),
+ new("vim", "VIM",
+ [
+ Vim("vim-write", ":w", ":w\r", "Save"),
+ Vim("vim-quit", ":q", ":q\r", "Quit"),
+ Vim("vim-write-quit", ":wq", ":wq\r", "Save and quit"),
+ Vim("vim-force-quit", ":q!", ":q!\r", "Quit without saving"),
+ Vim("vim-top", "gg", "gg", "First line"),
+ Vim("vim-bottom", "G", "G", "Last line"),
+ ]),
+ new("function", "FUNCTION",
+ [
+ Send("f1", "F1", "F1", Ss3('P')),
+ Send("f2", "F2", "F2", Ss3('Q')),
+ Send("f3", "F3", "F3", Ss3('R')),
+ Send("f4", "F4", "F4", Ss3('S')),
+ Send("f5", "F5", "F5", Csi("15~")),
+ Send("f6", "F6", "F6", Csi("17~")),
+ Send("f7", "F7", "F7", Csi("18~")),
+ Send("f8", "F8", "F8", Csi("19~")),
+ Send("f9", "F9", "F9", Csi("20~")),
+ Send("f10", "F10", "F10", Csi("21~")),
+ Send("f11", "F11", "F11", Csi("23~")),
+ Send("f12", "F12", "F12", Csi("24~")),
+ ]),
+ ];
+
+ ///
+ /// The bar as shipped: the keys this head's old two fixed rows carried, plus Alt and paste.
+ ///
+ ///
+ /// Modifiers and punctuation on the first row, cursor keys on the second — the split the old keypad's
+ /// remarks argue for, which is unchanged: the arrows are the keys pressed repeatedly and in sequence, so
+ /// they get the row nearest the thumb and, being fewer, the widest targets. Paste joins them because a
+ /// long press on the terminal is now the cursor gesture, and was the only way to paste before.
+ ///
+ internal static IReadOnlyList DefaultBar { get; } =
+ [
+ "esc", "tab", "ctrl", "alt", "pipe", "dash", "slash", "tilde",
+ "left", "down", "up", "right", "paste",
+ ];
+
+ /// Every shipped key by id.
+ internal static FrozenDictionary ById { get; } =
+ Groups.SelectMany(g => g.Keys).ToFrozenDictionary(k => k.Id, StringComparer.Ordinal);
+
+ /// Builds the key a stored custom entry describes, or null when its sequence does not parse.
+ internal static TerminalKey? FromCustom(string id, string label, string sequence) =>
+ KeySequence.TryParse(sequence, out var bytes) && bytes.Length > 0
+ ? new TerminalKey(id, label, TerminalKeyKind.Send, bytes, $"Sends {sequence}")
+ : null;
+}
diff --git a/src/DodoSSH.Client.Shell/Keyboard/TerminalModifiers.cs b/src/DodoSSH.Client.Shell/Keyboard/TerminalModifiers.cs
new file mode 100644
index 0000000..d46df2b
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/Keyboard/TerminalModifiers.cs
@@ -0,0 +1,57 @@
+namespace DodoSSH.Client.Shell.Keyboard;
+
+///
+/// What a latched Ctrl or Alt does to the key pressed after it.
+///
+///
+///
+/// The encodings xterm uses, and nothing cleverer. Ctrl with a character is that character's
+/// control form — c & 0x1F, the rule caret notation names. Alt is an Escape in front, which is
+/// what readline's M-b and M-f and every Emacs binding expect from a terminal. Ctrl or Alt with
+/// a cursor key is the modified CSI form, CSI 1 ; m X, which is how Ctrl+← jumps a word in bash and
+/// zsh.
+///
+///
+/// Anything else goes through untouched. Ctrl with a pasted line or with a function key has no
+/// encoding a shell agrees on, and mangling one into something else would be worse than ignoring the
+/// latch. The latch still releases, because the person pressed the key it was latched for.
+///
+///
+internal static class TerminalModifiers
+{
+ /// Applies whichever modifiers are latched to one key's worth of bytes.
+ internal static byte[] Apply(byte[] payload, bool control, bool alt)
+ {
+ ArgumentNullException.ThrowIfNull(payload);
+
+ if (!control && !alt)
+ {
+ return payload;
+ }
+
+ // ESC [ X for the six cursor keys that have a modified form: the arrows, Home and End.
+ if (payload is [0x1B, (byte)'[', var final] && final is (byte)'A' or (byte)'B' or (byte)'C'
+ or (byte)'D' or (byte)'H' or (byte)'F')
+ {
+ var modifier = 1 + (alt ? 2 : 0) + (control ? 4 : 0);
+
+ return [0x1B, (byte)'[', (byte)'1', (byte)';', (byte)('0' + modifier), final];
+ }
+
+ var result = payload;
+
+ if (control && payload is [var single])
+ {
+ result = [ControlForm(single)];
+ }
+
+ return alt ? [0x1B, .. result] : result;
+ }
+
+ /// The control character for one byte, or the byte itself where there is none.
+ /// Space as NUL is the one addition to caret notation, and every terminal makes it.
+ private static byte ControlForm(byte c) =>
+ c == (byte)' ' ? (byte)0x00
+ : c < 0x80 && KeySequence.TryControl((char)c, out var control) ? control
+ : c;
+}
diff --git a/src/DodoSSH.Client.Shell/Keyboard/TypedCommandHistory.cs b/src/DodoSSH.Client.Shell/Keyboard/TypedCommandHistory.cs
new file mode 100644
index 0000000..7a2238f
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/Keyboard/TypedCommandHistory.cs
@@ -0,0 +1,224 @@
+using System.Collections.ObjectModel;
+using System.Text;
+
+using DodoSSH.Client.Terminal;
+
+namespace DodoSSH.Client.Shell.Keyboard;
+
+/// One line the history kept: what ran, and where.
+internal sealed record HistoryEntry(string Command, string Host);
+
+///
+/// The commands typed on this phone, for the quick-access keyboard's HISTORY tab.
+///
+///
+///
+/// Only what the remote echoed, and that is the whole safety story. A terminal is one input stream,
+/// and a sudo password typed into it looks exactly like a command typed into it — right up to the
+/// moment the shell does or does not echo it. So nothing is kept on the strength of what was typed. On Enter
+/// the line the cursor is on is read back out of the session's own output (see ) and
+/// a command is kept only if it is there: a password prompt, which echoes nothing, leaves the line as the
+/// prompt alone and nothing is recorded. Full-screen programs are skipped outright, because "the line the
+/// cursor is on" in vi is a line of a file.
+///
+///
+/// Two ways to read the command back, the second stricter than the first. The prompt is noted when the
+/// first key of a line is pressed; if the line on Enter still starts with it, what follows is the command —
+/// which is what lets a line finished with Tab completion or recalled with ↑ be kept as the shell ran it.
+/// When the prompt has moved (a redraw, a multi-line prompt), the line is kept only if it was typed key by
+/// key with nothing the client cannot follow, and the echo ends with exactly that text.
+///
+///
+/// Memory only, and cleared on lock. Commands carry hostnames, paths and sometimes tokens on the
+/// command line. The settings file is plaintext and the cache is the vault's, so this is neither: it lasts
+/// as long as the process, the LOCK button empties it, and the tab says so.
+///
+///
+/// A line that fails any test is simply not kept. A history that misses a command costs one retype; one
+/// that kept a password would be showing it to whoever looks at the phone next.
+///
+///
+internal sealed class TypedCommandHistory
+{
+ /// The most commands kept. Older ones fall off the end.
+ internal const int Capacity = 100;
+
+ /// The longest command kept; anything longer is not something to retype from a list.
+ internal const int MaximumLength = 512;
+
+ private readonly Dictionary lines = [];
+
+ /// Most recent first, without duplicates.
+ internal ObservableCollection Entries { get; } = [];
+
+ /// Notes one key's worth of bytes about to be sent to a session.
+ /// The session they are going to.
+ /// What the tab is called, to label an entry with.
+ /// The bytes, after any latched modifier — what the remote will actually see.
+ ///
+ /// The session's recent output. Called before the bytes go, so on Enter it holds the echo of the line and
+ /// not yet the command's output.
+ ///
+ internal void Observe(uint sessionId, string host, ReadOnlySpan sent, Func recent)
+ {
+ ArgumentNullException.ThrowIfNull(recent);
+
+ if (!lines.TryGetValue(sessionId, out var line))
+ {
+ line = new LineInProgress();
+ lines[sessionId] = line;
+ }
+
+ foreach (var c in Encoding.UTF8.GetString(sent))
+ {
+ if (c is '\r' or '\n')
+ {
+ Commit(line, host, recent);
+ line.Reset();
+
+ continue;
+ }
+
+ if (!line.Started)
+ {
+ line.Begin(recent());
+ }
+
+ switch (c)
+ {
+ case '\x7f' or '\b':
+ if (line.Typed.Length > 0)
+ {
+ line.Typed.Length--;
+ }
+
+ break;
+
+ case '\x03' or '\x15':
+ // Ctrl-C abandons the line, Ctrl-U empties it; either way the next key starts afresh.
+ line.Reset();
+ break;
+
+ case '\x1b':
+ // A cursor key, Alt, a function key: the rest of these bytes are the sequence, and what
+ // the line now holds is the shell's business. The prompt route can still read it back.
+ line.Tainted = true;
+ return;
+
+ case < ' ':
+ // Tab, Ctrl-R, Ctrl-W and the rest: the line changed in a way not followed here.
+ line.Tainted = true;
+ break;
+
+ default:
+ line.Typed.Append(c);
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Notes that text is about to be typed into a session by something other than its keys — a history
+ /// entry or a snippet — so that the Enter after it can still be read back.
+ ///
+ internal void NoteInserted(uint sessionId, Func recent)
+ {
+ ArgumentNullException.ThrowIfNull(recent);
+
+ if (!lines.TryGetValue(sessionId, out var line))
+ {
+ line = new LineInProgress();
+ lines[sessionId] = line;
+ }
+
+ if (!line.Started)
+ {
+ line.Begin(recent());
+ }
+
+ // Not typed key by key, so only the prompt route may keep it.
+ line.Tainted = true;
+ }
+
+ /// Drops what was being typed into a session that has gone.
+ internal void Forget(uint sessionId) => lines.Remove(sessionId);
+
+ /// Forgets everything, for LOCK and for the tab's own CLEAR.
+ internal void Clear()
+ {
+ Entries.Clear();
+ lines.Clear();
+ }
+
+ private void Commit(LineInProgress line, string host, Func recent)
+ {
+ if (!line.Started || recent() is not { AlternateScreen: false } output)
+ {
+ return;
+ }
+
+ var shown = EchoedLine.LastLine(output.Tail);
+ var typed = line.Typed.ToString().Trim();
+ string? command = null;
+
+ if (line.Prompt is { Length: > 0 } prompt
+ && shown.StartsWith(prompt, StringComparison.Ordinal)
+ && shown[prompt.Length..].Trim() is { Length: > 0 } afterPrompt)
+ {
+ // Typed key by key, the echo has to be exactly what was typed — a prompt that echoes a star per
+ // character is still not a command. Tainted, the echo is the only account of the line there is.
+ command = line.Tainted || string.Equals(afterPrompt, typed, StringComparison.Ordinal) ? afterPrompt : null;
+ }
+ else if (!line.Tainted && typed.Length > 0 && shown.TrimEnd().EndsWith(typed, StringComparison.Ordinal))
+ {
+ command = typed;
+ }
+
+ if (command is null || command.Length > MaximumLength || !command.Any(char.IsLetterOrDigit))
+ {
+ return;
+ }
+
+ for (var i = Entries.Count - 1; i >= 0; i--)
+ {
+ if (string.Equals(Entries[i].Command, command, StringComparison.Ordinal))
+ {
+ Entries.RemoveAt(i);
+ }
+ }
+
+ Entries.Insert(0, new HistoryEntry(command, host));
+
+ while (Entries.Count > Capacity)
+ {
+ Entries.RemoveAt(Entries.Count - 1);
+ }
+ }
+
+ private sealed class LineInProgress
+ {
+ internal StringBuilder Typed { get; } = new();
+
+ /// What the cursor's line said before the first key of this line.
+ internal string? Prompt { get; private set; }
+
+ internal bool Started { get; private set; }
+
+ /// Whether something not followed here changed the line.
+ internal bool Tainted { get; set; }
+
+ internal void Begin(RecentOutput? output)
+ {
+ Started = true;
+ Prompt = output is { AlternateScreen: false } ? EchoedLine.LastLine(output.Tail).TrimEnd() : null;
+ }
+
+ internal void Reset()
+ {
+ Typed.Clear();
+ Prompt = null;
+ Started = false;
+ Tainted = false;
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index e1c588c..436af52 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -332,6 +332,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
private readonly ClientSettingsStore settings;
+ ///
+ /// The phone's keys for a terminal: the bar, the quick-access keyboard, and how both are arranged.
+ ///
+ internal TerminalKeyboardViewModel Keyboard { get; }
+
///
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
@@ -641,6 +646,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.workspace.RendererReattached += OnRendererReattached;
settings = new ClientSettingsStore(paths);
+ Keyboard = CreateKeyboard();
updateScreen = CreateUpdateScreen(updates);
@@ -651,6 +657,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
StartSessionShellTracking();
}
+ /// The phone's keys, wired to this shell's sessions.
+ ///
+ /// Built on every head because it is only state and a settings read, and the desktop simply never draws
+ /// it; a head-specific view model would be one more thing this constructor had to be told about per
+ /// platform. The customise sheet takes the screen the way the connect sheet does, so it moves the same
+ /// flag — see .
+ ///
+ private TerminalKeyboardViewModel CreateKeyboard()
+ {
+ var keyboard = new TerminalKeyboardViewModel(
+ settings,
+ CurrentTypingTarget,
+ SendTerminalInputAsync,
+ workspace.GetRecentOutput,
+ workspace.PasteAsync,
+ PasteIntoTerminalAsync);
+
+ keyboard.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName is nameof(TerminalKeyboardViewModel.IsCustomizing))
+ {
+ RaiseTerminalState();
+ }
+ };
+
+ return keyboard;
+ }
+
///
/// Takes this machine's own preferences off disk, before anything can read them.
///
@@ -1500,6 +1534,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
IsTerminalSurface
&& !IsConnectSheetOpen
&& !IsForwardsSheetOpen
+ && !Keyboard.IsCustomizing
&& !IsHostKeyDecisionShowing
&& SelectedTab is { HasSession: true };
@@ -3815,6 +3850,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
? null
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
+ // The commands typed on this phone go with the keychain, for the reason the recent connections below
+ // do: they are a record of where somebody works and what they ran there, and LOCK is the promise
+ // that nothing like that is left on screen. See TypedCommandHistory.
+ if (newValue is null)
+ {
+ Keyboard.ClearHistory();
+ }
+
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
// Emptied with the vault it was read out of. These rows are decrypted log entries — a host's name
@@ -4230,6 +4273,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
open.HostLabel, open.Address, open.StartedAt, deviceName)),
];
+ /// The session the phone's keys type into: the selected tab, once it has a shell.
+ private TypingTarget? CurrentTypingTarget() =>
+ SelectedTab is { HasSession: true } tab ? new TypingTarget(tab.SessionId, tab.Label) : null;
+
private InsertTarget CurrentInsertTarget() =>
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
diff --git a/src/DodoSSH.Client.Shell/ViewModels/TerminalKeyboardViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/TerminalKeyboardViewModel.cs
new file mode 100644
index 0000000..1ea4c5c
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/ViewModels/TerminalKeyboardViewModel.cs
@@ -0,0 +1,825 @@
+using System.Collections.ObjectModel;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+using DodoSSH.Client.Session;
+using DodoSSH.Client.Shell.Keyboard;
+using DodoSSH.Client.Terminal;
+
+namespace DodoSSH.Client.Shell.ViewModels;
+
+/// The quick-access keyboard's tabs.
+internal enum QuickAccessTab
+{
+ Keys,
+ Snippets,
+ History,
+ Appearance,
+}
+
+/// Where typed bytes are going: a session that is open, and what its tab is called.
+internal sealed record TypingTarget(uint SessionId, string Host);
+
+///
+/// The phone's keys for a terminal: the bar above the software keyboard, the quick-access keyboard that can
+/// take its place, the customise screen, and the cursor gesture's switch.
+///
+///
+///
+/// Here rather than in the Android head, because almost none of it is about Android. Which keys exist,
+/// how they are arranged, what a latched Ctrl does to the next one and what the history keeps are questions
+/// with right answers that can be tested on any machine, and the head is a project no test can reference.
+/// What stays in the head is what only a phone has: a software keyboard to hide and show, a touch stream to
+/// read a long press from, and the screen width that decides between a bottom panel and a side panel.
+///
+///
+/// Every key press, from the bar, the quick-access keyboard or the software keyboard itself, comes through
+/// . That is what makes the latches mean one thing — Ctrl pressed on the bar applies to
+/// a letter typed on Gboard — and what lets the history see the whole of a line rather than the part typed on
+/// one of the three.
+///
+///
+/// Customising saves as it goes. There is no draft and no SAVE: every change on the customise screen is
+/// a change to the bar that is already drawn under the sheet's scrim, and to the settings file, the moment it
+/// is made. A keyboard is not a document, and "did that save" is not a question it should raise.
+///
+///
+internal sealed partial class TerminalKeyboardViewModel : ObservableObject
+{
+ private readonly ClientSettingsStore settings;
+ private readonly Func target;
+ private readonly Func, ValueTask> send;
+ private readonly Func recent;
+ private readonly Func> insert;
+ private readonly Func paste;
+
+ private readonly List bar = [];
+ private readonly List groupOrder = [];
+ private readonly HashSet hiddenGroups = new(StringComparer.Ordinal);
+ private readonly List customKeys = [];
+
+ /// One button model per key id, shared wherever the key is drawn, so a latch lights all of them.
+ private readonly Dictionary buttons = new(StringComparer.Ordinal);
+
+ private readonly TypedCommandHistory history = new();
+
+ /// Where the arrangement is kept.
+ /// The session keys go to right now, or null when no tab has one.
+ /// Writes bytes to a session.
+ /// A session's recent output, for the history's echo test.
+ /// Types text into a session as the snippets screen does — the workspace's paste.
+ /// Pastes the clipboard into a session.
+ internal TerminalKeyboardViewModel(
+ ClientSettingsStore settings,
+ Func target,
+ Func, ValueTask> send,
+ Func recent,
+ Func> insert,
+ Func paste)
+ {
+ this.settings = settings;
+ this.target = target;
+ this.send = send;
+ this.recent = recent;
+ this.insert = insert;
+ this.paste = paste;
+
+ var stored = settings.Read().TerminalKeyboard ?? new TerminalKeyboardSettings();
+
+ Load(stored);
+
+ selectedTab = Enum.TryParse(stored.QuickAccessTab, out var tab) ? tab : QuickAccessTab.Keys;
+ isSidePanelOpen = stored.SidePanelOpen;
+ cursorGestures = stored.CursorGestures;
+
+ Rebuild();
+ }
+
+ /// The bar, as rows of at most .
+ internal ObservableCollection BarRows { get; } = [];
+
+ /// The KEYS tab: every group that is switched on, in order.
+ internal ObservableCollection Groups { get; } = [];
+
+ /// The HISTORY tab.
+ internal ObservableCollection History => history.Entries;
+
+ /// The customise screen's bar list.
+ internal ObservableCollection BarEditor { get; } = [];
+
+ /// The customise screen's group list.
+ internal ObservableCollection GroupEditor { get; } = [];
+
+ /// The customise screen's list of keys somebody added.
+ internal ObservableCollection CustomKeyEditor { get; } = [];
+
+ /// Whether the bar has any keys on it; an empty bar is drawn as nothing at all.
+ internal bool HasBar => bar.Count > 0;
+
+ /// Whether the HISTORY tab has anything to show.
+ internal bool HasHistory => History.Count > 0;
+
+ /// Whether the bar can take another key.
+ internal bool CanAddToBar => bar.Count < TerminalKeyCatalog.MaximumBarKeys;
+
+ /// The customise screen's count line.
+ internal string BarCountText => $"{bar.Count} of {TerminalKeyCatalog.MaximumBarKeys}";
+
+ [ObservableProperty]
+ private bool isControlLatched;
+
+ [ObservableProperty]
+ private bool isAltLatched;
+
+ /// Whether the quick-access keyboard is up in place of the software keyboard.
+ [ObservableProperty]
+ private bool isQuickAccessOpen;
+
+ /// Whether a wide screen draws the quick-access keyboard beside the terminal.
+ [ObservableProperty]
+ private bool isSidePanelOpen;
+
+ /// Whether the customise screen is up.
+ [ObservableProperty]
+ private bool isCustomizing;
+
+ /// Whether long-press-and-drag on the terminal moves the cursor.
+ [ObservableProperty]
+ private bool cursorGestures;
+
+ [ObservableProperty]
+ private QuickAccessTab selectedTab;
+
+ [ObservableProperty]
+ private string newKeyLabel = string.Empty;
+
+ [ObservableProperty]
+ private string newKeySequence = string.Empty;
+
+ [ObservableProperty]
+ private string customizeStatus = string.Empty;
+
+ internal bool IsKeysTab => SelectedTab is QuickAccessTab.Keys;
+
+ internal bool IsSnippetsTab => SelectedTab is QuickAccessTab.Snippets;
+
+ internal bool IsHistoryTab => SelectedTab is QuickAccessTab.History;
+
+ internal bool IsAppearanceTab => SelectedTab is QuickAccessTab.Appearance;
+
+ // ── typing ───────────────────────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Sends one key's worth of bytes to the selected session, through the latches and past the history.
+ ///
+ ///
+ ///
+ /// Both latches release after any key, including one they did not change. The person pressed Ctrl and
+ /// then a key; that was the key Ctrl was for, whatever it turned out to be.
+ ///
+ ///
+ /// Discarded rather than awaited: this is a keystroke, the workspace ignores a session that has gone,
+ /// and awaiting would serialise every key behind a slow link.
+ ///
+ ///
+ internal void Send(byte[] bytes)
+ {
+ ArgumentNullException.ThrowIfNull(bytes);
+
+ if (bytes.Length == 0 || target() is not { } to)
+ {
+ return;
+ }
+
+ var payload = TerminalModifiers.Apply(bytes, IsControlLatched, IsAltLatched);
+
+ IsControlLatched = false;
+ IsAltLatched = false;
+
+ history.Observe(to.SessionId, to.Host, payload, () => recent(to.SessionId));
+ OnPropertyChanged(nameof(HasHistory));
+
+ _ = send(to.SessionId, payload).AsTask();
+ }
+
+ /// Sends the cursor gesture's arrows, which no latch applies to.
+ ///
+ /// A gesture is not "the next key" in the sense a latch means — nobody pressing Ctrl on the bar and then
+ /// dragging the terminal means Ctrl+arrow by it, and spending the latch here would surprise the letter
+ /// typed afterwards. The history is still told, because the line has changed under it.
+ ///
+ internal void SendArrow(ArrowDirection direction, int count)
+ {
+ if (count <= 0 || target() is not { } to)
+ {
+ return;
+ }
+
+ string? id = direction switch
+ {
+ ArrowDirection.Left => "left",
+ ArrowDirection.Down => "down",
+ ArrowDirection.Up => "up",
+ ArrowDirection.Right => "right",
+ _ => null,
+ };
+
+ if (id is null)
+ {
+ return;
+ }
+
+ var one = TerminalKeyCatalog.ById[id].Bytes;
+ var payload = new byte[one.Length * count];
+
+ for (var i = 0; i < count; i++)
+ {
+ one.CopyTo(payload, i * one.Length);
+ }
+
+ history.Observe(to.SessionId, to.Host, one, () => recent(to.SessionId));
+
+ _ = send(to.SessionId, payload).AsTask();
+ }
+
+ /// What a key does when pressed, wherever it is drawn.
+ internal void Press(TerminalKey key)
+ {
+ ArgumentNullException.ThrowIfNull(key);
+
+ switch (key.Kind)
+ {
+ case TerminalKeyKind.Control:
+ IsControlLatched = !IsControlLatched;
+ break;
+
+ case TerminalKeyKind.Alt:
+ IsAltLatched = !IsAltLatched;
+ break;
+
+ case TerminalKeyKind.Paste:
+ if (target() is { } to)
+ {
+ history.NoteInserted(to.SessionId, () => recent(to.SessionId));
+ _ = paste(to.SessionId);
+ }
+
+ break;
+
+ default:
+ Send(key.Bytes);
+ break;
+ }
+ }
+
+ /// Notes that a session's tab is gone, so a half-typed line is not kept for it.
+ internal void ForgetSession(uint sessionId) => history.Forget(sessionId);
+
+ /// Empties the history, which LOCK does as well as the tab's own CLEAR.
+ [RelayCommand]
+ internal void ClearHistory()
+ {
+ history.Clear();
+ OnPropertyChanged(nameof(HasHistory));
+ }
+
+ /// Types a history entry at the prompt, without pressing Enter.
+ ///
+ /// The snippets screen's own route — typed rather than pasted for one line, and never with an Enter —
+ /// for the reason that screen gives: the remote may not be at a prompt any more, and running a command
+ /// out of a list is a thing to ask for rather than have happen.
+ ///
+ [RelayCommand]
+ private async Task InsertHistoryAsync(HistoryEntry? entry)
+ {
+ if (entry is null || target() is not { } to)
+ {
+ return;
+ }
+
+ history.NoteInserted(to.SessionId, () => recent(to.SessionId));
+
+ await insert(to.SessionId, entry.Command, false, CancellationToken.None).ConfigureAwait(true);
+ }
+
+ // ── the panels ───────────────────────────────────────────────────────────────────────────────────────
+
+ /// Raises or lowers the quick-access keyboard.
+ [RelayCommand]
+ private void ToggleQuickAccess() => IsQuickAccessOpen = !IsQuickAccessOpen;
+
+ /// Lowers the quick-access keyboard, for the software keyboard coming back.
+ internal void CloseQuickAccess() => IsQuickAccessOpen = false;
+
+ /// Shows or hides the wide screen's side panel, and remembers which.
+ [RelayCommand]
+ private void ToggleSidePanel()
+ {
+ IsSidePanelOpen = !IsSidePanelOpen;
+ Save();
+ }
+
+ [RelayCommand]
+ private void SelectTab(QuickAccessTab tab)
+ {
+ SelectedTab = tab;
+ Save();
+ }
+
+ [RelayCommand]
+ private void ToggleCursorGestures()
+ {
+ CursorGestures = !CursorGestures;
+ Save();
+ }
+
+ partial void OnSelectedTabChanged(QuickAccessTab value)
+ {
+ OnPropertyChanged(nameof(IsKeysTab));
+ OnPropertyChanged(nameof(IsSnippetsTab));
+ OnPropertyChanged(nameof(IsHistoryTab));
+ OnPropertyChanged(nameof(IsAppearanceTab));
+ }
+
+ partial void OnIsControlLatchedChanged(bool value) => RefreshLatches();
+
+ partial void OnIsAltLatchedChanged(bool value) => RefreshLatches();
+
+ private void RefreshLatches()
+ {
+ foreach (var button in buttons.Values)
+ {
+ button.IsLatched = button.Key.Kind switch
+ {
+ TerminalKeyKind.Control => IsControlLatched,
+ TerminalKeyKind.Alt => IsAltLatched,
+ _ => false,
+ };
+ }
+ }
+
+ // ── customising ──────────────────────────────────────────────────────────────────────────────────────
+
+ [RelayCommand]
+ private void OpenCustomize()
+ {
+ CustomizeStatus = string.Empty;
+ IsQuickAccessOpen = false;
+ IsCustomizing = true;
+ }
+
+ [RelayCommand]
+ private void CloseCustomize()
+ {
+ NewKeyLabel = string.Empty;
+ NewKeySequence = string.Empty;
+ CustomizeStatus = string.Empty;
+ IsCustomizing = false;
+ }
+
+ /// Puts the bar and the groups back as shipped. Keys somebody added are kept, off the bar.
+ ///
+ /// A custom key is a thing somebody wrote, and a reset that deleted it would be destroying work to tidy a
+ /// layout. It stays on the customise screen and in its own group, one tap from the bar again.
+ ///
+ [RelayCommand]
+ private void ResetLayout()
+ {
+ bar.Clear();
+ bar.AddRange(TerminalKeyCatalog.DefaultBar);
+ groupOrder.Clear();
+ hiddenGroups.Clear();
+ NormaliseGroupOrder();
+
+ CustomizeStatus = "The bar and the groups are back as they shipped.";
+ Commit();
+ }
+
+ /// Adds the key the two boxes describe, to its own group and to the bar if there is room.
+ [RelayCommand]
+ private void AddCustomKey()
+ {
+ var label = NewKeyLabel.Trim();
+
+ if (label.Length is 0 or > 10)
+ {
+ CustomizeStatus = "A key needs a label of one to ten characters.";
+ return;
+ }
+
+ if (!KeySequence.TryParse(NewKeySequence, out var bytes) || bytes.Length == 0)
+ {
+ CustomizeStatus = NewKeySequence.Length == 0
+ ? "Say what the key sends."
+ : "That sequence ends in a lone backslash or a \\x without two hex digits.";
+ return;
+ }
+
+ var id = TerminalKeyCatalog.CustomPrefix + Guid.CreateVersion7().ToString("N");
+
+ customKeys.Add(new CustomTerminalKey(id, label, NewKeySequence));
+ NormaliseGroupOrder();
+
+ var onBar = CanAddToBar;
+
+ if (onBar)
+ {
+ bar.Add(id);
+ }
+
+ CustomizeStatus = onBar
+ ? $"Added '{label}' to the bar."
+ : $"Added '{label}' to YOUR KEYS. The bar is full; take a key off it to make room.";
+
+ NewKeyLabel = string.Empty;
+ NewKeySequence = string.Empty;
+
+ Commit();
+ }
+
+ private void ToggleOnBar(string id)
+ {
+ if (bar.Remove(id))
+ {
+ Commit();
+ return;
+ }
+
+ if (!CanAddToBar)
+ {
+ CustomizeStatus = $"The bar holds {TerminalKeyCatalog.MaximumBarKeys} keys. Take one off first.";
+ return;
+ }
+
+ bar.Add(id);
+ CustomizeStatus = string.Empty;
+ Commit();
+ }
+
+ private void MoveOnBar(string id, int by)
+ {
+ var from = bar.IndexOf(id);
+ var to = from + by;
+
+ if (from < 0 || to < 0 || to >= bar.Count)
+ {
+ return;
+ }
+
+ (bar[from], bar[to]) = (bar[to], bar[from]);
+ Commit();
+ }
+
+ private void MoveGroup(string id, int by)
+ {
+ var from = groupOrder.IndexOf(id);
+ var to = from + by;
+
+ if (from < 0 || to < 0 || to >= groupOrder.Count)
+ {
+ return;
+ }
+
+ (groupOrder[from], groupOrder[to]) = (groupOrder[to], groupOrder[from]);
+ Commit();
+ }
+
+ ///
+ /// Saves and redraws the KEYS tab only. The switch that called this is a row of the customise screen's
+ /// own group list, and rebuilding that list would be pulling the control out from under the binding that
+ /// is still busy telling us about it.
+ ///
+ private void SetGroupShown(string id, bool shown)
+ {
+ var changed = shown ? hiddenGroups.Remove(id) : hiddenGroups.Add(id);
+
+ if (changed)
+ {
+ Save();
+ RebuildGroups();
+ }
+ }
+
+ private void DeleteCustomKey(string id)
+ {
+ customKeys.RemoveAll(k => string.Equals(k.Id, id, StringComparison.Ordinal));
+ bar.Remove(id);
+ buttons.Remove(id);
+ NormaliseGroupOrder();
+ Commit();
+ }
+
+ // ── state ────────────────────────────────────────────────────────────────────────────────────────────
+
+ private void Load(TerminalKeyboardSettings stored)
+ {
+ foreach (var custom in stored.CustomKeys ?? [])
+ {
+ if (custom is { Id: { } id, Label: { } label, Sequence: { } sequence }
+ && id.StartsWith(TerminalKeyCatalog.CustomPrefix, StringComparison.Ordinal)
+ && TerminalKeyCatalog.FromCustom(id, label, sequence) is not null
+ && customKeys.TrueForAll(k => !string.Equals(k.Id, id, StringComparison.Ordinal)))
+ {
+ customKeys.Add(custom);
+ }
+ }
+
+ foreach (var id in stored.Bar ?? [.. TerminalKeyCatalog.DefaultBar])
+ {
+ if (bar.Count < TerminalKeyCatalog.MaximumBarKeys && KeyFor(id) is not null && !bar.Contains(id))
+ {
+ bar.Add(id);
+ }
+ }
+
+ groupOrder.AddRange(stored.GroupOrder ?? []);
+ hiddenGroups.UnionWith(stored.HiddenGroups ?? []);
+
+ NormaliseGroupOrder();
+ }
+
+ ///
+ /// Every group this build has, exactly once: stored order first, anything new at the end.
+ ///
+ ///
+ /// A group shipped after somebody arranged theirs lands at the bottom rather than not at all — they did
+ /// not hide it, so it is not hidden — and one this build does not know is dropped.
+ ///
+ private void NormaliseGroupOrder()
+ {
+ var known = TerminalKeyCatalog.Groups.Select(g => g.Id).ToList();
+
+ if (customKeys.Count > 0)
+ {
+ known.Add(TerminalKeyCatalog.CustomGroupId);
+ }
+
+ var ordered = groupOrder
+ .Where(id => known.Contains(id, StringComparer.Ordinal))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ ordered.AddRange(known.Where(id => !ordered.Contains(id, StringComparer.Ordinal)));
+
+ groupOrder.Clear();
+ groupOrder.AddRange(ordered);
+ }
+
+ private TerminalKey? KeyFor(string id) =>
+ TerminalKeyCatalog.ById.TryGetValue(id, out var shipped) ? shipped
+ : customKeys.Find(k => string.Equals(k.Id, id, StringComparison.Ordinal)) is { } custom
+ ? TerminalKeyCatalog.FromCustom(custom.Id, custom.Label, custom.Sequence)
+ : null;
+
+ private TerminalKeyGroup? GroupFor(string id)
+ {
+ if (string.Equals(id, TerminalKeyCatalog.CustomGroupId, StringComparison.Ordinal))
+ {
+ return new TerminalKeyGroup(
+ id,
+ "YOUR KEYS",
+ [.. customKeys.Select(k => KeyFor(k.Id)).OfType()]);
+ }
+
+ return TerminalKeyCatalog.Groups.FirstOrDefault(g => string.Equals(g.Id, id, StringComparison.Ordinal));
+ }
+
+ private KeyButtonViewModel ButtonFor(TerminalKey key)
+ {
+ if (!buttons.TryGetValue(key.Id, out var button))
+ {
+ button = new KeyButtonViewModel(key, Press);
+ buttons[key.Id] = button;
+ }
+
+ return button;
+ }
+
+ private void Commit()
+ {
+ Save();
+ Rebuild();
+ }
+
+ private void Save() =>
+ settings.Write(settings.Read() with
+ {
+ TerminalKeyboard = new TerminalKeyboardSettings(
+ Bar: [.. bar],
+ GroupOrder: [.. groupOrder],
+ HiddenGroups: [.. hiddenGroups.Order(StringComparer.Ordinal)],
+ CustomKeys: [.. customKeys],
+ QuickAccessTab: SelectedTab.ToString(),
+ SidePanelOpen: IsSidePanelOpen,
+ CursorGestures: CursorGestures),
+ });
+
+ /// Redraws the KEYS tab.
+ private void RebuildGroups()
+ {
+ Groups.Clear();
+
+ foreach (var id in groupOrder)
+ {
+ if (!hiddenGroups.Contains(id) && GroupFor(id) is { Keys.Count: > 0 } group)
+ {
+ Groups.Add(new KeyGroupViewModel(group.Title, [.. group.Keys.Select(ButtonFor)]));
+ }
+ }
+ }
+
+ /// Redraws everything that depends on the arrangement.
+ ///
+ /// Wholesale rather than by diff. The lists are a few dozen rows, a change is a tap, and an incremental
+ /// update is a second account of the arrangement that can disagree with the first.
+ ///
+ private void Rebuild()
+ {
+ var barKeys = bar.Select(KeyFor).OfType().ToList();
+
+ BarRows.Clear();
+
+ foreach (var row in barKeys.Chunk(TerminalKeyCatalog.BarRowWidth))
+ {
+ BarRows.Add(new KeyRowViewModel([.. row.Select(ButtonFor)]));
+ }
+
+ RebuildGroups();
+
+ GroupEditor.Clear();
+
+ for (var i = 0; i < groupOrder.Count; i++)
+ {
+ if (GroupFor(groupOrder[i]) is not { } group)
+ {
+ continue;
+ }
+
+ var id = group.Id;
+
+ GroupEditor.Add(new GroupEditorRowViewModel(
+ group.Title,
+ !hiddenGroups.Contains(id),
+ shown => SetGroupShown(id, shown),
+ i > 0 ? () => MoveGroup(id, -1) : null,
+ i < groupOrder.Count - 1 ? () => MoveGroup(id, 1) : null,
+ [.. group.Keys.Select(k => new KeyChoiceViewModel(k.Label, bar.Contains(k.Id), () => ToggleOnBar(k.Id)))]));
+ }
+
+ BarEditor.Clear();
+
+ for (var i = 0; i < barKeys.Count; i++)
+ {
+ var id = barKeys[i].Id;
+
+ BarEditor.Add(new BarEditorRowViewModel(
+ barKeys[i],
+ i < TerminalKeyCatalog.BarRowWidth ? "TOP ROW" : "SECOND ROW",
+ i > 0 ? () => MoveOnBar(id, -1) : null,
+ i < barKeys.Count - 1 ? () => MoveOnBar(id, 1) : null,
+ () => ToggleOnBar(id)));
+ }
+
+ CustomKeyEditor.Clear();
+
+ foreach (var custom in customKeys)
+ {
+ var id = custom.Id;
+ CustomKeyEditor.Add(new CustomKeyRowViewModel(custom.Label, custom.Sequence, () => DeleteCustomKey(id)));
+ }
+
+ RefreshLatches();
+
+ OnPropertyChanged(nameof(HasBar));
+ OnPropertyChanged(nameof(CanAddToBar));
+ OnPropertyChanged(nameof(BarCountText));
+ }
+
+}
+
+/// One key as drawn: on the bar, on the quick-access keyboard, or both at once.
+internal sealed partial class KeyButtonViewModel : ObservableObject
+{
+ internal KeyButtonViewModel(TerminalKey key, Action press)
+ {
+ Key = key;
+ PressCommand = new RelayCommand(() => press(key));
+ }
+
+ internal TerminalKey Key { get; }
+
+ internal string Label => Key.Label;
+
+ internal string Hint => Key.Hint;
+
+ internal bool IsWide => Key.IsWide;
+
+ internal IRelayCommand PressCommand { get; }
+
+ /// Whether this is a modifier that is on, which has to look on.
+ [ObservableProperty]
+ private bool isLatched;
+}
+
+/// One row of the bar; the markup divides its width evenly between the keys.
+internal sealed record KeyRowViewModel(IReadOnlyList Keys);
+
+/// One group on the KEYS tab.
+internal sealed record KeyGroupViewModel(string Title, IReadOnlyList Keys);
+
+/// One key on the customise screen's bar list.
+internal sealed class BarEditorRowViewModel
+{
+ internal BarEditorRowViewModel(TerminalKey key, string row, Action? moveUp, Action? moveDown, Action remove)
+ {
+ Label = key.Label;
+ Hint = key.Hint;
+ Row = row;
+ MoveUpCommand = new RelayCommand(() => moveUp?.Invoke(), () => moveUp is not null);
+ MoveDownCommand = new RelayCommand(() => moveDown?.Invoke(), () => moveDown is not null);
+ RemoveCommand = new RelayCommand(remove);
+ }
+
+ internal string Label { get; }
+
+ internal string Hint { get; }
+
+ /// Which row of the bar the key lands on, since the first eight are the top row.
+ internal string Row { get; }
+
+ internal IRelayCommand MoveUpCommand { get; }
+
+ internal IRelayCommand MoveDownCommand { get; }
+
+ internal IRelayCommand RemoveCommand { get; }
+}
+
+/// One group on the customise screen: its switch, its place, and which of its keys are on the bar.
+internal sealed partial class GroupEditorRowViewModel : ObservableObject
+{
+ private readonly Action setShown;
+
+ internal GroupEditorRowViewModel(
+ string title,
+ bool isShown,
+ Action setShown,
+ Action? moveUp,
+ Action? moveDown,
+ IReadOnlyList keys)
+ {
+ Title = title;
+ this.isShown = isShown;
+ this.setShown = setShown;
+ Keys = keys;
+ MoveUpCommand = new RelayCommand(() => moveUp?.Invoke(), () => moveUp is not null);
+ MoveDownCommand = new RelayCommand(() => moveDown?.Invoke(), () => moveDown is not null);
+ }
+
+ internal string Title { get; }
+
+ internal IReadOnlyList Keys { get; }
+
+ internal IRelayCommand MoveUpCommand { get; }
+
+ internal IRelayCommand MoveDownCommand { get; }
+
+ /// Whether the group is drawn on the KEYS tab.
+ [ObservableProperty]
+ private bool isShown;
+
+ partial void OnIsShownChanged(bool value) => setShown(value);
+}
+
+/// A key on the customise screen, lit when it is on the bar; tapping it puts it on or takes it off.
+internal sealed class KeyChoiceViewModel
+{
+ internal KeyChoiceViewModel(string label, bool isOnBar, Action toggle)
+ {
+ Label = label;
+ IsOnBar = isOnBar;
+ ToggleCommand = new RelayCommand(toggle);
+ }
+
+ internal string Label { get; }
+
+ internal bool IsOnBar { get; }
+
+ internal IRelayCommand ToggleCommand { get; }
+}
+
+/// A key somebody added, on the customise screen, with the way to delete it.
+internal sealed class CustomKeyRowViewModel
+{
+ internal CustomKeyRowViewModel(string label, string sequence, Action delete)
+ {
+ Label = label;
+ Sequence = sequence;
+ DeleteCommand = new RelayCommand(delete);
+ }
+
+ internal string Label { get; }
+
+ internal string Sequence { get; }
+
+ internal IRelayCommand DeleteCommand { get; }
+}
diff --git a/src/DodoSSH.Client.Terminal/OutputTail.cs b/src/DodoSSH.Client.Terminal/OutputTail.cs
new file mode 100644
index 0000000..969c7e6
--- /dev/null
+++ b/src/DodoSSH.Client.Terminal/OutputTail.cs
@@ -0,0 +1,157 @@
+namespace DodoSSH.Client.Terminal;
+
+/// What one session's output has most recently looked like.
+/// The last bytes the remote wrote, oldest first.
+///
+/// Whether the remote has switched to the alternate screen — vi, less, htop — and not yet switched back.
+///
+public sealed record RecentOutput(byte[] Tail, bool AlternateScreen);
+
+///
+/// The last kilobyte a session's remote wrote, and whether a full-screen program is running.
+///
+///
+///
+/// This exists so that the phone's command history only keeps what the remote echoed. A terminal is
+/// one input stream, and the client cannot tell a shell prompt from a sudo password prompt by what is
+/// typed into it — only by whether the characters came back. A password typed with echo off leaves the
+/// prompt line exactly as it was, and that is the test the history applies before it keeps anything. See
+/// TypedCommandHistory in the shell.
+///
+///
+/// Kept beside the pump's read loop, and costing that loop almost nothing. Each read copies at most
+/// bytes into a ring and runs one vectorised search over the read for the
+/// alternate-screen switches, with no allocation. Locked rather than lock-free
+/// because it is written from the read loop's thread and read from the UI thread on Enter, and an Enter is
+/// rare enough that a lock is the honest price.
+///
+///
+/// Nothing here outlives the session or leaves this process. It is the same bytes the renderer is shown,
+/// held for as long as it takes for the next kilobyte to push them out.
+///
+///
+public sealed class OutputTail
+{
+ /// How much output is kept.
+ ///
+ /// A prompt and a long command line with room to spare. A line longer than this is not recorded at all,
+ /// which is the safe direction to fail in.
+ ///
+ public const int Capacity = 1024;
+
+ /// The longest switch sequence, less one: how far a match can straddle two reads.
+ private const int Overlap = 7;
+
+ private readonly Lock gate = new();
+ private readonly byte[] ring = new byte[Capacity];
+ private int start;
+ private int count;
+ private bool alternateScreen;
+
+ /// Takes a read's worth of output.
+ public void Append(ReadOnlySpan output)
+ {
+ if (output.IsEmpty)
+ {
+ return;
+ }
+
+ lock (gate)
+ {
+ // The seam first — the end of what is already held and the start of the new bytes — so a switch
+ // split across two reads is still seen whole; then the whole read, in which a later switch wins.
+ // All of it rather than its tail: a program that switches screens and then paints sixty
+ // kilobytes in the same read is the ordinary case, not the odd one.
+ var carried = Math.Min(count, Overlap);
+ Span seam = stackalloc byte[carried + Math.Min(output.Length, Overlap)];
+ CopyLast(seam[..carried]);
+ output[..(seam.Length - carried)].CopyTo(seam[carried..]);
+
+ alternateScreen = ScanForScreenSwitch(output, ScanForScreenSwitch(seam, alternateScreen));
+
+ foreach (var b in output.Length > Capacity ? output[^Capacity..] : output)
+ {
+ ring[(start + count) % Capacity] = b;
+
+ if (count < Capacity)
+ {
+ count++;
+ }
+ else
+ {
+ start = (start + 1) % Capacity;
+ }
+ }
+ }
+ }
+
+ /// A copy of what is held, oldest byte first.
+ public RecentOutput Snapshot()
+ {
+ lock (gate)
+ {
+ var copy = new byte[count];
+ CopyLast(copy);
+
+ return new RecentOutput(copy, alternateScreen);
+ }
+ }
+
+ /// Copies the last destination.Length held bytes. Called under the lock.
+ private void CopyLast(Span destination)
+ {
+ var offset = count - destination.Length;
+
+ for (var i = 0; i < destination.Length; i++)
+ {
+ destination[i] = ring[(start + offset + i) % Capacity];
+ }
+ }
+
+ ///
+ /// Whether the screen is alternate after this output, given whether it was before.
+ ///
+ ///
+ /// The three DEC private modes programs use for it — 1049, which every modern one sends, and the older
+ /// 47 and 1047 — set with h and reset with l. The last switch in the window wins.
+ ///
+ private static bool ScanForScreenSwitch(ReadOnlySpan window, bool current)
+ {
+ ReadOnlySpan prefix = [0x1B, (byte)'[', (byte)'?'];
+
+ var state = current;
+ var at = 0;
+
+ while (at < window.Length)
+ {
+ var found = window[at..].IndexOf(prefix);
+
+ if (found < 0)
+ {
+ break;
+ }
+
+ var digits = at + found + prefix.Length;
+ var end = digits;
+
+ while (end < window.Length && window[end] is >= (byte)'0' and <= (byte)'9')
+ {
+ end++;
+ }
+
+ if (end < window.Length && window[end] is (byte)'h' or (byte)'l')
+ {
+ var mode = window[digits..end];
+
+ if (mode.SequenceEqual("1049"u8) || mode.SequenceEqual("1047"u8) || mode.SequenceEqual("47"u8))
+ {
+ state = window[end] == (byte)'h';
+ }
+ }
+
+ at = digits;
+ }
+
+ return state;
+ }
+}
diff --git a/src/DodoSSH.Client.Terminal/TerminalSessionPump.cs b/src/DodoSSH.Client.Terminal/TerminalSessionPump.cs
index 5f95ce9..4e96319 100644
--- a/src/DodoSSH.Client.Terminal/TerminalSessionPump.cs
+++ b/src/DodoSSH.Client.Terminal/TerminalSessionPump.cs
@@ -167,6 +167,10 @@ public sealed class TerminalSessionPump : IAsyncDisposable
/// Bytes read from the remote and not yet sent to the renderer.
public int BytesPending => backlog.Buffered;
+ /// The last of what the remote wrote, for the phone's command history.
+ /// See for why a client keeps any of it.
+ public OutputTail Recent { get; } = new();
+
/// Total frames sent to the renderer, for the throughput harness.
public long FramesSent { get; private set; }
@@ -353,6 +357,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
}
BytesRead += read;
+ Recent.Append(buffer.AsSpan(0, read));
// Copied out of the rented buffer, because the backlog holds it until the renderer has
// room and the next read is already about to overwrite this one.
diff --git a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
index 551956a..4259a3c 100644
--- a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
+++ b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
@@ -280,6 +280,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
}
}
+ ///
+ /// What one live session's remote has most recently written, or null when the id names no session this
+ /// workspace still has open.
+ ///
+ ///
+ /// For the phone's command history, which keeps a typed line only once it has seen the remote echo it —
+ /// see . A copy, so the caller can take its time over it.
+ ///
+ public RecentOutput? GetRecentOutput(uint sessionId)
+ {
+ lock (sessionGate)
+ {
+ return sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted
+ ? session.Pump.Recent.Snapshot()
+ : null;
+ }
+ }
+
///
/// A live session's flow-control window, or null when the id names no session this workspace still has
/// open.
diff --git a/tests/DodoSSH.Client.App.Tests/TerminalKeyboardTests.cs b/tests/DodoSSH.Client.App.Tests/TerminalKeyboardTests.cs
new file mode 100644
index 0000000..4a437db
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Tests/TerminalKeyboardTests.cs
@@ -0,0 +1,481 @@
+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;
+
+///
+/// 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.
+///
+public sealed class TerminalKeyboardTests : IDisposable
+{
+ private readonly string directory =
+ Path.Combine(Path.GetTempPath(), $"dodossh-keys-{Guid.CreateVersion7():N}");
+
+ private readonly List sent = [];
+
+ /// What the remote has drawn so far, as the history's echo test reads it.
+ 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);
+
+ /// Types text one key at a time, echoing each key the way an ordinary shell prompt does.
+ 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();
+ }
+}
diff --git a/tests/DodoSSH.Client.Terminal.Tests/OutputTailTests.cs b/tests/DodoSSH.Client.Terminal.Tests/OutputTailTests.cs
new file mode 100644
index 0000000..b6f5d3b
--- /dev/null
+++ b/tests/DodoSSH.Client.Terminal.Tests/OutputTailTests.cs
@@ -0,0 +1,81 @@
+using System.Text;
+
+namespace DodoSSH.Client.Terminal.Tests;
+
+///
+/// That a session keeps the last of its output for the phone's history to read back, and knows when a
+/// full-screen program has it.
+///
+public sealed class OutputTailTests
+{
+ private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text);
+
+ [Fact]
+ public void WhatIsHeld_IsTheLastKilobyte_InOrder()
+ {
+ var tail = new OutputTail();
+
+ tail.Append(Ascii(new string('a', OutputTail.Capacity)));
+ tail.Append(Ascii("stan@box:~$ ls"));
+
+ var held = Encoding.ASCII.GetString(tail.Snapshot().Tail);
+
+ held.Length.ShouldBe(OutputTail.Capacity);
+ held.ShouldEndWith("aaastan@box:~$ ls");
+ }
+
+ [Fact]
+ public void AReadLargerThanTheTail_KeepsItsEnd()
+ {
+ var tail = new OutputTail();
+
+ tail.Append(Ascii(new string('x', 5000) + "the end"));
+
+ Encoding.ASCII.GetString(tail.Snapshot().Tail).ShouldEndWith("xthe end");
+ }
+
+ [Fact]
+ public void TheAlternateScreen_IsOnBetweenItsTwoSwitches()
+ {
+ var tail = new OutputTail();
+
+ tail.Append(Ascii("$ vi notes\r\n\x1b[?1049h\x1b[H~\r\n~"));
+ tail.Snapshot().AlternateScreen.ShouldBeTrue();
+
+ tail.Append(Ascii("\x1b[?1049l$ "));
+ tail.Snapshot().AlternateScreen.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ASwitchSplitAcrossTwoReads_IsStillSeen()
+ {
+ var tail = new OutputTail();
+
+ tail.Append(Ascii("$ less log\r\n\x1b[?10"));
+ tail.Append(Ascii("49h"));
+
+ tail.Snapshot().AlternateScreen.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ASwitchAtTheStartOfAHugeRead_IsNotLostWithTheBytesThatFallOffTheTail()
+ {
+ var tail = new OutputTail();
+
+ // A full-screen program's first frame: the switch, and then a screenful of paint.
+ tail.Append(Ascii("\x1b[?1049h" + new string('#', 60_000)));
+
+ tail.Snapshot().AlternateScreen.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void OtherPrivateModes_AreNotTheAlternateScreen()
+ {
+ var tail = new OutputTail();
+
+ // Bracketed paste and cursor visibility, which every prompt sends.
+ tail.Append(Ascii("\x1b[?2004h\x1b[?25h$ "));
+
+ tail.Snapshot().AlternateScreen.ShouldBeFalse();
+ }
+}