Public Access
Compare commits
2
Commits
c21de4ade4
...
96dd176121
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96dd176121 | ||
|
|
367cebae9b |
No files matched your search
@@ -10,7 +10,7 @@
|
|||||||
"rollForward": false
|
"rollForward": false
|
||||||
},
|
},
|
||||||
"vpk": {
|
"vpk": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.161",
|
||||||
"commands": [
|
"commands": [
|
||||||
"vpk"
|
"vpk"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -172,7 +172,7 @@
|
|||||||
constant rather than a setting: an operator who could answer the update check could pin a
|
constant rather than a setting: an operator who could answer the update check could pin a
|
||||||
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
|
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="Velopack" Version="1.2.0" />
|
<PackageVersion Include="Velopack" Version="1.2.161" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
|
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
using global::Android.OS;
|
||||||
using global::Android.Views;
|
using global::Android.Views;
|
||||||
|
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
using DodoSSH.Client.Shell.Keyboard;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Android.Platform;
|
namespace DodoSSH.Client.Android.Platform;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
|
/// Takes the keyboard away from the terminal's <c>WebView</c>, reports the taps that should bring it back,
|
||||||
/// back.
|
/// and turns a long press and drag into arrow keys.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -36,6 +41,23 @@ namespace DodoSSH.Client.Android.Platform;
|
|||||||
/// keyboard over the very output being scrolled to.
|
/// keyboard over the very output being scrolled to.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <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
|
/// 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
|
/// <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
|
/// 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>
|
/// </remarks>
|
||||||
private static WeakReference<global::Android.Webkit.WebView>? configured;
|
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>
|
/// <summary>
|
||||||
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
|
/// Makes the renderer unable to hold the keyboard, calls <paramref name="onTapped"/> when somebody taps
|
||||||
/// taps it.
|
/// it, and <paramref name="onArrows"/> while somebody long-presses and drags it.
|
||||||
/// </summary>
|
/// </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>
|
/// <remarks>
|
||||||
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
|
/// 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
|
/// <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
|
/// 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>).
|
/// elsewhere (see <c>docs/android-port.md</c>).
|
||||||
/// </remarks>
|
/// </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)
|
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
||||||
{
|
{
|
||||||
@@ -83,57 +114,246 @@ internal static class RendererFocus
|
|||||||
webView.FocusableInTouchMode = false;
|
webView.FocusableInTouchMode = false;
|
||||||
webView.ClearFocus();
|
webView.ClearFocus();
|
||||||
|
|
||||||
WatchForTaps(webView, onTapped);
|
WatchTouches(webView, onTapped, gesturesEnabled, onArrows);
|
||||||
|
|
||||||
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
|
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A tap is a press and a release that did not travel further than the platform's own
|
/// 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:
|
/// <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>
|
/// </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 touch = new TouchState(webView, gesturesEnabled, onArrows);
|
||||||
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
|
|
||||||
var downX = 0f;
|
|
||||||
var downY = 0f;
|
|
||||||
var moved = false;
|
|
||||||
|
|
||||||
webView.Touch += (_, e) =>
|
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)
|
if (e.Event is not { } motion)
|
||||||
{
|
{
|
||||||
|
e.Handled = false;
|
||||||
return;
|
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:
|
case MotionEventActions.Down:
|
||||||
downX = motion.GetX();
|
Press(motion);
|
||||||
downY = motion.GetY();
|
return false;
|
||||||
moved = 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:
|
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:
|
default:
|
||||||
onTapped();
|
return steering;
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
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)
|
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
|
||||||
|
|||||||
@@ -647,15 +647,34 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>Whether one was closed, and so whether back has been spent.</returns>
|
/// <returns>Whether one was closed, and so whether back has been spent.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Both are raised from the terminal's bar and the scrim under each covers that bar, so at most one is
|
/// The three sheets are raised from the terminal's bar or its keys and the scrim under each covers both,
|
||||||
/// ever open; the order below decides nothing.
|
/// 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.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static bool TryCloseATerminalSheet(MainWindowViewModel current)
|
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)
|
if (current.IsConnectSheetOpen)
|
||||||
{
|
{
|
||||||
current.CloseConnectSheetCommand.Execute(null);
|
current.CloseConnectSheetCommand.Execute(null);
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
xmlns:kb="using:DodoSSH.Client.Shell.Keyboard"
|
||||||
|
xmlns:views="using:DodoSSH.Client.Android.Views"
|
||||||
|
x:Class="DodoSSH.Client.Android.Views.QuickAccessKeyboard"
|
||||||
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
Background="{StaticResource DeepChrome}">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The quick-access keyboard: every key, snippet and command from history, and the terminal's appearance,
|
||||||
|
one tap from the prompt.
|
||||||
|
|
||||||
|
One control in two places. On a phone it takes the software keyboard's place under the terminal, at the
|
||||||
|
keyboard's own height so the bar above it does not move; on a wide screen it is a panel beside the
|
||||||
|
terminal that stays up while the software keyboard comes and goes. Same tabs, same rows, same view model
|
||||||
|
either way — see TerminalScreen for which one is drawn when.
|
||||||
|
|
||||||
|
It reopens to the tab last used, which is a setting rather than a field on this control: the tab is the
|
||||||
|
one somebody reaches for on this device, and the next launch should know it too. See
|
||||||
|
TerminalKeyboardViewModel.SelectedTab.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<UserControl.Styles>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ NOTHING HERE TAKES FOCUS, FOR THE REASON THE BAR'S KEYS DO NOT.
|
||||||
|
|
||||||
|
This panel is an extension of the keyboard, not a place for the keyboard to go. On a wide screen the
|
||||||
|
software keyboard can be up at the same time, and an ordinary button would take Avalonia's focus off
|
||||||
|
TerminalInputSurface on tap — tearing down the input connection and dropping the keyboard every time a
|
||||||
|
key in this panel was used. On a phone the keyboard is down while this is up, but the focus it would take
|
||||||
|
is the focus that brings the keyboard straight back when the terminal is tapped. One rule for the whole
|
||||||
|
control, so a button added to it later cannot forget.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button">
|
||||||
|
<Setter Property="Focusable" Value="False" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- The tab strip's buttons: an icon square, lit when it is the tab showing. -->
|
||||||
|
<Style Selector="Button.qtab">
|
||||||
|
<Setter Property="Width" Value="48" />
|
||||||
|
<Setter Property="Height" Value="40" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
|
||||||
|
<Setter Property="FontSize" Value="20" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.qtab:pressed /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.qtab.selected">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.qtab.selected /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentSoft}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- A group's caption over its keys. -->
|
||||||
|
<Style Selector="TextBlock.groupcaption">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
<Setter Property="FontSize" Value="9.5" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Margin" Value="4,10,0,4" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Keys in a panel are a little shorter than the bar's: there are many more of them, all reachable. -->
|
||||||
|
<Style Selector="Button.hotkey.panelkey">
|
||||||
|
<Setter Property="Height" Value="38" />
|
||||||
|
<Setter Property="MinHeight" Value="38" />
|
||||||
|
<Setter Property="Margin" Value="3" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
|
||||||
|
<!-- ============ the tabs ============ -->
|
||||||
|
<!--
|
||||||
|
Icons rather than words, as the keys tab's own grid is: four words at 360dp leave no room for the
|
||||||
|
keyboard button at the end, and each glyph is the one every phone uses for the thing — a grid of keys,
|
||||||
|
braces for snippets, a clock for history, a palette for appearance. Each carries its name for
|
||||||
|
accessibility, which is where a word belongs when there is no room to print one.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="8,6,8,2">
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4">
|
||||||
|
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsKeysTab}" Content=""
|
||||||
|
AutomationProperties.Name="Keys"
|
||||||
|
Command="{Binding Keyboard.SelectTabCommand}"
|
||||||
|
CommandParameter="{x:Static vm:QuickAccessTab.Keys}" />
|
||||||
|
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsSnippetsTab}" Content=""
|
||||||
|
AutomationProperties.Name="Snippets"
|
||||||
|
Command="{Binding Keyboard.SelectTabCommand}"
|
||||||
|
CommandParameter="{x:Static vm:QuickAccessTab.Snippets}" />
|
||||||
|
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsHistoryTab}" Content=""
|
||||||
|
AutomationProperties.Name="History"
|
||||||
|
Command="{Binding Keyboard.SelectTabCommand}"
|
||||||
|
CommandParameter="{x:Static vm:QuickAccessTab.History}" />
|
||||||
|
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsAppearanceTab}" Content=""
|
||||||
|
AutomationProperties.Name="Appearance"
|
||||||
|
Command="{Binding Keyboard.SelectTabCommand}"
|
||||||
|
CommandParameter="{x:Static vm:QuickAccessTab.Appearance}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Back to the phone's own keyboard. The one control here that is not a tab, so it sits apart behind a
|
||||||
|
hairline: it does not change what this panel shows, it puts the panel away.
|
||||||
|
-->
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
|
||||||
|
<Border Width="1" Height="22" Background="{StaticResource Border}" VerticalAlignment="Center" />
|
||||||
|
<Button Classes="qtab" Content="" AutomationProperties.Name="Show the keyboard"
|
||||||
|
Click="OnKeyboardRequested" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Panel Grid.Row="1">
|
||||||
|
|
||||||
|
<!-- ============ KEYS ============ -->
|
||||||
|
<ScrollViewer IsVisible="{Binding Keyboard.IsKeysTab}" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Margin="8,4,8,12">
|
||||||
|
|
||||||
|
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center"
|
||||||
|
Command="{Binding Keyboard.OpenCustomizeCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text=""
|
||||||
|
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="label" FontSize="10" Text="CUSTOMIZE" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The bar's own keys, first, when the bar is not drawn — which is a wide screen with this panel
|
||||||
|
open beside the terminal. A key somebody put on their bar is one they want within reach, and
|
||||||
|
hiding the bar must not hide it.
|
||||||
|
-->
|
||||||
|
<StackPanel IsVisible="{Binding $parent[views:QuickAccessKeyboard].ShowsBarGroup}">
|
||||||
|
<TextBlock Classes="groupcaption" Text="YOUR BAR" />
|
||||||
|
<ItemsControl ItemsSource="{Binding Keyboard.BarRows}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyRowViewModel">
|
||||||
|
<ItemsControl ItemsSource="{Binding Keys}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyButtonViewModel">
|
||||||
|
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
|
||||||
|
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
|
||||||
|
Command="{Binding PressCommand}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Keyboard.Groups}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyGroupViewModel">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Classes="groupcaption" Text="{Binding Title}" />
|
||||||
|
<!--
|
||||||
|
Four across, which at 360dp is an 84dp key — wide enough for "shift+tab" and "ctrl+B %"
|
||||||
|
in the mono face without shrinking it, and still four keys a thumb can tell apart.
|
||||||
|
-->
|
||||||
|
<ItemsControl ItemsSource="{Binding Keys}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyButtonViewModel">
|
||||||
|
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
|
||||||
|
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
|
||||||
|
Command="{Binding PressCommand}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- ============ SNIPPETS ============ -->
|
||||||
|
<!--
|
||||||
|
The keychain's snippets, through the shell's own InsertSnippet — the command the desktop sidebar's
|
||||||
|
rows run, which carries the snippets screen's whole safety story: typed rather than pasted for one
|
||||||
|
line, and no Enter unless the snippet was saved as one that runs. A second insert path here would be
|
||||||
|
a second place that story could go stale. The ▶ says which ones run.
|
||||||
|
-->
|
||||||
|
<ScrollViewer IsVisible="{Binding Keyboard.IsSnippetsTab}" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Margin="8,4,8,12" Spacing="2">
|
||||||
|
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible, FallbackValue={x:Null}}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||||
|
<Button Classes="row" MinHeight="48" Padding="12,6"
|
||||||
|
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="{Binding Label}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Preview}"
|
||||||
|
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1" Classes="label" FontSize="9" Text="▶ RUNS" Margin="10,0,0,0"
|
||||||
|
Foreground="{StaticResource WarnText}" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding RunsOnInsert}" />
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
|
||||||
|
Text="Tap a snippet to type it at the prompt. Snippets live in the keychain, where they are added and edited." />
|
||||||
|
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
|
||||||
|
Command="{Binding ShowScreenCommand}" CommandParameter="{x:Static vm:ShellScreen.Snippets}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="MANAGE SNIPPETS" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- ============ HISTORY ============ -->
|
||||||
|
<!--
|
||||||
|
What was typed on this phone and echoed back by the remote — never a line typed at a password prompt,
|
||||||
|
which echoes nothing. Kept in memory only and emptied by LOCK; the sentence under the list says so,
|
||||||
|
because a list of commands is exactly the kind of thing somebody should know the lifetime of. See
|
||||||
|
TypedCommandHistory for the whole of what is and is not kept.
|
||||||
|
-->
|
||||||
|
<ScrollViewer IsVisible="{Binding Keyboard.IsHistoryTab}" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Margin="8,4,8,12" Spacing="2">
|
||||||
|
<ItemsControl ItemsSource="{Binding Keyboard.History}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="kb:HistoryEntry">
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="12,6"
|
||||||
|
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).Keyboard.InsertHistoryCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5" Text="{Binding Command}"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="detail" FontSize="10" Text="{Binding Host}"
|
||||||
|
Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding !Keyboard.HasHistory}"
|
||||||
|
Text="Commands you type here appear in this list. Only what the remote echoes back is kept, so nothing typed at a password prompt ever is." />
|
||||||
|
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
|
||||||
|
Text="Tap a command to type it at the prompt without running it. The list is kept in memory only, and locking the keychain clears it." />
|
||||||
|
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
|
||||||
|
IsVisible="{Binding Keyboard.HasHistory}"
|
||||||
|
Command="{Binding Keyboard.ClearHistoryCommand}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="CLEAR HISTORY" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- ============ APPEARANCE ============ -->
|
||||||
|
<!--
|
||||||
|
The text size, which used to ride at the end of the arrow row as A− and A+. A phone cannot press
|
||||||
|
Ctrl+plus, so these are still buttons; they moved here because the bar is now somebody's own and two
|
||||||
|
fixed cells at its end were cells they could not have. Disabled at the ends rather than clamping
|
||||||
|
silently, for the reason they always were: a button that accepts taps and does nothing reads as the
|
||||||
|
terminal having stopped responding.
|
||||||
|
|
||||||
|
And the cursor gesture's switch, for somebody whose long press means something else to them.
|
||||||
|
-->
|
||||||
|
<ScrollViewer IsVisible="{Binding Keyboard.IsAppearanceTab}" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Margin="12,6,12,12" Spacing="8">
|
||||||
|
|
||||||
|
<TextBlock Classes="groupcaption" Text="TEXT SIZE" Margin="0,4,0,0" />
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
|
<Button Grid.Column="0" Classes="hotkey" Width="56" Command="{Binding ShrinkTerminalFontCommand}"
|
||||||
|
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||||
|
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||||
|
</Button>
|
||||||
|
<TextBlock Grid.Column="1" Classes="mono" FontSize="14" FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Text="{Binding TerminalFontSize, StringFormat='{}{0} px'}" />
|
||||||
|
<Button Grid.Column="2" Classes="hotkey" Width="56" Command="{Binding EnlargeTerminalFontCommand}"
|
||||||
|
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||||
|
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="3" Classes="row" MinHeight="40" Padding="12,0" Margin="8,0,0,0"
|
||||||
|
Command="{Binding ResetTerminalFontCommand}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="RESET" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="groupcaption" Text="CURSOR GESTURE" Margin="0,10,0,0" />
|
||||||
|
<Button Classes="row" MinHeight="52" Padding="12,8" Command="{Binding Keyboard.ToggleCursorGesturesCommand}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2">
|
||||||
|
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="Long-press and drag" />
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||||
|
Text="Moves the cursor or walks through history. Drag further to go faster." />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="ON" Margin="10,0,0,0"
|
||||||
|
Foreground="{StaticResource Live}" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding Keyboard.CursorGestures}" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="OFF" Margin="10,0,0,0"
|
||||||
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding !Keyboard.CursorGestures}" />
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</Panel>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
|
/// <summary>The quick-access keyboard: keys, snippets, history and appearance, in four tabs.</summary>
|
||||||
|
internal sealed partial class QuickAccessKeyboard : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>Whether the KEYS tab repeats the bar's own keys at the top.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public static readonly StyledProperty<bool> ShowsBarGroupProperty =
|
||||||
|
AvaloniaProperty.Register<QuickAccessKeyboard, bool>(nameof(ShowsBarGroup));
|
||||||
|
|
||||||
|
public QuickAccessKeyboard() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keyboard button in the tab strip was pressed: put this panel away and bring the phone's back.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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 <see cref="TerminalScreen"/> holds.
|
||||||
|
/// </remarks>
|
||||||
|
public event EventHandler? KeyboardRequested;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ShowsBarGroupProperty" />
|
||||||
|
public bool ShowsBarGroup
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsBarGroupProperty);
|
||||||
|
set => SetValue(ShowsBarGroupProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnKeyboardRequested(object? sender, RoutedEventArgs e) =>
|
||||||
|
KeyboardRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
job — see the remark on the header in PhoneShell.axaml. -->
|
job — see the remark on the header in PhoneShell.axaml. -->
|
||||||
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
|
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
|
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
|
||||||
@@ -304,11 +304,34 @@
|
|||||||
<Button Grid.Column="3" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
|
<Button Grid.Column="3" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
|
||||||
Click="OnSheetRequested"
|
Click="OnSheetRequested"
|
||||||
Command="{Binding OpenConnectSheetCommand}" />
|
Command="{Binding OpenConnectSheetCommand}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The wide screen's side panel, shown and hidden — the quick-access keyboard beside the terminal
|
||||||
|
rather than under it. Here rather than at the start of the key bar, which is where a phone keeps
|
||||||
|
its toggle: on a tablet the bar is not drawn while the panel is open, and the control that brings
|
||||||
|
the panel back cannot live inside the thing the panel replaced. Lit while open, like a latched key.
|
||||||
|
-->
|
||||||
|
<Button Grid.Column="4" Classes="icon" Height="34" Margin="0,0,4,0"
|
||||||
|
Classes.accent="{Binding Keyboard.IsSidePanelOpen}"
|
||||||
|
AutomationProperties.Name="Keys, snippets and history"
|
||||||
|
IsVisible="{Binding $parent[views:TerminalScreen].IsWide}"
|
||||||
|
Command="{Binding Keyboard.ToggleSidePanelCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ============ the renderer ============ -->
|
<!-- ============ the renderer, and the wide screen's side panel ============ -->
|
||||||
<Panel Grid.Row="1">
|
<!--
|
||||||
|
Two columns, and the second is collapsed except on a wide screen with the panel open. Beside the
|
||||||
|
renderer rather than over it, for the reason nothing on this head is drawn over it: the WebView is a
|
||||||
|
native child composited above everything Avalonia paints, so a panel laid on top would be invisible.
|
||||||
|
Taking its width off the terminal refits the grid and tells the remote, exactly as the software
|
||||||
|
keyboard taking its height does.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
|
||||||
|
<Panel Grid.Column="0">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
============ nothing open ============
|
============ nothing open ============
|
||||||
@@ -551,60 +574,21 @@
|
|||||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- ============ accessory keys ============ -->
|
|
||||||
<!--
|
<!--
|
||||||
The row every Android SSH client ships, and the reason is not preference: a software keyboard has no
|
The quick-access keyboard as a side panel: additional keys, snippets, history and appearance in view
|
||||||
Ctrl, no Esc, no Tab and no arrows, and without them a phone cannot interrupt a process, complete a
|
while the terminal is worked in, which is what a tablet's width is for. 320 wide, which at the 600dp
|
||||||
path, or reach the previous command. Ctrl and Alt latch — pressed once they apply to the next key and
|
where this head turns wide leaves the terminal 280 — a narrow terminal, and a panel somebody can close
|
||||||
then release, because holding a modifier while typing is not possible one-thumbed.
|
from the bar above; at a tablet's 900 it is a third.
|
||||||
|
|
||||||
|
ShowsBarGroup, because the bar under the terminal stands down while this is open and the keys someone
|
||||||
|
put on it have to go somewhere they can still be reached.
|
||||||
-->
|
-->
|
||||||
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
|
<Border Grid.Column="1" Width="320" BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
||||||
<!--
|
IsVisible="{Binding $parent[views:TerminalScreen].ShowsSidePanel}">
|
||||||
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
|
<views:QuickAccessKeyboard x:Name="SidePanel" ShowsBarGroup="True" />
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
What this replaced was one 33-pixel row holding eleven 30-by-44 keys inside a horizontal ScrollViewer,
|
|
||||||
and it was wrong in two ways that compounded each other.
|
|
||||||
|
|
||||||
── the scroller was eating the presses ──────────────────────────────────────────────────────────────
|
|
||||||
A horizontally scrollable strip and a row of buttons want opposite things from the same gesture. Every
|
|
||||||
tap starts as a press and a few pixels of travel, and a ScrollViewer that can pan claims exactly that:
|
|
||||||
a thumb coming down at an angle — which is how a thumb comes down — is read as the start of a pan, the
|
|
||||||
button never sees a click, and the row slides slightly instead. Pressing a key and having the keys
|
|
||||||
move is the single most confidence-destroying thing a keyboard can do, and it happened often enough
|
|
||||||
to be the first thing anybody noticed about this row.
|
|
||||||
|
|
||||||
── and the keys were too small to hit anyway ─────────────────────────────────────────────────────────
|
|
||||||
30 tall. Android's own minimum touch target is 48 and every accessory row in every terminal on the
|
|
||||||
platform is somewhere near it. The height had been taken down from 38 to save a strip of terminal,
|
|
||||||
which is a trade worth making right up until the control stops working, and 30 is past that.
|
|
||||||
|
|
||||||
── what the other clients do, because all of them agree ─────────────────────────────────────────────
|
|
||||||
Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it.
|
|
||||||
They divide the width evenly between a fixed set of keys and, where the set does not fit, they use a
|
|
||||||
second row — Termux ships two by default. That is not convention for its own sake: a key whose
|
|
||||||
position is fixed can be hit without looking, which is the entire point of a modifier row you use
|
|
||||||
while reading the output above it. A key that might have scrolled has to be found first.
|
|
||||||
|
|
||||||
So: two rows, every key stretched to an equal share of the width, none of it scrollable, and 40 tall
|
|
||||||
instead of 30. Nothing was dropped — all eleven keys are here, and the four that mattered most are the
|
|
||||||
ones that benefit, because the arrows were the keys living past the right edge.
|
|
||||||
|
|
||||||
── what it costs, stated rather than buried ─────────────────────────────────────────────────────────
|
|
||||||
About 51 pixels of terminal, which is four or five lines of output. That is a real price and it is the
|
|
||||||
right one: the row that was cheaper did not work, and a terminal with five more lines and no reachable
|
|
||||||
Ctrl is not the better surface.
|
|
||||||
|
|
||||||
── the split ────────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
Modifiers and punctuation on top, cursor keys underneath. The arrows go on the lower row because they
|
|
||||||
are the keys pressed repeatedly and in sequence — history, then along the line — so they want the row
|
|
||||||
the thumb rests nearest, and there are only four of them, which leaves them the widest targets on the
|
|
||||||
whole keypad. The text-size buttons ride at that row's right-hand end, outside the flex.
|
|
||||||
|
|
||||||
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
|
|
||||||
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of
|
|
||||||
its own — and a terminal whose last line of output is flush against a row of grey keys reads as one
|
|
||||||
surface that has gone wrong rather than as two that are different things.
|
|
||||||
-->
|
|
||||||
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
||||||
<!--
|
<!--
|
||||||
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
|
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
|
||||||
@@ -654,71 +638,90 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
|
<!-- ============ the keys ============ -->
|
||||||
<StackPanel Spacing="4">
|
<!--
|
||||||
|
The keys a software keyboard does not have, and the way to all the others.
|
||||||
|
|
||||||
|
── the bar ──────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
The row every Android SSH client ships, and the reason is not preference: a software keyboard has no
|
||||||
|
Ctrl, no Esc, no Tab and no arrows, and without them a phone cannot interrupt a process, complete a
|
||||||
|
path, or reach the previous command. Ctrl and Alt latch — pressed once they apply to the next key, from
|
||||||
|
the bar or from the phone's own keyboard, and then release — because holding a modifier while typing is
|
||||||
|
not possible one-thumbed.
|
||||||
|
|
||||||
|
It is somebody's own now: which keys, in what order, set on the customise screen and kept on this
|
||||||
|
device. What did not change is the shape the old fixed keypad argued its way to, and the argument still
|
||||||
|
holds. Nothing here scrolls — a horizontally scrollable strip reads a thumb coming down at an angle as
|
||||||
|
the start of a pan, and the key never sees the press. Every key gets an equal share of its row's width,
|
||||||
|
so "ctrl" and "|" are the same target. At most eight to a row and two rows, which keeps a key at about
|
||||||
|
41dp on a 360dp phone; anything past sixteen belongs on the quick-access keyboard. The first eight are
|
||||||
|
the top row, so a person arranging their bar decides the rows by the order alone.
|
||||||
|
|
||||||
|
As shipped: modifiers and punctuation on top, cursor keys underneath, nearest the thumb, because they
|
||||||
|
are the keys pressed repeatedly and in sequence — history, then along the line.
|
||||||
|
|
||||||
|
── the grid button ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
At the bar's left-hand end, outside the rows, and the one key nobody can remove: it is the way to the
|
||||||
|
quick-access keyboard and from there to the customise screen, so a bar arranged to nothing is still a
|
||||||
|
bar with a way back. Lit while the panel is up, as a latched modifier is, because it is a state.
|
||||||
|
|
||||||
|
── the panel ────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
The quick-access keyboard, in the software keyboard's place: the code-behind puts the phone's keyboard
|
||||||
|
away as this opens and gives this the height that keyboard last had, so the bar does not move between
|
||||||
|
the two. Tapping the terminal brings the phone's keyboard back and puts this away again.
|
||||||
|
|
||||||
|
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
|
||||||
|
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of its
|
||||||
|
own — and a terminal whose last line is flush against a row of grey keys reads as one surface gone wrong.
|
||||||
|
-->
|
||||||
|
<StackPanel Grid.Row="3" IsVisible="{Binding $parent[views:TerminalScreen].ShowsKeys}">
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="Auto,*" Margin="8,5,8,4">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
|
Focusable="False", for the reason every key carries it — see the remark on Button.hotkey's use in
|
||||||
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
|
QuickAccessKeyboard. A button here that took focus would take it off the terminal's input surface,
|
||||||
between them instead of taking what their labels happen to need — "ctrl" is four characters and
|
and the keyboard would go with it.
|
||||||
"|" is one, and on a row of keys those two want the same target, not proportional ones.
|
|
||||||
-->
|
-->
|
||||||
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
|
<Button Grid.Column="0" Classes="hotkey" Width="44" Focusable="False" VerticalAlignment="Bottom"
|
||||||
|
Margin="0,0,4,0"
|
||||||
|
Classes.latched="{Binding Keyboard.IsQuickAccessOpen}"
|
||||||
|
AutomationProperties.Name="Keys, snippets and history"
|
||||||
|
IsVisible="{Binding !$parent[views:TerminalScreen].IsWide}"
|
||||||
|
Click="OnQuickAccessToggled">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<!--
|
<ItemsControl Grid.Column="1" ItemsSource="{Binding Keyboard.BarRows}">
|
||||||
The cursor keys, and the two text-size buttons riding at the end.
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><StackPanel Spacing="4" /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyRowViewModel">
|
||||||
|
<ItemsControl ItemsSource="{Binding Keys}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><UniformGrid Rows="1" /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyButtonViewModel">
|
||||||
|
<Button Classes="hotkey" Focusable="False" Classes.latched="{Binding IsLatched}"
|
||||||
|
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
|
||||||
|
Command="{Binding PressCommand}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
|
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||||
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
|
Height="{Binding $parent[views:TerminalScreen].QuickAccessHeight}"
|
||||||
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
|
IsVisible="{Binding $parent[views:TerminalScreen].ShowsQuickAccessPanel}">
|
||||||
them every time the row's contents changed.
|
<views:QuickAccessKeyboard x:Name="BottomPanel" />
|
||||||
-->
|
</Border>
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
</StackPanel>
|
||||||
|
|
||||||
<UniformGrid x:Name="ArrowKeys" Grid.Column="0" Columns="4" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The text-size buttons.
|
|
||||||
|
|
||||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
|
||||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the
|
|
||||||
state in which hunting for the control that fixes it is worst. Nothing on this keypad scrolls
|
|
||||||
any more, so that argument is now satisfied by construction rather than by placement.
|
|
||||||
|
|
||||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
|
||||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in
|
|
||||||
this screen.
|
|
||||||
|
|
||||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
|
||||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
|
||||||
never look like.
|
|
||||||
|
|
||||||
◆ Focusable="False", for the reason the accessory keys carry in code — see
|
|
||||||
TerminalScreen.axaml.cs. Every button on this keypad sits over a terminal somebody is typing
|
|
||||||
into, and an ordinary one takes Avalonia's focus off the input surface on tap. Making the text
|
|
||||||
smaller must not cost the keyboard.
|
|
||||||
-->
|
|
||||||
<!-- No Spacing: Button.hotkey carries a 2-pixel side margin, so the gaps here match the
|
|
||||||
UniformGrid cells' without being counted twice. -->
|
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="8,0,0,0">
|
|
||||||
<Border Width="1" Height="20" Background="{StaticResource Border}" Margin="0,0,4,0"
|
|
||||||
VerticalAlignment="Center" />
|
|
||||||
|
|
||||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
|
||||||
Command="{Binding ShrinkTerminalFontCommand}"
|
|
||||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
|
||||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
|
||||||
Command="{Binding EnlargeTerminalFontCommand}"
|
|
||||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
|
||||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -866,6 +869,185 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ the customise sheet ============ -->
|
||||||
|
<!--
|
||||||
|
Reached from CUSTOMIZE at the top of the quick-access keyboard's KEYS tab: which keys are on the bar
|
||||||
|
and in what order, which groups the KEYS tab shows and in what order, and keys of one's own.
|
||||||
|
|
||||||
|
Taller than the other two sheets, nearly the whole display, because it is a page of settings rather
|
||||||
|
than a menu — and it scrolls for the same reason. It takes the screen the way they do too: opening it
|
||||||
|
collapses the renderer (see MainWindowViewModel.IsTerminalShowing), so nothing is drawn over the
|
||||||
|
WebView and the boxes on it can have the keyboard.
|
||||||
|
|
||||||
|
Every change applies the moment it is made, to the bar and to the settings file, which is why there is
|
||||||
|
DONE and no SAVE. See TerminalKeyboardViewModel.
|
||||||
|
|
||||||
|
↑ and ↓ rather than drag handles. A drag inside a scrolling sheet is the same gesture as a scroll and
|
||||||
|
has to be told apart by a long press nobody can see; two buttons are slower and never wrong.
|
||||||
|
-->
|
||||||
|
<Panel IsVisible="{Binding Keyboard.IsCustomizing}">
|
||||||
|
|
||||||
|
<Button Classes="scrim" Command="{Binding Keyboard.CloseCustomizeCommand}" />
|
||||||
|
|
||||||
|
<Border Margin="0,40,0,0" Background="{StaticResource Panel}"
|
||||||
|
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||||
|
CornerRadius="22,22,0,0" Padding="20,18,20,0">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*">
|
||||||
|
|
||||||
|
<Border Grid.Row="0" Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||||
|
HorizontalAlignment="Center" Margin="0,0,0,12" />
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="0,0,0,8">
|
||||||
|
<TextBlock Grid.Column="0" Classes="title" Text="CUSTOMIZE KEYS" FontSize="13"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button Grid.Column="1" Classes="row" MinHeight="40" Padding="14,0"
|
||||||
|
Command="{Binding Keyboard.CloseCustomizeCommand}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="DONE" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Spacing="6" Margin="0,0,0,24">
|
||||||
|
|
||||||
|
<!-- ── the bar ── -->
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" Text="THE BAR" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="detail" FontSize="10" Text="{Binding Keyboard.BarCountText}" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||||
|
Text="The first eight keys are the top row and the rest the second. Tap a key in a group below to put it on the bar or take it off." />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Keyboard.BarEditor}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:BarEditorRowViewModel">
|
||||||
|
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,4">
|
||||||
|
<Grid ColumnDefinitions="64,*,Auto,Auto,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||||
|
Text="{Binding Label}" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<StackPanel Grid.Column="1" Spacing="1" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Hint}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="detail" FontSize="9" Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="{Binding Row}" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
|
||||||
|
AutomationProperties.Name="Move earlier" Command="{Binding MoveUpCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="3" Classes="icon" Height="40" Width="40"
|
||||||
|
AutomationProperties.Name="Move later" Command="{Binding MoveDownCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="4" Classes="icon" Height="40" Width="40"
|
||||||
|
AutomationProperties.Name="Take off the bar" Command="{Binding RemoveCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||||
|
Foreground="{StaticResource TextFaint}" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<TextBlock Classes="body" TextWrapping="Wrap" IsVisible="{Binding !Keyboard.HasBar}"
|
||||||
|
Text="The bar is empty, so only the grid button is drawn above the keyboard." />
|
||||||
|
|
||||||
|
<!-- ── the groups ── -->
|
||||||
|
<TextBlock Classes="label" Text="GROUPS" Margin="0,16,0,0" />
|
||||||
|
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||||
|
Text="The quick-access keyboard shows the ticked groups, in this order. A lit key is on the bar." />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Keyboard.GroupEditor}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:GroupEditorRowViewModel">
|
||||||
|
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,6,0,10">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||||
|
<CheckBox Grid.Column="0" IsChecked="{Binding IsShown}" MinHeight="40">
|
||||||
|
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="{Binding Title}" />
|
||||||
|
</CheckBox>
|
||||||
|
<Button Grid.Column="1" Classes="icon" Height="40" Width="40"
|
||||||
|
AutomationProperties.Name="Move up" Command="{Binding MoveUpCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
|
||||||
|
AutomationProperties.Name="Move down" Command="{Binding MoveDownCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<ItemsControl ItemsSource="{Binding Keys}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:KeyChoiceViewModel">
|
||||||
|
<Button Classes="chiptoggle" Classes.worn="{Binding IsOnBar}" Margin="0,0,6,6"
|
||||||
|
Command="{Binding ToggleCommand}">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- ── keys of one's own ── -->
|
||||||
|
<TextBlock Classes="label" Text="ADD A KEY" Margin="0,16,0,0" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Keyboard.CustomKeyEditor}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:CustomKeyRowViewModel">
|
||||||
|
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,4">
|
||||||
|
<Grid ColumnDefinitions="64,*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||||
|
Text="{Binding Label}" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="detail" FontSize="10.5" Text="{Binding Sequence}"
|
||||||
|
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
|
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
|
||||||
|
AutomationProperties.Name="Delete this key" Command="{Binding DeleteCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||||
|
Foreground="{StaticResource DangerText}" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ShowSuggestions off on both boxes, for the reason the terminal's own input surface turns it off:
|
||||||
|
an autocorrecting keyboard capitalises the e in \e and puts a full stop after ^C, and the key
|
||||||
|
then sends something nobody typed.
|
||||||
|
-->
|
||||||
|
<TextBox Classes="field" Text="{Binding Keyboard.NewKeyLabel}" MaxLength="10"
|
||||||
|
TextInputOptions.ShowSuggestions="False"
|
||||||
|
PlaceholderText="Label, as it appears on the key" />
|
||||||
|
<TextBox Classes="field" Text="{Binding Keyboard.NewKeySequence}"
|
||||||
|
TextInputOptions.ShowSuggestions="False"
|
||||||
|
PlaceholderText="What it sends, e.g. \e:wq\r or ^C" />
|
||||||
|
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||||
|
Text="\e is Escape, \r is Enter, \t is Tab, and ^ before a letter is Ctrl; \^ is a plain caret. Keys are kept on this phone in plain text, so this is no place for a password." />
|
||||||
|
<Button Classes="primary" Content="ADD KEY" Command="{Binding Keyboard.AddCustomKeyCommand}" />
|
||||||
|
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||||
|
Text="{Binding Keyboard.CustomizeStatus}"
|
||||||
|
IsVisible="{Binding Keyboard.CustomizeStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
|
<Button Classes="secondary" Content="RESET TO DEFAULTS" Margin="0,16,0,0"
|
||||||
|
Command="{Binding Keyboard.ResetLayoutCommand}" />
|
||||||
|
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||||
|
Text="Puts the bar and the groups back as they shipped. Keys you added are kept." />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
</UserControl>
|
</UserControl>
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Platform;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Layout;
|
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
using DodoSSH.Client.Android.Platform;
|
using DodoSSH.Client.Android.Platform;
|
||||||
|
using DodoSSH.Client.Shell.Keyboard;
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
|
// 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;
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
/// <summary>Design 03 — the terminal, and the accessory key row under it.</summary>
|
/// <summary>Design 03 — the terminal, and the keys under or beside it.</summary>
|
||||||
internal sealed partial class TerminalScreen : UserControl
|
internal sealed partial class TerminalScreen : UserControl
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// About what Gboard and Samsung's keyboard take in portrait. It is only a starting point: the first time
|
||||||
/// Chosen by what a shell session actually needs rather than by what a keyboard has: Esc leaves vi's
|
/// the software keyboard is up, its own height replaces this, so the bar above the panel does not move
|
||||||
/// insert mode, Tab completes a path, Ctrl makes <c>^C</c> reachable, the arrows reach history, and the
|
/// when one is swapped for the other.
|
||||||
/// 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.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// 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.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <summary>The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.</summary>
|
private const double DefaultQuickAccessHeight = 280;
|
||||||
/// <remarks>
|
|
||||||
/// 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 <c>Columns</c> on the UniformGrid; the two numbers have to agree, and the row
|
|
||||||
/// simply gets narrower keys.
|
|
||||||
/// </remarks>
|
|
||||||
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),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// <summary>The lower row: the cursor keys, which is where they became reachable.</summary>
|
public static readonly StyledProperty<bool> IsWideProperty =
|
||||||
/// <remarks>
|
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(IsWide));
|
||||||
/// <para>
|
|
||||||
/// 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.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// ◆ <b>Left, down, up, right</b> — 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.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// 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.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
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),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// <summary>
|
public static readonly StyledProperty<bool> ShowsKeysProperty =
|
||||||
/// Whether the next ordinary key should be sent as a control character.
|
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsKeys));
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 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.
|
|
||||||
/// </remarks>
|
|
||||||
private bool controlLatched;
|
|
||||||
|
|
||||||
private Button? controlKey;
|
public static readonly StyledProperty<bool> ShowsQuickAccessPanelProperty =
|
||||||
|
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsQuickAccessPanel));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> ShowsSidePanelProperty =
|
||||||
|
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsSidePanel));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<double> QuickAccessHeightProperty =
|
||||||
|
AvaloniaProperty.Register<TerminalScreen, double>(nameof(QuickAccessHeight), DefaultQuickAccessHeight);
|
||||||
|
|
||||||
private readonly TerminalInputSurface input;
|
private readonly TerminalInputSurface input;
|
||||||
|
|
||||||
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
|
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
|
||||||
private MainWindowViewModel? subscribed;
|
private MainWindowViewModel? subscribed;
|
||||||
|
|
||||||
|
/// <summary>The phone shell this screen sits in, for its width class.</summary>
|
||||||
|
private PhoneShell? phoneShell;
|
||||||
|
|
||||||
|
/// <summary>The software keyboard, while this control is attached. Null on a platform without one.</summary>
|
||||||
|
private IInputPane? softKeyboard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
|
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -115,10 +74,14 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
BuildAccessoryRow();
|
|
||||||
|
|
||||||
input = this.FindControl<TerminalInputSurface>("Input")!;
|
input = this.FindControl<TerminalInputSurface>("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<QuickAccessKeyboard>("BottomPanel")!.KeyboardRequested += OnKeyboardRequested;
|
||||||
|
this.FindControl<QuickAccessKeyboard>("SidePanel")!.KeyboardRequested += OnKeyboardRequested;
|
||||||
|
|
||||||
var renderer = this.FindControl<NativeWebView>("Renderer")!;
|
var renderer = this.FindControl<NativeWebView>("Renderer")!;
|
||||||
|
|
||||||
@@ -140,6 +103,7 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
{
|
{
|
||||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||||
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||||
|
previous.Keyboard.PropertyChanged -= OnKeyboardPropertyChanged;
|
||||||
subscribed = null;
|
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
|
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
|
||||||
// they look. See MainWindowViewModel.TerminalFocusRequested.
|
// they look. See MainWindowViewModel.TerminalFocusRequested.
|
||||||
shell.TerminalFocusRequested += OnTerminalFocusRequested;
|
shell.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||||
|
shell.Keyboard.PropertyChanged += OnKeyboardPropertyChanged;
|
||||||
subscribed = shell;
|
subscribed = shell;
|
||||||
|
|
||||||
terminalWasShowing = shell.IsTerminalShowing;
|
terminalWasShowing = shell.IsTerminalShowing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RefreshKeys();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the screen is wide enough for the side panel. <see cref="PhoneShell.IsWide"/>, mirrored.</summary>
|
||||||
|
public bool IsWide
|
||||||
|
{
|
||||||
|
get => GetValue(IsWideProperty);
|
||||||
|
private set => SetValue(IsWideProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the bar under the terminal is drawn.</summary>
|
||||||
|
public bool ShowsKeys
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsKeysProperty);
|
||||||
|
private set => SetValue(ShowsKeysProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the quick-access keyboard is drawn under the bar, in the software keyboard's place.</summary>
|
||||||
|
public bool ShowsQuickAccessPanel
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsQuickAccessPanelProperty);
|
||||||
|
private set => SetValue(ShowsQuickAccessPanelProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the quick-access keyboard is drawn beside the terminal.</summary>
|
||||||
|
public bool ShowsSidePanel
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsSidePanelProperty);
|
||||||
|
private set => SetValue(ShowsSidePanelProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How tall the quick-access keyboard is on a phone: the software keyboard's last height.</summary>
|
||||||
|
public double QuickAccessHeight
|
||||||
|
{
|
||||||
|
get => GetValue(QuickAccessHeightProperty);
|
||||||
|
private set => SetValue(QuickAccessHeightProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TerminalKeyboardViewModel? Keyboard => (DataContext as MainWindowViewModel)?.Keyboard;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnAttachedToVisualTree(e);
|
||||||
|
|
||||||
|
phoneShell = this.FindAncestorOfType<PhoneShell>();
|
||||||
|
|
||||||
|
if (phoneShell is not null)
|
||||||
|
{
|
||||||
|
phoneShell.PropertyChanged += OnPhoneShellPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
softKeyboard = TopLevel.GetTopLevel(this)?.InputPane;
|
||||||
|
|
||||||
|
if (softKeyboard is not null)
|
||||||
|
{
|
||||||
|
softKeyboard.StateChanged += OnSoftKeyboardChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Works out which of the three key surfaces is drawn: the bar, the panel under it, the panel beside.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Wide, the side panel replaces the bar while it is open.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Wide, the bottom panel never shows.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notes the software keyboard's height, and lowers the quick-access keyboard when it comes back.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The grid button: swaps the software keyboard for the quick-access keyboard, or back.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The panel's keyboard button: put the panel away and bring the phone's keyboard back.</summary>
|
||||||
|
private void OnKeyboardRequested(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
Keyboard?.CloseQuickAccess();
|
||||||
|
input.Acquire();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A tap on the terminal: the phone's keyboard, in place of the panel if that was up.</summary>
|
||||||
|
private void OnRendererTapped()
|
||||||
|
{
|
||||||
|
Keyboard?.CloseQuickAccess();
|
||||||
|
input.Acquire();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The cursor gesture's arrows, from <see cref="RendererFocus"/>.</summary>
|
||||||
|
private void OnCursorGesture(ArrowDirection direction, int count) =>
|
||||||
|
Keyboard?.SendArrow(direction, count);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
|
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -171,6 +372,8 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RefreshKeys();
|
||||||
|
|
||||||
var showing = shell.IsTerminalShowing;
|
var showing = shell.IsTerminalShowing;
|
||||||
|
|
||||||
if (showing == terminalWasShowing)
|
if (showing == terminalWasShowing)
|
||||||
@@ -207,7 +410,8 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
/// See <see cref="RendererFocus"/> for why a <c>WebView</c> that cannot hold focus still scrolls, and why
|
/// See <see cref="RendererFocus"/> for why a <c>WebView</c> that cannot hold focus still scrolls, and why
|
||||||
/// the tap has to be reported rather than left to the page.
|
/// the tap has to be reported rather than left to the page.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
|
private void AdoptRenderer() =>
|
||||||
|
RendererFocus.Suppress(OnRendererTapped, () => Keyboard?.CursorGestures == true, OnCursorGesture);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Puts the keyboard away on the way to the connect menu or the forwards sheet.
|
/// 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;
|
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<UniformGrid>("AccessoryKeys")!, ModifierKeys);
|
|
||||||
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Puts one table's worth of keys into one row of the keypad.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 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.
|
|
||||||
/// </remarks>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>One key of the accessory row, before its click is wired.</summary>
|
|
||||||
/// <remarks>Split from <see cref="BuildAccessoryRow"/> for length rather than for reuse.</remarks>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// 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.
|
|
||||||
/// </remarks>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Sends a literal string, for the keys that carry text rather than a control code.</summary>
|
|
||||||
/// <remarks>Kept because the snippet feature will type into a terminal exactly this way.</remarks>
|
|
||||||
internal void Send(string text) => SendAsync(Encoding.UTF8.GetBytes(text));
|
|
||||||
}
|
}
|
||||||
@@ -86,9 +86,9 @@
|
|||||||
},
|
},
|
||||||
"Velopack": {
|
"Velopack": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[1.2.0, )",
|
"requested": "[1.2.161, )",
|
||||||
"resolved": "1.2.0",
|
"resolved": "1.2.161",
|
||||||
"contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw=="
|
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
|
||||||
},
|
},
|
||||||
"Avalonia.Angle.Windows.Natives": {
|
"Avalonia.Angle.Windows.Natives": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
|
|||||||
@@ -26,10 +26,15 @@ namespace DodoSSH.Client.Session;
|
|||||||
/// <param name="SessionSidebarOpen">
|
/// <param name="SessionSidebarOpen">
|
||||||
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
|
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="TerminalKeyboard">
|
||||||
|
/// How the phone's keys above the keyboard are arranged, or null for the arrangement it ships with. See
|
||||||
|
/// <see cref="TerminalKeyboardSettings"/>.
|
||||||
|
/// </param>
|
||||||
public sealed record ClientSettings(
|
public sealed record ClientSettings(
|
||||||
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
|
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
|
||||||
bool AutomaticUpdateChecks = true,
|
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.
|
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,
|
WriteIndented = true,
|
||||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||||
[JsonSerializable(typeof(ClientSettings))]
|
[JsonSerializable(typeof(ClientSettings))]
|
||||||
|
[JsonSerializable(typeof(TerminalKeyboardSettings))]
|
||||||
internal sealed partial class ClientSettingsJsonContext : JsonSerializerContext;
|
internal sealed partial class ClientSettingsJsonContext : JsonSerializerContext;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How the phone's terminal keys are arranged on this device.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ids, not keys.</b> Every list here names keys and groups by the stable ids in the shell's key catalog
|
||||||
|
/// (<c>TerminalKeyCatalog</c>) 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>In <see cref="ClientSettings"/>, so on this device and in plain text.</b> 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 <c>:wq</c>, and the customise screen says
|
||||||
|
/// outright that this file is not a place for a password.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Null lists mean "as shipped" rather than "empty". An empty <see cref="Bar"/> is a choice somebody made,
|
||||||
|
/// and has to survive a launch as one.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Bar">The keys on the bar above the software keyboard, in order.</param>
|
||||||
|
/// <param name="GroupOrder">The order of the groups on the quick-access keyboard's KEYS tab.</param>
|
||||||
|
/// <param name="HiddenGroups">Groups turned off on the KEYS tab.</param>
|
||||||
|
/// <param name="CustomKeys">Keys somebody added for themselves.</param>
|
||||||
|
/// <param name="QuickAccessTab">The quick-access keyboard's tab last used, which it reopens to.</param>
|
||||||
|
/// <param name="SidePanelOpen">Whether a wide screen shows the quick-access keyboard beside the terminal.</param>
|
||||||
|
/// <param name="CursorGestures">Whether long-press-and-drag on the terminal moves the cursor.</param>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>A key somebody added: what it says on it, and what it types.</summary>
|
||||||
|
/// <param name="Id">Stable, so the bar can refer to it. Always starts <c>custom:</c>.</param>
|
||||||
|
/// <param name="Label">What the key shows.</param>
|
||||||
|
/// <param name="Sequence">
|
||||||
|
/// What it sends, in the notation the customise screen accepts — <c>\e</c>, <c>\r</c>, <c>^C</c> and so on —
|
||||||
|
/// rather than as bytes, so the file stays readable and the parser stays the one place escapes are decided.
|
||||||
|
/// </param>
|
||||||
|
public sealed record CustomTerminalKey(string Id, string Label, string Sequence);
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
namespace DodoSSH.Client.Shell.Keyboard;
|
||||||
|
|
||||||
|
/// <summary>Which arrow key a cursor gesture is pressing, if any.</summary>
|
||||||
|
internal enum ArrowDirection
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Left,
|
||||||
|
Down,
|
||||||
|
Up,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Long-press-and-drag on the terminal as a joystick for the arrow keys.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A joystick rather than a trackpad, and the difference is what the gesture is for.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The first step is immediate and the second waits.</b> 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
|
||||||
|
/// <see cref="FirstRepeatDelayMs"/> before repeating — the delay every hardware keyboard puts between a
|
||||||
|
/// keypress and auto-repeat, for the same reason.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The axis is sticky.</b> 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 <see cref="AxisSwitchRatio"/> to take over.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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 <c>RendererFocus</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class ArrowGesture
|
||||||
|
{
|
||||||
|
/// <summary>How far from the press the finger can wander before anything is sent, in dp.</summary>
|
||||||
|
internal const double DeadZone = 16;
|
||||||
|
|
||||||
|
/// <summary>Where the second speed starts, in dp from the press.</summary>
|
||||||
|
internal const double SecondSpeedAt = 64;
|
||||||
|
|
||||||
|
/// <summary>Where the third speed starts, in dp from the press.</summary>
|
||||||
|
internal const double ThirdSpeedAt = 128;
|
||||||
|
|
||||||
|
/// <summary>How long the slowest speed waits after its first key before repeating.</summary>
|
||||||
|
internal const long FirstRepeatDelayMs = 400;
|
||||||
|
|
||||||
|
/// <summary>How much the other axis has to dominate before the direction changes axis.</summary>
|
||||||
|
internal const double AxisSwitchRatio = 1.4;
|
||||||
|
|
||||||
|
/// <summary>The most keys one call reports, so a stalled clock cannot come back as a burst.</summary>
|
||||||
|
private const int MaximumBurst = 3;
|
||||||
|
|
||||||
|
private long nextDue;
|
||||||
|
|
||||||
|
/// <summary>The key being held, or <see cref="ArrowDirection.None"/> inside the dead zone.</summary>
|
||||||
|
internal ArrowDirection Direction { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The speed, from 1 to 3, or 0 inside the dead zone.</summary>
|
||||||
|
internal int Speed { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The interval between repeats at a speed.</summary>
|
||||||
|
internal static long IntervalMs(int speed) => speed switch
|
||||||
|
{
|
||||||
|
1 => 220,
|
||||||
|
2 => 90,
|
||||||
|
_ => 35,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Takes the finger's new displacement from where the press landed.</summary>
|
||||||
|
/// <returns>How many keys to send now, in <see cref="Direction"/>.</returns>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lets time pass with the finger held still.</summary>
|
||||||
|
/// <returns>How many keys have come due, in <see cref="Direction"/>.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lets go.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Shell.Keyboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The line the cursor is on, as a terminal would draw it from the last of a session's output.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Just enough of a terminal to read back a line being edited</b>, 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class EchoedLine
|
||||||
|
{
|
||||||
|
/// <summary>Reads the cursor's line out of a session's recent output.</summary>
|
||||||
|
internal static string LastLine(ReadOnlySpan<byte> 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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Follows one escape sequence starting at <paramref name="at"/>.</summary>
|
||||||
|
/// <returns>The index of its last character.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Shell.Keyboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads what a custom key should send, in the notation people already write it in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Two notations, both the ones a terminal user has seen before: backslash escapes as <c>printf</c> and
|
||||||
|
/// <c>bind</c> write them (<c>\e</c>, <c>\r</c>, <c>\t</c>, <c>\n</c>, <c>\\</c>, <c>\xHH</c>), and caret
|
||||||
|
/// notation as <c>stty</c> and every man page write control characters (<c>^C</c>, <c>^[</c>, <c>^?</c>).
|
||||||
|
/// Anything else is typed as it stands, in UTF-8.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A caret only means Ctrl in front of a character that has a control form</b> — a letter, or one of
|
||||||
|
/// <c>@[\]^_?</c>. Elsewhere, as in <c>^1</c> or a trailing <c>^</c>, it is a caret. The cost is that
|
||||||
|
/// <c>grep ^root</c> written here sends Ctrl-R; <c>\^</c> is the way to say a literal caret before a
|
||||||
|
/// letter, and the customise screen says so.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Refusing is reserved for what cannot mean anything — a backslash at the very end, or <c>\x</c> without
|
||||||
|
/// two hex digits — so a sequence that parses is one that sends exactly what it says.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class KeySequence
|
||||||
|
{
|
||||||
|
/// <summary>Turns notation into bytes.</summary>
|
||||||
|
/// <returns>False when the notation is malformed; <paramref name="bytes"/> is then empty.</returns>
|
||||||
|
internal static bool TryParse(string notation, out byte[] bytes)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(notation);
|
||||||
|
|
||||||
|
var output = new List<byte>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the escape whose backslash is at <paramref name="i"/>, leaving it on its last character.</summary>
|
||||||
|
private static bool TryEscape(string notation, ref int i, List<byte> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves the plain text gathered so far into the output, as UTF-8.</summary>
|
||||||
|
private static void FlushLiteral(List<byte> output, StringBuilder literal)
|
||||||
|
{
|
||||||
|
if (literal.Length > 0)
|
||||||
|
{
|
||||||
|
output.AddRange(Encoding.UTF8.GetBytes(literal.ToString()));
|
||||||
|
literal.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The control character caret notation names, where there is one.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>^?</c> is DEL rather than a masked byte — the one exception every table of caret notation makes,
|
||||||
|
/// and the one people reach for as "backspace".
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
namespace DodoSSH.Client.Shell.Keyboard;
|
||||||
|
|
||||||
|
/// <summary>What pressing a terminal key does.</summary>
|
||||||
|
internal enum TerminalKeyKind
|
||||||
|
{
|
||||||
|
/// <summary>Sends its bytes, after any latched modifier has had its say.</summary>
|
||||||
|
Send,
|
||||||
|
|
||||||
|
/// <summary>Latches Ctrl for the next key, and releases itself after it.</summary>
|
||||||
|
Control,
|
||||||
|
|
||||||
|
/// <summary>Latches Alt for the next key, and releases itself after it.</summary>
|
||||||
|
Alt,
|
||||||
|
|
||||||
|
/// <summary>Pastes the clipboard, through the same path a right-click on the desktop takes.</summary>
|
||||||
|
Paste,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One key the phone's own keyboard does not have.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A record for the convenience of construction and nothing else: two keys are the same key when their
|
||||||
|
/// <see cref="Id"/> is, which is what the bar stores. The byte array would make record equality reference
|
||||||
|
/// equality anyway, so nothing here leans on it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="IsWide"/> exists for the labels that are words — <c>ctrl+B %</c> — 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Id">Stable across releases; the bar and the group order are stored by it.</param>
|
||||||
|
/// <param name="Label">What the key says.</param>
|
||||||
|
/// <param name="Kind">What pressing it does.</param>
|
||||||
|
/// <param name="Bytes">What it sends, for <see cref="TerminalKeyKind.Send"/>. Empty otherwise.</param>
|
||||||
|
/// <param name="Hint">What it is for, for the customise screen and for accessibility.</param>
|
||||||
|
internal sealed record TerminalKey(string Id, string Label, TerminalKeyKind Kind, byte[] Bytes, string Hint)
|
||||||
|
{
|
||||||
|
/// <summary>Whether the label is long enough to want a double-width cell.</summary>
|
||||||
|
internal bool IsWide => Label.Length > 4;
|
||||||
|
|
||||||
|
/// <summary>Whether this is one of the two latching modifiers.</summary>
|
||||||
|
internal bool IsModifier => Kind is TerminalKeyKind.Control or TerminalKeyKind.Alt;
|
||||||
|
|
||||||
|
/// <summary>Whether this key was added by the person using it rather than shipped.</summary>
|
||||||
|
internal bool IsCustom => Id.StartsWith(TerminalKeyCatalog.CustomPrefix, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A named set of keys on the quick-access keyboard.</summary>
|
||||||
|
/// <param name="Id">Stable across releases; the order and the hidden set are stored by it.</param>
|
||||||
|
/// <param name="Title">The caption above the keys.</param>
|
||||||
|
/// <param name="Keys">The keys, in the order they are drawn.</param>
|
||||||
|
internal sealed record TerminalKeyGroup(string Id, string Title, IReadOnlyList<TerminalKey> Keys);
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Shell.Keyboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every key the phone ships with, in the groups the quick-access keyboard draws them in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The ids are a stored format.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Cursor and function keys are written as the sequences xterm sends in its default mode</b> — CSI for
|
||||||
|
/// the arrows, SS3 for F1 to F4 — for the reason <c>TerminalInputSurface</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class TerminalKeyCatalog
|
||||||
|
{
|
||||||
|
/// <summary>What every custom key's id starts with, so one can never collide with a shipped key.</summary>
|
||||||
|
internal const string CustomPrefix = "custom:";
|
||||||
|
|
||||||
|
/// <summary>The id of the group custom keys are drawn in.</summary>
|
||||||
|
internal const string CustomGroupId = "custom";
|
||||||
|
|
||||||
|
/// <summary>The most keys the bar holds.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Two rows of <see cref="BarRowWidth"/>. 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.
|
||||||
|
/// </remarks>
|
||||||
|
internal const int MaximumBarKeys = 16;
|
||||||
|
|
||||||
|
/// <summary>The most keys one row of the bar divides its width between.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
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];
|
||||||
|
|
||||||
|
/// <summary>Ctrl-B and then a key: one press of a tmux binding under the default prefix.</summary>
|
||||||
|
private static TerminalKey Tmux(string id, string key, string hint) =>
|
||||||
|
new(id, $"ctrl+B {key}", TerminalKeyKind.Send, [0x02, .. Encoding.ASCII.GetBytes(key)], hint);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Escape first, then the command: whatever mode vi was in, this is the one it is meant for.
|
||||||
|
/// </summary>
|
||||||
|
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<char, string> 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",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The groups as shipped, in their shipped order.</summary>
|
||||||
|
internal static IReadOnlyList<TerminalKeyGroup> 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~")),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The bar as shipped: the keys this head's old two fixed rows carried, plus Alt and paste.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
internal static IReadOnlyList<string> DefaultBar { get; } =
|
||||||
|
[
|
||||||
|
"esc", "tab", "ctrl", "alt", "pipe", "dash", "slash", "tilde",
|
||||||
|
"left", "down", "up", "right", "paste",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Every shipped key by id.</summary>
|
||||||
|
internal static FrozenDictionary<string, TerminalKey> ById { get; } =
|
||||||
|
Groups.SelectMany(g => g.Keys).ToFrozenDictionary(k => k.Id, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Builds the key a stored custom entry describes, or null when its sequence does not parse.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
namespace DodoSSH.Client.Shell.Keyboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What a latched Ctrl or Alt does to the key pressed after it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The encodings xterm uses, and nothing cleverer.</b> Ctrl with a character is that character's
|
||||||
|
/// control form — <c>c & 0x1F</c>, the rule caret notation names. Alt is an Escape in front, which is
|
||||||
|
/// what readline's <c>M-b</c> and <c>M-f</c> and every Emacs binding expect from a terminal. Ctrl or Alt with
|
||||||
|
/// a cursor key is the modified CSI form, <c>CSI 1 ; m X</c>, which is how Ctrl+← jumps a word in bash and
|
||||||
|
/// zsh.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Anything else goes through untouched.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class TerminalModifiers
|
||||||
|
{
|
||||||
|
/// <summary>Applies whichever modifiers are latched to one key's worth of bytes.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The control character for one byte, or the byte itself where there is none.</summary>
|
||||||
|
/// <remarks>Space as NUL is the one addition to caret notation, and every terminal makes it.</remarks>
|
||||||
|
private static byte ControlForm(byte c) =>
|
||||||
|
c == (byte)' ' ? (byte)0x00
|
||||||
|
: c < 0x80 && KeySequence.TryControl((char)c, out var control) ? control
|
||||||
|
: c;
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Shell.Keyboard;
|
||||||
|
|
||||||
|
/// <summary>One line the history kept: what ran, and where.</summary>
|
||||||
|
internal sealed record HistoryEntry(string Command, string Host);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The commands typed on this phone, for the quick-access keyboard's HISTORY tab.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Only what the remote echoed, and that is the whole safety story.</b> A terminal is one input stream,
|
||||||
|
/// and a <c>sudo</c> 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 <see cref="EchoedLine"/>) 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Two ways to read the command back, the second stricter than the first.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Memory only, and cleared on lock.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class TypedCommandHistory
|
||||||
|
{
|
||||||
|
/// <summary>The most commands kept. Older ones fall off the end.</summary>
|
||||||
|
internal const int Capacity = 100;
|
||||||
|
|
||||||
|
/// <summary>The longest command kept; anything longer is not something to retype from a list.</summary>
|
||||||
|
internal const int MaximumLength = 512;
|
||||||
|
|
||||||
|
private readonly Dictionary<uint, LineInProgress> lines = [];
|
||||||
|
|
||||||
|
/// <summary>Most recent first, without duplicates.</summary>
|
||||||
|
internal ObservableCollection<HistoryEntry> Entries { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Notes one key's worth of bytes about to be sent to a session.</summary>
|
||||||
|
/// <param name="sessionId">The session they are going to.</param>
|
||||||
|
/// <param name="host">What the tab is called, to label an entry with.</param>
|
||||||
|
/// <param name="sent">The bytes, after any latched modifier — what the remote will actually see.</param>
|
||||||
|
/// <param name="recent">
|
||||||
|
/// 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.
|
||||||
|
/// </param>
|
||||||
|
internal void Observe(uint sessionId, string host, ReadOnlySpan<byte> sent, Func<RecentOutput?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal void NoteInserted(uint sessionId, Func<RecentOutput?> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops what was being typed into a session that has gone.</summary>
|
||||||
|
internal void Forget(uint sessionId) => lines.Remove(sessionId);
|
||||||
|
|
||||||
|
/// <summary>Forgets everything, for LOCK and for the tab's own CLEAR.</summary>
|
||||||
|
internal void Clear()
|
||||||
|
{
|
||||||
|
Entries.Clear();
|
||||||
|
lines.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Commit(LineInProgress line, string host, Func<RecentOutput?> 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();
|
||||||
|
|
||||||
|
/// <summary>What the cursor's line said before the first key of this line.</summary>
|
||||||
|
internal string? Prompt { get; private set; }
|
||||||
|
|
||||||
|
internal bool Started { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Whether something not followed here changed the line.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -332,6 +332,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private readonly ClientSettingsStore settings;
|
private readonly ClientSettingsStore settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The phone's keys for a terminal: the bar, the quick-access keyboard, and how both are arranged.
|
||||||
|
/// </summary>
|
||||||
|
internal TerminalKeyboardViewModel Keyboard { get; }
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
|
/// 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
|
/// 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;
|
this.workspace.RendererReattached += OnRendererReattached;
|
||||||
|
|
||||||
settings = new ClientSettingsStore(paths);
|
settings = new ClientSettingsStore(paths);
|
||||||
|
Keyboard = CreateKeyboard();
|
||||||
|
|
||||||
updateScreen = CreateUpdateScreen(updates);
|
updateScreen = CreateUpdateScreen(updates);
|
||||||
|
|
||||||
@@ -651,6 +657,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
StartSessionShellTracking();
|
StartSessionShellTracking();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The phone's keys, wired to this shell's sessions.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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 <see cref="IsTerminalShowing"/>.
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Takes this machine's own preferences off disk, before anything can read them.
|
/// Takes this machine's own preferences off disk, before anything can read them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1500,6 +1534,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
IsTerminalSurface
|
IsTerminalSurface
|
||||||
&& !IsConnectSheetOpen
|
&& !IsConnectSheetOpen
|
||||||
&& !IsForwardsSheetOpen
|
&& !IsForwardsSheetOpen
|
||||||
|
&& !Keyboard.IsCustomizing
|
||||||
&& !IsHostKeyDecisionShowing
|
&& !IsHostKeyDecisionShowing
|
||||||
&& SelectedTab is { HasSession: true };
|
&& SelectedTab is { HasSession: true };
|
||||||
|
|
||||||
@@ -3815,6 +3850,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
? null
|
? null
|
||||||
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
|
: 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);
|
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
|
// 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)),
|
open.HostLabel, open.Address, open.StartedAt, deviceName)),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>The session the phone's keys type into: the selected tab, once it has a shell.</summary>
|
||||||
|
private TypingTarget? CurrentTypingTarget() =>
|
||||||
|
SelectedTab is { HasSession: true } tab ? new TypingTarget(tab.SessionId, tab.Label) : null;
|
||||||
|
|
||||||
private InsertTarget CurrentInsertTarget() =>
|
private InsertTarget CurrentInsertTarget() =>
|
||||||
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
|
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>The quick-access keyboard's tabs.</summary>
|
||||||
|
internal enum QuickAccessTab
|
||||||
|
{
|
||||||
|
Keys,
|
||||||
|
Snippets,
|
||||||
|
History,
|
||||||
|
Appearance,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Where typed bytes are going: a session that is open, and what its tab is called.</summary>
|
||||||
|
internal sealed record TypingTarget(uint SessionId, string Host);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Here rather than in the Android head, because almost none of it is about Android.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Every key press, from the bar, the quick-access keyboard or the software keyboard itself, comes through
|
||||||
|
/// <see cref="Send"/>.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Customising saves as it goes.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class TerminalKeyboardViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly ClientSettingsStore settings;
|
||||||
|
private readonly Func<TypingTarget?> target;
|
||||||
|
private readonly Func<uint, ReadOnlyMemory<byte>, ValueTask> send;
|
||||||
|
private readonly Func<uint, RecentOutput?> recent;
|
||||||
|
private readonly Func<uint, string, bool, CancellationToken, Task<bool>> insert;
|
||||||
|
private readonly Func<uint, Task> paste;
|
||||||
|
|
||||||
|
private readonly List<string> bar = [];
|
||||||
|
private readonly List<string> groupOrder = [];
|
||||||
|
private readonly HashSet<string> hiddenGroups = new(StringComparer.Ordinal);
|
||||||
|
private readonly List<CustomTerminalKey> customKeys = [];
|
||||||
|
|
||||||
|
/// <summary>One button model per key id, shared wherever the key is drawn, so a latch lights all of them.</summary>
|
||||||
|
private readonly Dictionary<string, KeyButtonViewModel> buttons = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private readonly TypedCommandHistory history = new();
|
||||||
|
|
||||||
|
/// <param name="settings">Where the arrangement is kept.</param>
|
||||||
|
/// <param name="target">The session keys go to right now, or null when no tab has one.</param>
|
||||||
|
/// <param name="send">Writes bytes to a session.</param>
|
||||||
|
/// <param name="recent">A session's recent output, for the history's echo test.</param>
|
||||||
|
/// <param name="insert">Types text into a session as the snippets screen does — the workspace's paste.</param>
|
||||||
|
/// <param name="paste">Pastes the clipboard into a session.</param>
|
||||||
|
internal TerminalKeyboardViewModel(
|
||||||
|
ClientSettingsStore settings,
|
||||||
|
Func<TypingTarget?> target,
|
||||||
|
Func<uint, ReadOnlyMemory<byte>, ValueTask> send,
|
||||||
|
Func<uint, RecentOutput?> recent,
|
||||||
|
Func<uint, string, bool, CancellationToken, Task<bool>> insert,
|
||||||
|
Func<uint, Task> 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<QuickAccessTab>(stored.QuickAccessTab, out var tab) ? tab : QuickAccessTab.Keys;
|
||||||
|
isSidePanelOpen = stored.SidePanelOpen;
|
||||||
|
cursorGestures = stored.CursorGestures;
|
||||||
|
|
||||||
|
Rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The bar, as rows of at most <see cref="TerminalKeyCatalog.BarRowWidth"/>.</summary>
|
||||||
|
internal ObservableCollection<KeyRowViewModel> BarRows { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>The KEYS tab: every group that is switched on, in order.</summary>
|
||||||
|
internal ObservableCollection<KeyGroupViewModel> Groups { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>The HISTORY tab.</summary>
|
||||||
|
internal ObservableCollection<HistoryEntry> History => history.Entries;
|
||||||
|
|
||||||
|
/// <summary>The customise screen's bar list.</summary>
|
||||||
|
internal ObservableCollection<BarEditorRowViewModel> BarEditor { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>The customise screen's group list.</summary>
|
||||||
|
internal ObservableCollection<GroupEditorRowViewModel> GroupEditor { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>The customise screen's list of keys somebody added.</summary>
|
||||||
|
internal ObservableCollection<CustomKeyRowViewModel> CustomKeyEditor { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Whether the bar has any keys on it; an empty bar is drawn as nothing at all.</summary>
|
||||||
|
internal bool HasBar => bar.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>Whether the HISTORY tab has anything to show.</summary>
|
||||||
|
internal bool HasHistory => History.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>Whether the bar can take another key.</summary>
|
||||||
|
internal bool CanAddToBar => bar.Count < TerminalKeyCatalog.MaximumBarKeys;
|
||||||
|
|
||||||
|
/// <summary>The customise screen's count line.</summary>
|
||||||
|
internal string BarCountText => $"{bar.Count} of {TerminalKeyCatalog.MaximumBarKeys}";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isControlLatched;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isAltLatched;
|
||||||
|
|
||||||
|
/// <summary>Whether the quick-access keyboard is up in place of the software keyboard.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isQuickAccessOpen;
|
||||||
|
|
||||||
|
/// <summary>Whether a wide screen draws the quick-access keyboard beside the terminal.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isSidePanelOpen;
|
||||||
|
|
||||||
|
/// <summary>Whether the customise screen is up.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isCustomizing;
|
||||||
|
|
||||||
|
/// <summary>Whether long-press-and-drag on the terminal moves the cursor.</summary>
|
||||||
|
[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 ───────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends one key's worth of bytes to the selected session, through the latches and past the history.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sends the cursor gesture's arrows, which no latch applies to.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What a key does when pressed, wherever it is drawn.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Notes that a session's tab is gone, so a half-typed line is not kept for it.</summary>
|
||||||
|
internal void ForgetSession(uint sessionId) => history.Forget(sessionId);
|
||||||
|
|
||||||
|
/// <summary>Empties the history, which LOCK does as well as the tab's own CLEAR.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
internal void ClearHistory()
|
||||||
|
{
|
||||||
|
history.Clear();
|
||||||
|
OnPropertyChanged(nameof(HasHistory));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Types a history entry at the prompt, without pressing Enter.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
[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 ───────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Raises or lowers the quick-access keyboard.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleQuickAccess() => IsQuickAccessOpen = !IsQuickAccessOpen;
|
||||||
|
|
||||||
|
/// <summary>Lowers the quick-access keyboard, for the software keyboard coming back.</summary>
|
||||||
|
internal void CloseQuickAccess() => IsQuickAccessOpen = false;
|
||||||
|
|
||||||
|
/// <summary>Shows or hides the wide screen's side panel, and remembers which.</summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts the bar and the groups back as shipped. Keys somebody added are kept, off the bar.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds the key the two boxes describe, to its own group and to the bar if there is room.</summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every group this build has, exactly once: stored order first, anything new at the end.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
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<TerminalKey>()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>Redraws the KEYS tab.</summary>
|
||||||
|
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)]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Redraws everything that depends on the arrangement.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
private void Rebuild()
|
||||||
|
{
|
||||||
|
var barKeys = bar.Select(KeyFor).OfType<TerminalKey>().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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One key as drawn: on the bar, on the quick-access keyboard, or both at once.</summary>
|
||||||
|
internal sealed partial class KeyButtonViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
internal KeyButtonViewModel(TerminalKey key, Action<TerminalKey> 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; }
|
||||||
|
|
||||||
|
/// <summary>Whether this is a modifier that is on, which has to look on.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isLatched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One row of the bar; the markup divides its width evenly between the keys.</summary>
|
||||||
|
internal sealed record KeyRowViewModel(IReadOnlyList<KeyButtonViewModel> Keys);
|
||||||
|
|
||||||
|
/// <summary>One group on the KEYS tab.</summary>
|
||||||
|
internal sealed record KeyGroupViewModel(string Title, IReadOnlyList<KeyButtonViewModel> Keys);
|
||||||
|
|
||||||
|
/// <summary>One key on the customise screen's bar list.</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>Which row of the bar the key lands on, since the first eight are the top row.</summary>
|
||||||
|
internal string Row { get; }
|
||||||
|
|
||||||
|
internal IRelayCommand MoveUpCommand { get; }
|
||||||
|
|
||||||
|
internal IRelayCommand MoveDownCommand { get; }
|
||||||
|
|
||||||
|
internal IRelayCommand RemoveCommand { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One group on the customise screen: its switch, its place, and which of its keys are on the bar.</summary>
|
||||||
|
internal sealed partial class GroupEditorRowViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly Action<bool> setShown;
|
||||||
|
|
||||||
|
internal GroupEditorRowViewModel(
|
||||||
|
string title,
|
||||||
|
bool isShown,
|
||||||
|
Action<bool> setShown,
|
||||||
|
Action? moveUp,
|
||||||
|
Action? moveDown,
|
||||||
|
IReadOnlyList<KeyChoiceViewModel> 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<KeyChoiceViewModel> Keys { get; }
|
||||||
|
|
||||||
|
internal IRelayCommand MoveUpCommand { get; }
|
||||||
|
|
||||||
|
internal IRelayCommand MoveDownCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>Whether the group is drawn on the KEYS tab.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isShown;
|
||||||
|
|
||||||
|
partial void OnIsShownChanged(bool value) => setShown(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A key on the customise screen, lit when it is on the bar; tapping it puts it on or takes it off.</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A key somebody added, on the customise screen, with the way to delete it.</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
namespace DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
|
/// <summary>What one session's output has most recently looked like.</summary>
|
||||||
|
/// <param name="Tail">The last bytes the remote wrote, oldest first.</param>
|
||||||
|
/// <param name="AlternateScreen">
|
||||||
|
/// Whether the remote has switched to the alternate screen — vi, less, htop — and not yet switched back.
|
||||||
|
/// </param>
|
||||||
|
public sealed record RecentOutput(byte[] Tail, bool AlternateScreen);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The last kilobyte a session's remote wrote, and whether a full-screen program is running.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>This exists so that the phone's command history only keeps what the remote echoed.</b> A terminal is
|
||||||
|
/// one input stream, and the client cannot tell a shell prompt from a <c>sudo</c> 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
|
||||||
|
/// <c>TypedCommandHistory</c> in the shell.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Kept beside the pump's read loop, and costing that loop almost nothing.</b> Each read copies at most
|
||||||
|
/// <see cref="Capacity"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OutputTail
|
||||||
|
{
|
||||||
|
/// <summary>How much output is kept.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public const int Capacity = 1024;
|
||||||
|
|
||||||
|
/// <summary>The longest switch sequence, less one: how far a match can straddle two reads.</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Takes a read's worth of output.</summary>
|
||||||
|
public void Append(ReadOnlySpan<byte> 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<byte> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A copy of what is held, oldest byte first.</summary>
|
||||||
|
public RecentOutput Snapshot()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var copy = new byte[count];
|
||||||
|
CopyLast(copy);
|
||||||
|
|
||||||
|
return new RecentOutput(copy, alternateScreen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Copies the last <c>destination.Length</c> held bytes. Called under the lock.</summary>
|
||||||
|
private void CopyLast(Span<byte> destination)
|
||||||
|
{
|
||||||
|
var offset = count - destination.Length;
|
||||||
|
|
||||||
|
for (var i = 0; i < destination.Length; i++)
|
||||||
|
{
|
||||||
|
destination[i] = ring[(start + offset + i) % Capacity];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the screen is alternate after this output, given whether it was before.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The three DEC private modes programs use for it — 1049, which every modern one sends, and the older
|
||||||
|
/// 47 and 1047 — set with <c>h</c> and reset with <c>l</c>. The last switch in the window wins.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool ScanForScreenSwitch(ReadOnlySpan<byte> window, bool current)
|
||||||
|
{
|
||||||
|
ReadOnlySpan<byte> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,6 +167,10 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
/// <summary>Bytes read from the remote and not yet sent to the renderer.</summary>
|
/// <summary>Bytes read from the remote and not yet sent to the renderer.</summary>
|
||||||
public int BytesPending => backlog.Buffered;
|
public int BytesPending => backlog.Buffered;
|
||||||
|
|
||||||
|
/// <summary>The last of what the remote wrote, for the phone's command history.</summary>
|
||||||
|
/// <remarks>See <see cref="OutputTail"/> for why a client keeps any of it.</remarks>
|
||||||
|
public OutputTail Recent { get; } = new();
|
||||||
|
|
||||||
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
|
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
|
||||||
public long FramesSent { get; private set; }
|
public long FramesSent { get; private set; }
|
||||||
|
|
||||||
@@ -353,6 +357,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
BytesRead += read;
|
BytesRead += read;
|
||||||
|
Recent.Append(buffer.AsSpan(0, read));
|
||||||
|
|
||||||
// Copied out of the rented buffer, because the backlog holds it until the renderer has
|
// 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.
|
// room and the next read is already about to overwrite this one.
|
||||||
|
|||||||
@@ -280,6 +280,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What one live session's remote has most recently written, or null when the id names no session this
|
||||||
|
/// workspace still has open.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For the phone's command history, which keeps a typed line only once it has seen the remote echo it —
|
||||||
|
/// see <see cref="OutputTail"/>. A copy, so the caller can take its time over it.
|
||||||
|
/// </remarks>
|
||||||
|
public RecentOutput? GetRecentOutput(uint sessionId)
|
||||||
|
{
|
||||||
|
lock (sessionGate)
|
||||||
|
{
|
||||||
|
return sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted
|
||||||
|
? session.Pump.Recent.Snapshot()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A live session's flow-control window, or null when the id names no session this workspace still has
|
/// A live session's flow-control window, or null when the id names no session this workspace still has
|
||||||
/// open.
|
/// open.
|
||||||
|
|||||||
@@ -491,7 +491,7 @@
|
|||||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||||
"DodoSSH.Client.Transfer": "[1.0.0, )",
|
"DodoSSH.Client.Transfer": "[1.0.0, )",
|
||||||
"Microsoft.TSS": "[2.1.1, )",
|
"Microsoft.TSS": "[2.1.1, )",
|
||||||
"Velopack": "[1.2.0, )"
|
"Velopack": "[1.2.161, )"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dodossh.client.api": {
|
"dodossh.client.api": {
|
||||||
@@ -788,9 +788,9 @@
|
|||||||
},
|
},
|
||||||
"Velopack": {
|
"Velopack": {
|
||||||
"type": "CentralTransitive",
|
"type": "CentralTransitive",
|
||||||
"requested": "[1.2.0, )",
|
"requested": "[1.2.161, )",
|
||||||
"resolved": "1.2.0",
|
"resolved": "1.2.161",
|
||||||
"contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw=="
|
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The phone's terminal keys: what each sends, what a latch does to the next one, the cursor gesture's
|
||||||
|
/// speeds, the history's refusal to keep what was not echoed, and the arrangement surviving a relaunch.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TerminalKeyboardTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string directory =
|
||||||
|
Path.Combine(Path.GetTempPath(), $"dodossh-keys-{Guid.CreateVersion7():N}");
|
||||||
|
|
||||||
|
private readonly List<byte[]> sent = [];
|
||||||
|
|
||||||
|
/// <summary>What the remote has drawn so far, as the history's echo test reads it.</summary>
|
||||||
|
private string screen = "stan@box:~$ ";
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text);
|
||||||
|
|
||||||
|
private static string Shown(byte[] bytes) => Encoding.ASCII.GetString(bytes);
|
||||||
|
|
||||||
|
// ── notation ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(@"\e:wq\r", "\x1b:wq\r")]
|
||||||
|
[InlineData("^C", "\x03")]
|
||||||
|
[InlineData("^c", "\x03")]
|
||||||
|
[InlineData("^[", "\x1b")]
|
||||||
|
[InlineData("^?", "\x7f")]
|
||||||
|
[InlineData(@"\x1b[A", "\x1b[A")]
|
||||||
|
[InlineData(@"\^C", "^C")]
|
||||||
|
[InlineData("^1", "^1")]
|
||||||
|
[InlineData("git status", "git status")]
|
||||||
|
[InlineData(@"a\\b", @"a\b")]
|
||||||
|
public void Notation_IsReadTheWayATerminalUserWritesIt(string notation, string expected)
|
||||||
|
{
|
||||||
|
KeySequence.TryParse(notation, out var bytes).ShouldBeTrue();
|
||||||
|
|
||||||
|
Shown(bytes).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(@"oops\")]
|
||||||
|
[InlineData(@"\x1")]
|
||||||
|
[InlineData(@"\xZZ")]
|
||||||
|
public void MalformedNotation_IsRefusedRatherThanGuessedAt(string notation) =>
|
||||||
|
KeySequence.TryParse(notation, out _).ShouldBeFalse();
|
||||||
|
|
||||||
|
// ── latches ──────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("c", true, false, "\x03")]
|
||||||
|
[InlineData("C", true, false, "\x03")]
|
||||||
|
[InlineData(" ", true, false, "\0")]
|
||||||
|
[InlineData("b", false, true, "\u001bb")]
|
||||||
|
[InlineData("\x1b[D", true, false, "\x1b[1;5D")]
|
||||||
|
[InlineData("\x1b[C", true, true, "\x1b[1;7C")]
|
||||||
|
[InlineData("\x1b[A", false, true, "\x1b[1;3A")]
|
||||||
|
[InlineData("ls", true, false, "ls")]
|
||||||
|
[InlineData("\x1b[15~", true, false, "\x1b[15~")]
|
||||||
|
public void ALatchedModifier_EncodesTheNextKeyTheWayXtermDoes(string key, bool control, bool alt, string expected) =>
|
||||||
|
Shown(TerminalModifiers.Apply(Ascii(key), control, alt)).ShouldBe(expected);
|
||||||
|
|
||||||
|
// ── the cursor gesture ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADragInsideTheDeadZone_SendsNothing()
|
||||||
|
{
|
||||||
|
var gesture = new ArrowGesture();
|
||||||
|
|
||||||
|
gesture.Move(10, -8, 0).ShouldBe(0);
|
||||||
|
gesture.Advance(2_000).ShouldBe(0);
|
||||||
|
gesture.Direction.ShouldBe(ArrowDirection.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LeavingTheDeadZone_SendsOneKeyAtOnce_AndTheSlowSpeedWaitsBeforeRepeating()
|
||||||
|
{
|
||||||
|
var gesture = new ArrowGesture();
|
||||||
|
|
||||||
|
gesture.Move(-30, 0, 1_000).ShouldBe(1);
|
||||||
|
gesture.Direction.ShouldBe(ArrowDirection.Left);
|
||||||
|
gesture.Speed.ShouldBe(1);
|
||||||
|
|
||||||
|
// Held: nothing more until the first-repeat delay is up, then one per interval.
|
||||||
|
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs - 1).ShouldBe(0);
|
||||||
|
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs).ShouldBe(1);
|
||||||
|
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs + ArrowGesture.IntervalMs(1)).ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DraggingFurtherTheSameWay_ShiftsThroughTheThreeSpeeds()
|
||||||
|
{
|
||||||
|
var gesture = new ArrowGesture();
|
||||||
|
|
||||||
|
gesture.Move(0, -30, 0);
|
||||||
|
gesture.Speed.ShouldBe(1);
|
||||||
|
gesture.Direction.ShouldBe(ArrowDirection.Up);
|
||||||
|
|
||||||
|
gesture.Move(0, -ArrowGesture.SecondSpeedAt, 10);
|
||||||
|
gesture.Speed.ShouldBe(2);
|
||||||
|
|
||||||
|
gesture.Move(0, -ArrowGesture.ThirdSpeedAt, 20);
|
||||||
|
gesture.Speed.ShouldBe(3);
|
||||||
|
|
||||||
|
// A second at the fastest speed is many keys; the same second at the slowest is a handful.
|
||||||
|
var fast = 0;
|
||||||
|
|
||||||
|
for (var t = 20L; t <= 1_020; t += 10)
|
||||||
|
{
|
||||||
|
fast += gesture.Advance(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
var slow = new ArrowGesture();
|
||||||
|
var slowCount = slow.Move(0, -30, 0);
|
||||||
|
|
||||||
|
for (var t = 0L; t <= 1_000; t += 10)
|
||||||
|
{
|
||||||
|
slowCount += slow.Advance(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
fast.ShouldBeGreaterThan(slowCount * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ASmallDriftOffTheAxis_DoesNotTurnLeftIntoUp()
|
||||||
|
{
|
||||||
|
var gesture = new ArrowGesture();
|
||||||
|
|
||||||
|
gesture.Move(-40, 0, 0);
|
||||||
|
gesture.Move(-40, -45, 10);
|
||||||
|
|
||||||
|
gesture.Direction.ShouldBe(ArrowDirection.Left);
|
||||||
|
|
||||||
|
// Clearly vertical now, past the hysteresis.
|
||||||
|
gesture.Move(-20, -60, 20).ShouldBe(1);
|
||||||
|
gesture.Direction.ShouldBe(ArrowDirection.Up);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComingBackToTheCentre_Stops()
|
||||||
|
{
|
||||||
|
var gesture = new ArrowGesture();
|
||||||
|
|
||||||
|
gesture.Move(50, 0, 0);
|
||||||
|
gesture.Move(3, 2, 10).ShouldBe(0);
|
||||||
|
|
||||||
|
gesture.Direction.ShouldBe(ArrowDirection.None);
|
||||||
|
gesture.Advance(5_000).ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── reading back the echo ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("\x1b[32mstan@box\x1b[0m:~$ ls -la", "stan@box:~$ ls -la")]
|
||||||
|
[InlineData("old output\r\n$ git statsu\b \b\b \bus", "$ git status")]
|
||||||
|
[InlineData("$ rm -rf build\r$ ls\x1b[K", "$ ls")]
|
||||||
|
[InlineData("\x1b]0;stan@box: ~\a$ uptime", "$ uptime")]
|
||||||
|
[InlineData("$ ech\x1b[1D\x1b[1@o", "$ ecoh")]
|
||||||
|
public void TheCursorsLine_IsReadAsATerminalWouldDrawIt(string output, string expected) =>
|
||||||
|
EchoedLine.LastLine(Ascii(output)).ShouldBe(expected);
|
||||||
|
|
||||||
|
// ── the history ──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private RecentOutput Recent() => new(Ascii(screen), AlternateScreen: false);
|
||||||
|
|
||||||
|
/// <summary>Types text one key at a time, echoing each key the way an ordinary shell prompt does.</summary>
|
||||||
|
private void TypeEchoed(TypedCommandHistory history, string text)
|
||||||
|
{
|
||||||
|
foreach (var c in text)
|
||||||
|
{
|
||||||
|
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
||||||
|
screen += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ACommandTheShellEchoed_IsKept()
|
||||||
|
{
|
||||||
|
var history = new TypedCommandHistory();
|
||||||
|
|
||||||
|
TypeEchoed(history, "uptime");
|
||||||
|
history.Observe(1, "box", "\r"u8, Recent);
|
||||||
|
|
||||||
|
history.Entries.ShouldHaveSingleItem().ShouldBe(new HistoryEntry("uptime", "box"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APasswordTypedWithEchoOff_IsNeverKept()
|
||||||
|
{
|
||||||
|
var history = new TypedCommandHistory();
|
||||||
|
screen = "[sudo] password for stan: ";
|
||||||
|
|
||||||
|
// Typed, but nothing comes back: the prompt line is exactly what it was.
|
||||||
|
foreach (var c in "hunter2")
|
||||||
|
{
|
||||||
|
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.Observe(1, "box", "\r"u8, Recent);
|
||||||
|
|
||||||
|
history.Entries.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APasswordTypedAfterAnArrowKey_IsStillNeverKept()
|
||||||
|
{
|
||||||
|
var history = new TypedCommandHistory();
|
||||||
|
screen = "Password: ";
|
||||||
|
|
||||||
|
history.Observe(1, "box", "\x1b[D"u8, Recent);
|
||||||
|
|
||||||
|
foreach (var c in "s3cret")
|
||||||
|
{
|
||||||
|
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.Observe(1, "box", "\r"u8, Recent);
|
||||||
|
|
||||||
|
history.Entries.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APromptThatEchoesStars_IsNotACommand()
|
||||||
|
{
|
||||||
|
var history = new TypedCommandHistory();
|
||||||
|
screen = "Passphrase: ";
|
||||||
|
|
||||||
|
foreach (var c in "s3cret")
|
||||||
|
{
|
||||||
|
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
||||||
|
screen += '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
history.Observe(1, "box", "\r"u8, Recent);
|
||||||
|
|
||||||
|
history.Entries.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ALineFinishedWithTab_IsKeptAsTheShellCompletedIt()
|
||||||
|
{
|
||||||
|
var history = new TypedCommandHistory();
|
||||||
|
|
||||||
|
TypeEchoed(history, "cd /var/lo");
|
||||||
|
history.Observe(1, "box", "\t"u8, Recent);
|
||||||
|
screen += "g/";
|
||||||
|
history.Observe(1, "box", "\r"u8, Recent);
|
||||||
|
|
||||||
|
history.Entries.ShouldHaveSingleItem().Command.ShouldBe("cd /var/log/");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NothingTypedIntoAFullScreenProgram_IsKept()
|
||||||
|
{
|
||||||
|
var history = new TypedCommandHistory();
|
||||||
|
|
||||||
|
RecentOutput InVi() => new(Ascii("~\r\n~\r\nhello"), AlternateScreen: true);
|
||||||
|
|
||||||
|
foreach (var c in "hello")
|
||||||
|
{
|
||||||
|
history.Observe(1, "box", Ascii(c.ToString()), InVi);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.Observe(1, "box", "\r"u8, InVi);
|
||||||
|
|
||||||
|
history.Entries.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RunningACommandAgain_MovesItToTheTopRatherThanListingItTwice()
|
||||||
|
{
|
||||||
|
var history = new TypedCommandHistory();
|
||||||
|
|
||||||
|
foreach (var command in new[] { "ls", "df -h", "ls" })
|
||||||
|
{
|
||||||
|
screen = "$ ";
|
||||||
|
TypeEchoed(history, command);
|
||||||
|
history.Observe(1, "box", "\r"u8, Recent);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.Entries.Select(e => e.Command).ShouldBe(["ls", "df -h"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CtrlC_AbandonsTheLine()
|
||||||
|
{
|
||||||
|
var history = new TypedCommandHistory();
|
||||||
|
|
||||||
|
TypeEchoed(history, "rm -rf /tmp/x");
|
||||||
|
history.Observe(1, "box", "\x03"u8, Recent);
|
||||||
|
screen = "stan@box:~$ ";
|
||||||
|
history.Observe(1, "box", "\r"u8, Recent);
|
||||||
|
|
||||||
|
history.Entries.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the view model ───────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private ClientSettingsStore Settings() => new(new ClientPaths(directory));
|
||||||
|
|
||||||
|
private TerminalKeyboardViewModel Keyboard(ClientSettingsStore? store = null) =>
|
||||||
|
new(
|
||||||
|
store ?? Settings(),
|
||||||
|
() => new TypingTarget(1, "box"),
|
||||||
|
(_, bytes) =>
|
||||||
|
{
|
||||||
|
sent.Add(bytes.ToArray());
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
},
|
||||||
|
_ => Recent(),
|
||||||
|
(_, _, _, _) => Task.FromResult(true),
|
||||||
|
_ => Task.CompletedTask);
|
||||||
|
|
||||||
|
private static KeyButtonViewModel Button(TerminalKeyboardViewModel keyboard, string id) =>
|
||||||
|
keyboard.Groups.SelectMany(g => g.Keys).First(k => k.Key.Id == id);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CtrlOnTheBar_AppliesToTheNextLetterFromTheSoftKeyboard_AndThenLetsGo()
|
||||||
|
{
|
||||||
|
var keyboard = Keyboard();
|
||||||
|
|
||||||
|
Button(keyboard, "ctrl").PressCommand.Execute(null);
|
||||||
|
keyboard.IsControlLatched.ShouldBeTrue();
|
||||||
|
Button(keyboard, "ctrl").IsLatched.ShouldBeTrue();
|
||||||
|
|
||||||
|
keyboard.Send(Ascii("c"));
|
||||||
|
keyboard.Send(Ascii("c"));
|
||||||
|
|
||||||
|
sent.Select(Shown).ShouldBe(["\x03", "c"]);
|
||||||
|
keyboard.IsControlLatched.ShouldBeFalse();
|
||||||
|
Button(keyboard, "ctrl").IsLatched.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheBarAsShipped_IsTwoRows_ModifiersAboveCursorKeys()
|
||||||
|
{
|
||||||
|
var keyboard = Keyboard();
|
||||||
|
|
||||||
|
keyboard.BarRows.Count.ShouldBe(2);
|
||||||
|
keyboard.BarRows[0].Keys.Select(k => k.Label).ShouldBe(["esc", "tab", "ctrl", "alt", "|", "-", "/", "~"]);
|
||||||
|
keyboard.BarRows[1].Keys.Select(k => k.Label).ShouldBe(["←", "↓", "↑", "→", "paste"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ACustomisedBar_SurvivesARelaunch()
|
||||||
|
{
|
||||||
|
var store = Settings();
|
||||||
|
var keyboard = Keyboard(store);
|
||||||
|
|
||||||
|
keyboard.OpenCustomizeCommand.Execute(null);
|
||||||
|
keyboard.BarEditor[1].MoveUpCommand.Execute(null); // tab before esc
|
||||||
|
keyboard.BarEditor.Single(r => r.Label == "paste").RemoveCommand.Execute(null);
|
||||||
|
keyboard.NewKeyLabel = "wq";
|
||||||
|
keyboard.NewKeySequence = @"\e:wq\r";
|
||||||
|
keyboard.AddCustomKeyCommand.Execute(null);
|
||||||
|
|
||||||
|
var relaunched = Keyboard(store);
|
||||||
|
var labels = relaunched.BarRows.SelectMany(r => r.Keys).Select(k => k.Label).ToList();
|
||||||
|
|
||||||
|
labels[0].ShouldBe("tab");
|
||||||
|
labels[1].ShouldBe("esc");
|
||||||
|
labels.ShouldNotContain("paste");
|
||||||
|
labels[^1].ShouldBe("wq");
|
||||||
|
relaunched.Groups.Last().Title.ShouldBe("YOUR KEYS");
|
||||||
|
|
||||||
|
relaunched.BarRows.SelectMany(r => r.Keys).Last().PressCommand.Execute(null);
|
||||||
|
Shown(sent.Last()).ShouldBe("\x1b:wq\r");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AHiddenGroup_IsLeftOffTheKeysTab_AndStaysHidden()
|
||||||
|
{
|
||||||
|
var store = Settings();
|
||||||
|
var keyboard = Keyboard(store);
|
||||||
|
|
||||||
|
keyboard.GroupEditor.Single(g => g.Title == "TMUX").IsShown = false;
|
||||||
|
|
||||||
|
keyboard.Groups.Select(g => g.Title).ShouldNotContain("TMUX");
|
||||||
|
Keyboard(store).Groups.Select(g => g.Title).ShouldNotContain("TMUX");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reset_PutsTheBarBack_ButKeepsKeysSomebodyWrote()
|
||||||
|
{
|
||||||
|
var keyboard = Keyboard();
|
||||||
|
|
||||||
|
keyboard.NewKeyLabel = "deploy";
|
||||||
|
keyboard.NewKeySequence = "make deploy";
|
||||||
|
keyboard.AddCustomKeyCommand.Execute(null);
|
||||||
|
|
||||||
|
keyboard.ResetLayoutCommand.Execute(null);
|
||||||
|
|
||||||
|
keyboard.BarRows.SelectMany(r => r.Keys).Select(k => k.Key.Id).ShouldBe(TerminalKeyCatalog.DefaultBar);
|
||||||
|
keyboard.CustomKeyEditor.ShouldHaveSingleItem().Label.ShouldBe("deploy");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AKeyWithANonsenseSequence_IsRefusedWithAReason()
|
||||||
|
{
|
||||||
|
var keyboard = Keyboard();
|
||||||
|
|
||||||
|
keyboard.NewKeyLabel = "bad";
|
||||||
|
keyboard.NewKeySequence = @"\x1";
|
||||||
|
keyboard.AddCustomKeyCommand.Execute(null);
|
||||||
|
|
||||||
|
keyboard.CustomKeyEditor.ShouldBeEmpty();
|
||||||
|
keyboard.CustomizeStatus.ShouldNotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheBarHoldsSixteenKeys_AndSaysSoWhenFull()
|
||||||
|
{
|
||||||
|
var keyboard = Keyboard();
|
||||||
|
|
||||||
|
foreach (var choice in keyboard.GroupEditor.Single(g => g.Title == "FUNCTION").Keys.ToList())
|
||||||
|
{
|
||||||
|
// Rebuilt after every toggle, so look the chip up again each time.
|
||||||
|
keyboard.GroupEditor.Single(g => g.Title == "FUNCTION").Keys
|
||||||
|
.Single(k => k.Label == choice.Label).ToggleCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboard.BarRows.Sum(r => r.Keys.Count).ShouldBe(TerminalKeyCatalog.MaximumBarKeys);
|
||||||
|
keyboard.CanAddToBar.ShouldBeFalse();
|
||||||
|
keyboard.CustomizeStatus.ShouldContain("16");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheQuickAccessKeyboard_ReopensToTheTabLastUsed()
|
||||||
|
{
|
||||||
|
var store = Settings();
|
||||||
|
|
||||||
|
Keyboard(store).SelectTabCommand.Execute(QuickAccessTab.History);
|
||||||
|
|
||||||
|
var relaunched = Keyboard(store);
|
||||||
|
|
||||||
|
relaunched.SelectedTab.ShouldBe(QuickAccessTab.History);
|
||||||
|
relaunched.IsHistoryTab.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnIdThisBuildDoesNotKnow_IsSkippedRatherThanRefused()
|
||||||
|
{
|
||||||
|
var store = Settings();
|
||||||
|
store.Write(new ClientSettings(TerminalKeyboard: new TerminalKeyboardSettings(
|
||||||
|
Bar: ["esc", "from-the-future", "up"],
|
||||||
|
GroupOrder: ["vim", "nonsense"])));
|
||||||
|
|
||||||
|
var keyboard = Keyboard(store);
|
||||||
|
|
||||||
|
keyboard.BarRows.ShouldHaveSingleItem().Keys.Select(k => k.Key.Id).ShouldBe(["esc", "up"]);
|
||||||
|
keyboard.Groups[0].Title.ShouldBe("VIM");
|
||||||
|
keyboard.Groups.Count.ShouldBe(TerminalKeyCatalog.Groups.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheGesturesArrows_IgnoreALatchedCtrl()
|
||||||
|
{
|
||||||
|
var keyboard = Keyboard();
|
||||||
|
|
||||||
|
Button(keyboard, "ctrl").PressCommand.Execute(null);
|
||||||
|
keyboard.SendArrow(ArrowDirection.Up, 2);
|
||||||
|
|
||||||
|
Shown(sent.Single()).ShouldBe("\x1b[A\x1b[A");
|
||||||
|
keyboard.IsControlLatched.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -480,7 +480,7 @@
|
|||||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||||
"DodoSSH.Client.Transfer": "[1.0.0, )",
|
"DodoSSH.Client.Transfer": "[1.0.0, )",
|
||||||
"Microsoft.TSS": "[2.1.1, )",
|
"Microsoft.TSS": "[2.1.1, )",
|
||||||
"Velopack": "[1.2.0, )"
|
"Velopack": "[1.2.161, )"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dodossh.client.api": {
|
"dodossh.client.api": {
|
||||||
@@ -777,9 +777,9 @@
|
|||||||
},
|
},
|
||||||
"Velopack": {
|
"Velopack": {
|
||||||
"type": "CentralTransitive",
|
"type": "CentralTransitive",
|
||||||
"requested": "[1.2.0, )",
|
"requested": "[1.2.161, )",
|
||||||
"resolved": "1.2.0",
|
"resolved": "1.2.161",
|
||||||
"contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw=="
|
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Terminal.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user