Android: a customisable key bar, a quick-access keyboard, a long-press cursor gesture, and a side panel on tablets
ci / build and test (push) Successful in 4m52s
ci / desktop nightly (push) Failing after 58s
ci / android head (push) Successful in 5m59s
ci / api image (push) Successful in 8s

The key bar above the software keyboard is now the user's own: which keys, in what order, up to sixteen
in two rows that never scroll. Ctrl and Alt latch across the bar and the phone's keyboard, and Ctrl or Alt
with a cursor key sends the modified CSI form. Seven predefined groups (essentials, cursor, control,
symbols, tmux, vim, function) and keys of one's own, written in \e / ^C notation, are arranged on a
customise sheet that saves as it goes.

A grid button swaps the software keyboard for a quick-access keyboard of the same height, with KEYS,
SNIPPETS, HISTORY and APPEARANCE tabs; it reopens to the tab last used. Snippets go through the shell's
existing InsertSnippet. History keeps a typed command only once the remote has echoed it, so nothing
typed at a password prompt is ever recorded; it is memory-only and LOCK clears it. Each session pump now
keeps its last kilobyte of output and the alternate-screen state for that test (OutputTail).

Long-press and drag on the terminal sends arrow keys, as a joystick with three speeds by distance;
scrolling is unchanged and there is no mode switch. It takes the touch before Chromium's own long press,
so a held finger no longer pastes; paste is on the bar by default instead. A switch in APPEARANCE turns
the gesture off. The text-size buttons moved from the bar to APPEARANCE.

On wide screens the same tabs open as a side panel beside the terminal, toggled from the top bar, with
the bar's own keys repeated at the top of KEYS while the bar stands down.

The model, gesture arithmetic, echo reading and history are in DodoSSH.Client.Shell and covered by
TerminalKeyboardTests and OutputTailTests. Builds clean; not yet exercised on a device, because the
emulator's install is locked behind a passphrase that was not recorded.
This commit is contained in:
jaap-jan committed 2026-10-03 14:29:22 +02:00
1 parent c21de4ade4
commit 367cebae9b
22 files changed
+3945 -340

No files matched your search

