Centre a button caption in the button, not just the button in its parent
ci / build and test (push) Successful in 2m28s
ci / android head (push) Successful in 3m18s
ci / desktop nightly (push) Successful in 47s
ci / api image (push) Successful in 27s

App.axaml's Button.ghost, Button.accent, Button.danger rule set
VerticalAlignment and never VerticalContentAlignment. The first places the
button in its parent; the second places the caption in the button, and its
default is Stretch — so on any of these given a fixed Height the content
presenter stretched the caption TextBlock to the whole content box, and a
TextBlock draws its line at the top of whatever it is given. Measured on the
hosts toolbar, whose three buttons are 40 pixels: nine above the ink and
twenty below it. Every box was the height it declared, which is why this read
as one of them being the wrong height — nothing was mis-sized, the labels sat
in the top third.

Center rather than a hand-tuned Padding, because the gap is the difference
between the line box and the content box and moves with the font size: these
carry 11.5 by default and the primary action overrides it to 13.5. It is the
three shapes that were missed rather than a new idiom — navseg, sesstab,
headerghost, sidebarrow, fieldrow and paneicon all state it already, as does
every one of the phone head's own button classes. 134 buttons carry these
three classes; the ones that show it are those with an explicit Height, which
is both toolbars, the drawer's Save/Cancel pair, and the import screen. A
button sized to its own caption was already right and is untouched.
HorizontalContentAlignment is deliberately left alone: it is Stretch too and
invisible on a self-sized button, and the flyout rows that are stretched wide
ask for Left themselves.

