Lay the phone out like the desktop when the surface is not a phone
ci / build and test (push) Successful in 2m54s
ci / android head (push) Failing after 3m15s
ci / api image (push) Successful in 46s

Three destinations in a bar and everything else behind SETTINGS is the right
shape at 360dp, where a fourth entry costs the width of the three that are there.
On a tablet, an unfolded foldable or a landscape phone it is the wrong one: there
is room for every destination at once, and the hub becomes an extra tap between
somebody and a screen they can already see space for.

So at 600dp — Android's own boundary between a compact window and a medium one,
in the density-independent units Avalonia lays out in — the bar stands down and
PhoneRail takes the left edge with all nine on it. It is the desktop's NavRail
arrangement rather than its file: the two heads cannot share a view, and this one
draws the phone's destination set with the phone's palette and touch targets.

The flags are computed in code rather than assembled in the markup because none
of them is a single question any more, and Avalonia's bindings have no "and" —
and the header's condition is an "or", which not even a wrapper can express. That
header is the one worth reading twice: narrow it stands down behind SETTINGS, so
the hub's screens can draw their own; wide there is no hub to be behind, so it
stays up everywhere. Losing it on the keychain would be losing the only LOCK
button on the surface.

Removing the hub means removing the routes into it, and there were four kinds.
The rail has no SETTINGS entry, because that screen is a menu of the rail. The
five back arrows in the screens under it are hidden, since an arrow to a screen
the layout removed is the one control on a header that leads nowhere. The system
back gesture goes to Hosts instead. And unfolding while sitting on the hub moves
to Hosts, rather than leaving somebody on a list of things now visible beside it.

One bug fixed on the way: OnBodyResized returned early unless the keyboard was
open, so a foldable would have opened to a phone layout until somebody typed
something. The chrome is refreshed first and unconditionally; the early return
belongs to the older job below it.

