Stop the phone's terminal being a page you can pinch
ci / build and test (push) Successful in 1m3s
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 36s

The terminal on Android could be pinch-zoomed and double-tap zoomed, which a terminal must not
be: the grid is sized to the window by the fit addon and the column count is told to the remote,
so zooming makes the visible width disagree with what the far end is wrapping to, and puts the
cell under your thumb somewhere other than where you tapped.

Three things, and the first is the one that made it look worst. The page had no viewport meta tag
at all. An Android WebView with no viewport lays out at a notional 980 CSS pixels and scales the
result down to fit, so a terminal built to fill the window was drawn small and then offered as
something to zoom around in. width=device-width makes one CSS pixel one layout pixel, which is
what the fit addon has been assuming all along and what a desktop WebView gives without being
asked.

Second, the gestures. touch-action: pan-y keeps the only one a terminal wants — dragging the
scrollback — and refuses pinch-zoom and the double-tap zoom that fired on every attempt to place
a cursor. text-size-adjust stops Android's own font inflation, which resizes text it judges too
small without telling the page and leaves the characters no longer matching the grid that was
measured.

Third, the knob that actually disables zoom: BuiltInZoomControls on the WebView. user-scalable=no
is in the viewport tag for completeness and does nothing on its own — Blink has ignored it since
Chrome 48 and WebView follows Blink. That is worth knowing before somebody removes the C# and
trusts the meta tag.

The page and stylesheet are shared with the desktop head, deliberately, and none of it costs
anything there: a desktop WebView already lays out at device width, and a Windows touchscreen
should not be pinch-zooming a terminal either.

This does not settle whether the phone should keep a browser-based terminal at all, which is the
question actually asked. It does remove the reason it was asked, and every complaint about this
renderer so far has turned out to be configuration rather than the approach.

Verified by building the head in Debug and Release and by the desktop layout suite, which draws
the same assets. Not verified on a device — nothing in this head is; see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 22:45:11 +02:00
co-authored by Claude Opus 5
parent 52596aac76
commit 5cb9ffaf9d
3 changed files with 61 additions and 1 deletions
@@ -5,6 +5,7 @@ using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Platform;
using DodoSSH.Client.Shell.ViewModels;
@@ -70,16 +71,44 @@ internal sealed partial class TerminalScreen : UserControl
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)
{
this.FindControl<NativeWebView>("Renderer")!.Source = shell.TerminalPageUrl;
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")!;
@@ -23,6 +23,20 @@ body {
background: var(--dodo-background);
color: var(--dodo-foreground);
font-family: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
/*
Touch rules, and they only ever matter on the phone — a desktop WebView has no gestures to give
away. pan-y keeps the one gesture a terminal wants, dragging the scrollback, and refuses the two
it must not have: pinch-zoom, and the double-tap zoom that would otherwise fire on every attempt
to place the cursor.
text-size-adjust stops Android's own font inflation. It resizes text it judges too small without
telling the page, which in a terminal means characters that no longer match the cell grid the fit
addon measured — the rows stop lining up and the columns are wrong by a character or two.
*/
touch-action: pan-y;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
#root {
@@ -4,6 +4,23 @@
<meta charset="utf-8">
<title>DodoSSH terminal</title>
<!--
Load-bearing on a phone, and missing until a terminal was first pinch-zoomed on one. Without it an
Android WebView lays the page out at a notional 980 CSS pixels wide and scales the result down to
fit, so a terminal sized to fill the window renders small and is then offered to the user as
something to zoom around in. width=device-width makes one CSS pixel one layout pixel, which is what
the fit addon has been assuming all along, and what a desktop WebView gives for free.
viewport-fit=cover so the page reaches under a phone's rounded corners rather than being
letterboxed; the shell keeps its own chrome clear of the system insets already.
user-scalable=no is here for completeness and is NOT what stops the pinching — Blink has ignored it
since Chrome 48, WebView included. The knob that works is on the WebView; see TerminalScreen in the
Android head.
-->
<meta name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<!--
A restrictive CSP. The page is served over loopback from our own process, but it hosts a
terminal — arbitrary remote output is written into it — so the cheapest insurance against a