Give a shell the whole phone, and one bar to leave it by

A connected phone was drawing five rows of chrome around the thing the user
opened it for. The vault header at 56, the terminal's own tab strip at 52, a
connection line at 36, the shells strip at 46 and the four-entry bottom bar at
64: at 360dp that is about a third of the display, and every row of it was
about somewhere the user was not. What replaces them is one 52-pixel bar drawn
by the surface itself — back on the left, the session pills, and a `+` across
from them — and then the terminal.

Three of those rows belong to `PhoneShell` and each is now bound on
`IsShowingPages`. That is the same question asked once rather than three
conditions that could drift: the surface is either a page or a terminal, and
these are the chrome a page has. The header needed a wrapper because Avalonia's
bindings have no "and" and it already had a condition of its own; the strip
needed one for the same reason. The bottom bar had none and is bound directly.

The back arrow goes to the page the terminal was opened over rather than to
Hosts by name, because the system back gesture already picks that and an arrow
landing somewhere else would be the second of two answers to one question. The
bar's `+` raises a sheet offering the three connections this application can
make — a shell, a host's files over SFTP, a bucket — since SFTP and S3 used to
be two taps through the bottom bar's MORE and the bar is not on screen here. A
control that replaced it and led to one of the three would have quietly removed
the other two.

Two things moved rather than being dropped. The text-size buttons are pinned at
the right-hand end of the accessory key row, outside its scroller: the
connection line existed to keep them from scrolling out of reach, and being
outside the scroller answers that argument rather than abandoning it. The
dialled address moved onto the connecting card, which is the moment it is worth
reading — what is being connected to, before anything has answered — and after
that the shell's own prompt says it more accurately than a header derived from
the keychain ever did.

The sheet collapses the renderer rather than covering it. Whether Android's
`WebView` composites above Avalonia content the way Win32's child window does is
still unverified — `docs/android-port.md` has said so since the port — so this
follows the desktop's palette and gives up the rectangle outright, which is
correct under either answer. It collapses `IsTerminalShowing` and not
`IsTerminalSurface`, because the bar the sheet was raised from is part of that
surface and dropping it would take the bar, the tabs and the whole arrangement
with it, leaving the sheet floating over the page underneath.

`OnSurfaceChanged` is the one place the flag is lowered, and that is the load-
bearing half. Every way out of a terminal ends there — a destination, the files
screen, the palette connecting to a host, closing the last tab, a lock — and
each of them would otherwise leave a sheet set over a page. Not merely untidy:
the flag holds the renderer blank, so the next return to the terminal would
draw the menu again over a rectangle kept blank by it. Opening is refused off
the terminal surface for the same reason from the other direction.

The back gesture gains a guard above the switch, in the shape of the editor
guard that arrived with the phone's `+`. It is nearer than any of them: with no
header and no bottom bar, while the menu is up that gesture is the only way off
it other than the scrim and CANCEL.

The bottom bar's Terminal entry lost its `IsCurrent` binding. The bar is
collapsed on that surface, so the binding could only ever be read as false, and
a rule about a state the control cannot be in is a claim that it can.

