Files
jaap-jan ccaf7a8e72
ci / build and test (pull_request) Failing after 2m33s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m34s
Give the terminal back the width and the keyboard the session shell took
Seven things reported from a day's use of v5b's session shell, and they are one
commit because five of them are the same complaint from different angles: the
window spends too much of itself on chrome describing the session, and the parts
that are not chrome do not behave.

◆ THE HOST HEADER IS GONE, and that is a deliberate departure from the design.

Terminal.dc.html and SFTP.dc.html both draw a 60-pixel row above the pane: the
address on the left, a cross-surface button on the right. Both facts are worth
having and the strip they sat in was not — the tab already names the host, and a
full-width bar repeating it was the cheapest 60 pixels in the layout to give
back. The address is now the first line of the sidebar and the button is
stretched across the column under it, so nothing is lost and the pane is taller.

Which word that button carries and which command it runs used to be handed in
from the two usage sites in MainWindow.axaml, because the control was drawn
twice. One sidebar cannot do that, so SessionCrossSurfaceLabel and
OpenOtherSurfaceCommand resolve it in the shell — the same place SessionAddress
already decides which surface's fact to read. The two directions underneath are
untouched: SelectFilesHostAsync for a tab's host, OpenTerminalForFilesHostAsync
for a fresh terminal at whatever SFTP has open.

SessionHeader.axaml is deleted rather than left unused, and LayoutHarness stops
subtracting its 60 pixels from every session screen's budget — the same
treatment the retired window-wide tab strip got, and for the same reason: a
constant for chrome nobody draws is a suite quietly measuring the wrong
rectangle.

◆ AND THE SIDEBAR CLOSES, which the design has no state for at all.

300 pixels of an 1081-pixel minimum is a great deal to spend on a list that is
often two rows long. The column now folds to a 34-pixel rail carrying the
chevron that brings it back — a rail rather than nothing, because a panel that
vanishes leaving no trace is one people report as lost rather than as closed.
Both states live in the one control and swap on IsSessionSidebarOpen, so
MainWindow's own "Auto" column takes whichever width is showing without knowing
the state exists.

Written through to ClientSettings.SessionSidebarOpen rather than held for the
session. It is a decision about how much of the window a terminal gets, and one
that had to be made again on every launch would not really be on offer.

---- THE FOUR SMALLER ONES ----

A SNIP LANDED IN A TERMINAL NOBODY COULD TYPE AT, and looked selected when it
got there. Two causes with nothing in common. The click moved Win32 focus onto
the sidebar row, and term.focus() in the page cannot take it back — only the
host can, so the shell raises TerminalFocusRequested and the window answers with
the same posted focus every other path here uses. The highlight was bash: xterm
wraps a paste in bracketed-paste markers, readline marks what arrives inside
them as an active region, and it stays in reverse video until the next
keystroke. Right for a clipboard paste, wrong for a snippet picked off a
sidebar. Single-line snips are typed rather than pasted now, which needs no
markers; multi-line still pastes, because "runs three commands unasked" is the
worse of the two failures and the markers are the whole of what prevents it.

A BLACK BAR UNDER THE TERMINAL, on Windows. xterm.css paints its scrolling
viewport #000 — its own comment explains why, and it is a macOS scrollbar
concern. Everywhere else that black is covered by the rows, except along the
bottom: the fit addon floors the row count, so the remainder below the last
whole row is bare viewport, up to a line tall, against this page's #171a26. The
light square at its right-hand end is where WebView2's classic scrollbar corner
lands. The viewport is repainted in the page's own background, and the scrollbar
with it — thin and in these colours rather than a grey Windows channel down the
side of a near-black terminal, and kept rather than hidden, because a surface
that scrolls with no sign that it does is worse than a quiet bar.

THE PINS ROW DREW A TOFU BOX. U+E946 is not in the embedded Material Icons face
at all — that file is the 2019 build and its cmap skips E944 and E946 — so the
rail's Pins row and the hosts screen's own pin badge have both been drawing a
missing-glyph rectangle since v5b picked the codepoint. push_pin in that vintage
is U+F10D, verified against the file rather than against a codepoints table for
a later release of the font. Every other icon codepoint in the repository was
audited the same way; this was the only miss.

