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
+3949 -344

No files matched your search

@@ -1,10 +1,15 @@
using global::Android.OS;
using global::Android.Views;
using Avalonia.Threading;
using DodoSSH.Client.Shell.Keyboard;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
/// back.
/// Takes the keyboard away from the terminal's <c>WebView</c>, reports the taps that should bring it back,
/// and turns a long press and drag into arrow keys.
/// </summary>
/// <remarks>
/// <para>
@@ -36,6 +41,23 @@ namespace DodoSSH.Client.Android.Platform;
/// keyboard over the very output being scrolled to.
/// </para>
/// <para>
/// <b>A long press and a drag is the cursor gesture, and scrolling is still everything else.</b> There is no
/// mode to switch: a finger that moves straight away is a scroll, exactly as before, and the page gets every
/// event of it. A finger held still past <see cref="CursorGestureDelay"/> becomes a joystick for the arrow
/// keys — see <see cref="ArrowGesture"/> for the speeds — and from that moment the rest of the touch is this
/// class's: the <c>WebView</c> is sent a cancel, so it lets go of whatever it thought was starting, and is
/// not shown another event until the finger lifts. A haptic tick says the gesture has begun, because nothing
/// can be drawn over the terminal to say it instead.
/// </para>
/// <para>
/// The delay is a little short of the platform's long-press timeout, and that is the point of it. Chromium
/// starts its own long press — selection, and a <c>contextmenu</c> event the page answers with a paste — at
/// the platform timeout, so taking the touch away first is what keeps a held finger from pasting the
/// clipboard into a shell. Both timers run on the main looper and this one is posted first and shorter, so
/// it wins without a race. With the gesture switched off nothing is taken, and a long press does what it
/// did before.
/// </para>
/// <para>
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
/// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
@@ -52,17 +74,26 @@ internal static class RendererFocus
/// </remarks>
private static WeakReference<global::Android.Webkit.WebView>? configured;
/// <summary>How long a finger has to stay still for the cursor gesture, as a share of the long-press timeout.</summary>
private const double CursorGestureDelay = 0.8;
/// <summary>How often a held gesture is asked whether another arrow has come due — about one frame.</summary>
private const long TickMs = 16;
/// <summary>
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
/// taps it.
/// Makes the renderer unable to hold the keyboard, calls <paramref name="onTapped"/> when somebody taps
/// it, and <paramref name="onArrows"/> while somebody long-presses and drags it.
/// </summary>
/// <param name="onTapped">A tap: bring the keyboard back.</param>
/// <param name="gesturesEnabled">Asked at each press, so the setting applies from the next touch.</param>
/// <param name="onArrows">How many arrows to send, and which.</param>
/// <remarks>
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
/// <c>WebView</c> does not exist until the surface it lives on has been shown — and because Android is
/// free to tear its renderer down and build a new one, which is a case this head already handles
/// elsewhere (see <c>docs/android-port.md</c>).
/// </remarks>
public static void Suppress(Action onTapped)
public static void Suppress(Action onTapped, Func<bool> gesturesEnabled, Action<ArrowDirection, int> onArrows)
{
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
{
@@ -83,57 +114,246 @@ internal static class RendererFocus
webView.FocusableInTouchMode = false;
webView.ClearFocus();
WatchForTaps(webView, onTapped);
WatchTouches(webView, onTapped, gesturesEnabled, onArrows);
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
}
/// <summary>
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
/// Calls <paramref name="onTapped"/> for a tap, runs the cursor gesture for a long press, and stays out of
/// the way of everything else.
/// </summary>
/// <remarks>
/// A tap is a press and a release that did not travel further than the platform's own
/// <c>ScaledTouchSlop</c> — the same test every Android view uses, and necessary rather than fastidious:
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to.
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to. The
/// same slop is what cancels a pending long press, so a scroll can never turn into the gesture halfway.
/// </remarks>
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
private static void WatchTouches(
global::Android.Webkit.WebView webView,
Action onTapped,
Func<bool> gesturesEnabled,
Action<ArrowDirection, int> onArrows)
{
// The fallback is the platform's own historical default, for a context that has somehow gone.
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
var downX = 0f;
var downY = 0f;
var moved = false;
var touch = new TouchState(webView, gesturesEnabled, onArrows);
webView.Touch += (_, e) =>
{
// Observed, never consumed. The WebView's own OnTouchEvent still runs, so the page scrolls.
e.Handled = false;
if (e.Event is not { } motion)
{
e.Handled = false;
return;
}
switch (motion.Action)
// Observed and passed on, except while the cursor gesture holds the finger: then the page must not
// see it scroll.
e.Handled = touch.Handle(motion, onTapped);
};
}
/// <summary>One finger's worth of touch on the terminal, from press to release.</summary>
/// <remarks>
/// <para>
/// A class rather than locals captured by the handler, because two timers — the long press and the
/// repeat tick — read and write the same state from outside the touch callback.
/// </para>
/// <para>
/// Avalonia's <see cref="DispatcherTimer"/> rather than <c>View.PostDelayed</c>. On this head the
/// dispatcher is the main looper, so the timers land on the same thread and in the same queue as the
/// touches and as Chromium's own long press; and a dispatcher timer is a plain managed object, where a
/// posted <c>Runnable</c> is a Java peer that would want disposing for the life of a view that has none.
/// </para>
/// </remarks>
private sealed class TouchState
{
private readonly global::Android.Webkit.WebView webView;
private readonly Func<bool> gesturesEnabled;
private readonly Action<ArrowDirection, int> onArrows;
private readonly ArrowGesture gesture = new();
private readonly int slop;
private readonly float density;
private readonly DispatcherTimer longPress;
private readonly DispatcherTimer tick;
private float downX;
private float downY;
private float lastX;
private float lastY;
private long downTime;
private bool moved;
private bool steering;
private int lastSpeed;
internal TouchState(
global::Android.Webkit.WebView webView,
Func<bool> gesturesEnabled,
Action<ArrowDirection, int> onArrows)
{
this.webView = webView;
this.gesturesEnabled = gesturesEnabled;
this.onArrows = onArrows;
// The fallbacks are the platform's own historical defaults, for a context that has somehow gone.
slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
density = webView.Resources?.DisplayMetrics?.Density is > 0 and var d ? d : 1;
longPress = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(ViewConfiguration.LongPressTimeout * CursorGestureDelay),
};
longPress.Tick += (_, _) => BeginSteering();
tick = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(TickMs) };
tick.Tick += (_, _) => Tick();
}
/// <returns>Whether the event is consumed rather than passed on to the page.</returns>
internal bool Handle(MotionEvent motion, Action onTapped)
{
switch (motion.ActionMasked)
{
case MotionEventActions.Down:
downX = motion.GetX();
downY = motion.GetY();
moved = false;
Press(motion);
return false;
break;
case MotionEventActions.PointerDown:
// A second finger is a pinch or a two-finger scroll, never the cursor gesture.
longPress.Stop();
moved = true;
return steering;
case MotionEventActions.Move when steering:
Steer(motion.GetX(), motion.GetY());
return true;
case MotionEventActions.Move:
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
Travel(motion.GetX(), motion.GetY());
return false;
break;
case MotionEventActions.Up or MotionEventActions.Cancel:
return Release(motion.ActionMasked is MotionEventActions.Up, onTapped);
case MotionEventActions.Up when !moved:
onTapped();
break;
default:
return steering;
}
};
}
private void Press(MotionEvent motion)
{
downX = lastX = motion.GetX();
downY = lastY = motion.GetY();
downTime = motion.DownTime;
moved = false;
steering = false;
longPress.Stop();
if (gesturesEnabled())
{
longPress.Start();
}
}
/// <summary>A finger moving before the long press: past the slop it is a scroll, and stays one.</summary>
private void Travel(float x, float y)
{
lastX = x;
lastY = y;
if (!moved && (Math.Abs(x - downX) > slop || Math.Abs(y - downY) > slop))
{
moved = true;
longPress.Stop();
}
}
/// <returns>Whether the release is consumed — it is, when it ends the cursor gesture.</returns>
private bool Release(bool lifted, Action onTapped)
{
longPress.Stop();
if (steering)
{
EndSteering();
return true;
}
if (lifted && !moved)
{
onTapped();
}
return false;
}
/// <summary>The finger has been held still long enough: take the touch and start steering.</summary>
private void BeginSteering()
{
longPress.Stop();
steering = true;
lastSpeed = 0;
gesture.Reset();
// The page has seen a press and nothing since; a cancel tells it that press is over, so neither
// its scroller nor its own long press goes on to do anything with it. Parents are asked not to
// intercept either — the gesture now owns the finger until it lifts.
var now = SystemClock.UptimeMillis();
using (var cancel = MotionEvent.Obtain(downTime, now, MotionEventActions.Cancel, lastX, lastY, MetaKeyStates.None))
{
if (cancel is not null)
{
webView.OnTouchEvent(cancel);
cancel.Recycle();
}
}
webView.Parent?.RequestDisallowInterceptTouchEvent(true);
webView.PerformHapticFeedback(FeedbackConstants.LongPress);
tick.Start();
}
private void Steer(float x, float y)
{
var count = gesture.Move((x - downX) / density, (y - downY) / density, SystemClock.UptimeMillis());
Emit(count);
}
/// <summary>The repeat clock: whatever has come due since the last frame.</summary>
private void Tick()
{
if (steering)
{
Emit(gesture.Advance(SystemClock.UptimeMillis()));
}
}
private void Emit(int count)
{
if (count > 0)
{
onArrows(gesture.Direction, count);
}
// A tick under the thumb each time the speed shifts, which is the only way to feel the three
// levels without looking: the terminal under the finger cannot draw anything to show them.
if (gesture.Speed > lastSpeed && lastSpeed > 0)
{
webView.PerformHapticFeedback(FeedbackConstants.ClockTick);
}
lastSpeed = gesture.Speed;
}
private void EndSteering()
{
steering = false;
tick.Stop();
gesture.Reset();
}
}
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
@@ -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" />
<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>
<!--
The cursor keys, and the two text-size buttons riding at the end.
<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>
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>
<Button Classes="hotkey" Width="44" Focusable="False"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<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));
}