Public Access
The sweep the ghost/accent/danger fix implied: navuser, poprow, panechip, chiptoggle and choice each set VerticalContentAlignment now, because each set everything else about how its content sits and left that one to Avalonia's Stretch default. None of them was misbehaving. Every one is content-sized everywhere it is used today, so Stretch and Center agreed and this moves nothing — 113 buttons across 29 screens and cards measured byte-identical before and after, the strips that have no height of their own included. What it buys is that the day one of them is given a height, it is already right rather than quietly drawing its label in the top third. flat and cat are deliberately NOT swept in, and the reasoning that would sweep them is exactly the trap. flat carries the titlebar's search pill, a Border.searchpill with no height of its own that is meant to fill all 35 pixels of its button — the usage states HorizontalContentAlignment="Stretch" and takes the vertical default to match. cat carries the keychain rail's accent strip, a Border.rowmark whose style sets Width="2" and no height at all, "at full row height" by its own remark. Centring either from the style shrinks a pill and a strip that are correct today. AStretchingShapeStillFillsItsButton pins both, and fails when flat is centred. ButtonCaptionTests covers the five new shapes on the existing rule. Its stretch-fill assertion reads the content slot off the presenter rather than recomputing it from the button's Padding: the shapes differ in whether their presenter also draws a border, and a hand-rolled sum was two pixels out on Button.cat for that reason.
220 lines
11 KiB
C#
220 lines
11 KiB
C#
using System.Globalization;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Presenters;
|
|
using Avalonia.Layout;
|
|
using Avalonia.VisualTree;
|
|
|
|
namespace DodoSSH.Client.App.Layout.Tests;
|
|
|
|
/// <summary>
|
|
/// The button shapes that centre their caption do, and the two that deliberately do not still fill.
|
|
/// </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>
|
|
/// <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>
|
|
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")]
|
|
[InlineData("navuser")]
|
|
[InlineData("poprow")]
|
|
[InlineData("panechip")]
|
|
[InlineData("chiptoggle")]
|
|
[InlineData("choice")]
|
|
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"));
|
|
}
|
|
|
|
/// <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(
|
|
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);
|
|
}
|