THE KBD CHIP CUT THE CHORD IN HALF. 34 pixels is the design's width for a chip
reading ⌘K, and this build substitutes CTRL K — six characters and a space,
wider than 34 at 10.5 mono. MinWidth and padding instead, so the design's
footprint survives for the day this face has a ⌘ to draw.

---- AND THE POPOVER UNDER THE USER CHIP ----

Reported as not matching the design, and it was not: Button.poprow set a corner
radius and a padding and never touched the Background, so every row wore the
Fluent theme's own #33FFFFFF button fill. Six raised pills stacked in a menu the
design draws as six lines of text — and the hover rule underneath was already
correct and simply invisible against a fill that never went away. Set on the
ContentPresenter as well as on the Button, the same as Button.flat, because the
theme binds its brush there and a Background set only on the control loses to
it. The panel itself gets this window's own radius-12 card treatment through a
FlyoutPresenter class rather than by widening the shared context-menu rule, and
Vaults and Preferences stop being drawn one step dimmer than Settings and
Logout, which read as two disabled entries in a menu of five live ones.

---- WHAT PROVES IT ----

Three tests in the layout suite, two of them checked against the defect they
describe: the popover row's resting fill (fails with #33ffffff without the
style), and the kbd chip against the natural width of its own text, measured on
a detached copy because a TextBlock's DesiredSize is already clipped to what it
was given and reports 34 inside a 34-pixel chip either way. SessionSidebarTests
is new — the sidebar has never been laid out by a test, and it now holds a
string of unbounded length beside a button that has to stay clickable. In the
shell suite: the cross-surface row in both directions, the closed state
surviving to disk, and the focus request being made when a snip lands and not
made when it does not.
2026-08-10 16:49:44 +02:00

