Files
DodoSSH/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
T
jaap-jan cddfeb1f55 Keep the phone's nav under Connections when nothing is running
The chrome stands down for a shell, and it was standing down for the whole
terminal surface. Those parted company when that surface gained a connect page:
with no tabs open it draws a box, a CONNECT button and the machines connected to
before, which is a page in everything but which enum it is in. A third of the
display is worth giving to a shell and is not worth giving to that. Worse, it is
the one screen somebody arrives at by closing their last tab — so the state the
collapsed bar was most likely to be seen in was the state where it left the
system back gesture as the only route to Hosts or Settings.

So RefreshChrome reads one more question. IsTerminalSurface with no tabs joins
the pages in both flags, which keeps the rail and the bar in step: above 600dp
the rail is the bar, and fixing only the narrow layout would leave an unfolded
device on the same screen with the same nothing. The vault header is deliberately
not part of it. The surface draws its own bar with back and the +, and a header
above that is the second row of chrome this head exists to avoid.

The Connections entry lights for the first time, on IsTerminalSurface. It was
left unbound on the argument that the bar was never drawn while that surface was
up, so a lit state was unreachable — that argument is now false, and the flag is
unambiguous on a control that is only drawn in two situations: false on every
page, true on the connect page, and never read while a shell is showing. A bar
sitting under a screen it does not point at is the entry looking broken instead.

Nothing here is testable on this head — the phone's rectangles have no coverage,
for the reasons Phase 8 of manual-checks records — so 11.7 gains the check that
the bar is there with Connections lit, and 11.1 keeps the one that it is gone
with a shell up, which is the half that pays for the arrangement.
2026-08-06 07:38:31 +02:00

384 lines
23 KiB
XML

