Public Access
Typing into a shell on the phone has been the worst thing about this head, and
the reason is not the renderer. It is that the keyboard over the terminal was
configured by Chromium rather than by us: a prose keyboard, with a suggestion
strip, autocorrect, autocapitalisation and a predictive layer that inserts text
ahead of the cursor — over a surface where every character is a command.
The obvious fix does not exist. xterm.js already sets every hint the web
platform has on its helper textarea — autocorrect, autocapitalize and
spellcheck are all off in the vendored bundle — and Android ignores all three,
because they are advisory. What actually silences a keyboard on this platform
is an inputType carrying TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, which is what
Termux's own TerminalView sets and the reason typing in Termux feels like a
terminal. A page inside a WebView cannot reach that field: Chromium derives the
whole EditorInfo from the focused DOM node, and no attribute produces the
visible-password variation.
Avalonia can reach it, on the version already pinned here. Decompiled from
Avalonia.Android 12.1.1, AndroidInputMethod.OnCreateInputConnection:
if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
0x80080 is VISIBLE_PASSWORD | NO_SUGGESTIONS — Termux's pair exactly. So the
keyboard behaviour wanted is one attached property away from any Avalonia
control, and permanently out of reach of the WebView. That asymmetry is the
whole decision; ADR 0015 records it, along with the two stages deliberately not
taken and what would falsify the premise.
◆ THE INPUT PATH LEAVES THE WEBVIEW. THE RENDERER DOES NOT MOVE.
TerminalInputSurface is a 1x1 focusable control that draws nothing and is where
every character typed into a shell now arrives. It is not stretched over the
renderer, which would be the obvious shape and is wrong three times over: the
WebView composites above everything Avalonia paints so it would be invisible,
it would never see the touches the WebView consumes first, and it would sit
over the connect page sharing that Panel whenever no session is open. Focus
does not need area.
Its IME client retains nothing and claims nothing — no surrounding text, no
preedit — because a terminal has no document for an input method to edit. The
line being edited is on the far side of an SSH channel and this client has
never been told what is on it.
◆ THE FOCUS FIGHT ENDS BY REMOVING A CONTESTANT.
TerminalFocus is deleted rather than amended. It existed to hand Android's own
view focus back to the WebView after AvaloniaView.DispatchTouchEvent took it at
both ends of every touch, and it was correct for as long as the input
connection belonged to the page. It no longer does, so the focus the platform
hands to Avalonia's view is now the focus the keyboard wants. The accessory
row's two posted counter-moves go with it; the keys stay Focusable=false, which
is now what keeps Avalonia's focus on the input surface.
RendererFocus replaces it with the opposite move: the WebView is made
non-focusable, so it cannot take an input connection and Chromium's own
showSoftInput cannot succeed from it. Non-focusable is not non-interactive —
focusability governs keyboard input, not touch delivery — so the page keeps its
pan-y scrollback untouched. What it can no longer do is raise a keyboard, so
taps on it are observed with Handled=false throughout and reported, filtered by
the platform's own ScaledTouchSlop. Without that filter, dragging the
scrollback would raise the keyboard over the output being scrolled to.
◆ TWO THINGS FOUND ON THE WAY, ONE OF THEM OLD.
The Ctrl latch never worked with the software keyboard. Not a regression and
not new — everything typed went straight into the page and never passed
SendAsync, which is where the latch lives, so `ctrl` then `c` was inert unless
the second key also came from the accessory row. Routing input through Avalonia
fixes it as a side effect, and check 11.10d exists to keep it fixed.
Avalonia raises a text-input event for any key whose UnicodeChar is 32 or
above, whether or not the key event was handled — it reads the handled flag
only to fold into its own return value. Ctrl-C on a hardware keyboard therefore
produced 0x03 and then a literal c. controlByteAlreadySent swallows the second.
The keys in TryTranslate need no such guard, their UnicodeChars being below 32.
One deliberate loss, in the open: Delete sends backspace. Avalonia turns an
IME's deleteSurroundingText into a dispatched KEYCODE_FORWARD_DEL, so a soft
keyboard taking that route is indistinguishable here from a real Delete key —
and a backspace that deletes forwards on every phone is worse than a missing
forward-delete on the few devices that have one.
Nothing here is verifiable without a device, so the checks are written rather
than the tests: 11.10b is the falsification test and should be run first,
because everything staged on this stands or falls on whether one attached
property actually silences a real keyboard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
367 lines
16 KiB
C#
367 lines
16 KiB
C#
using System.ComponentModel;
|
|
using System.Text;
|
|
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Layout;
|
|
using Avalonia.Markup.Xaml;
|
|
using Avalonia.Media;
|
|
using Avalonia.Platform;
|
|
using Avalonia.Threading;
|
|
|
|
using DodoSSH.Client.Android.Platform;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
|
|
// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
|
|
// implicit usings, so the bare name is ambiguous — the same collision the namespace itself causes
|
|
// for Android.App. See the note at the top of MainActivity.
|
|
using Application = Avalonia.Application;
|
|
using Button = Avalonia.Controls.Button;
|
|
|
|
namespace DodoSSH.Client.Android.Views;
|
|
|
|
/// <summary>Design 03 — the terminal, and the accessory key row under it.</summary>
|
|
internal sealed partial class TerminalScreen : UserControl
|
|
{
|
|
/// <summary>
|
|
/// The keys a software keyboard does not have.
|
|
/// </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>
|
|
/// </remarks>
|
|
private static readonly (string Label, byte[] Bytes, bool Latches)[] Keys =
|
|
[
|
|
("esc", [0x1B], false),
|
|
("tab", [0x09], false),
|
|
("ctrl", [], true),
|
|
("|", [(byte)'|'], false),
|
|
("-", [(byte)'-'], false),
|
|
("~", [(byte)'~'], false),
|
|
("/", [(byte)'/'], false),
|
|
|
|
// The cursor keys, as 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.
|
|
("↑", [0x1B, (byte)'[', (byte)'A'], false),
|
|
("↓", [0x1B, (byte)'[', (byte)'B'], false),
|
|
("←", [0x1B, (byte)'[', (byte)'D'], false),
|
|
("→", [0x1B, (byte)'[', (byte)'C'], false),
|
|
];
|
|
|
|
/// <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;
|
|
|
|
private Button? controlKey;
|
|
|
|
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>
|
|
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The keyboard is raised on the transition into the terminal and not on every notification of it.
|
|
/// <c>IsTerminalShowing</c> is a computed property re-raised by several other setters, so acting on the
|
|
/// notification rather than the edge would call <c>ShowSoftInput</c> repeatedly for one arrival.
|
|
/// </remarks>
|
|
private bool terminalWasShowing;
|
|
|
|
public TerminalScreen()
|
|
{
|
|
AvaloniaXamlLoader.Load(this);
|
|
|
|
BuildAccessoryRow();
|
|
|
|
input = this.FindControl<TerminalInputSurface>("Input")!;
|
|
input.Input += (_, e) => SendAsync(e.Bytes);
|
|
|
|
var renderer = this.FindControl<NativeWebView>("Renderer")!;
|
|
|
|
renderer.EnvironmentRequested += (sender, e) =>
|
|
{
|
|
OnRendererEnvironmentRequested(sender, e);
|
|
|
|
// The precise moment the platform WebView is being built, which is the earliest one at which
|
|
// there is a native view to take the keyboard away from — and the one that fires again if
|
|
// Android reclaims the renderer and the page comes back. Posted rather than called, because the
|
|
// child is not in the hierarchy yet while its environment is still being asked for.
|
|
Dispatcher.UIThread.Post(AdoptRenderer, DispatcherPriority.Loaded);
|
|
};
|
|
|
|
// The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
|
|
DataContextChanged += (_, _) =>
|
|
{
|
|
if (subscribed is { } previous)
|
|
{
|
|
previous.PropertyChanged -= OnShellPropertyChanged;
|
|
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
|
subscribed = null;
|
|
}
|
|
|
|
if (DataContext is MainWindowViewModel shell)
|
|
{
|
|
renderer.Source = shell.TerminalPageUrl;
|
|
|
|
shell.PropertyChanged += OnShellPropertyChanged;
|
|
|
|
// The snippets screen's "TYPE INTO {tab}" lands here as well as on the desktop. It types at
|
|
// 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;
|
|
subscribed = shell;
|
|
|
|
terminalWasShowing = shell.IsTerminalShowing;
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
|
|
/// </summary>
|
|
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName is not nameof(MainWindowViewModel.IsTerminalShowing) ||
|
|
sender is not MainWindowViewModel shell)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var showing = shell.IsTerminalShowing;
|
|
|
|
if (showing == terminalWasShowing)
|
|
{
|
|
return;
|
|
}
|
|
|
|
terminalWasShowing = showing;
|
|
|
|
if (!showing)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Posted to Loaded so the renderer's own native child, which becomes visible on this same change,
|
|
// exists by the time AdoptRenderer goes looking for it. Idempotent with the EnvironmentRequested
|
|
// path above: whichever runs first configures the view and the other finds it already done.
|
|
Dispatcher.UIThread.Post(
|
|
() =>
|
|
{
|
|
AdoptRenderer();
|
|
input.Acquire();
|
|
},
|
|
DispatcherPriority.Loaded);
|
|
}
|
|
|
|
private void OnTerminalFocusRequested(object? sender, EventArgs e) =>
|
|
Dispatcher.UIThread.Post(input.Acquire, DispatcherPriority.Loaded);
|
|
|
|
/// <summary>
|
|
/// Takes the keyboard off the renderer and puts the taps on it to use.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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);
|
|
|
|
/// <summary>
|
|
/// Puts the keyboard away on the way to the connect menu.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A handler beside the button's command rather than something the command does, because the command is
|
|
/// on the shared view model and this is a platform call: the desktop head has no software keyboard to
|
|
/// put away, and a shell view model that knew about Android's input-method manager would be the first
|
|
/// thing in that layer that did.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The keyboard is up in the ordinary case.</b> This button sits above a terminal somebody is typing
|
|
/// into, so the sheet it raises would otherwise arrive underneath a keyboard covering half the display —
|
|
/// and the three rows on it are the bottom half. Worse, the keyboard's inset shortens everything the
|
|
/// phone draws (see <c>PhoneShell.ApplyKeyboardInset</c>), so the sheet would be laid out into the
|
|
/// remaining strip rather than merely hidden behind it.
|
|
/// </para>
|
|
/// <para>
|
|
/// Click and Command both fire for one press and their order is not something to rely on. Neither reads
|
|
/// what the other writes: this one talks to the platform and the command sets a flag, so they commute.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void OnConnectMenuRequested(object? sender, RoutedEventArgs e) => SoftKeyboard.Hide();
|
|
|
|
/// <summary>
|
|
/// Takes the browser gestures back off a surface that is not a web page.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A terminal is a fixed grid that the fit addon sizes to the window. Pinch-zoom breaks that in both
|
|
/// directions at once: the visible width stops matching the column count the remote was told about, so
|
|
/// wrapping goes wrong, and the part of the grid under the thumb is no longer the part that gets the
|
|
/// tap. It is the WebView's own zoom rather than anything the page asked for.
|
|
/// </para>
|
|
/// <para>
|
|
/// This is the knob that works. <c>user-scalable=no</c> in the page's viewport tag does not: Blink has
|
|
/// ignored it since Chrome 48 for accessibility reasons and WebView follows Blink. The page still
|
|
/// carries the viewport tag, for the layout width rather than the zoom — see WebAssets/terminal.html.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static void OnRendererEnvironmentRequested(object? sender, EventArgs e)
|
|
{
|
|
if (e is AndroidWebViewEnvironmentRequestedEventArgs android)
|
|
{
|
|
android.BuiltInZoomControls = false;
|
|
}
|
|
}
|
|
|
|
private void BuildAccessoryRow()
|
|
{
|
|
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
|
|
|
// ── 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.
|
|
|
|
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 = new TextBlock
|
|
{
|
|
Text = label,
|
|
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
|
|
FontSize = 11,
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
},
|
|
|
|
// 44 wide, and that is the number that matters: the design draws these flexed across the
|
|
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
|
|
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
|
|
// costs nothing a width does: the keys are a single row with the terminal above and the
|
|
// system's gesture bar below, so there is no neighbour a short press can land on instead.
|
|
MinWidth = 44,
|
|
Height = 30,
|
|
Padding = new Thickness(10, 0),
|
|
CornerRadius = new CornerRadius(9),
|
|
Background = Palette("Panel"),
|
|
BorderBrush = Palette("BorderMid"),
|
|
BorderThickness = new Thickness(1),
|
|
Foreground = Palette("TextDim"),
|
|
HorizontalContentAlignment = HorizontalAlignment.Center,
|
|
|
|
// ◆ 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.
|
|
controlKey.Background = Palette(controlLatched ? "Active" : "Panel");
|
|
controlKey.Foreground = Palette(controlLatched ? "AccentText" : "TextDim");
|
|
}
|
|
|
|
/// <summary>One brush from <c>Theme/Palette.axaml</c>, by key.</summary>
|
|
/// <remarks>
|
|
/// The accessory row is built in code because its keys come from a table, so its colours cannot be set in
|
|
/// XAML with the rest of the screen's. Resolving them by name is the next best thing: a row that named
|
|
/// its own blues is how the palette ends up with a fifth surface nobody meant to add.
|
|
/// </remarks>
|
|
private static IBrush? Palette(string key) => Application.Current?.FindResource(key) as IBrush;
|
|
|
|
/// <remarks>
|
|
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
|
|
/// 0x1A, which is letter & 0x1F. Applied only to letters, because Ctrl with an arrow key is a
|
|
/// different sequence entirely and silently mangling one into a control byte would be worse than
|
|
/// ignoring the latch.
|
|
/// </remarks>
|
|
private void SendAsync(byte[] bytes)
|
|
{
|
|
if (DataContext is not MainWindowViewModel { SelectedTab: { } tab } shell)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var payload = bytes;
|
|
|
|
if (controlLatched && bytes.Length == 1)
|
|
{
|
|
var c = bytes[0];
|
|
|
|
if (c is >= (byte)'a' and <= (byte)'z' or >= (byte)'A' and <= (byte)'Z')
|
|
{
|
|
payload = [(byte)(c & 0x1F)];
|
|
}
|
|
|
|
ToggleControl();
|
|
}
|
|
|
|
// Discarded rather than awaited: this is a keystroke, the workspace ignores a session that has
|
|
// gone, and a button handler that awaited would serialise the row behind a slow link.
|
|
_ = shell.SendTerminalInputAsync(tab.SessionId, payload).AsTask();
|
|
}
|
|
|
|
/// <summary>Sends a literal string, for the keys that carry text rather than a control code.</summary>
|
|
/// <remarks>Kept because the snippet feature will type into a terminal exactly this way.</remarks>
|
|
internal void Send(string text) => SendAsync(Encoding.UTF8.GetBytes(text));
|
|
}
|