@@ -1,10 +1,15 @@
using global::Android.OS;
using global::Android.Views;
using Avalonia.Threading;
using DodoSSH.Client.Shell.Keyboard;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
/// back.
/// Takes the keyboard away from the terminal's <c>WebView</c>, reports the taps that should bring it back,
/// and turns a long press and drag into arrow keys.
/// </summary>
/// <remarks>
/// <para>
@@ -36,6 +41,23 @@ namespace DodoSSH.Client.Android.Platform;
/// keyboard over the very output being scrolled to.
/// </para>
/// <para>
/// <b>A long press and a drag is the cursor gesture, and scrolling is still everything else.</b> There is no
/// mode to switch: a finger that moves straight away is a scroll, exactly as before, and the page gets every
/// event of it. A finger held still past <see cref="CursorGestureDelay"/> becomes a joystick for the arrow
/// keys — see <see cref="ArrowGesture"/> for the speeds — and from that moment the rest of the touch is this
/// class's: the <c>WebView</c> is sent a cancel, so it lets go of whatever it thought was starting, and is
/// not shown another event until the finger lifts. A haptic tick says the gesture has begun, because nothing
/// can be drawn over the terminal to say it instead.
/// </para>
/// <para>
/// The delay is a little short of the platform's long-press timeout, and that is the point of it. Chromium
/// starts its own long press — selection, and a <c>contextmenu</c> event the page answers with a paste — at
/// the platform timeout, so taking the touch away first is what keeps a held finger from pasting the
/// clipboard into a shell. Both timers run on the main looper and this one is posted first and shorter, so
/// it wins without a race. With the gesture switched off nothing is taken, and a long press does what it
/// did before.
/// </para>
/// <para>
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
/// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
@@ -52,17 +74,26 @@ internal static class RendererFocus
/// </remarks>
private static WeakReference<global::Android.Webkit.WebView>? configured;
/// <summary>How long a finger has to stay still for the cursor gesture, as a share of the long-press timeout.</summary>
private const double CursorGestureDelay = 0.8;
/// <summary>How often a held gesture is asked whether another arrow has come due — about one frame.</summary>
private const long TickMs = 16;
/// <summary>
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
/// taps it.
/// Makes the renderer unable to hold the keyboard, calls <paramref name="onTapped"/> when somebody taps
/// it, and <paramref name="onArrows"/> while somebody long-presses and drags it.
/// </summary>
/// <param name="onTapped">A tap: bring the keyboard back.</param>
/// <param name="gesturesEnabled">Asked at each press, so the setting applies from the next touch.</param>
/// <param name="onArrows">How many arrows to send, and which.</param>
/// <remarks>
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
/// <c>WebView</c> does not exist until the surface it lives on has been shown — and because Android is
/// free to tear its renderer down and build a new one, which is a case this head already handles
/// elsewhere (see <c>docs/android-port.md</c>).
/// </remarks>
public static void Suppress(Action onTapped)
public static void Suppress(Action onTapped, Func<bool> gesturesEnabled, Action<ArrowDirection, int> onArrows)
{
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
{
@@ -83,57 +114,246 @@ internal static class RendererFocus
webView.FocusableInTouchMode = false;
webView.ClearFocus();
WatchForTaps(webView, onTapped);
WatchTouches(webView, onTapped, gesturesEnabled, onArrows);
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
}
/// <summary>
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
/// Calls <paramref name="onTapped"/> for a tap, runs the cursor gesture for a long press, and stays out of
/// the way of everything else.
/// </summary>
/// <remarks>
/// A tap is a press and a release that did not travel further than the platform's own
/// <c>ScaledTouchSlop</c> — the same test every Android view uses, and necessary rather than fastidious:
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to.
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to. The
/// same slop is what cancels a pending long press, so a scroll can never turn into the gesture halfway.
/// </remarks>
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
private static void WatchTouches(
global::Android.Webkit.WebView webView,
Action onTapped,
Func<bool> gesturesEnabled,
Action<ArrowDirection, int> onArrows)
{
// The fallback is the platform's own historical default, for a context that has somehow gone.
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
var downX = 0f;
var downY = 0f;
var moved = false;
var touch = new TouchState(webView, gesturesEnabled, onArrows);
webView.Touch += (_, e) =>
{
// Observed, never consumed. The WebView's own OnTouchEvent still runs, so the page scrolls.
e.Handled = false;
if (e.Event is not { } motion)
{
e.Handled = false;
return;
}
switch (motion.Action)
// Observed and passed on, except while the cursor gesture holds the finger: then the page must not
// see it scroll.
e.Handled = touch.Handle(motion, onTapped);
};
}
/// <summary>One finger's worth of touch on the terminal, from press to release.</summary>
/// <remarks>
/// <para>
/// A class rather than locals captured by the handler, because two timers — the long press and the
/// repeat tick — read and write the same state from outside the touch callback.
/// </para>
/// <para>
/// Avalonia's <see cref="DispatcherTimer"/> rather than <c>View.PostDelayed</c>. On this head the
/// dispatcher is the main looper, so the timers land on the same thread and in the same queue as the
/// touches and as Chromium's own long press; and a dispatcher timer is a plain managed object, where a
/// posted <c>Runnable</c> is a Java peer that would want disposing for the life of a view that has none.
/// </para>
/// </remarks>
private sealed class TouchState
{
private readonly global::Android.Webkit.WebView webView;
private readonly Func<bool> gesturesEnabled;
private readonly Action<ArrowDirection, int> onArrows;
private readonly ArrowGesture gesture = new();
private readonly int slop;
private readonly float density;
private readonly DispatcherTimer longPress;
private readonly DispatcherTimer tick;
private float downX;
private float downY;
private float lastX;
private float lastY;
private long downTime;
private bool moved;
private bool steering;
private int lastSpeed;
internal TouchState(
global::Android.Webkit.WebView webView,
Func<bool> gesturesEnabled,
Action<ArrowDirection, int> onArrows)
{
this.webView = webView;
this.gesturesEnabled = gesturesEnabled;
this.onArrows = onArrows;
// The fallbacks are the platform's own historical defaults, for a context that has somehow gone.
slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
density = webView.Resources?.DisplayMetrics?.Density is > 0 and var d ? d : 1;
longPress = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(ViewConfiguration.LongPressTimeout * CursorGestureDelay),
};
longPress.Tick += (_, _) => BeginSteering();
tick = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(TickMs) };
tick.Tick += (_, _) => Tick();
}
/// <returns>Whether the event is consumed rather than passed on to the page.</returns>
internal bool Handle(MotionEvent motion, Action onTapped)
{
switch (motion.ActionMasked)
{
case MotionEventActions.Down:
downX = motion.GetX();
downY = motion.GetY();
moved = false;
Press(motion);
return false;
break;
case MotionEventActions.PointerDown:
// A second finger is a pinch or a two-finger scroll, never the cursor gesture.
longPress.Stop();
moved = true;
return steering;
case MotionEventActions.Move when steering:
Steer(motion.GetX(), motion.GetY());
return true;
case MotionEventActions.Move:
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
Travel(motion.GetX(), motion.GetY());
return false;
break;
case MotionEventActions.Up or MotionEventActions.Cancel:
return Release(motion.ActionMasked is MotionEventActions.Up, onTapped);
case MotionEventActions.Up when !moved:
default:
return steering;
}
}
private void Press(MotionEvent motion)
{
downX = lastX = motion.GetX();
downY = lastY = motion.GetY();
downTime = motion.DownTime;
moved = false;
steering = false;
longPress.Stop();
if (gesturesEnabled())
{
longPress.Start();
}
}
/// <summary>A finger moving before the long press: past the slop it is a scroll, and stays one.</summary>
private void Travel(float x, float y)
{
lastX = x;
lastY = y;
if (!moved && (Math.Abs(x - downX) > slop || Math.Abs(y - downY) > slop))
{
moved = true;
longPress.Stop();
}
}
/// <returns>Whether the release is consumed — it is, when it ends the cursor gesture.</returns>
private bool Release(bool lifted, Action onTapped)
{
longPress.Stop();
if (steering)
{
EndSteering();
return true;
}
if (lifted && !moved)
{
onTapped();
}
return false;
}
/// <summary>The finger has been held still long enough: take the touch and start steering.</summary>
private void BeginSteering()
{
longPress.Stop();
steering = true;
lastSpeed = 0;
gesture.Reset();
// The page has seen a press and nothing since; a cancel tells it that press is over, so neither
// its scroller nor its own long press goes on to do anything with it. Parents are asked not to
// intercept either — the gesture now owns the finger until it lifts.
var now = SystemClock.UptimeMillis();
using (var cancel = MotionEvent.Obtain(downTime, now, MotionEventActions.Cancel, lastX, lastY, MetaKeyStates.None))
{
if (cancel is not null)
{
webView.OnTouchEvent(cancel);
cancel.Recycle();
}
}
webView.Parent?.RequestDisallowInterceptTouchEvent(true);
webView.PerformHapticFeedback(FeedbackConstants.LongPress);
tick.Start();
}
private void Steer(float x, float y)
{
var count = gesture.Move((x - downX) / density, (y - downY) / density, SystemClock.UptimeMillis());
Emit(count);
}
/// <summary>The repeat clock: whatever has come due since the last frame.</summary>
private void Tick()
{
if (steering)
{
Emit(gesture.Advance(SystemClock.UptimeMillis()));
}
}
private void Emit(int count)
{
if (count > 0)
{
onArrows(gesture.Direction, count);
}
// A tick under the thumb each time the speed shifts, which is the only way to feel the three
// levels without looking: the terminal under the finger cannot draw anything to show them.
if (gesture.Speed > lastSpeed && lastSpeed > 0)
{
webView.PerformHapticFeedback(FeedbackConstants.ClockTick);
}
lastSpeed = gesture.Speed;
}
break;
private void EndSteering()
{
steering = false;
tick.Stop();
gesture.Reset();
}
};
}
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
@@ -647,15 +647,34 @@ internal sealed partial class PhoneShell : UserControl
}
/// <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>
/// <returns>Whether one was closed, and so whether back has been spent.</returns>
/// <remarks>
/// Both are raised from the terminal's bar and the scrim under each covers that bar, so at most one is
/// ever open; the order below decides nothing.
/// The three sheets are raised from the terminal's bar or its keys and the scrim under each covers both,
/// so at most one is ever open. The quick-access keyboard comes second because it is the one that can be
/// up with nothing over it — opening the customise sheet lowers it, so the two never compete either.
/// </remarks>
private static bool TryCloseATerminalSheet(MainWindowViewModel current)
{
// The customise sheet, raised from the quick-access keyboard. DONE is the only other way off it, and
// every change on it has already been saved, so back loses nothing.
if (current.Keyboard.IsCustomizing)
{
current.Keyboard.CloseCustomizeCommand.Execute(null);
return true;
}
// The quick-access keyboard stands where the software keyboard stands, and back puts the software
// keyboard away without leaving the terminal — so it does the same for this. Only on the terminal
// surface: the flag outlives a trip to another screen, where there is no panel to lower.
if (current.Keyboard.IsQuickAccessOpen && current.IsTerminalShowing)
{
current.Keyboard.CloseQuickAccess();
return true;
}
if (current.IsConnectSheetOpen)
{
current.CloseConnectSheetCommand.Execute(null);
@@ -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="&#xE5C3;"
AutomationProperties.Name="Keys"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.Keys}" />
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsSnippetsTab}" Content="&#xE86F;"
AutomationProperties.Name="Snippets"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.Snippets}" />
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsHistoryTab}" Content="&#xE889;"
AutomationProperties.Name="History"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.History}" />
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsAppearanceTab}" Content="&#xE40A;"
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="&#xE312;" 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="&#xE429;"
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. -->
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
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
@@ -304,11 +304,34 @@
<Button Grid.Column="3" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
Click="OnSheetRequested"
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="&#xF114;"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</Grid>
</Border>
<!-- ============ the renderer ============ -->
<Panel Grid.Row="1">
<!-- ============ the renderer, and the wide screen's side panel ============ -->
<!--
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 ============
@@ -551,60 +574,21 @@
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Panel>
<!-- ============ accessory keys ============ -->
<!--
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 and
then release, because holding a modifier while typing is not possible one-thumbed.
The quick-access keyboard as a side panel: additional keys, snippets, history and appearance in view
while the terminal is worked in, which is what a tablet's width is for. 320 wide, which at the 600dp
where this head turns wide leaves the terminal 280 — a narrow terminal, and a panel somebody can close
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. -->
<!--
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
<Border Grid.Column="1" Width="320" BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
IsVisible="{Binding $parent[views:TerminalScreen].ShowsSidePanel}">
<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 ============ -->
<!--
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>
</Border>
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
<StackPanel Spacing="4">
<!-- ============ the keys ============ -->
<!--
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
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
between them instead of taking what their labels happen to need — "ctrl" is four characters and
"|" is one, and on a row of keys those two want the same target, not proportional ones.
Focusable="False", for the reason every key carries it — see the remark on Button.hotkey's use in
QuickAccessKeyboard. A button here that took focus would take it off the terminal's input surface,
and the keyboard would go with it.
-->
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
<!--
The cursor keys, and the two text-size buttons riding at the end.
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
them every time the row's contents changed.
-->
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
<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 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="&#xE5C3;"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
<Button Classes="hotkey" Width="44" Focusable="False"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
</StackPanel>
<ItemsControl Grid.Column="1" ItemsSource="{Binding Keyboard.BarRows}">
<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>
</StackPanel>
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
Height="{Binding $parent[views:TerminalScreen].QuickAccessHeight}"
IsVisible="{Binding $parent[views:TerminalScreen].ShowsQuickAccessPanel}">
<views:QuickAccessKeyboard x:Name="BottomPanel" />
</Border>
</StackPanel>
</Grid>
@@ -866,6 +869,185 @@
</Border>
</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="&#xE5D8;" />
</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="&#xE5DB;" />
</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="&#xE5CD;"
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="&#xE5D8;" />
</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="&#xE5DB;" />
</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="&#xE872;"
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>
</UserControl>
@@ -1,17 +1,16 @@
using System.ComponentModel;
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Platform;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.Android.Platform;
using DodoSSH.Client.Shell.Keyboard;
using DodoSSH.Client.Shell.ViewModels;
// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
@@ -22,85 +21,45 @@ using Button = Avalonia.Controls.Button;
namespace DodoSSH.Client.Android.Views;
/// <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
{
/// <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>
/// <remarks>
/// <para>
/// Chosen by what a shell session actually needs rather than by what a keyboard has: Esc leaves vi's
/// insert mode, Tab completes a path, Ctrl makes <c>^C</c> reachable, the arrows reach history, and the
/// pipe and hyphen are two characters that are three taps deep on every Android keyboard and appear in
/// almost every command worth typing on a phone.
/// </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>
/// About what Gboard and Samsung's keyboard take in portrait. It is only a starting point: the first time
/// the software keyboard is up, its own height replaces this, so the bar above the panel does not move
/// when one is swapped for the other.
/// </remarks>
/// <summary>The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.</summary>
/// <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),
];
private const double DefaultQuickAccessHeight = 280;
/// <summary>The lower row: the cursor keys, which is where they became reachable.</summary>
/// <remarks>
/// <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),
];
public static readonly StyledProperty<bool> IsWideProperty =
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(IsWide));
/// <summary>
/// Whether the next ordinary key should be sent as a control character.
/// </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;
public static readonly StyledProperty<bool> ShowsKeysProperty =
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsKeys));
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;
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
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>
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
/// </summary>
@@ -115,10 +74,14 @@ internal sealed partial class TerminalScreen : UserControl
{
AvaloniaXamlLoader.Load(this);
BuildAccessoryRow();
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")!;
@@ -140,6 +103,7 @@ internal sealed partial class TerminalScreen : UserControl
{
previous.PropertyChanged -= OnShellPropertyChanged;
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
previous.Keyboard.PropertyChanged -= OnKeyboardPropertyChanged;
subscribed = null;
}
@@ -153,13 +117,250 @@ internal sealed partial class TerminalScreen : UserControl
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
// they look. See MainWindowViewModel.TerminalFocusRequested.
shell.TerminalFocusRequested += OnTerminalFocusRequested;
shell.Keyboard.PropertyChanged += OnKeyboardPropertyChanged;
subscribed = shell;
terminalWasShowing = shell.IsTerminalShowing;
}
RefreshKeys();
};
}
/// <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>
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
/// </summary>
@@ -171,6 +372,8 @@ internal sealed partial class TerminalScreen : UserControl
return;
}
RefreshKeys();
var showing = shell.IsTerminalShowing;
if (showing == terminalWasShowing)
@@ -207,7 +410,8 @@ internal sealed partial class TerminalScreen : UserControl
/// See <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.
/// </remarks>
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
private void AdoptRenderer() =>
RendererFocus.Suppress(OnRendererTapped, () => Keyboard?.CursorGestures == true, OnCursorGesture);
/// <summary>
/// Puts the keyboard away on the way to the connect menu or the forwards sheet.
@@ -260,129 +464,4 @@ internal sealed partial class TerminalScreen : UserControl
android.BuiltInZoomControls = false;
}
}
private void BuildAccessoryRow()
{
// ── nothing to hand back any more ──────────────────────────────────────────────────────────────
// This row used to counter both halves of every press with a posted TerminalFocus.Return(), because
// the platform requests Android's own focus for Avalonia's view after dispatching each handled
// touch, and the keyboard belonged to the WebView's input connection. Under ADR 0015 it belongs to
// TerminalInputSurface, which is an Avalonia control — so the focus the platform takes is the focus
// the keyboard already wants, and the counter-move is not merely unnecessary but wrong. The keys
// stay Focusable=false, which is now what keeps Avalonia's own focus on the input surface.
Fill(this.FindControl<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 &amp; 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));
}
+7 -1
View File
@@ -26,10 +26,15 @@ namespace DodoSSH.Client.Session;
/// <param name="SessionSidebarOpen">
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
/// </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(
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
bool AutomaticUpdateChecks = true,
bool SessionSidebarOpen = true)
bool SessionSidebarOpen = true,
TerminalKeyboardSettings? TerminalKeyboard = null)
{
/*
A positional record, and the defaults live on the parameters rather than on property initializers.
@@ -213,4 +218,5 @@ public sealed class ClientSettingsStore(ClientPaths paths)
WriteIndented = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(ClientSettings))]
[JsonSerializable(typeof(TerminalKeyboardSettings))]
internal sealed partial class ClientSettingsJsonContext : JsonSerializerContext;
@@ -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 &amp; 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>
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>
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
@@ -641,6 +646,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.workspace.RendererReattached += OnRendererReattached;
settings = new ClientSettingsStore(paths);
Keyboard = CreateKeyboard();
updateScreen = CreateUpdateScreen(updates);
@@ -651,6 +657,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
StartSessionShellTracking();
}
/// <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>
/// Takes this machine's own preferences off disk, before anything can read them.
/// </summary>
@@ -1500,6 +1534,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
IsTerminalSurface
&& !IsConnectSheetOpen
&& !IsForwardsSheetOpen
&& !Keyboard.IsCustomizing
&& !IsHostKeyDecisionShowing
&& SelectedTab is { HasSession: true };
@@ -3815,6 +3850,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
? null
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
// The commands typed on this phone go with the keychain, for the reason the recent connections below
// do: they are a record of where somebody works and what they ran there, and LOCK is the promise
// that nothing like that is left on screen. See TypedCommandHistory.
if (newValue is null)
{
Keyboard.ClearHistory();
}
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
// Emptied with the vault it was read out of. These rows are decrypted log entries — a host's name
@@ -4230,6 +4273,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
open.HostLabel, open.Address, open.StartedAt, deviceName)),
];
/// <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() =>
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; }
}
+157
View File
@@ -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>
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>
public long FramesSent { get; private set; }
@@ -353,6 +357,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
}
BytesRead += read;
Recent.Append(buffer.AsSpan(0, read));
// Copied out of the rented buffer, because the backlog holds it until the renderer has
// room and the next read is already about to overwrite this one.
@@ -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>
/// A live session's flow-control window, or null when the id names no session this workspace still has
/// open.
@@ -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();
}
}
@@ -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();
}
}