using System.ComponentModel;
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
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;
/// Design 03 — the terminal, and the accessory key row under it.
internal sealed partial class TerminalScreen : UserControl
{
///
/// The keys a software keyboard does not have.
///
///
///
/// 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 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.
///
///
/// 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.
///
///
/// The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.
///
/// 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 Columns on the UniformGrid; the two numbers have to agree, and the row
/// simply gets narrower keys.
///
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),
];
/// The lower row: the cursor keys, which is where they became reachable.
///
///
/// 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.
///
///
/// ◆ Left, down, up, right — 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.
///
///
/// 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.
///
///
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),
];
///
/// Whether the next ordinary key should be sent as a control character.
///
///
/// 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.
///
private bool controlLatched;
private Button? controlKey;
private readonly TerminalInputSurface input;
/// The shell whose PropertyChanged this screen is subscribed to, so it can unsubscribe.
private MainWindowViewModel? subscribed;
///
/// What was last seen to be.
///
///
/// The keyboard is raised on the transition into the terminal and not on every notification of it.
/// IsTerminalShowing is a computed property re-raised by several other setters, so acting on the
/// notification rather than the edge would call ShowSoftInput repeatedly for one arrival.
///
private bool terminalWasShowing;
public TerminalScreen()
{
AvaloniaXamlLoader.Load(this);
BuildAccessoryRow();
input = this.FindControl("Input")!;
input.Input += (_, e) => SendAsync(e.Bytes);
var renderer = this.FindControl("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;
}
};
}
///
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
///
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);
///
/// Takes the keyboard off the renderer and puts the taps on it to use.
///
///
/// See for why a WebView that cannot hold focus still scrolls, and why
/// the tap has to be reported rather than left to the page.
///
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
///
/// Puts the keyboard away on the way to the connect menu or the forwards sheet.
///
///
///
/// One handler for both buttons in the terminal's bar that raise a sheet, since what follows is true of
/// either: each is a panel at the bottom of the screen, which is where the keyboard is.
///
///
/// 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.
///
///
/// The keyboard is up in the ordinary case. 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 rows on it are the bottom half. Worse, the keyboard's inset shortens everything the
/// phone draws (see PhoneShell.ApplyKeyboardInset), so the sheet would be laid out into the
/// remaining strip rather than merely hidden behind it.
///
///
/// 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.
///
///
private void OnSheetRequested(object? sender, RoutedEventArgs e) => SoftKeyboard.Hide();
///
/// Takes the browser gestures back off a surface that is not a web page.
///
///
///
/// 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.
///
///
/// This is the knob that works. user-scalable=no 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.
///
///
private static void OnRendererEnvironmentRequested(object? sender, EventArgs e)
{
if (e is AndroidWebViewEnvironmentRequestedEventArgs android)
{
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("AccessoryKeys")!, ModifierKeys);
Fill(this.FindControl("ArrowKeys")!, CursorKeys);
}
/// Puts one table's worth of keys into one row of the keypad.
///
/// 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.
///
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);
}
}
/// One key of the accessory row, before its click is wired.
/// Split from for length rather than for reuse.
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);
}
///
/// 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.
///
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();
}
/// Sends a literal string, for the keys that carry text rather than a control code.
/// Kept because the snippet feature will type into a terminal exactly this way.
internal void Send(string text) => SendAsync(Encoding.UTF8.GetBytes(text));
}