Rebuild the terminal's keypad, and fix what a device run showed
ci / build and test (push) Failing after 3m0s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m56s

Six defects, all found by running this head on an emulator against a local API rather than by reading it.
They are in one commit because two of the files carry several of them and splitting those would mean
hand-editing patches; each is argued at its own site.

── the keypad, which is most of this ───────────────────────────────────────────────────────────────────
It was eleven 30-by-44 keys in a horizontal ScrollViewer, and it was wrong twice over. A pan and a press
begin with the same few pixels of travel, so a thumb coming down at an angle — which is how a thumb comes
down — was read as the start of a pan and the press was never delivered; the row slid slightly instead.
And 30 tall is well under the 48 Android asks for, so the presses that did arrive were hard to aim. The
arrows, the keys pressed most and in sequence, were the ones parked past the right-hand edge.

Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it: they
divide the width evenly and add a second row when the set does not fit. That is not convention for its own
sake — a key whose position is fixed can be hit without looking, which is the whole point of a modifier row
used while reading the output above it.

So: two UniformGrid rows, every key an equal share of the width, nothing scrollable, 40 tall. Every key
kept. Arrows on the lower row where the thumb rests, reordered to ← ↓ ↑ → so the pairs that undo each other
are neighbours. It costs about 51 pixels of terminal, four or five lines, and that is the right trade: the
row that was cheaper did not work.

The keys also gain a pressed state, which they had none of. They are Focusable="False" so that using one
does not take the keyboard off the terminal, and a button that never takes focus never shows the adorner
Fluent would otherwise give it — leaving no feedback at all on a control whose only job is to be pressed.
Their looks move from assignments in the code-behind to Button.hotkey, which is what lets a style say
:pressed, and the Ctrl latch becomes a class rather than two brushes written by hand.

── and five smaller ones ────────────────────────────────────────────────────────────────────────────────
The keyboard's inset was never cleared when the app was resumed without the keyboard it was applied for.
Android takes the keyboard away as the activity stops and Avalonia's input pane raises no StateChanged,
the pane going with the window rather than closing in front of one — so the interface came back holding a
whole keyboard's height of empty canvas. 838 of 2340 pixels on a Pixel 5, on every screen, until something
opened and closed the keyboard. MainActivity now raises PhoneEnvironment.Resumed and PhoneShell re-asks the
pane. It asks rather than assuming zero: a permission prompt or the biometric sheet can hand the activity
back with the keyboard genuinely still up, and flattening the inset there would strand the focused box
under a real one.

TextBlock.detail never wrapped, where .body beside it always has. A block that neither wraps nor trims lays
out at its full single-line width and the parent clips it, with no ellipsis to say anything is missing — so
the add sheet read "A machine to connect to, filed under a group if you" and stopped. Which of the fifty-odd
.detail blocks overflows is a function of width and font scale rather than of the string, so this is a style
rather than an attribute on the handful visibly wrong on one phone. The nine that genuinely want one line
now say NoWrap beside their TextTrimming, which is the pairing that was always meant.

The two port boxes are NumericUpDowns and were the only unthemed controls on this head — Fluent's border and
fill, next to a TextBox.field sharing their row. App.axaml has styled NumericUpDown for the desktop all
along; this head never had one.

Three buttons classed "icon accent" were painting Fluent's own Button.accent, #6275AC, a blue in no palette
here. The desktop overrides that style and this head did not, so the same class read correctly there and not
here.

The host key sheet said "First contact with 10.0.2.2 · 2222 . Nothing in this keychain…" — Avalonia puts a
space between adjacent Runs and the last one began with the full stop. A gap before a full stop, on the one
dialogue whose whole job is to be read carefully before it is answered.

── checks ──────────────────────────────────────────────────────────────────────────────────────────────
manual-checks gains 10.4a and 11.10f. 10.4 already covered dismissing the keyboard with back or the
chevron but not leaving the app with it up, which is the path the inset bug shipped through; 10.4a covers
both directions, since a fix that flattened the inset on every resume would pass one half and fail the
other. 11.10f guards the keypad against a ScrollViewer being put back around it.
This commit is contained in:
jaap-jan committed 2026-08-26 13:28:59 +02:00
1 parent 91f7586b22
commit aff776cb42
13 files changed
+488 -108

No files matched your search

