Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
T
jaap-janandClaude Opus 5 3627021420 Give the desktop the second design too, and the window the size it now needs
The desktop v2 design is the other half of the one the phone took last commit, and this is its chrome: a
190-pixel labelled sidebar where the 54-pixel icon rail was, a titlebar with the search box centred in it,
and session tabs drawn as pills. The palette was already here — it is shared, and moved when the phone's
did — so what this changes is shape rather than colour.

**The window's minimum grew, and by exactly what v2 added.** The sidebar is 136 wider and the chrome 14
taller, so 880x560 became 1016x574. That is not a round number somebody liked: it leaves every screen the
same 826x464 it was designed against, which is the arithmetic the layout suite is built on. Four of the
tables stop fitting at 690 wide, so widening the sidebar and leaving the window alone would have broken
them somewhere no test was looking. LayoutHarness carries the new constants and the suite still passes at
the minimum, which is the whole reason it exists.

The rail's five-character abbreviations are gone with the width that caused them — PINS and SNIPS are Pins
and Snippets again — and each row gains a glyph and a count. A count is drawn only where one is real, so
SFTP, Logs and Preferences show nothing rather than a zero: a transfer queue's depth is not how many files
a screen holds, and a log has no total until it is read. The count beside Pins is the vault's own, not the
Pins screen's VisiblePins, which is the filtered list and would have made the sidebar count whatever
somebody had just typed into a filter box on another screen. Teams has no count for a related reason: they
are read from the server when that screen is opened rather than on unlock, so a number there would read 0
until somebody had already been to look.

One colour moved with it, finishing what the repalette started: the live-session summaries on the unlock and
sign-out cards were Info, so the two heads disagreed about a fact the phone paints green. They match again.

**Buckets became a destination rather than a mode**, which is what the design draws and what the phone
already does. The HOST / BUCKET pair inside the files screen is gone; ShellScreen.Buckets draws the same
TransfersScreen with the other picker, and the sidebar entry is what sets it. That also settles an old
disagreement rather than merely moving it: TotalItemCount is keys plus passwords and excludes buckets, so
the number beside the keychain used to disagree with the list under it, and now counts what that screen
shows.

There is one session behind both file destinations, so asking for the other kind while something is open is
refused rather than obeyed — and refusing means staying put. An earlier turn of this had it move anyway and
only decline to switch the picker, which put the S3 entry in the sidebar over a screen still listing an
SFTP host: two pieces of chrome disagreeing about where you are, which is worse than the navigation simply
not happening. The message that says so goes to Transfers.Status, which turned out to be drawn in the same
grid cell as the connected chip — survivable while it was mostly read before connecting, and not once a
refusal reports itself there. It has its own column now.

The design has nine entries' worth of screens and draws five. Pins, Teams, Import and Preferences are
built, working screens, so they keep their entries — the sidebar is labelled now and has the room, and
dropping an entry would have stranded a screen rather than simplified anything. The Team vault card the
design pins to the foot is not drawn: it is a second route to a screen already in the list, carrying a seat
count nothing here produces.

**The status bar survives the design that deletes it**, cut down to one thing. Two of the three facts it
carried moved into the titlebar with v2 — the sync word is beside its dot and the shortcut hint is inside
the box that uses it — so those are gone from it rather than printed twice. The third is Vault.Status, the
only channel this application has for saying a save failed or a merge picked a winner. The design is a
mock-up of an afternoon that goes well and has nowhere to put a sentence like that; dropping the bar would
have meant dropping the sentence or repeating it on nine screens.

What v2 draws and this does not is in docs/design-import-gaps.md, and it is the same list as the phone's
for the same reasons: the forwarding screen and both its chips, the host detail's fingerprint, tags and
last-session cards, the keychain's rotate button, the logs' FOLLOW pill and severity filters, and the
session footer's latency. The terminal is not inset behind a rounded frame either — it is a native child
window that composites above everything Avalonia paints, so the frame would clip nothing, which is the same
answer the phone gave.

**The light theme is not built.** Its accent is #6D5AE6, a different hue rather than a tint of the dark
one, so it needs every colour doubled, a variant to switch on, the renderer's own page switching with it,
and contrast checked twice. That is a piece of work rather than a setting, and it is separable from the
layout — which is why this commit is the layout.

