using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.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.
///
///
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),
];
///
/// 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;
public TerminalScreen()
{
AvaloniaXamlLoader.Load(this);
BuildAccessoryRow();
var renderer = this.FindControl("Renderer")!;
renderer.EnvironmentRequested += OnRendererEnvironmentRequested;
// The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
DataContextChanged += (_, _) =>
{
if (DataContext is MainWindowViewModel shell)
{
renderer.Source = shell.TerminalPageUrl;
}
};
}
///
/// 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()
{
var row = this.FindControl("AccessoryKeys")!;
foreach (var (label, bytes, latches) in Keys)
{
var key = new Button
{
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,
};
if (latches)
{
controlKey = key;
key.Click += (_, _) => ToggleControl();
}
else
{
key.Click += (_, _) => SendAsync(bytes);
}
row.Children.Add(key);
}
}
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");
}
/// One brush from Theme/Palette.axaml, by key.
///
/// 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.
///
private static IBrush? Palette(string key) => Application.Current?.FindResource(key) as IBrush;
///
/// 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));
}