Give the file listings' columns their content back
ci / build and test (pull_request) Successful in 2m32s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m7s
ci / api image (pull_request) Successful in 4s

The columns in both panes were fixed pixel widths, and measuring them against
what they actually draw found them wrong at both ends of the range this screen
gets.

◆ MODIFIED WAS TRUNCATED AT EVERY WIDTH THE APPLICATION HAS EVER BEEN DRAWN AT.
It prints a sixteen-character timestamp, which needs 116 pixels, and it had 52.
On a maximised window that read "2026-08" beside a NAME column with four
hundred empty pixels in it. PERMS needs 60 for a ten-character mode and had 48;
SIZE needs 58 for "999.9 GB" and had 56.

◆ AND AT THE SESSION SHELL'S OWN NARROWEST, NAME MEASURED ZERO PIXELS. Twenty
for the glyph plus 56+52+48 for the numbers is 176 of the 180 a row has, so the
remote pane drew sizes, timestamps and modes for rows whose names were not on
screen at all. That is the same fault as the one above rather than a second
one: the numbers were picked for the narrow budget, could not have fitted their
own content there, and never grew anywhere else.

Star columns with a MaxWidth answer both, because the cap is what makes "share
the width" mean the right thing at each end. Wide, the three numeric columns
stop growing at what their text needs and NAME keeps every remaining pixel.
Narrow, nothing carries a MinWidth to overflow with, so all four give ground
together and NAME — weighted heaviest — is the last to run out. MODIFIED takes
the extra weight so it reaches its cap first, being the widest content and the
column most often actually read; the local pane's NAME carries the remote's
PERMS weight folded into it, which is what makes SIZE and MODIFIED come out the
same width on both sides of the arrow column. Timestamps.Format already argued
that both panes write a time the same way so the two can be read against each
other, and two different widths would have made that argument for nothing.

◆ THE HEADINGS RAN TOGETHER, and that was visible as one word: SIZE ended on
the pixel MODIFIED began at, so the row read "SIZEMODIFIED". They are
right-aligned in their own columns, so the gap has to come from a left margin
on each — which does not move the label and does stop the column before it
reaching this one. The three now carry one, and ellipsize rather than clip
where even the label has no room.

◆ TWO PLACEHOLDERS WERE CUT OFF MID-WORD, which reads as a rendering fault
rather than as a hint. MKDIR's box drew "new directo" — 101 pixels of text in
74 pixels of room — and says "directory" now, since the button beside it
carries the "new" half already. The pane header's path box drew a path ending
mid-segment at the narrow budget and says "path": the only time that
placeholder shows is on a pane with nothing connected, where the box is
disabled and the invitation below is doing the explaining, and what the box is
for is in its tooltip where there is room for it.

FOUR COPIES OF THE COLUMN DEFINITIONS, one heading row and one row template per
pane. Avalonia cannot share a ColumnDefinitions instance between two Grids —
each mutates its own computed sizes — and SharedSizeGroup only applies to Auto
columns, so what catches a copy that drifts is measurement rather than
discipline: every cell is held against the width its own text needs when
unconstrained, the headings against each other, and NAME against zero at the
narrowest budget. Nothing asserts a number this file also wrote down, which is
the failure mode a test of a layout most easily has.

The roomy end is measured at the width a maximised 1843-pixel window leaves,
which is where this was reported from; LayoutHarness.SessionScreenWidth cannot
express it because that constant is pinned to the window's minimum, so the
arithmetic is spelled out beside the test that uses it.
This commit is contained in:
jaap-jan committed 2026-08-21 14:01:45 +02:00
1 parent 882f01c515
commit b2f93a172b
2 files changed
+350 -13

No files matched your search