371 lines
20 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.
///
/// v5b: 1016x574 became 1081x583, exactly what the titlebar's and the rail's own fidelity passes added —
/// see <see cref="TitleBarHeight"/>, <see cref="NavRailWidth"/> and the matching remark in
/// <c>MainWindow.axaml</c>. <see cref="ScreenWidth"/> and <see cref="ScreenHeight"/> are both unchanged
/// by the move, because the minimum grew by exactly what the two grew by.
/// </remarks>
internal const double MinimumWidth = 1081;
/// <inheritdoc cref="MinimumWidth" />
internal const double MinimumHeight = 583;
/// <summary>The hosts drawer's fixed width, from <c>HostDrawer.axaml</c>.</summary>
/// <remarks>
/// This was <c>HostSidebarWidth</c> at 268, taken from a column definition on the hosts screen. The
/// drawer states its own width instead — it is the only thing in its column and the column is
/// <c>Auto</c> — so the number lives on the control now, and this constant follows it. It was 304 through
/// v4; v5 widened it to 320 for the ADDRESS field's own breathing room, and <c>App.axaml</c>'s
/// <c>Border.tile</c> narrowed to keep two columns fitting the grid beside it at the window's minimum.
/// </remarks>
internal const double HostDrawerWidth = 320;
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
internal const double NavRailWidth = 255;
/// <summary>Settings mode's own rail, from <c>SettingsNav.axaml</c> — wider than <see cref="NavRailWidth"/>.</summary>
internal const double SettingsNavWidth = 340;
/// <summary>
/// The width settings mode's own content column asks for, from the design's <c>width:1100px</c>.
/// </summary>
/// <remarks>
/// A <c>MaxWidth</c> on the page, not a <c>Width</c> — see the same trade <c>TitleBar.axaml</c>'s own
/// search box makes with its own <c>MaxWidth="514"</c>, and for the identical reason:
/// <see cref="SettingsContentWidth"/> below is smaller than this at the window's minimum, and a page
/// that insisted on the full 1100 would arrange its own rows past the edge of the rectangle settings
/// mode actually gives them.
/// </remarks>
internal const double SettingsDesignContentWidth = 1100;
/// <summary>The width a settings page's content column actually gets at the window's minimum.</summary>
internal static double SettingsContentWidth => MinimumWidth - SettingsNavWidth;
/// <summary>What settings mode leaves a page between its own titlebar and the window's bottom edge.</summary>
/// <remarks>
/// Settings mode has no status bar and no update banner of its own — see <c>MainWindow.axaml</c>'s own
/// remark on why both are hidden while <c>IsSettingsMode</c> is true — so this is
/// <see cref="MinimumHeight"/> less only <see cref="TitleBarHeight"/>, not <see cref="ContentHeight"/>'s
/// own subtraction of <see cref="StatusBarHeight"/> too.
/// </remarks>
internal static double SettingsContentHeight => MinimumHeight - TitleBarHeight;
/// <summary>
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
/// </summary>
/// <remarks>
/// Both are fixed heights declared in their own markup — 53 and 24 — rather than shapes that grow with
/// their contents, which is what makes stating them here honest. A test holds each control to its own
/// number, so the budget below cannot drift away from what the window actually leaves.
///
/// v5b: the titlebar's own 44 became 53, the design's own height; see <see cref="MinimumHeight"/> for
/// the matching rise that keeps every screen below it the same size it always measured.
///
/// A third constant, <c>TerminalTabsHeight</c>, stood beside these two through v5b's chrome wave: the
/// window-wide tab strip that used to sit above every screen, 42 pixels, whether or not there were any
/// tabs to draw. v5b's session-shell wave retires that strip — see <c>MainWindow.axaml</c>'s own remark
/// on where a session's tabs live now — and with it the constant: <see cref="ScreenHeight"/> no longer
/// subtracts anything for a row that no longer exists as chrome above every screen. The tab row itself
/// is now inside the two screens that carry one, at its own 38-pixel height; see
/// <see cref="SessionTabRowHeight"/>, which only those two screens' own budgets pay.
/// </remarks>
internal const double TitleBarHeight = 53;
/// <inheritdoc cref="TitleBarHeight" />
internal const double StatusBarHeight = 24;
/// <summary>The v5b session shell's own tab row, from <c>App.axaml</c>'s <c>Button.sesstab</c> rule.</summary>
/// <remarks>
/// Not part of <see cref="ScreenHeight"/>'s budget, unlike the retired window-wide strip this replaced:
/// only the terminal and SFTP surfaces pay it, out of their own 26-pixel padded column — see
/// <see cref="SessionShellPadding"/> — rather than every screen paying it as chrome. Stated here so a
/// test can hold <c>SessionTabRow</c> to it the same way <c>TheChromeIsTheHeightTheBudgetAssumes</c>
/// holds the titlebar and the status bar to theirs.
/// </remarks>
internal const double SessionTabRowHeight = 38;
/// <summary>The v5b session shell's own padded column, from the design's <c>padding: 26px</c>.</summary>
internal const double SessionShellPadding = 26;
/// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary>
internal const double SessionSidebarWidth = 300;
/*
A third session-shell constant stood here through wave B and C: SessionHeaderHeight, 60 pixels, for
the host header that sat above the pane on both surfaces. v5c-4 retires that row — its address and
its cross-surface button both live in the sidebar now; see SessionSidebar.axaml — so the pane between
the tab row and the status bar is 60 pixels taller and this budget no longer subtracts anything for
it. The same treatment the retired window-wide tab strip got above, and for the same reason: a
constant for chrome that is not drawn is a budget that quietly under-measures every screen.
*/
/// <summary>The v5b session shell's own status bar, from <c>SessionStatusBar.axaml</c>.</summary>
internal const double SessionStatusBarHeight = 37;
/// <summary>
/// The bordered container both session-shell screens sit inside, from <c>MainWindow.axaml</c>'s
/// <c>BorderThickness="1"</c> around the header/pane/status-bar column and the sidebar beside it.
/// </summary>
internal const double SessionShellBorderThickness = 1;
/// <summary>
/// ◆ THE REAL BUDGET WAVE C CLOSES. What the terminal and SFTP surfaces' own screen — <c>TransfersScreen</c>
/// today, and whatever sits in the terminal's own pane — actually gets once the session shell built in
/// wave B has taken its padding, its tab row, its header and its status bar. Wave B left
/// <c>MeasureConnectingAsync</c> and <c>MeasureHostKeyAsync</c> measuring at the roomier
/// <see cref="ScreenWidth"/>/<see cref="ScreenHeight"/> instead, with a remark on each admitting the gap;
/// this is what closes it.
/// </summary>
/// <remarks>
/// The arithmetic, top to bottom: <see cref="ScreenHeight"/> less <see cref="SessionShellPadding"/> on
/// both the top and the bottom of the outer padded column, less <see cref="SessionTabRowHeight"/> for the
/// tab row that sits above the bordered container, less <see cref="SessionShellBorderThickness"/> on both
/// the top and the bottom of that border, less <see cref="SessionStatusBarHeight"/> for the one fixed
/// strip left below the pane — v5c-4 retired the header above it; see the note where its constant was.
/// </remarks>
internal static double SessionScreenHeight =>
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
- SessionStatusBarHeight;
/// <summary>
/// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS
/// column showing beside it.
/// </summary>
/// <remarks>
/// <see cref="ScreenWidth"/> less <see cref="SessionShellPadding"/> on both the left and the right of the
/// outer padded column, less <see cref="SessionShellBorderThickness"/> on both the left and the right of
/// the bordered container, less <see cref="SessionSidebarWidth"/> when the sidebar is showing beside the
/// pane rather than collapsed — see <c>MainWindowViewModel.ShowsQuickAccessSidebar</c>, which for the
/// SFTP surface is exactly <c>Transfers.IsConnected</c>: the caller passes that fact in rather than this
/// harness guessing it, because it is a fact about a view model this file knows nothing about.
/// </remarks>
internal static double SessionScreenWidth(bool sidebarVisible) =>
ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness)
- (sidebarVisible ? SessionSidebarWidth : 0);
/// <summary>The update banner's fixed height, from <c>UpdateBanner.axaml</c>.</summary>
/// <remarks>
/// Deliberately <em>not</em> part of <see cref="ScreenHeight"/>'s budget, unlike the three constants
/// above it. The titlebar, the tab strip and the status bar are unconditional — every screen pays them
/// on every launch, which is what makes subtracting them honest. This one is up only while an update is
/// waiting to be installed, so folding it into the budget would have every screen measured against a
/// height it usually has more than. What it does mean is that a screen shown with the banner up gets 48
/// fewer pixels than the suite otherwise checks, which is the trade this row makes and the reason it is
/// one line high.
/// </remarks>
internal const double UpdateBannerHeight = 48;
/// <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 full-bleed page screen actually gets at the window's minimum.
/// </summary>
/// <remarks>
/// Equal to <see cref="ContentHeight"/> since v5b's session-shell wave retired the window-wide tab strip
/// that used to be subtracted here — see <see cref="TitleBarHeight"/>'s own remark. The terminal and
/// SFTP surfaces pay for their own tab row, header and status bar out of their own budget now, which
/// this constant does not describe; a test measuring either of those two screens has to account for the
/// session shell's own geometry rather than reading it off this property.
/// </remarks>
internal static double ScreenHeight => ContentHeight;
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
/// <summary>
/// v5c-3: what the S3 usage of <c>TransfersScreen</c> gets, now that <c>MainWindow.axaml</c> gives it the
/// session shell's own 26px-padded, 1px-bordered LOOK with none of its machinery — no tab row, header,
/// status bar or sidebar to take further space off it.
/// </summary>
internal static double BucketsScreenWidth =>
ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness);
/// <inheritdoc cref="BucketsScreenWidth" />
internal static double BucketsScreenHeight =>
ScreenHeight - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness);
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.#}");
}