Public Access
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.
This commit is contained in:
1 parent
c21de4ade4
commit
367cebae9b
22 files changed
+3949
-344
No files matched your search
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
|
||||
/// back.
|
||||
/// Takes the keyboard away from the terminal's <c>WebView</c>, reports the taps that should bring it back,
|
||||
/// and turns a long press and drag into arrow keys.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -36,6 +41,23 @@ namespace DodoSSH.Client.Android.Platform;
|
||||
/// keyboard over the very output being scrolled to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A long press and a drag is the cursor gesture, and scrolling is still everything else.</b> 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 <see cref="CursorGestureDelay"/> becomes a joystick for the arrow
|
||||
/// keys — see <see cref="ArrowGesture"/> for the speeds — and from that moment the rest of the touch is this
|
||||
/// class's: the <c>WebView</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <c>contextmenu</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
|
||||
/// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and
|
||||
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
|
||||
@@ -52,17 +74,26 @@ internal static class RendererFocus
|
||||
/// </remarks>
|
||||
private static WeakReference<global::Android.Webkit.WebView>? configured;
|
||||
|
||||
/// <summary>How long a finger has to stay still for the cursor gesture, as a share of the long-press timeout.</summary>
|
||||
private const double CursorGestureDelay = 0.8;
|
||||
|
||||
/// <summary>How often a held gesture is asked whether another arrow has come due — about one frame.</summary>
|
||||
private const long TickMs = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
|
||||
/// taps it.
|
||||
/// Makes the renderer unable to hold the keyboard, calls <paramref name="onTapped"/> when somebody taps
|
||||
/// it, and <paramref name="onArrows"/> while somebody long-presses and drags it.
|
||||
/// </summary>
|
||||
/// <param name="onTapped">A tap: bring the keyboard back.</param>
|
||||
/// <param name="gesturesEnabled">Asked at each press, so the setting applies from the next touch.</param>
|
||||
/// <param name="onArrows">How many arrows to send, and which.</param>
|
||||
/// <remarks>
|
||||
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
|
||||
/// <c>WebView</c> 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 <c>docs/android-port.md</c>).
|
||||
/// </remarks>
|
||||
public static void Suppress(Action onTapped)
|
||||
public static void Suppress(Action onTapped, Func<bool> gesturesEnabled, Action<ArrowDirection, int> 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<global::Android.Webkit.WebView>(webView);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
|
||||
/// Calls <paramref name="onTapped"/> for a tap, runs the cursor gesture for a long press, and stays out of
|
||||
/// the way of everything else.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A tap is a press and a release that did not travel further than the platform's own
|
||||
/// <c>ScaledTouchSlop</c> — 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.
|
||||
/// </remarks>
|
||||
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
|
||||
private static void WatchTouches(
|
||||
global::Android.Webkit.WebView webView,
|
||||
Action onTapped,
|
||||
Func<bool> gesturesEnabled,
|
||||
Action<ArrowDirection, int> 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);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>One finger's worth of touch on the terminal, from press to release.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Avalonia's <see cref="DispatcherTimer"/> rather than <c>View.PostDelayed</c>. 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 <c>Runnable</c> is a Java peer that would want disposing for the life of a view that has none.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class TouchState
|
||||
{
|
||||
private readonly global::Android.Webkit.WebView webView;
|
||||
private readonly Func<bool> gesturesEnabled;
|
||||
private readonly Action<ArrowDirection, int> 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<bool> gesturesEnabled,
|
||||
Action<ArrowDirection, int> 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();
|
||||
}
|
||||
|
||||
/// <returns>Whether the event is consumed rather than passed on to the page.</returns>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A finger moving before the long press: past the slop it is a scroll, and stays one.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <returns>Whether the release is consumed — it is, when it ends the cursor gesture.</returns>
|
||||
private bool Release(bool lifted, Action onTapped)
|
||||
{
|
||||
longPress.Stop();
|
||||
|
||||
if (steering)
|
||||
{
|
||||
EndSteering();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lifted && !moved)
|
||||
{
|
||||
onTapped();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>The finger has been held still long enough: take the touch and start steering.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The repeat clock: whatever has come due since the last frame.</summary>
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user