<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.PhoneShell"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource Canvas}">
<!--
The phone's single view, and the counterpart of the desktop head's MainWindow — except that this one has
no window, no nav rail, no titlebar and no status bar.
It switches on ShellState, which is the same six states the desktop has because both heads share one
state machine. What differs is only what each one draws.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
The desktop's eight rail destinations become four in a bottom bar, and the rest live one tap deeper
behind MORE: snippets, SFTP, S3, logs, preferences — and teams, which v2 did not draw and which is
argued for on the screen itself. That is the v2 design's own arrangement, and it replaces the first
design's four, which had nothing behind them at all. Five characters was a desktop constraint and the
phone uses words.
The order is the design's rather than the rail's. Terminal sits second, beside Hosts, because those two
are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control
computes, and the bar's is the one that differs — it stays up on Connections with nothing running, which
is the terminal surface drawing a page rather than a shell. See PhoneShell.RefreshChrome.
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
What takes their place is one 35-pixel bar drawn by the surface itself, carrying back on the left and
the sessions and a + across from it. See TerminalScreen.axaml.
-->
<!--
Named, and the name is load-bearing: everything the phone draws is inside this one element, so its
bottom margin is the single place the software keyboard can be kept off the box being typed into,
whichever of the eleven screens is showing. See PhoneShell.axaml.cs.
-->
<Panel x:Name="Body">
<!-- ============ getting in ============ -->
<views:PendingScreen IsVisible="{Binding IsStarting}"
Heading="OPENING THE KEYCHAIN"
Detail="Reading this phone's local cache to find out whether it is enrolled." />
<views:ServerScreen IsVisible="{Binding IsNeedingServer}" />
<views:EnrollScreen IsVisible="{Binding IsNeedingEnrollment}" />
<views:RecoveryCodeScreen IsVisible="{Binding IsShowingRecoveryCode}" />
<views:LockedScreen IsVisible="{Binding IsLocked}" />
<!-- ============ unlocked ============ -->
<!--
◆ TWO LAYOUTS, AND THE WIDTH DECIDES WHICH.
Below 600dp this is the phone it always was: a header, the screen, the shells strip and a three-entry
bar across the bottom, with everything else one tap deeper behind SETTINGS. That is right at 360dp,
where a fourth bar entry costs the width of the three that are there.
At or above it — a tablet, an unfolded foldable, a landscape phone, a freeform window — the bar stands
down and PhoneRail takes the left edge with every destination on it at once, which is the desktop
head's arrangement. The hub is not one of them: it is a menu of the rail.
The three flags are computed in code rather than assembled here, because none of them is a single
question any more and Avalonia's bindings have no "and" — and the header's is an "or", which not even
a wrapper can express. See PhoneShell.RefreshChrome, which is also where the one behavioural
consequence lives: unfolding while sitting on the hub moves to Hosts, rather than leaving somebody on
a list of things they can now see beside it.
An outer Grid of two columns rather than a DockPanel, so the rail's width is the rail's own business
and the content takes what is left.
-->
<Grid IsVisible="{Binding IsUnlocked}" ColumnDefinitions="Auto,*">
<views:PhoneRail Grid.Column="0"
IsVisible="{Binding $parent[views:PhoneShell].ShowsRail}" />
<Grid Grid.Column="1" RowDefinitions="Auto,*,Auto,Auto">
<!--
The header: which vault, and whether it is synced.
Hidden behind SETTINGS on a narrow surface, and that is the design's arrangement rather than a
saving. v2 gives every screen one header carrying that screen's own name and its own actions — a
back arrow, an add, a refresh — so the hub's screens draw their own and this one stands down rather
than stacking a second row of chrome above theirs. What is left is HOSTS, which is the screen the
application opens on and the one where the vault's name and the sync light are the most useful thing
a header could say.
◆ One flag where there were two nested conditions, and it gained a third meaning with the rail. It
still stands down over a terminal, and still stands down behind SETTINGS on a narrow surface — but
on a wide one it stays up everywhere, because there is no hub to be behind and the screens on the
rail draw no header of their own. Losing it on the keychain would be losing the only LOCK button on
the surface. See PhoneShell.ShowsVaultHeader.
-->
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultHeader}">
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="14,0" Height="56">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<!--
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
fact about the vault, and the design gives it the accent as a solid tile — which is also what
the launcher icon draws, so the two agree.
-->
<Border Grid.Column="0" Width="26" Height="26" CornerRadius="8"
Background="{StaticResource Accent}" VerticalAlignment="Center">
<TextBlock Text="&gt;_" Foreground="{StaticResource AccentInk}"
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Grid.Column="1" Classes="heading" Margin="10,0,8,0" FontSize="16"
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
<!--
The sync light, and it is green only when it has earned it — see SyncLabel. The design draws
a permanently green "Synced" here, which is the one claim on that mock-up this application
will not make.
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="9" Text="{Binding SyncLabel}" />
</StackPanel>
<!--
◆ THE PENCIL, and it is here rather than on the row for the reason the whole gesture split
happened: choosing a host on this head no longer raises a bar carrying EDIT, because that bar
was five controls over the bottom of the list in the way of a tap that means "connect". EDIT is
the one of the five common enough to be worth a control that is always in the same place, so it
is in the header — the phone's only piece of persistent chrome — and the long press still
reaches the other four. See HostsScreen.axaml.
Two conditions, nested rather than combined, because Avalonia's bindings have no "and" and the
two belong to different view models: which screen is showing is the shell's question, and
whether there is a host to edit is the vault's. That is the same arrangement the header itself
is wrapped in one level up.
Collapsed rather than disabled when there is nothing chosen. A greyed pencil sitting beside the
vault's name on every screen would be a permanent reminder of a control that is only ever about
one row.
-->
<Panel Grid.Column="3" IsVisible="{Binding IsHostsShowing}">
<Button Classes="icon" Content="✎" FontSize="15" Margin="4,0,0,0"
IsVisible="{Binding Vault.CanEditSelectedHost}"
Command="{Binding Vault.EditSelectedHostCommand}"
ToolTip.Tip="Edit the selected host" />
</Panel>
<Button Grid.Column="4" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
ToolTip.Tip="Lock the keychain">
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
Foreground="{StaticResource TextDim}" />
</Button>
</Grid>
</Border>
</Panel>
<!-- The screens. Only one draws; which one is the shell's business. -->
<Panel Grid.Row="1">
<!--
Wrapped rather than bound directly, and the desktop head's MainWindow carries the same note for
the same reason: this element's visibility is the shell's business and its data context is the
vault, and putting both on one element resolves IsVisible against the vault — where
IsHostsShowing does not exist. It is a compile error here rather than a silent blank screen only
because compiled bindings are on.
-->
<Panel IsVisible="{Binding IsHostsShowing}">
<views:HostsScreen DataContext="{Binding Vault}" />
</Panel>
<Panel IsVisible="{Binding IsKeychainShowing}">
<views:KeychainScreen DataContext="{Binding Vault}" />
</Panel>
<!--
IsTerminalSurface rather than IsTerminalShowing: a tab exists from the moment connecting starts and
its session does not, so this screen has to be on show while there is nothing to render — it is
what says which machine is being connected to. Which of the two things inside it draws is decided
in the screen itself. See TerminalTabViewModel.
-->
<views:TerminalScreen IsVisible="{Binding IsTerminalSurface}" />
<!--
============ under MORE ============
The hub itself takes the shell as its data context, because every row on it is a navigation
command; the destinations behind it each take the view model they are about, so each one is
wrapped. SnippetsScreen and LogsScreen are nullable on the shell — they are rebuilt on every
unlock and nulled on lock — and it is the collapsed wrapper that keeps a template from binding
against nothing.
-->
<views:MoreScreen IsVisible="{Binding IsMoreShowing}" />
<Panel IsVisible="{Binding IsSnippetsShowing}">
<views:SnippetsScreen DataContext="{Binding SnippetsScreen}" />
</Panel>
<Panel IsVisible="{Binding IsLogsShowing}">
<views:LogsScreen DataContext="{Binding LogsScreen}" />
</Panel>
<!--
The sixth destination behind MORE, and the one v2 never drew — see the comment on the screen
itself. Wrapped like its neighbours even though Vaults is not nullable: the reason for the
wrapper is the data context, not the null. IsVaultsShowing is the shell's and Vaults is not the
shell.
Vaults, not Vault: this one is the vaults themselves and the people in them, where the other is
one vault's contents and is what the hosts and keychain screens draw.
-->
<Panel IsVisible="{Binding IsVaultsShowing}">
<views:VaultsScreen DataContext="{Binding Vaults}" />
</Panel>
<!--
One screen for both file destinations. SFTP and S3 differ in which picker they offer and in
nothing else below it — the panes, the queue and the transfers are the same IRemoteFileStore
either way — so drawing them twice would be two copies of a screen that has to stay identical.
Which picker shows is TransfersViewModel.Remote, set on arrival by the shell.
-->
<Panel IsVisible="{Binding IsTransfersShowing}">
<views:FilesScreen DataContext="{Binding Transfers}" />
</Panel>
<Panel IsVisible="{Binding IsBucketsShowing}">
<views:FilesScreen DataContext="{Binding Transfers}" />
</Panel>
<!--
Preferences carries the header its siblings under MORE have, written here rather than inside the
screen: the screen is one of two that hang off this hub with a back arrow, and the arrow belongs
to the shell that knows where back is.
It held a PendingScreen until it had a setting on it. The one it gained is this phone's device
key — the fingerprint the lock screen offers and that nothing here could previously enrol.
-->
<Grid IsVisible="{Binding IsPreferencesShowing}" RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0">
<!-- Gone on a wide surface, with the five on the screens under the hub and for the reason
written there. -->
<Button Grid.Column="0" Classes="icon" Content="←" Command="{Binding ShowScreenCommand}"
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Preferences" Margin="4,0" />
</Grid>
<views:PreferencesScreen Grid.Row="1" />
</Grid>
</Panel>
<!--
◆ The two host-key decisions, over everything. Outside the screen Panel deliberately: file transfer
makes its own trust decision on the desktop, and the phone now has that screen — so a sheet nested
inside the hosts screen would be one the files screen could not raise.
-->
<Panel Grid.Row="1">
<views:HostKeySheet DataContext="{Binding Vault}" />
</Panel>
<!--
The shells strip. The design puts it above the nav on every screen, and that is the single most
desktop-shaped idea in the product — it is what makes a terminal a surface you switch to rather than
a screen you navigate away from. Kept, because losing it would make an open shell invisible from the
host list, which is where you are when you open the next one.
v2 draws the sessions as pills rather than as a labelled row, and drops the word SHELLS: with a
rounded chip carrying a live dot and a name, the label was spending nine characters of a 360dp row
saying what the row already looks like.
On every screen except the one it names. The terminal draws these same sessions in its own bar, and
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
whether there are any tabs and this one is about which surface is up.
-->
<Panel Grid.Row="2" IsVisible="{Binding IsShowingPages}">
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="9"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green only while there is a shell behind the tab. It used to be lit unconditionally,
which was true when a tab could not exist without a session; one can now —
connecting opens the tab first — and a dot that was green before anything had
answered would be the one thing on this strip claiming something untrue.
-->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</Panel>
<!-- ============ navigation ============ -->
<!--
Gone while a shell is showing, which is the whole of that surface's arrangement: the bar's three
destinations are replaced by a back arrow and a + that leads to all of them, both in the terminal's
own bar. See TerminalScreen.axaml.
◆ A shell rather than the surface, which are not the same question — Connections with nothing running
is that surface drawing a page, and the bar stays under it. The screen it stands down for is the one
that pays for the room; a box asking which machine to connect to does not, and taking the nav away
from somebody who has just closed their last tab left them with only the back gesture.
◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
── three rather than four ─────────────────────────────────────────────────────────────────────────
The keychain moved behind SETTINGS. A bottom bar is for the places a session moves between, and keys,
credentials and tags are not that — they are managed occasionally and then left alone, which is the
shape of everything else already behind the hub. What is left is the two halves of using this
application, and the drawer holding the rest.
-->
<Border Grid.Row="3" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Height="64">
<Grid ColumnDefinitions="*,*,*">
<views:NavButton Grid.Column="0" Label="Hosts" Glyph="▤" IsCurrent="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}" />
<!--
The terminal is a surface rather than a page — see ShellSurface — so this one does not go
through ShowScreen. Its own command is on the shell.
"Connections" rather than "Terminal", and the word does more work than a rename usually does:
with nothing open this surface is where a connection is *made* — a machine typed in by hand, and
the ones connected to before — so a tab labelled Terminal would name the thing behind the door
rather than the door. The enum member stays ShellSurface.Terminal, for the reason the keychain's
tab is not called Vault: the surface is a terminal, and the word a user reads is the product's.
◆ It lights again, and IsTerminalSurface is the right question rather than a near miss. This bar
is drawn in exactly two situations — a page, and Connections with nothing running — and that flag
separates them: false for every page, true for the one screen where this is where you are. With a
shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was
collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the
bar can now sit under and not point at is the entry looking broken instead.
-->
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
IsCurrent="{Binding IsTerminalSurface}"
Command="{Binding ShowTerminalCommand}" />
<!--
IsMoreSurface rather than IsMoreShowing: this tab stands for the hub and everything behind it,
and a bar that went dark the moment you opened one of them would only ever light two of its
three entries.
"Settings" and a gear, where it was "More" and a hamburger. The hub held a handful of things
that did not fit and could not be called anything better than More; with the keychain, teams and
preferences all on it, it is the place everything that is not connecting to something lives —
which is what a gear has meant on a phone since before this application existed.
-->
<views:NavButton Grid.Column="2" Label="Settings" Glyph="⚙" IsCurrent="{Binding IsMoreSurface}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
</Grid>
</Border>
</Grid>
</Grid>
</Panel>
</UserControl>