@@ -341,7 +341,7 @@
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
Text="{Binding HostLabel}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
TextTrimming="CharacterEllipsis" />
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="detail" FontSize="10"
VerticalAlignment="Center" Text="{Binding Started}" />
@@ -505,13 +505,50 @@
-->
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
<!--
33 tall rather than 50, a third off it like the bar at the top, with the keys inside coming down from
38 to 30 for the reason the pills did: a row that shrank around its own contents would clip them.
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
The five pixels above it are a gap and not a border. The renderer is a native child view, so nothing
Avalonia draws can sit on top of it — a hairline between the two 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.
What this replaced was one 33-pixel row holding eleven 30-by-44 keys inside a horizontal ScrollViewer,
and it was wrong in two ways that compounded each other.
── the scroller was eating the presses ──────────────────────────────────────────────────────────────
A horizontally scrollable strip and a row of buttons want opposite things from the same gesture. Every
tap starts as a press and a few pixels of travel, and a ScrollViewer that can pan claims exactly that:
a thumb coming down at an angle — which is how a thumb comes down — is read as the start of a pan, the
button never sees a click, and the row slides slightly instead. Pressing a key and having the keys
move is the single most confidence-destroying thing a keyboard can do, and it happened often enough
to be the first thing anybody noticed about this row.
── and the keys were too small to hit anyway ─────────────────────────────────────────────────────────
30 tall. Android's own minimum touch target is 48 and every accessory row in every terminal on the
platform is somewhere near it. The height had been taken down from 38 to save a strip of terminal,
which is a trade worth making right up until the control stops working, and 30 is past that.
── what the other clients do, because all of them agree ─────────────────────────────────────────────
Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it.
They divide the width evenly between a fixed set of keys and, where the set does not fit, they use a
second row — Termux ships two by default. That is not convention for its own sake: a key whose
position is fixed can be hit without looking, which is the entire point of a modifier row you use
while reading the output above it. A key that might have scrolled has to be found first.
So: two rows, every key stretched to an equal share of the width, none of it scrollable, and 40 tall
instead of 30. Nothing was dropped — all eleven keys are here, and the four that mattered most are the
ones that benefit, because the arrows were the keys living past the right edge.
── what it costs, stated rather than buried ─────────────────────────────────────────────────────────
About 51 pixels of terminal, which is four or five lines of output. That is a real price and it is the
right one: the row that was cheaper did not work, and a terminal with five more lines and no reachable
Ctrl is not the better surface.
── the split ────────────────────────────────────────────────────────────────────────────────────────
Modifiers and punctuation on top, cursor keys underneath. The arrows go on the lower row because they
are the keys pressed repeatedly and in sequence — history, then along the line — so they want the row
the thumb rests nearest, and there are only four of them, which leaves them the widest targets on the
whole keypad. The text-size buttons ride at that row's right-hand end, outside the flex.
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
so nothing Avalonia draws can sit on top of it — a hairline between the two 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.
-->
<!-- ============ what a terminal says once its shell has stopped ============ -->
<!--
@@ -562,62 +599,70 @@
</StackPanel>
</Border>
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
<Grid ColumnDefinitions="*,Auto">
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
VerticalAlignment="Center" />
</ScrollViewer>
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
<StackPanel Spacing="4">
<!--
The text-size buttons, pinned at this row's right-hand end rather than scrolling with the keys
beside them.
They used to have a row of their own above the terminal, on the argument that the accessory row
scrolls and these two must never be off-screen — a terminal too small to read is exactly the state
in which hunting for the control that fixes it is worst. That argument is answered rather than
abandoned: outside the ScrollViewer they cannot scroll away, and the row they had costs 36 pixels
on a surface this change exists to give back.
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
has no keyboard to press it with, which is why the two heads differ here and nowhere else in this
screen.
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
nothing reads as the terminal having stopped responding, which is the one thing this screen must
never look like.
◆ Focusable="False", for the reason the accessory keys carry in code — see
TerminalScreen.axaml.cs. Every button on this row sits over a terminal somebody is typing into,
and an ordinary one takes Avalonia's focus off the NativeWebView on tap, which makes the package
resign the page's focus. Making the text smaller must not cost the keyboard.
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
between them instead of taking what their labels happen to need — "ctrl" is four characters and
"|" is one, and on a row of keys those two want the same target, not proportional ones.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5" Margin="8,0,12,0"
VerticalAlignment="Center">
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
VerticalAlignment="Center" />
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" Focusable="False"
Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A−" />
</Button>
<!--
The cursor keys, and the two text-size buttons riding at the end.
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" Focusable="False"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
</StackPanel>
</Grid>
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
them every time the row's contents changed.
-->
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
<UniformGrid x:Name="ArrowKeys" Grid.Column="0" Columns="4" />
<!--
The text-size buttons.
They used to have a row of their own above the terminal, on the argument that the accessory row
scrolls and these two must never be off-screen — a terminal too small to read is exactly the
state in which hunting for the control that fixes it is worst. Nothing on this keypad scrolls
any more, so that argument is now satisfied by construction rather than by placement.
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
has no keyboard to press it with, which is why the two heads differ here and nowhere else in
this screen.
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
nothing reads as the terminal having stopped responding, which is the one thing this screen must
never look like.
◆ Focusable="False", for the reason the accessory keys carry in code — see
TerminalScreen.axaml.cs. Every button on this keypad sits over a terminal somebody is typing
into, and an ordinary one takes Avalonia's focus off the input surface on tap. Making the text
smaller must not cost the keyboard.
-->
<!-- No Spacing: Button.hotkey carries a 2-pixel side margin, so the gaps here match the
UniformGrid cells' without being counted twice. -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="8,0,0,0">
<Border Width="1" Height="20" Background="{StaticResource Border}" Margin="0,0,4,0"
VerticalAlignment="Center" />
<Button Classes="hotkey" Width="44" Focusable="False"
Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A−" />
</Button>
<Button Classes="hotkey" Width="44" Focusable="False"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
</StackPanel>
</Grid>
</StackPanel>
</Border>
</Grid>