ButtonCaptionTests measures a bare Button, since Application.Styles is global
and a screen-level test would pin one toolbar and leave the rest to the same
defect. It measures the laid-out line rather than the TextBlock's arranged
bounds, and that distinction is the test: under Stretch those bounds fill the
content box and so are symmetrical whether or not the ink in them is. The
first draft asserted on them and passed against the defect; the calibration
test caught it, and against the old markup all three shapes now fail naming
their own gap.
This commit is contained in:
2026-08-09 08:02:15 +02:00
parent 242280ce6b
commit 21cf77f64a
2 changed files with 159 additions and 1 deletions
+21 -1
View File
@@ -268,7 +268,26 @@
<Setter Property="Foreground" Value="{StaticResource TextFaint}" /> <Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style> </Style>
<!-- Every button in this window is small, mono and tracked out; only the colours differ. --> <!--
Every button in this window is small, mono and tracked out; only the colours differ.
◆ VerticalContentAlignment IS NOT VerticalAlignment, and this style used to set only the second. The
first places the caption inside the button; the second places the button inside its parent. Avalonia's
default for content alignment is Stretch, so on any of these given a fixed Height — the hosts toolbar's
three at 40, and every dialog's row of them — the ContentPresenter stretched the caption's TextBlock to
the full content box and a TextBlock draws its line at the TOP of its bounds. Measured on the hosts
toolbar: a 40-pixel button with 9 pixels above the ink and 20 below it. Nothing was the wrong height,
which is why this read as one — the box was right and the label sat in the top third of it.
Center rather than a hand-tuned Padding, because the gap is the difference between the line box and the
content box and so moves with the font size: these carry 11.5 by default and the primary action
overrides it to 13.5. Every other button class here already sets it — navseg, sesstab, headerghost,
sidebarrow, fieldrow, paneicon — so this is the three shapes that were missed rather than a new idiom.
HorizontalContentAlignment is deliberately left alone. It is Stretch too, and it is invisible on a
button sized to its own caption; the ones that are stretched wide state their own (the flyouts' rows
ask for Left), and centring those from here would move text nobody complained about.
-->
<Style Selector="Button.ghost, Button.accent, Button.danger"> <Style Selector="Button.ghost, Button.accent, Button.danger">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" /> <Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11.5" /> <Setter Property="FontSize" Value="11.5" />
@@ -277,6 +296,7 @@
<Setter Property="Padding" Value="10,5" /> <Setter Property="Padding" Value="10,5" />
<Setter Property="MinHeight" Value="0" /> <Setter Property="MinHeight" Value="0" />
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style> </Style>
<!-- <!--
@@ -0,0 +1,138 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.VisualTree;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The three button shapes centre their caption inside a button taller than the caption.
/// </summary>
/// <remarks>
/// <para>
/// ◆ THE DEFECT THIS EXISTS FOR was read as a height problem and was not one. <c>App.axaml</c>'s
/// <c>Button.ghost, Button.accent, Button.danger</c> rule set <c>VerticalAlignment</c> — where the button
/// sits in its parent — and never <c>VerticalContentAlignment</c>, where the caption sits in the button.
/// Avalonia's default for the second is <c>Stretch</c>, so on any of these given a fixed <c>Height</c> the
/// content presenter stretched the caption's <see cref="TextBlock"/> to the whole content box, and a
/// <see cref="TextBlock"/> draws its line at the TOP of its bounds. The hosts toolbar's three 40-pixel
/// buttons measured 9 pixels above the ink and 20 below it. Every box was the height it declared, which is
/// exactly why it read as one being wrong: nothing was mis-sized, the labels sat in the top third.
/// </para>
/// <para>
/// A bare <see cref="Button"/> rather than a screen, because <c>Application.Styles</c> is global — the same
/// property <see cref="LayoutHarnessTests"/> leans on — so this measures the style rule itself rather than
/// one of the hundred-odd places it lands. A screen-level test would pin one toolbar and leave the dialogs,
/// the drawer's Save/Cancel pair and the import screen's buttons uncovered by the thing that fixed them all.
/// </para>
/// <para>
/// ◆ THE LINE BOX IS MEASURED, NOT THE TextBlock's ARRANGED BOUNDS, and the difference is the whole test.
/// A stretched caption's <see cref="Visual.Bounds"/> fill the content box, so they are symmetrical about
/// the button's middle under the defect just as they are under the fix — the first draft of this suite
/// asserted on them, passed on both, and was caught only by
/// <see cref="AStretchedCaption_IsNotCentred_AndIsCaught"/>. What actually moves is where the line sits
/// INSIDE those bounds: <see cref="TextBlock"/> draws at the top of whatever it is given, so the ink is
/// centred only when the box it is drawn in is its own line box.
/// </para>
/// <para>
/// The gaps are compared to each other rather than to a number. What the caption's own line box measures is
/// a property of JetBrains Mono at whichever size the caller set — 11.5 by default and 13.5 on the primary
/// 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.
/// </para>
/// </remarks>
public sealed class ButtonCaptionTests
{
/// <summary>Taller than any caption these carry, which is the condition that exposes the defect.</summary>
/// <remarks>
/// The hosts and keychain toolbars' own number. A button left to size itself cannot show this at all:
/// its content box is its caption's line box exactly, so <c>Stretch</c> and <c>Center</c> agree and a
/// test written against one would pass under either.
/// </remarks>
private const double FixedHeight = 40;
/// <remarks>One pixel, for a content box whose odd leftover cannot be halved evenly.</remarks>
private const double Tolerance = 1;
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Theory]
[InlineData("ghost")]
[InlineData("accent")]
[InlineData("danger")]
public async Task ACaptionIsCentredInAButtonTallerThanItself(string shape)
{
await MeasureAsync(
shape,
alignment: null,
(above, below) =>
Math.Abs(above - below).ShouldBeLessThanOrEqualTo(
Tolerance,
string.Create(
CultureInfo.InvariantCulture,
$"Button.{shape} left {above:0.#} above the caption and {below:0.#} below it.")));
}
/// <summary>
/// The instrument's own calibration: the check fails on the arrangement the fix replaced.
/// </summary>
/// <remarks>
/// The same practice <see cref="LayoutHarnessTests"/> states for the clipping harness, and it earns its
/// place here for the same reason. Everything above passes if <c>VerticalContentAlignment</c> is Center
/// AND it passes if the caption happens to fill its box, so without this a rule quietly reverted to
/// Stretch would have to be caught by eye again. Stretch is set inline here rather than by editing the
/// style sheet, because <c>Application.Styles</c> is global and a test that mutated it would be changing
/// every other test in the assembly out from under itself.
/// </remarks>
[Fact]
public async Task AStretchedCaption_IsNotCentred_AndIsCaught()
{
await MeasureAsync(
"ghost",
VerticalAlignment.Stretch,
(above, below) => (below - above).ShouldBeGreaterThan(
Tolerance,
"the caption should sit high, which is the defect this suite was written for"));
}
private static Task MeasureAsync(
string shape, VerticalAlignment? alignment, Action<double, double> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var button = new Button { Content = "Group ▾", Height = FixedHeight };
button.Classes.Add(shape);
if (alignment is { } forced)
{
button.VerticalContentAlignment = forced;
}
var window = LayoutHarness.HostAtMinimumSize(
button, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
var caption = button.GetVisualDescendants().OfType<TextBlock>().Single();
var origin = caption.TranslatePoint(default, button);
origin.ShouldNotBeNull("the caption is not in the button's visual tree");
// The laid-out line rather than caption.Bounds.Height, which under Stretch is the
// content box and so is symmetrical whether or not the ink in it is — see the remark on
// the class.
var line = caption.TextLayout.Height;
var above = origin.Value.Y;
var below = button.Bounds.Height - above - line;
assert(above, below);
}
finally
{
window.Close();
}
},
Token);
}