Files
DodoSSH/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs
T
jaap-jan a2f0d4813a Take a third off both of the terminal's bars, and centre the cross in a tab
The bar the last commit put above a shell opened at 52 and the accessory row
under it at 50, both inherited from the arrangement they replaced rather than
measured against the one they are in. Neither is carrying a title or a sentence
any more — the top one holds two icons and a row of pills, the bottom one a
line of keys — so a third comes off each: 35 and 33.

Every height inside them came down too. The pills go 44 to 30, the icon squares
34 rather than 44, the keys 38 to 30. A bar that shrank around contents that
did not would not have saved anything; it would have moved the clipping
somewhere harder to see.

Two of those numbers had arguments written against them and both arguments
change rather than disappear. The pill was 44 because it contains the one
control on this head that is destructive with neither confirmation nor undo,
and that is now carried by width — the cross keeps its full 44-pixel column,
and what it gave up is vertical slack in a row where nothing sits above or
below it to be hit by mistake. The keys were 38 for the same kind of reason,
and the 44 that mattered there was always the width: ten keys flexed across
360dp is 32 pixels each, which is what the horizontal minimum exists to refuse.
Both comments say what replaced the reasoning rather than quietly showing a
smaller number.

The close cross was not vertically centred, and it was not a rounding error.
`Button.row` sets `HorizontalContentAlignment` and says nothing about the other
axis, so the glyph sat against the top of its own column while the label beside
it was centred by the stack panel it lives in. On the control that ends a
session that reads as a misprint. Both alignments are now stated, on the button
and on the text.

The `+` loses the accent and becomes the same `Button.icon` as the arrow across
from it. The two are a matched pair at either end of one bar — one leaves this
surface, one adds to it — and an accented one ranked itself above the way out.
The accent fill belongs to the floating `+` on HOSTS, which is the only action
on its screen; this one is not.

Five pixels between the renderer and the keys, as a margin rather than a
border. The renderer is a native child view and nothing Avalonia draws can sit
on top of it, so a hairline there would have to be a row of its own — and a
terminal whose last line of output is flush against a row of grey keys reads as
one surface that has gone wrong rather than as two that are different things.

The four places that named the old bar height are corrected, including manual
check 11.5, which asserted a number that would now fail.
2026-08-03 14:54:16 +02:00

218 lines
8.6 KiB
C#

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;
/// <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;
public TerminalScreen()
{
AvaloniaXamlLoader.Load(this);
BuildAccessoryRow();
var renderer = this.FindControl<NativeWebView>("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;
}
};
}
/// <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")!;
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");
}
/// <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 &amp; 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));
}