Give the desktop a macOS head, signed from the first release #3

Merged
jaap-jan merged 2 commits from claude/macos-build-release-2a8a0d into main 2026-08-10 08:59:00 +00:00
6 changed files with 153 additions and 34 deletions
Showing only changes of commit ca081af209 - Show all commits
+9 -6
View File
@@ -1750,15 +1750,18 @@ tap of an arrow key.
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
keep typing on the software keyboard. keep typing on the software keyboard.
**Pass:** the keyboard does not change — not its layout, not its suggestion strip, not its height — and **Pass:** the keyboard settles back unchangedsame layout, same suggestion strip, same height — and
everything typed after the tap still reaches the terminal. The accessory row stays visible above the everything typed after the tap still reaches the terminal. The accessory row stays visible above the
keyboard throughout. keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being **Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
handed back. This is the half `Focusable = false` cannot reach — the platform moves its focus on the touch handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
itself, before Avalonia decides anything — and the symptom chain is the keyboard swapping to its no-input view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
layout and the inset churn parking it over the very row that was tapped. See layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
`TerminalFocus` in the Android head's Platform folder. timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
### 11.11 Closing a connection and opening a new one both take you somewhere real ### 11.11 Closing a connection and opening a new one both take you somewhere real
@@ -10,17 +10,24 @@ namespace DodoSSH.Client.Android.Platform;
/// <b>The sibling of <see cref="SoftKeyboard"/>, and it exists for the same reason that one does:</b> the /// <b>The sibling of <see cref="SoftKeyboard"/>, and it exists for the same reason that one does:</b> the
/// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does /// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does
/// not own. The accessory row's keys are already <c>Focusable=false</c> — see TerminalScreen — so Avalonia's /// not own. The accessory row's keys are already <c>Focusable=false</c> — see TerminalScreen — so Avalonia's
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>: the /// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>:
/// tap lands on Avalonia's own input view, which is focusable-in-touch-mode because Avalonia's text boxes /// <c>AvaloniaView.DispatchTouchEvent</c> (decompiled from Avalonia.Android 12.1.1) ends every handled
/// need it to be, and the platform hands that view focus on the way to delivering the touch. The WebView's /// touch — DOWN and UP alike — with a <c>RequestFocus()</c> for Avalonia's own view. The WebView's input
/// input connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no /// connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no text,
/// text, and the inset churn that follows can leave it sitting on top of the very row that was tapped. /// and the inset churn that follows can leave it sitting on top of the very row that was tapped.
/// </para> /// </para>
/// <para> /// <para>
/// So each accessory key hands focus straight back once its byte is on the wire. The page inside the /// <b>Posted, not called — the posting is the fix's second attempt, and the first one's failure is why.</b>
/// WebView never noticed anything — its own DOM focus never moved — so regaining native focus re-establishes /// The first version called <c>RequestFocus()</c> from the keys' own Click handlers, which fire
/// the same input connection and the keyboard settles back to what it was. Skipped when the WebView is still /// <em>inside</em> the UP event's dispatch — and the platform's own request runs <em>after</em> dispatch
/// focused, which makes the call free on any platform arrangement where the steal never happened. /// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable
/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that
/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is
/// about to be made stale by the very mechanism this exists to counter.
/// </para>
/// <para>
/// The page inside the WebView never noticed any of this — its own DOM focus never moved — so regaining
/// native focus re-establishes the same input connection and the keyboard settles back to what it was.
/// </para> /// </para>
/// <para> /// <para>
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, because the /// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, because the
@@ -39,10 +46,18 @@ internal static class TerminalFocus
return; return;
} }
if (FindWebView(decor) is { IsFocused: false } webView) if (FindWebView(decor) is not { } webView)
{ {
webView.RequestFocus(); return;
} }
webView.Post(() =>
{
if (!webView.IsFocused)
{
webView.RequestFocus();
}
});
} }
private static View? FindWebView(ViewGroup parent) private static View? FindWebView(ViewGroup parent)
+17 -1
View File
@@ -116,11 +116,27 @@
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
</Style> </Style>
<!-- A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse. --> <!--
A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse.
◆ VerticalContentAlignment, because that height is the whole point of this class and Avalonia's default
for content alignment is Stretch — so the content presenter stretched the caption to the full row and a
TextBlock draws its line at the TOP of what it is given. Most rows here never showed it, having a
StackPanel or a Grid of already-centred children in them, which is what made the four that did look
like four unrelated mistakes: the breadcrumb chips and the up-one-directory button on FilesScreen, and
TerminalScreen's CLOSE THIS TAB, each a bare TextBlock in a row 36 or 44 tall with no vertical padding.
Measured at those numbers, the caption sat flush against the top edge with 21 to 33 pixels below it.
The desktop head's App.axaml carries the same setter on its own shapes for the same reason, and excludes
two of them — see the remark on Button.ghost there. Nothing is excluded here: no row's content depends
on being stretched, there being no full-height strip inside any of the thirty-three, and the Grids that
stop filling hold only children that already centre themselves, so they land where they always did.
-->
<Style Selector="Button.row"> <Style Selector="Button.row">
<Setter Property="MinHeight" Value="54" /> <Setter Property="MinHeight" Value="54" />
<Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" /> <Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="Transparent" /> <Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="10" /> <Setter Property="CornerRadius" Value="10" />
@@ -139,29 +139,27 @@ internal sealed partial class TerminalScreen : UserControl
{ {
var row = this.FindControl<StackPanel>("AccessoryKeys")!; var row = this.FindControl<StackPanel>("AccessoryKeys")!;
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
// length of the press; and the Return is posted past the current dispatch, so its ordering against
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
foreach (var (label, bytes, latches) in Keys) foreach (var (label, bytes, latches) in Keys)
{ {
var key = CreateKey(label); var key = CreateKey(label);
// TerminalFocus.Return in both, after the key has done its work: by Click time the touch has
// already moved Android's own focus onto Avalonia's input view, and leaving it there is what
// swaps the keyboard out from over the terminal. Returning it is free when nothing moved.
if (latches) if (latches)
{ {
controlKey = key; controlKey = key;
key.Click += (_, _) => key.Click += (_, _) => ToggleControl();
{
ToggleControl();
TerminalFocus.Return();
};
} }
else else
{ {
key.Click += (_, _) => key.Click += (_, _) => SendAsync(bytes);
{
SendAsync(bytes);
TerminalFocus.Return();
};
} }
row.Children.Add(key); row.Children.Add(key);
@@ -213,8 +211,9 @@ internal sealed partial class TerminalScreen : UserControl
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that // At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia // presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
// decides about its element — taking the keyboard's input connection off the terminal and // decides about its element — taking the keyboard's input connection off the terminal and
// swapping its layout mid-typing. The Click wiring in BuildAccessoryRow hands that half // swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
// back; see TerminalFocus for the whole story. // that half back; see TerminalFocus for the whole story, including why the handing back has
// to be posted rather than done inline.
Focusable = false, Focusable = false,
}; };
+5
View File
@@ -153,6 +153,7 @@
<Setter Property="CornerRadius" Value="6" /> <Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="6,2" /> <Setter Property="Padding" Value="6,2" />
<Setter Property="MinHeight" Value="0" /> <Setter Property="MinHeight" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" /> <Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" /> <Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontWeight" Value="Medium" /> <Setter Property="FontWeight" Value="Medium" />
@@ -463,6 +464,7 @@
<Setter Property="Padding" Value="13,7" /> <Setter Property="Padding" Value="13,7" />
<Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" /> <Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="12" /> <Setter Property="CornerRadius" Value="12" />
</Style> </Style>
<Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter">
@@ -482,6 +484,7 @@
<Style Selector="Button.poprow"> <Style Selector="Button.poprow">
<Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" /> <Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Padding" Value="11,4" /> <Setter Property="Padding" Value="11,4" />
<Setter Property="CornerRadius" Value="8" /> <Setter Property="CornerRadius" Value="8" />
<Setter Property="MinHeight" Value="20" /> <Setter Property="MinHeight" Value="20" />
@@ -671,6 +674,7 @@
--> -->
<Style Selector="Button.choice"> <Style Selector="Button.choice">
<Setter Property="Padding" Value="10,5" /> <Setter Property="Padding" Value="10,5" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" /> <Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10.5" /> <Setter Property="FontSize" Value="10.5" />
<Setter Property="LetterSpacing" Value="0.5" /> <Setter Property="LetterSpacing" Value="0.5" />
@@ -1120,6 +1124,7 @@
<Style Selector="Button.panechip"> <Style Selector="Button.panechip">
<Setter Property="Padding" Value="8,4" /> <Setter Property="Padding" Value="8,4" />
<Setter Property="MinHeight" Value="0" /> <Setter Property="MinHeight" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" /> <Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" /> <Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="Medium" /> <Setter Property="FontWeight" Value="Medium" />
@@ -1,13 +1,14 @@
using System.Globalization; using System.Globalization;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.VisualTree; using Avalonia.VisualTree;
namespace DodoSSH.Client.App.Layout.Tests; namespace DodoSSH.Client.App.Layout.Tests;
/// <summary> /// <summary>
/// The three button shapes centre their caption inside a button taller than the caption. /// The button shapes that centre their caption do, and the two that deliberately do not still fill.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -41,6 +42,15 @@ namespace DodoSSH.Client.App.Layout.Tests;
/// action — so an absolute expectation would be a font metric written down in a test file, and it would move /// action — so an absolute expectation would be a font metric written down in a test file, and it would move
/// the day the face does. "Centred" survives both. /// the day the face does. "Centred" survives both.
/// </para> /// </para>
/// <para>
/// The five shapes beyond the original three were swept in afterwards, and none of them was misbehaving
/// when it was: every one is content-sized everywhere it is used today, so <c>Stretch</c> and <c>Center</c>
/// agreed and the change moved nothing — 113 buttons across 29 screens measured byte-identical before and
/// after. What the sweep buys is that the day any of them is given a height, it is already right. That is
/// also why <see cref="AStretchingShapeStillFillsItsButton"/> matters more than it looks: the same
/// reasoning applied to <c>flat</c> or <c>cat</c> would break a pill and a strip that are currently
/// correct.
/// </para>
/// </remarks> /// </remarks>
public sealed class ButtonCaptionTests public sealed class ButtonCaptionTests
{ {
@@ -61,6 +71,11 @@ public sealed class ButtonCaptionTests
[InlineData("ghost")] [InlineData("ghost")]
[InlineData("accent")] [InlineData("accent")]
[InlineData("danger")] [InlineData("danger")]
[InlineData("navuser")]
[InlineData("poprow")]
[InlineData("panechip")]
[InlineData("chiptoggle")]
[InlineData("choice")]
public async Task ACaptionIsCentredInAButtonTallerThanItself(string shape) public async Task ACaptionIsCentredInAButtonTallerThanItself(string shape)
{ {
await MeasureAsync( await MeasureAsync(
@@ -96,6 +111,72 @@ public sealed class ButtonCaptionTests
"the caption should sit high, which is the defect this suite was written for")); "the caption should sit high, which is the defect this suite was written for"));
} }
/// <summary>
/// <c>flat</c> and <c>cat</c> are excluded from the rule above, and must stay excluded.
/// </summary>
/// <remarks>
/// <para>
/// Both stretch their content on purpose, and both would be silently broken by a later pass that
/// "finished" the sweep the rest of these classes belong to — which is exactly why this is a test and
/// not a comment.
/// </para>
/// <para>
/// <c>flat</c> carries the titlebar's search pill, a <c>Border.searchpill</c> with no height of its own
/// that is meant to fill all 35 pixels of the button; the usage states
/// <c>HorizontalContentAlignment="Stretch"</c> and relies on the vertical default matching it. Centring
/// from the style would shrink that pill to its caption's line box inside a button twice as tall.
/// <c>cat</c> carries the keychain rail's accent strip, a <c>Border.rowmark</c> whose style sets
/// <c>Width="2"</c> and no height at all — "at full row height", says the rule's own remark — so its
/// height is the stretch and nothing else.
/// </para>
/// <para>
/// Asserted as "the content fills the button", not as "the caption is off-centre": what these two need
/// is the fill, and a test phrased the other way would still pass if the fill broke in some new way.
/// </para>
/// </remarks>
[Theory]
[InlineData("flat")]
[InlineData("cat")]
public async Task AStretchingShapeStillFillsItsButton(string shape)
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
// A bare Border is what both of them actually hold: no height, sized only by its parent.
var fill = new Border();
var button = new Button { Content = fill, Height = FixedHeight };
button.Classes.Add(shape);
var window = LayoutHarness.HostAtMinimumSize(
button, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
// The slot read off the presenter rather than recomputed from the button's Padding:
// these shapes differ in whether their presenter also draws a border, and a hand-rolled
// sum was two pixels out on Button.cat for exactly that reason.
var presenter = button.GetVisualDescendants()
.OfType<ContentPresenter>()
.Single(p => string.Equals(p.Name, "PART_ContentPresenter", StringComparison.Ordinal));
var slot = presenter.Bounds.Height
- presenter.Padding.Top - presenter.Padding.Bottom
- presenter.BorderThickness.Top - presenter.BorderThickness.Bottom;
fill.Bounds.Height.ShouldBe(
slot,
Tolerance,
$"Button.{shape} must stretch its content — the search pill and the rail's accent "
+ "strip have no height of their own");
}
finally
{
window.Close();
}
},
Token);
}
private static Task MeasureAsync( private static Task MeasureAsync(
string shape, VerticalAlignment? alignment, Action<double, double> assert) => string shape, VerticalAlignment? alignment, Action<double, double> assert) =>
LayoutHarness.OnTheUiThreadAsync( LayoutHarness.OnTheUiThreadAsync(