The screens themselves are restyled through the shared vocabulary rather than rebuilt: corner radii,
chips, cards and the accent's ink, all in App.axaml, so every screen moves at once. Their layouts are left
alone deliberately. The design draws read-only detail panes and these screens carry the editors and forms
it has no equivalent of, so replacing a layout with the mock-up's would have lost the half that is
actually used.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the 68 layout cases
that stand up real Avalonia and measure every screen at the new minimum. Both heads build. Not run on a
machine with a display — see docs/manual-checks.md for what wants looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 19:29:21 +02:00

235 lines
10 KiB
C#

using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Threading;
using Avalonia.VisualTree;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// Lays out real XAML at a real size and reports anything a user could not click.
/// </summary>
/// <remarks>
/// <para>
/// The defect this exists for is a control arranged past the edge of its container. It is invisible to every
/// other suite here — no other test loads a <c>.axaml</c> — and this window has shipped it once, when the
/// setup screens rendered sliced with their buttons unreachable at the default width.
/// </para>
/// <para>
/// A control inside a <see cref="ScrollViewer"/> is exempt, and that exemption is load-bearing rather than a
/// convenience: a list longer than its viewport is the normal case, and treating a row scrolled out of sight
/// as a defect would make this harness cry wolf on every populated list. What is left after the exemption is
/// the class of thing that has no way to come back into view.
/// </para>
/// </remarks>
internal static class LayoutHarness
{
/// <summary>The window's own declared minimum, which is the size that has to work.</summary>
/// <remarks>
/// Taken from <c>MainWindow.axaml</c>'s <c>MinWidth</c>/<c>MinHeight</c> by hand. A test asserts these
/// two constants still match the XAML, so the harness cannot quietly start measuring a window larger
/// than the one a user is allowed to drag to.
/// </remarks>
internal const double MinimumWidth = 1016;
/// <inheritdoc cref="MinimumWidth" />
internal const double MinimumHeight = 574;
/// <summary>The host sidebar's fixed width, from the hosts screen's <c>ColumnDefinitions</c>.</summary>
internal const double HostSidebarWidth = 268;
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
internal const double NavRailWidth = 190;
/// <summary>
/// What the titlebar, the tab strip and the status bar take off the window before any screen gets a
/// pixel.
/// </summary>
/// <remarks>
/// All three are fixed heights declared in their own markup — 44, 42 and 24 — rather than shapes that
/// grow with their contents, which is what makes stating them here honest. Three tests hold the three
/// controls to those numbers, so the budget below cannot drift away from what the window actually
/// leaves.
/// </remarks>
internal const double TitleBarHeight = 44;
/// <inheritdoc cref="TitleBarHeight" />
internal const double StatusBarHeight = 24;
/// <summary>
/// <inheritdoc cref="TitleBarHeight" path="/summary" />
/// </summary>
/// <remarks>
/// It comes off every screen, not just the hosts screen, which is the layout consequence of the strip
/// spanning the window. The strip does not collapse when there are no tabs — a row of chrome that came
/// and went would move every screen up and down by 42 pixels each time the last tab closed — so this is
/// a fixed cost rather than a conditional one, and the budget can be a constant.
/// </remarks>
internal const double TerminalTabsHeight = 42;
/// <summary>
/// What a setup card leaves its contents: its maximum width, less the padding on both sides.
/// </summary>
/// <remarks>
/// From <c>Border.card</c> in <c>App.axaml</c> — <c>MaxWidth</c> 520 and <c>Padding</c> 24 — because the
/// cards themselves live inside <c>MainWindow.axaml</c>, which cannot be laid out here at all. Measuring
/// a card's contents at the size the card gives them is the closest this harness can get to the unlock
/// screen, and it is the half that has something to blow: the frame is fixed and the contents are not.
/// </remarks>
internal const double CardContentWidth = 520 - (2 * 24);
/// <inheritdoc cref="CardContentWidth" />
/// <remarks>
/// Measured against <see cref="ContentHeight"/> and not against <see cref="ScreenHeight"/>, which is a
/// distinction the tab strip introduced and which is worth stating: a setup card is shown while the
/// vault is <em>not</em> open, and the strip lives inside the unlocked half of the window. So the card
/// gets the whole area between the titlebar and the status bar, and taking the strip off its budget
/// would have this harness fail a card that fits.
/// </remarks>
internal static double CardContentHeight => ContentHeight - (2 * 24);
/// <summary>Everything between the titlebar and the status bar, at the window's minimum.</summary>
internal static double ContentHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
/// <summary>The height a screen actually gets at the window's minimum.</summary>
/// <remarks>
/// Less than <see cref="ContentHeight"/> by the tab strip, which spans every screen and does not
/// collapse when there are no tabs.
/// </remarks>
internal static double ScreenHeight => ContentHeight - TerminalTabsHeight;
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
private static readonly HeadlessUnitTestSession Session =
HeadlessUnitTestSession.GetOrStartForAssembly(typeof(LayoutHarness).Assembly);
/// <summary>
/// Runs one body on Avalonia's dispatcher thread.
/// </summary>
/// <remarks>
/// Everything that touches a control has to happen here. The session owns the thread and the
/// application, so this is also what serialises the suite — Avalonia's platform is process-global and
/// two tests laying out windows at once would share one dispatcher.
/// </remarks>
internal static Task OnTheUiThreadAsync(Action body, CancellationToken cancellationToken) =>
Session.Dispatch(body, cancellationToken);
/// <summary>Shows a window at a given size and lets layout finish.</summary>
internal static void Settle(Window window, double width, double height)
{
ArgumentNullException.ThrowIfNull(window);
window.Width = width;
window.Height = height;
// None, because a headless window still reserves space for decorations it does not draw, and the
// budget being measured is the client area the application actually gets.
window.WindowDecorations = WindowDecorations.None;
window.Show();
// Show() queues layout rather than performing it. Without this the tree is measured but not
// arranged, and every Bounds read below would be a zero rectangle — which would make this harness
// report the whole window as unreachable, or worse, report nothing at all.
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
}
/// <summary>Wraps a control in a host window sized to the application's minimum.</summary>
internal static Window HostAtMinimumSize(Control content, double width, double height)
{
var window = new Window { Content = content };
Settle(window, width, height);
return window;
}
/// <summary>
/// Every interactive control that is laid out where it cannot be used, described for a failure message.
/// </summary>
/// <remarks>
/// Returns descriptions rather than controls because the value of this harness is entirely in what it
/// says when it fails: "something is clipped" sends the reader back to a 500-line XAML file, while
/// "Button 'Save' at 8,486 486x32 falls outside 820x520" names the control and the edge it crossed.
/// </remarks>
internal static IReadOnlyList<string> Unreachable(Window window)
{
ArgumentNullException.ThrowIfNull(window);
var client = new Rect(window.ClientSize);
var found = new List<string>();
foreach (var control in window.GetVisualDescendants().OfType<Control>())
{
if (Fault(control, client, window) is { } fault)
{
found.Add(fault);
}
}
return found;
}
private static string? Fault(Control control, Rect client, Visual window)
{
if (!IsInteractive(control) || !control.IsEffectivelyVisible || IsScrollable(control))
{
return null;
}
if (control.TranslatePoint(default, window) is not { } origin)
{
return null;
}
var box = new Rect(origin, control.Bounds.Size);
// Zero size is a defect for a control the theme gives a height to and a normal state for one sized by
// its content: an empty list is zero pixels tall and correct, a squashed button is neither. Learned
// from this firing on KeyList in a vault with no keys in it.
if (control is not ListBox && (box.Width <= 0 || box.Height <= 0))
{
return Describe(control, box, client, "was arranged with no size");
}
return client.Contains(box) ? null : Describe(control, box, client, "falls outside the window");
}
/// <remarks>
/// The controls a user has to be able to reach. A clipped <see cref="TextBlock"/> is a cosmetic problem
/// and a clipped <see cref="Button"/> is a dead end, so only the second kind is worth failing a build
/// over — and keeping the list short is what stops this harness from becoming a pixel-diff nobody
/// trusts.
/// </remarks>
private static bool IsInteractive(Control control) =>
control is Button or TextBox or CheckBox or ComboBox or NumericUpDown or ListBox;
private static bool IsScrollable(Control control) =>
control.GetVisualAncestors().OfType<ScrollViewer>().Any();
private static string Describe(Control control, Rect box, Rect client, string fault)
{
var name = control.Name is { Length: > 0 } named ? $" '{named}'" : Label(control);
return string.Create(
CultureInfo.InvariantCulture,
$"{control.GetType().Name}{name} {fault}: {Format(box)} is not inside {Format(client)}");
}
/// <remarks>
/// A button's caption, because "Save" identifies the control to a reader far better than its position
/// in a visual tree does.
/// </remarks>
private static string Label(Control control) => control switch
{
Button { Content: string caption } => $" '{caption}'",
TextBox { PlaceholderText: { Length: > 0 } placeholder } => $" (placeholder '{placeholder}')",
_ => string.Empty,
};
private static string Format(Rect rect) => string.Create(
CultureInfo.InvariantCulture,
$"{rect.X:0.#},{rect.Y:0.#} {rect.Width:0.#}x{rect.Height:0.#}");
}