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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +