From 367cebae9bcb830ab393f15f87ae339ca1fdb312 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Sat, 3 Oct 2026 14:29:22 +0200 Subject: [PATCH] Android: a customisable key bar, a quick-access keyboard, a long-press cursor gesture, and a side panel on tablets The key bar above the software keyboard is now the user's own: which keys, in what order, up to sixteen in two rows that never scroll. Ctrl and Alt latch across the bar and the phone's keyboard, and Ctrl or Alt with a cursor key sends the modified CSI form. Seven predefined groups (essentials, cursor, control, symbols, tmux, vim, function) and keys of one's own, written in \e / ^C notation, are arranged on a customise sheet that saves as it goes. A grid button swaps the software keyboard for a quick-access keyboard of the same height, with KEYS, SNIPPETS, HISTORY and APPEARANCE tabs; it reopens to the tab last used. Snippets go through the shell's existing InsertSnippet. History keeps a typed command only once the remote has echoed it, so nothing typed at a password prompt is ever recorded; it is memory-only and LOCK clears it. Each session pump now keeps its last kilobyte of output and the alternate-screen state for that test (OutputTail). Long-press and drag on the terminal sends arrow keys, as a joystick with three speeds by distance; scrolling is unchanged and there is no mode switch. It takes the touch before Chromium's own long press, so a held finger no longer pastes; paste is on the bar by default instead. A switch in APPEARANCE turns the gesture off. The text-size buttons moved from the bar to APPEARANCE. On wide screens the same tabs open as a side panel beside the terminal, toggled from the top bar, with the bar's own keys repeated at the top of KEYS while the bar stands down. The model, gesture arithmetic, echo reading and history are in DodoSSH.Client.Shell and covered by TerminalKeyboardTests and OutputTailTests. Builds clean; not yet exercised on a device, because the emulator's install is locked behind a passphrase that was not recorded. --- .../Platform/RendererFocus.cs | 278 +++++- .../Views/PhoneShell.axaml.cs | 25 +- .../Views/QuickAccessKeyboard.axaml | 325 +++++++ .../Views/QuickAccessKeyboard.axaml.cs | 40 + .../Views/TerminalScreen.axaml | 410 ++++++--- .../Views/TerminalScreen.axaml.cs | 473 +++++----- src/DodoSSH.Client.Session/ClientSettings.cs | 8 +- .../TerminalKeyboardSettings.cs | 47 + .../Keyboard/ArrowGesture.cs | 171 ++++ .../Keyboard/EchoedLine.cs | 205 +++++ .../Keyboard/KeySequence.cs | 162 ++++ .../Keyboard/TerminalKey.cs | 55 ++ .../Keyboard/TerminalKeyCatalog.cs | 199 +++++ .../Keyboard/TerminalModifiers.cs | 57 ++ .../Keyboard/TypedCommandHistory.cs | 224 +++++ .../ViewModels/MainWindowViewModel.cs | 47 + .../ViewModels/TerminalKeyboardViewModel.cs | 825 ++++++++++++++++++ src/DodoSSH.Client.Terminal/OutputTail.cs | 157 ++++ .../TerminalSessionPump.cs | 5 + .../TerminalWorkspace.cs | 18 + .../TerminalKeyboardTests.cs | 481 ++++++++++ .../OutputTailTests.cs | 81 ++ 22 files changed, 3949 insertions(+), 344 deletions(-) create mode 100644 src/DodoSSH.Client.Android/Views/QuickAccessKeyboard.axaml create mode 100644 src/DodoSSH.Client.Android/Views/QuickAccessKeyboard.axaml.cs create mode 100644 src/DodoSSH.Client.Session/TerminalKeyboardSettings.cs create mode 100644 src/DodoSSH.Client.Shell/Keyboard/ArrowGesture.cs create mode 100644 src/DodoSSH.Client.Shell/Keyboard/EchoedLine.cs create mode 100644 src/DodoSSH.Client.Shell/Keyboard/KeySequence.cs create mode 100644 src/DodoSSH.Client.Shell/Keyboard/TerminalKey.cs create mode 100644 src/DodoSSH.Client.Shell/Keyboard/TerminalKeyCatalog.cs create mode 100644 src/DodoSSH.Client.Shell/Keyboard/TerminalModifiers.cs create mode 100644 src/DodoSSH.Client.Shell/Keyboard/TypedCommandHistory.cs create mode 100644 src/DodoSSH.Client.Shell/ViewModels/TerminalKeyboardViewModel.cs create mode 100644 src/DodoSSH.Client.Terminal/OutputTail.cs create mode 100644 tests/DodoSSH.Client.App.Tests/TerminalKeyboardTests.cs create mode 100644 tests/DodoSSH.Client.Terminal.Tests/OutputTailTests.cs 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +