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)); }