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