What this does not do is use the width *inside* a screen — the host list is one
column at any size. Two columns needs the row model to change, because that list
is headings and hosts in one sequence and a heading has to span, and that model
is shared with the desktop. Check 8.1 walks the rail; nothing here is covered by
a test, for the reason 8.0 exists.
This commit is contained in:
2026-08-05 20:50:18 +02:00
parent 0c61ea3a97
commit 5cb361ea13
11 changed files with 500 additions and 38 deletions
+11
View File
@@ -553,6 +553,17 @@ does not draw is anything that takes access away: REMOVE and WITHDRAW KEY act on
irreversible revocation under a thumb with its explanation in a tooltip nothing can show is the wrong irreversible revocation under a thumb with its explanation in a tooltip nothing can show is the wrong
trade. trade.
**On a surface wider than 600dp it lays itself out like the desktop.** A tablet, an unfolded foldable, a
phone in landscape or a freeform window gets a nav rail down the left carrying every destination at once,
and the three-entry bottom bar stands down with the SETTINGS hub it led to — a hub is an extra tap between
you and a screen there is visibly room for. Below that width nothing changes. The one number is Android's
own boundary between a compact window and a medium one, measured in the density-independent units Avalonia
lays out in.
What it does *not* yet do is use the extra width inside a screen: the host list is still one column at any
size. Two columns needs the row model to change — the list is headings and hosts in one sequence, and a
heading has to span — which is shared with the desktop and is its own piece of work.
**Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than **Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than
leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so
every control on that screen would have had no effect. See every control on that screen would have had no effect. See
+46 -16
View File
@@ -917,7 +917,37 @@ repository; see `docs/platform-flags.md`. Get the stack with `adb logcat -d -b c
Every check below assumes this one passed, and none of them can be reached if it did not. Every check below assumes this one passed, and none of them can be reached if it did not.
### 8.1 The + is reachable and is not covering anything ### 8.1 A wide surface draws the rail, and a narrow one draws the bar · **needs a tablet, a foldable or a resizable window**
Open the application on something at least 600dp wide — a tablet, an unfolded foldable, a phone in
landscape, or a freeform window dragged wide.
**Pass:** a rail down the left edge carrying Hosts, Connections, Keychain, Snippets, SFTP, S3, Logs, Vaults
and Preferences, with Preferences at the foot. **No bar across the bottom.** The header with the vault's
name, the sync light and LOCK is up, and stays up on every one of those screens rather than disappearing on
the keychain the way it does on a phone.
Now make it narrow again — fold it, rotate it, drag the window in.
**Pass:** the rail goes, the bottom bar comes back with its three entries, and the screens behind SETTINGS
have their back arrows again.
Go to SETTINGS on the narrow surface, then widen it without touching anything else.
**Pass:** it lands on Hosts rather than sitting on the hub. The hub is a list of the destinations the rail
now carries, so staying there would be a menu of things visible beside it.
On the wide surface, open the keychain and press the system back gesture.
**Pass:** Hosts. Not the hub — that screen is not drawn here, and backing into it would strand somebody on
a surface with no way off it but a second back.
**Failure means:** a rail *and* a bottom bar at once is `ShowsRail` and `ShowsBottomBar` no longer being
each other's opposite. A back arrow on a wide surface leads to a screen the layout removed. And a bar that
never becomes a rail is the resize handler having gone back to returning early when the keyboard is
closed — which is how a foldable would open to a phone layout until somebody typed something.
### 8.2 The + is reachable and is not covering anything
Look at the bottom right of the host list with no host selected. Look at the bottom right of the host list with no host selected.
@@ -930,7 +960,7 @@ and hold a host so the connect bar rises.
harness would catch it if it could run — its only fit rule is containment inside the window, never overlap harness would catch it if it could run — its only fit rule is containment inside the window, never overlap
between two controls, so a button sitting squarely on top of CONNECT reports clean. between two controls, so a button sitting squarely on top of CONNECT reports clean.
### 8.2 The sheet says what each choice does ### 8.3 The sheet says what each choice does
Tap the +. Tap the +.
@@ -941,7 +971,7 @@ with a sentence under it, and CANCEL. The scrim darkens the list.
not a decision, and navigating away is a fine way to answer it. Compare the changed-host-key screen, which not a decision, and navigating away is a fine way to answer it. Compare the changed-host-key screen, which
must offer no way out but BACK. must offer no way out but BACK.
### 8.3 The sheet is dismissible three ways ### 8.4 The sheet is dismissible three ways
Tap the scrim. Reopen, tap CANCEL. Reopen, use the system back gesture. Tap the scrim. Reopen, tap CANCEL. Reopen, use the system back gesture.
@@ -950,7 +980,7 @@ Tap the scrim. Reopen, tap CANCEL. Reopen, use the system back gesture.
**Failure means:** back is being handled by the screen switch rather than by the guard above it, and the **Failure means:** back is being handled by the screen switch rather than by the guard above it, and the
gesture has left HOSTS instead of closing the sheet. gesture has left HOSTS instead of closing the sheet.
### 8.4 An empty port box says what it will dial ### 8.5 An empty port box says what it will dial
Add a group with a default port of 2222, then tap + → New host and choose that group in the GROUP picker. Add a group with a default port of 2222, then tap + → New host and choose that group in the GROUP picker.
@@ -960,7 +990,7 @@ chosen. The username box behaves the same way.
**Failure means:** the editor loaded a resolved value into the box instead of a placeholder behind it — **Failure means:** the editor loaded a resolved value into the box instead of a placeholder behind it —
saving would then pin 2222 onto the host, and changing the group later would leave that host behind. saving would then pin 2222 onto the host, and changing the group later would leave that host behind.
### 8.5 A host created that way inherits rather than copies ### 8.6 A host created that way inherits rather than copies
Save that host, then edit the group's default port to 2200 and look at the host's row. Save that host, then edit the group's default port to 2200 and look at the host's row.
@@ -968,7 +998,7 @@ Save that host, then edit the group's default port to 2200 and look at the host'
**Failure means:** the port was copied at creation and the group is a one-shot template. **Failure means:** the port was copied at creation and the group is a one-shot template.
### 8.6 Filing a host into a group does not silently pin its password ### 8.7 Filing a host into a group does not silently pin its password
Add a group with a default SSH key. Add a host with no group and leave AUTHENTICATION on **Password (ask Add a group with a default SSH key. Add a host with no group and leave AUTHENTICATION on **Password (ask
each time)**. Save. Now edit it and choose the group. each time)**. Save. Now edit it and choose the group.
@@ -979,7 +1009,7 @@ each time)**. Save. Now edit it and choose the group.
**Failure means:** the picker's "Password (ask each time)" was written as a decision when it was only the **Failure means:** the picker's "Password (ask each time)" was written as a decision when it was only the
absence of one, and this host will never use the fleet's key. absence of one, and this host will never use the fleet's key.
### 8.7 A host can still refuse its group's key ### 8.8 A host can still refuse its group's key
On that same host, set AUTHENTICATION back to **Password (ask each time)** and save. On that same host, set AUTHENTICATION back to **Password (ask each time)** and save.
@@ -987,7 +1017,7 @@ On that same host, set AUTHENTICATION back to **Password (ask each time)** and s
**Failure means:** the fourth state has no storage and the host is inheriting whatever the group says. **Failure means:** the fourth state has no storage and the host is inheriting whatever the group says.
### 8.8 Back closes the topmost thing, one at a time ### 8.9 Back closes the topmost thing, one at a time
Tap + → New host, then use the back gesture. Tap + → New host, then use the back gesture.
@@ -998,7 +1028,7 @@ Repeat with the group menu: tap the `⋯` on a group heading, then back.
**Pass:** the menu closes and nothing is armed behind it — no editor, no question, no move panel. **Pass:** the menu closes and nothing is armed behind it — no editor, no question, no move panel.
### 8.9 Every target is a thumb's size ### 8.10 Every target is a thumb's size
Walk both sheets' rows, the editor's fields, both pickers, the checkbox and the four buttons. Walk both sheets' rows, the editor's fields, both pickers, the checkbox and the four buttons.
@@ -1007,7 +1037,7 @@ Walk both sheets' rows, the editor's fields, both pickers, the checkbox and the
**Failure means:** a control took its size from its glyph. The desktop suite's only tap-target assertion has **Failure means:** a control took its size from its glyph. The desktop suite's only tap-target assertion has
a floor of 20 pixels — set for a mouse — so copying it here would certify a target no thumb can hit. a floor of 20 pixels — set for a mouse — so copying it here would certify a target no thumb can hit.
### 8.10 The group menu names its group, and the heading still fits ### 8.11 The group menu names its group, and the heading still fits
Needs a vault with at least one group holding a host. On HOSTS, look at the group's heading row first. Needs a vault with at least one group holding a host. On HOSTS, look at the group's heading row first.
@@ -1024,7 +1054,7 @@ scrim closes it.
**Failure means:** if the name is missing, the sheet is bound to the wrong thing; the whole point of naming **Failure means:** if the name is missing, the sheet is bound to the wrong thing; the whole point of naming
it is that the heading it was raised from may have scrolled away under the scrim. it is that the heading it was raised from may have scrolled away under the scrim.
### 8.11 Deleting a group asks the second question ### 8.12 Deleting a group asks the second question
`⋯` → Delete on a group with at least two hosts filed under it. `⋯` → Delete on a group with at least two hosts filed under it.
@@ -1041,7 +1071,7 @@ Now tick it and press DELETE.
**Pass:** the group and its hosts are gone. Untick it on a third group and the hosts survive under UNGROUPED. **Pass:** the group and its hosts are gone. Untick it on a third group and the hosts survive under UNGROUPED.
### 8.12 Moving a group names the shelf · **needs a second writable vault** ### 8.13 Moving a group names the shelf · **needs a second writable vault**
`⋯` → Move to another vault. `⋯` → Move to another vault.
@@ -1060,7 +1090,7 @@ selection, which this head never sets. That is the exact failure the header-aime
With only one writable vault, the entry answers on the status line instead and no panel opens. That is the With only one writable vault, the entry answers on the status line instead and no panel opens. That is the
intended answer, not a failure. intended answer, not a failure.
### 8.13 DELETE under a host is not beside EDIT ### 8.14 DELETE under a host is not beside EDIT
Press and hold a host so the connect bar rises. Press and hold a host so the connect bar rises.
@@ -1076,7 +1106,7 @@ puts them back with the host still there.
the second press landing on whatever the list rebuilt underneath. the second press landing on whatever the list rebuilt underneath.
### 8.14 A tap connects, and a long press is what raises the bar · **the one worth the most care** ### 8.15 A tap connects, and a long press is what raises the bar · **the one worth the most care**
On a host bound to a key or a stored credential — one that needs nothing typed — tap the row once. On a host bound to a key or a stored credential — one that needs nothing typed — tap the row once.
@@ -1100,7 +1130,7 @@ a password host with an empty box is worse: the connection fails somewhere the u
press that connects means `held` in `HostsScreen.axaml.cs` is not suppressing the `Tapped` that follows a press that connects means `held` in `HostsScreen.axaml.cs` is not suppressing the `Tapped` that follows a
hold, so one touch fires both gestures. hold, so one touch fires both gestures.
### 8.15 The pencil in the header edits the chosen host ### 8.16 The pencil in the header edits the chosen host
With a host chosen — by a tap that connected, or by a long press — look at the header beside LOCK. With a host chosen — by a tap that connected, or by a long press — look at the header beside LOCK.
@@ -1337,7 +1367,7 @@ Tapping the hand-typed one puts its address back in the box on this screen and l
**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should **Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should
not have. If the password came back, something is storing it — it never was. And if the keychain one arrives not have. If the password came back, something is storing it — it never was. And if the keychain one arrives
with the host selected but **no bar**, `ConnectToRecent` is setting the selection rather than opening the with the host selected but **no bar**, `ConnectToRecent` is setting the selection rather than opening the
pane: since 8.14 a selection alone raises nothing, so that is a screen with nothing to press. pane: since 8.15 a selection alone raises nothing, so that is a screen with nothing to press.
### 11.9 Settings holds the keychain, and back walks out of it ### 11.9 Settings holds the keychain, and back walks out of it
@@ -52,7 +52,13 @@
<!-- ============ header ============ --> <!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0"> <Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
<!--
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
somewhere the layout has removed. See PhoneShell.IsWide.
-->
<Button Grid.Column="0" Classes="icon" Content="←" <Button Grid.Column="0" Classes="icon" Content="←"
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}" Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" /> CommandParameter="{x:Static vm:ShellScreen.More}" />
@@ -43,7 +43,13 @@
skipped it would strand the four rows underneath it. skipped it would strand the four rows underneath it.
--> -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0"> <Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0">
<!--
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
somewhere the layout has removed. See PhoneShell.IsWide.
-->
<Button Grid.Column="0" Classes="icon" Content="←" <Button Grid.Column="0" Classes="icon" Content="←"
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}" Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" /> CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Keychain" Margin="4,0" /> <TextBlock Grid.Column="1" Classes="heading" Text="Keychain" Margin="4,0" />
@@ -30,7 +30,13 @@
<!-- ============ header ============ --> <!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Height="56" Margin="8,0"> <Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Height="56" Margin="8,0">
<!--
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
somewhere the layout has removed. See PhoneShell.IsWide.
-->
<Button Grid.Column="0" Classes="icon" Content="←" <Button Grid.Column="0" Classes="icon" Content="←"
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}" Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" /> CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Logs" Margin="4,0" /> <TextBlock Grid.Column="1" Classes="heading" Text="Logs" Margin="4,0" />
@@ -0,0 +1,215 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.Android.Views.PhoneRail"
x:DataType="vm:MainWindowViewModel">
<!--
◆ The destinations down the left edge, on a surface too wide to be a phone.
This is the desktop head's NavRail arrangement rather than a second design: a column of destinations at
the left, each a glyph and a word, the current one lit. What it is not is that head's *file* — the two
heads cannot share a view, and this one draws the phone's destination set with the phone's palette and
the phone's touch targets.
── WHY IT EXISTS AT ALL ────────────────────────────────────────────────────────────────────────────────
The bottom bar carries three entries and everything else lives one tap deeper behind SETTINGS. That is
the right shape at 360dp, where a fourth entry costs the width of the three that are there. It is the
wrong shape on a tablet or an unfolded foldable, where there is room for every destination at once and
the hub becomes an extra tap between the user and a screen they can already see space for.
So above the breakpoint the bar stands down and this takes its place, with all nine destinations that
were split between the bar and the hub. SETTINGS is not among them: it is a list of the things on this
rail, and a rail entry leading to a menu of the rail would be furniture.
── WHAT IT SHOWS THAT THE HUB DID NOT ──────────────────────────────────────────────────────────────────
A count beside Hosts and Keychain, which is what the desktop rail's extra width buys there too. Only
where a count is real: Logs has no total until it is read and Preferences is not a quantity, so those
show nothing rather than a zero — a number the application never computed is worse than a blank.
── SFTP AND S3 ARE HERE, WHERE THE DESKTOP PUTS THEM IN ITS TAB STRIP ──────────────────────────────────
That head has fixed tabs for the two destinations that are not about the keychain; this one has no tab
strip, and its hub already listed them beside the rest. Putting them anywhere else would be inventing a
third arrangement for one screen's worth of difference.
── STYLES ARE LOCAL, COLOURS ARE NOT ───────────────────────────────────────────────────────────────────
The .railentry styles live in this file because they are this control's own shape and nothing else wears
them; every colour in them is resolved from Theme/Palette.axaml by key, which is the rule that matters —
a rail that named its own blues is how a fifth surface colour ends up in the application.
-->
<UserControl.Styles>
<Style Selector="Button.railentry">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="12,0" />
<!-- 48, not the bar's 64: a rail entry is a row rather than a target at the screen's edge, and nine
of them at 64 would not fit the shortest surface this layout is drawn on. Still above the 44
every other touch target here is held to. -->
<Setter Property="MinHeight" Value="48" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
<Style Selector="Button.railentry:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Panel}" />
</Style>
<Style Selector="Button.railentry.active /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
</Style>
<Style Selector="Button.railentry TextBlock.railglyph">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Width" Value="24" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Button.railentry TextBlock.raillabel">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<Style Selector="Button.railentry TextBlock.railcount">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!-- Three signals for the current entry rather than one, which is the rule NavButton already states for
the bar it replaces: colour alone at this size is what a user with a colour deficiency is left
with. Here it is the filled row, the accent, and the weight. -->
<Style Selector="Button.railentry.active TextBlock.railglyph">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<Style Selector="Button.railentry.active TextBlock.raillabel">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
</UserControl.Styles>
<Border Width="188" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
<!--
DockPanel with LastChildFill false, exactly as the desktop rail does it: Preferences is pinned to the
foot because it is the one entry about the machine rather than about the keychain, and everything else
flows from the top. A ScrollViewer over the top group so that a short surface — a foldable's cover
display, a small window in Android's freeform mode — scrolls the list rather than clipping it.
-->
<DockPanel LastChildFill="False">
<StackPanel DockPanel.Dock="Bottom" Margin="8,0,8,10">
<Button Classes="railentry" Classes.active="{Binding IsPreferencesShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="⚙" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Preferences" />
</Grid>
</Button>
</StackPanel>
<ScrollViewer DockPanel.Dock="Top" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8,10,8,0" Spacing="2">
<Button Classes="railentry" Classes.active="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="▤" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Hosts" />
<TextBlock Grid.Column="2" Classes="railcount" Text="{Binding Vault.Hosts.Count}" />
</Grid>
</Button>
<!--
The terminal is a surface rather than a page, so it goes through its own command — the same
reason the bottom bar's middle entry does. It is never lit, and deliberately: this rail is
collapsed while a shell is showing, so IsCurrent could only ever read false. What marks that
destination as current is the surface filling the screen.
No count, unlike the two above it. The shells strip sits above the bottom of every page on this
surface and is that count, drawn as the sessions themselves — a number beside this word would be
the same fact said twice, three inches apart.
-->
<Button Classes="railentry" Command="{Binding ShowTerminalCommand}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
</Grid>
</Button>
<Border Height="1" Background="{StaticResource Border}" Margin="4,8" />
<Button Classes="railentry" Classes.active="{Binding IsKeychainShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Keychain}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="⚿" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Keychain" />
<TextBlock Grid.Column="2" Classes="railcount" Text="{Binding Vault.Keys.Count}" />
</Grid>
</Button>
<Button Classes="railentry" Classes.active="{Binding IsSnippetsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Snippets" />
</Grid>
</Button>
<Button Classes="railentry" Classes.active="{Binding IsTransfersShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="⇅" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="SFTP" />
</Grid>
</Button>
<Button Classes="railentry" Classes.active="{Binding IsBucketsShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="◳" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="S3" />
</Grid>
</Button>
<Button Classes="railentry" Classes.active="{Binding IsLogsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="≡" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Logs" />
</Grid>
</Button>
<Button Classes="railentry" Classes.active="{Binding IsVaultsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vaults}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="◎" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Vaults" />
</Grid>
</Button>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Border>
</UserControl>
@@ -0,0 +1,15 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>The destinations down the left edge, on a surface too wide to be a phone.</summary>
/// <remarks>
/// No code at all, unlike every other view on this head: it is buttons bound to commands the shell already
/// has, and the one thing that decides whether it is drawn — the width — belongs to <c>PhoneShell</c>,
/// which is the control that measures the surface.
/// </remarks>
internal sealed partial class PhoneRail : UserControl
{
public PhoneRail() => AvaloniaXamlLoader.Load(this);
}
@@ -52,26 +52,52 @@
<views:LockedScreen IsVisible="{Binding IsLocked}" /> <views:LockedScreen IsVisible="{Binding IsLocked}" />
<!-- ============ unlocked ============ --> <!-- ============ unlocked ============ -->
<Grid IsVisible="{Binding IsUnlocked}" RowDefinitions="Auto,*,Auto,Auto"> <!--
◆ 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. The header: which vault, and whether it is synced.
Hidden behind SETTINGS, and that is the design's arrangement rather than a saving. v2 gives every Hidden behind SETTINGS on a narrow surface, and that is the design's arrangement rather than a
screen one header carrying that screen's own name and its own actions — a back arrow, an add, a saving. v2 gives every screen one header carrying that screen's own name and its own actions — a
refresh — so the hub's screens draw their own and this one stands down rather than stacking a second back arrow, an add, a refresh — so the hub's screens draw their own and this one stands down rather
row of chrome above theirs. What is left is HOSTS, which is the screen the application opens on and than stacking a second row of chrome above theirs. What is left is HOSTS, which is the screen the
the one where the vault's name and the sync light are the most useful thing a header could say. application opens on and the one where the vault's name and the sync light are the most useful thing
a header could say.
Wrapped rather than given a second condition, because Avalonia's bindings have no "and": the wrapper ◆ One flag where there were two nested conditions, and it gained a third meaning with the rail. It
collapses it over a terminal, where the surface draws its own bar and the vault's name is not what still stands down over a terminal, and still stands down behind SETTINGS on a narrow surface — but
the user is looking at. That is one of three rows this Grid stands down while a shell is showing — on a wide one it stays up everywhere, because there is no hub to be behind and the screens on the
see the strip and the bottom bar below. 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 IsShowingPages}"> <Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultHeader}">
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}" <Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="14,0" Height="56" BorderThickness="0,0,0,1" Padding="14,0" Height="56">
IsVisible="{Binding !IsMoreSurface}">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto"> <Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<!-- <!--
@@ -211,7 +237,10 @@
--> -->
<Grid IsVisible="{Binding IsPreferencesShowing}" RowDefinitions="Auto,*"> <Grid IsVisible="{Binding IsPreferencesShowing}" RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0"> <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}" <Button Grid.Column="0" Classes="icon" Content="←" Command="{Binding ShowScreenCommand}"
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
CommandParameter="{x:Static vm:ShellScreen.More}" /> CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Preferences" Margin="4,0" /> <TextBlock Grid.Column="1" Classes="heading" Text="Preferences" Margin="4,0" />
</Grid> </Grid>
@@ -283,8 +312,8 @@
destinations are replaced by a back arrow and a + that leads to all of them, both in the terminal's 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. own bar. See TerminalScreen.axaml.
This one is bound directly rather than wrapped — its visibility is a single question and it has no ◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
second condition of its own to keep separate. the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
── three rather than four ───────────────────────────────────────────────────────────────────────── ── three rather than four ─────────────────────────────────────────────────────────────────────────
The keychain moved behind SETTINGS. A bottom bar is for the places a session moves between, and keys, The keychain moved behind SETTINGS. A bottom bar is for the places a session moves between, and keys,
@@ -292,7 +321,7 @@
shape of everything else already behind the hub. What is left is the two halves of using this 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. application, and the drawer holding the rest.
--> -->
<Border Grid.Row="3" IsVisible="{Binding IsShowingPages}" <Border Grid.Row="3" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Height="64"> BorderThickness="0,1,0,0" Height="64">
<Grid ColumnDefinitions="*,*,*"> <Grid ColumnDefinitions="*,*,*">
@@ -338,6 +367,8 @@
</Grid> </Grid>
</Grid>
</Panel> </Panel>
</UserControl> </UserControl>
@@ -44,6 +44,23 @@ internal sealed partial class PhoneShell : UserControl
private bool offeredDeviceUnlock; private bool offeredDeviceUnlock;
/// <summary>
/// The width above which this surface is laid out like the desktop.
/// </summary>
/// <remarks>
/// <para>
/// 600, which is Android's own boundary between a compact window and a medium one, and is where a
/// tablet, an unfolded foldable and a landscape phone land on the far side. It is measured in the units
/// Avalonia lays out in, which are density-independent — so this is 600dp and not 600 physical pixels,
/// and a 1080-pixel phone at 3× density is correctly on the narrow side of it.
/// </para>
/// <para>
/// One number rather than a set of them. Android names three window classes and this head has two
/// layouts, so a second breakpoint would be a third arrangement nothing has been designed for.
/// </para>
/// </remarks>
private const double WideAt = 600;
public PhoneShell() public PhoneShell()
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
@@ -72,9 +89,105 @@ internal sealed partial class PhoneShell : UserControl
thisLockIsTheLaunch = true; thisLockIsTheLaunch = true;
TryOfferDeviceUnlock(); TryOfferDeviceUnlock();
} }
RefreshChrome();
}; };
} }
/// <summary>Whether this surface is wide enough to be laid out like the desktop.</summary>
/// <remarks>
/// A property of the control rather than of the view model, because it is a fact about the surface and
/// the view models are shared with a head where it is always true. The markup reads it through
/// <c>$parent[views:PhoneShell]</c>.
/// </remarks>
public static readonly StyledProperty<bool> IsWideProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
/// <summary>Whether the rail down the left edge is drawn.</summary>
public static readonly StyledProperty<bool> ShowsRailProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
/// <summary>Whether the three-entry bar across the bottom is drawn.</summary>
public static readonly StyledProperty<bool> ShowsBottomBarProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
/// <summary>Whether the header carrying the vault's name, the sync light and LOCK is drawn.</summary>
public static readonly StyledProperty<bool> ShowsVaultHeaderProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultHeader));
/// <inheritdoc cref="IsWideProperty" />
public bool IsWide
{
get => GetValue(IsWideProperty);
private set => SetValue(IsWideProperty, value);
}
/// <inheritdoc cref="ShowsRailProperty" />
public bool ShowsRail
{
get => GetValue(ShowsRailProperty);
private set => SetValue(ShowsRailProperty, value);
}
/// <inheritdoc cref="ShowsBottomBarProperty" />
public bool ShowsBottomBar
{
get => GetValue(ShowsBottomBarProperty);
private set => SetValue(ShowsBottomBarProperty, value);
}
/// <inheritdoc cref="ShowsVaultHeaderProperty" />
public bool ShowsVaultHeader
{
get => GetValue(ShowsVaultHeaderProperty);
private set => SetValue(ShowsVaultHeaderProperty, value);
}
/// <summary>
/// Works out which chrome this surface should be wearing.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Three flags computed here rather than three conditions in the markup, because Avalonia's
/// bindings have no "and" and none of these is a single question any more.</b> Everywhere else on this
/// head that costs a wrapper element; here it would cost two nested ones per row and the header's would
/// have to be an "or", which a wrapper cannot express at all.
/// </para>
/// <para>
/// <b>The header is the one worth reading twice.</b> Narrow, it stands down behind SETTINGS, because the
/// screens under that hub draw their own header with a back arrow and two rows of chrome is what this
/// surface exists to avoid. Wide, there is no hub to be behind and no back arrow to duplicate — the rail
/// is how you leave — so the vault's name, the sync light and LOCK stay where they are on every screen.
/// Losing them on the keychain would be losing the only LOCK button on the surface.
/// </para>
/// <para>
/// Recomputed on every shell notification rather than on a named list of them. Three boolean
/// comparisons and no allocation is cheaper than being wrong: the properties this reads are computed
/// ones, and which of them raise a change is a fact about a file in another project that nothing here
/// would notice going stale.
/// </para>
/// </remarks>
private void RefreshChrome()
{
var wide = body.Bounds.Width >= WideAt;
var pages = shell?.IsShowingPages == true;
// Before the flags, because it changes what one of them reads. Nothing else on this head navigates
// in response to a resize, and this is not navigation for its own sake: the hub is a list of the
// destinations the rail now carries, so an unfolded device would otherwise sit on a menu of things
// it can already see. Only from the hub itself — a screen reached through it stays put, because the
// user asked for that screen rather than for the menu.
if (wide && !IsWide && shell is { Screen: ShellScreen.More })
{
shell.ShowScreenCommand.Execute(ShellScreen.Hosts);
}
IsWide = wide;
ShowsRail = wide && pages;
ShowsBottomBar = !wide && pages;
ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
}
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{ {
if (shell is null) if (shell is null)
@@ -82,6 +195,8 @@ internal sealed partial class PhoneShell : UserControl
return; return;
} }
RefreshChrome();
if (e.PropertyName is nameof(MainWindowViewModel.State)) if (e.PropertyName is nameof(MainWindowViewModel.State))
{ {
ApplyScreenshotPolicy(shell.State); ApplyScreenshotPolicy(shell.State);
@@ -228,14 +343,22 @@ internal sealed partial class PhoneShell : UserControl
} }
/// <summary> /// <summary>
/// Scrolls whatever has the keyboard back into view once the room left for it is known. /// Answers a resize: which chrome this surface wears, and where the keyboard left the focused box.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The one moment this is needed is the one no other handler sees: the box was focused while the whole /// ◆ <b>Two jobs, and the chrome's is unconditional while the keyboard's is not.</b> A rotation, an
/// screen was available, and the space it sits in shrank afterwards. Both ways of losing that space end /// unfold and Android's freeform window all arrive here and all of them can cross the width at which
/// here — the margin applied above, and the platform shortening the window on Android 14 and earlier — /// this surface stops being a phone — so <see cref="RefreshChrome"/> runs first and runs always. What
/// which is why the resize is the trigger rather than either of the two things that cause it. /// follows it is the older job, and the early return below belongs to that one alone: it used to be the
/// first thing in this method, which would have meant a foldable opening to a bottom bar until somebody
/// typed something.
/// </para>
/// <para>
/// The one moment the scroll is needed is the one no other handler sees: the box was focused while the
/// whole screen was available, and the space it sits in shrank afterwards. Both ways of losing that
/// space end here — the margin applied above, and the platform shortening the window on Android 14 and
/// earlier — which is why the resize is the trigger rather than either of the two things that cause it.
/// </para> /// </para>
/// <para> /// <para>
/// Posted rather than called, and at <c>Loaded</c> priority, because the size change is raised during /// Posted rather than called, and at <c>Loaded</c> priority, because the size change is raised during
@@ -249,6 +372,8 @@ internal sealed partial class PhoneShell : UserControl
/// </remarks> /// </remarks>
private void OnBodyResized(object? sender, SizeChangedEventArgs e) private void OnBodyResized(object? sender, SizeChangedEventArgs e)
{ {
RefreshChrome();
if (keyboard is not { State: InputPaneState.Open }) if (keyboard is not { State: InputPaneState.Open })
{ {
return; return;
@@ -349,7 +474,12 @@ internal sealed partial class PhoneShell : UserControl
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults
or ShellScreen.Keychain: or ShellScreen.Keychain:
current.ShowScreenCommand.Execute(ShellScreen.More);
// ◆ The hub, or Hosts where there is no hub. A wide surface draws the rail instead and
// never draws SETTINGS, so backing out to it would land on a screen with no way off it but
// a second back — and the arrows in these screens' own headers are hidden there for the
// same reason. Home is where back goes when the thing you came from is not on the surface.
current.ShowScreenCommand.Execute(IsWide ? ShellScreen.Hosts : ShellScreen.More);
e.Handled = true; e.Handled = true;
break; break;
@@ -28,7 +28,13 @@
<!-- ============ header ============ --> <!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Height="56" Margin="8,0"> <Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Height="56" Margin="8,0">
<!--
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
somewhere the layout has removed. See PhoneShell.IsWide.
-->
<Button Grid.Column="0" Classes="icon" Content="←" <Button Grid.Column="0" Classes="icon" Content="←"
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}" Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" /> CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Snippets" Margin="4,0" /> <TextBlock Grid.Column="1" Classes="heading" Text="Snippets" Margin="4,0" />
@@ -80,7 +80,13 @@
<!-- ============ header ============ --> <!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto" Height="56" Margin="8,0"> <Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto" Height="56" Margin="8,0">
<!--
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
somewhere the layout has removed. See PhoneShell.IsWide.
-->
<Button Grid.Column="0" Classes="icon" Content="←" <Button Grid.Column="0" Classes="icon" Content="←"
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}" Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" /> CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Vaults" Margin="4,0" /> <TextBlock Grid.Column="1" Classes="heading" Text="Vaults" Margin="4,0" />