Three tests in `ShellFlowTests`, which is where shared state-machine behaviour
for this head goes: the collapse and its recovery, the refusal to open over a
page, and the sheet lowering both by a menu entry and by a route it was never
wired to. Everything visual needs a device, so it is phase 11 of
`docs/manual-checks.md` — and 11.2 is the check that would finally settle the
compositing question this head has carried as unverified since the port.
This commit is contained in:
2026-08-03 14:33:16 +02:00
parent ce86a4ff72
commit 80ae586fc4
9 changed files with 591 additions and 172 deletions
@@ -799,8 +799,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
/// locked vault (the unlock card), the page area (every screen uses the full width), and the
/// quick-connect palette.
/// locked vault (the unlock card), the page area (every screen uses the full width), the quick-connect
/// palette, and the phone's connect sheet.
/// </para>
/// <para>
/// <b>The sheet is here rather than in <see cref="IsTerminalSurface"/>, and the palette is not.</b> The
/// palette replaces the whole surface, so collapsing everything the terminal half draws is right. The
/// sheet is raised from the terminal's own top bar and that bar has to stay on screen behind it —
/// dropping the surface would take the bar, the tabs and the phone's whole chrome with it and leave the
/// sheet floating over the page underneath. So only the renderer's rectangle is given up.
/// </para>
/// <para>
/// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
@@ -829,7 +836,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// safe — that detaches it and destroys the whole WebView2 process tree.
/// </para>
/// </remarks>
internal bool IsTerminalShowing => IsTerminalSurface && SelectedTab is { HasSession: true };
internal bool IsTerminalShowing =>
IsTerminalSurface && !IsConnectSheetOpen && SelectedTab is { HasSession: true };
/// <summary>
/// Whether the terminal half of the window is the half being shown, pane or no pane.
@@ -893,6 +901,52 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void ShowTerminal() => Surface = ShellSurface.Terminal;
/// <summary>
/// Whether the phone's connect menu is open over the terminal.
/// </summary>
/// <remarks>
/// <para>
/// Drawn by the Android head alone, and shell state rather than something that view could hold on its
/// own for the reason <see cref="IsSearching"/> is: it has to collapse the renderer while it is up. See
/// <see cref="IsTerminalShowing"/>.
/// </para>
/// <para>
/// It exists because the phone gives a terminal the whole screen. The bottom bar and the vault header
/// are gone while a shell is showing, so the three things that bar was the way to — a host, a host's
/// files, a bucket — need a way back that is not "leave the terminal first and remember what you were
/// doing". The menu is that, and every entry on it is one of the two navigation commands above.
/// </para>
/// </remarks>
[ObservableProperty]
private bool isConnectSheetOpen;
/// <summary>Raises the connect menu over the terminal.</summary>
/// <remarks>
/// Gated on the terminal surface rather than merely trusting its only button to be off screen otherwise.
/// The flag collapses the renderer, so one set while a page was showing would be a sheet nobody can see
/// holding a terminal hidden that nothing would put back.
/// </remarks>
[RelayCommand]
private void OpenConnectSheet()
{
if (!IsTerminalSurface)
{
return;
}
IsConnectSheetOpen = true;
}
/// <summary>Lowers the connect menu, leaving the terminal where it was.</summary>
/// <remarks>
/// The scrim, the CANCEL row and the system back gesture all come here. Choosing an entry does not, and
/// does not need to: every entry navigates, and leaving the terminal surface lowers the sheet on its own
/// — see <see cref="OnSurfaceChanged"/>, which is what makes "the sheet is only ever up over a terminal"
/// true of routes nobody wrote it for.
/// </remarks>
[RelayCommand]
private void CloseConnectSheet() => IsConnectSheetOpen = false;
// ---- Open terminals ----
/// <summary>
@@ -2537,7 +2591,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
/// <inheritdoc cref="OnScreenChanged" />
partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
/// <remarks>
/// <b>The one place the connect sheet is lowered by something other than a tap.</b> Every way out of a
/// terminal ends here — a rail or bottom-bar destination, the files screen, the palette connecting to a
/// host, closing the last tab, a lock — and each of them would otherwise leave the flag set on a shell
/// showing a page. That is not merely untidy: the flag collapses the renderer, so the next return to the
/// terminal would draw the sheet again over a rectangle held blank by it.
/// </remarks>
partial void OnSurfaceChanged(ShellSurface value)
{
if (value is not ShellSurface.Terminal)
{
IsConnectSheetOpen = false;
}
RaiseSurfaceState();
}
/// <remarks>
/// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
@@ -2600,6 +2669,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
partial void OnIsSearchingChanged(bool value) => RaiseTerminalState();
/// <inheritdoc cref="OnIsSearchingChanged" />
partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
/// <remarks>
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
/// <see cref="IsAskingForThePassphrase"/>.