@@ -1,5 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Threading;
@@ -2171,6 +2172,262 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
/// this measures the tighter, sidebar-narrowed shape without having to say so twice.
/// </remarks>
// ---- ◆ The listings' columns ----
/// <remarks>
/// <para>
/// <b>Every cell shows what it holds, at a width this screen actually gets.</b> The columns were fixed
/// pixel widths until they were measured against their own content: MODIFIED prints a sixteen-character
/// timestamp needing 116 pixels and had 52, so it was truncated at every width the application has ever
/// been drawn at — on a maximised window it read "2026-08" beside a NAME column with four hundred empty
/// pixels in it. PERMS and SIZE were short by the same kind of margin.
/// </para>
/// <para>
/// Asserted by measuring each cell's own text unconstrained and holding the rendered width against it,
/// rather than against numbers written down here: a column that fits is a column at least as wide as
/// what it has to draw, and any other formulation is this test agreeing with the markup about an
/// arithmetic they both got from the same place.
/// </para>
/// </remarks>
[Fact]
public async Task TheListingColumnsShowTheirContentInFullWhenThereIsRoom()
{
SeedBothListings();
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, MaximisedSessionScreenWidth, LayoutHarness.SessionScreenHeight);
try
{
foreach (var text in new[] { "2.1 KB", "2026-08-21 11:36", "-rw-r--r--", "2026-07-04 09:00" })
{
var cell = screen.GetVisualDescendants()
.OfType<TextBlock>()
.First(block => string.Equals(block.Text, text, StringComparison.Ordinal));
cell.Bounds.Width.ShouldBeGreaterThanOrEqualTo(
Unconstrained(cell) - 0.5, $"'{text}' has to fit the column it is drawn in");
}
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// The headings are right-aligned in their own columns, so two of them whose columns are exactly the
/// width of their text run together: SIZE ended on the pixel MODIFIED began at, and the row read
/// "SIZEMODIFIED". This holds the gap that fixes it — a left margin on each of the three, which does not
/// move a right-aligned label but does stop the column before it reaching this one.
/// </remarks>
[Fact]
public async Task TheListingHeadingsDoNotRunIntoEachOther()
{
SeedBothListings();
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, MaximisedSessionScreenWidth, LayoutHarness.SessionScreenHeight);
try
{
// Per pane, not across both. Each has its own NAME at its own left edge, and a list of
// all seven sorted by position pairs the local pane's last heading with the remote
// pane's first — two labels in different panes, which no amount of margin separates.
var rows = screen.GetVisualDescendants()
.OfType<Grid>()
.Where(grid => grid.Children.OfType<TextBlock>().Any(
block => block.Classes.Contains("label")
&& string.Equals(block.Text, "NAME", StringComparison.Ordinal)))
.ToList();
rows.Count.ShouldBe(2, "one heading row per pane");
foreach (var row in rows)
{
var headings = row.Children
.OfType<TextBlock>()
.Where(block => block.Classes.Contains("label"))
.Select(block => (block.Text, Left: block.Bounds.X, Right: block.Bounds.Right))
.OrderBy(heading => heading.Left)
.ToList();
foreach (var (before, after) in headings.Zip(headings.Skip(1)))
{
after.Left.ShouldBeGreaterThan(
before.Right, $"'{before.Text}' must not touch '{after.Text}'");
}
}
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// <para>
/// The other end of the same fault. At the 204 pixels the session shell's own sidebar leaves a pane, the
/// fixed columns took 176 of a row's 180 and the remote pane's NAME cell measured <b>zero</b> — a file
/// browser drawing sizes, timestamps and modes for rows whose names were not on screen at all.
/// </para>
/// <para>
/// No column carries a MinWidth, which is what keeps this from being a fight the row can lose: at any
/// width all four give ground together, and NAME — weighted heaviest — is the last to run out. What is
/// asserted is only that it has not run out, because how much of a name fits in 204 pixels is a question
/// with no good answer.
/// </para>
/// </remarks>
[Fact]
public async Task TheNameColumnSurvivesTheNarrowestBudget()
{
SeedBothListings();
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
try
{
foreach (var name in new[] { ".bash_history", ".azure-devops" })
{
var cell = screen.GetVisualDescendants()
.OfType<TextBlock>()
.First(block => string.Equals(block.Text, name, StringComparison.Ordinal));
cell.Bounds.Width.ShouldBeGreaterThan(20, $"'{name}' must be readable at all");
}
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// A placeholder cut off mid-word reads as a rendering fault rather than as a hint — MKDIR's box drew
/// "new directo" and the pane header's drew a path ending mid-segment. Both are boxes whose width is
/// fixed by the pane's own narrowest budget, so the text is what had to give.
/// </remarks>
[Fact]
public async Task ThePaneBoxesPlaceholdersFitTheBoxesTheyAreIn()
{
transfers.IsConnected = true;
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
try
{
foreach (var box in screen.GetVisualDescendants().OfType<TextBox>())
{
// The picker's own password box is in the collapsed half of the invitation, so it
// lays out at zero width and has no room for anything. Only what is on screen.
if (box.Watermark is not { Length: > 0 } mark || !box.IsEffectivelyVisible)
{
continue;
}
var room = box.Bounds.Width - box.Padding.Left - box.Padding.Right;
room.ShouldBeGreaterThanOrEqualTo(
Measured(mark, box.FontSize, box.FontFamily),
$"the placeholder '{mark}' has to fit its own box");
}
}
finally
{
window.Close();
}
},
Token);
}
/// <summary>Puts one row of each pane's own shape in, with content of a real length.</summary>
/// <remarks>
/// The local pane is cleared first for the reason <see cref="TheRowTemplateShowsSizeAndDateColumns"/>
/// gives: it lists this fixture's real home directory, and a virtualizing ListBox realises only the rows
/// that fit — a row appended after those would never be built, and the assertion would find nothing to
/// have missed.
/// </remarks>
private void SeedBothListings()
{
transfers.IsConnected = true;
transfers.RemotePath = "/root";
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
".bash_history",
"/root/.bash_history",
SftpEntryKind.File,
2_150,
new DateTimeOffset(2026, 8, 21, 11, 36, 0, TimeSpan.Zero),
"-rw-r--r--")));
transfers.LocalEntries.Clear();
transfers.LocalEntries.Add(new LocalEntryRowViewModel(new LocalEntry(
".azure-devops",
Path.Combine(Path.GetTempPath(), ".azure-devops"),
IsDirectory: true,
Length: 0,
new DateTimeOffset(2026, 7, 4, 9, 0, 0, TimeSpan.Zero))));
}
/// <summary>
/// What a session-shell screen gets on a maximised window, which is where the columns were reported
/// wrong.
/// </summary>
/// <remarks>
/// A 1843-pixel window less the shell's own 26-pixel padding a side, its 1-pixel border a side, and
/// QUICK ACCESS's 300 — the arithmetic <see cref="LayoutHarness.SessionScreenWidth"/> does, at a width
/// that constant cannot express because it is pinned to the window's <em>minimum</em>. Both belong in
/// this file: the minimum is what everything else here measures against, and this is the width at which
/// the numeric columns reach their caps and the question "does the content fit" has a yes to give.
/// Below it they share the width instead, which is what
/// <see cref="TheNameColumnSurvivesTheNarrowestBudget"/> holds the other end of.
/// </remarks>
private const double MaximisedSessionScreenWidth = 1843 - (2 * 26) - (2 * 1) - 300;
/// <summary>What a rendered block would want if nothing bounded it.</summary>
private static double Unconstrained(TextBlock block) =>
Measured(block.Text ?? string.Empty, block.FontSize, block.FontFamily);
/// <inheritdoc cref="Unconstrained" />
private static double Measured(string text, double fontSize, Avalonia.Media.FontFamily family)
{
var block = new TextBlock { Text = text, FontSize = fontSize, FontFamily = family };
block.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
return block.DesiredSize.Width;
}
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>