Merge branch 'main' into claude/angry-cray-f3d496

# Conflicts:
#	README.md
This commit is contained in:
2026-08-03 14:58:57 +02:00
57 changed files with 8090 additions and 400 deletions
@@ -1,25 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The launcher mark, and it is the same mark ServerScreen and LockedScreen draw: a square
outline in the accent with >_ inside it. Redrawn as a vector rather than exported as a
bitmap so there is one geometry to change and no set of five PNG densities to forget one
of.
The launcher mark, and it is the same mark PhoneShell's header and the desktop titlebar draw:
>_ in the canvas colour on a solid accent tile. Filled rather than outlined since v2.
#5B8CFF is AccentColor from DodoSSH.Client.Shell's Theme/Palette.axaml, written out
because an Android resource cannot reference a XAML dictionary. The same duplication
colors.xml already carries for the window background, and the same rule applies: if the
palette moves, this moves with it.
The tile is not in this file. It is the background layer — @color/dodo_accent, see ic_launcher.xml
— and that is the whole trick of the filled design on Android: the rounding a launcher applies is
its mask, so letting the mask make the tile gets a squircle on one device and a circle on another
without either being drawn here. A rounded rectangle painted into this layer would be a second
rounded shape inside the first, visibly clipped at the corners on any device whose mask is not the
one it was drawn for.
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer
18 on each edge is what the launcher eats for masking and parallax. That 72 is a width,
though, and the mark is a square: its corners are what a circular mask reaches first. At
48 across the corners land 33.9 out against a radius of 36 and read as clipped even
though they technically clear it. 42 puts them at 29.7, which is margin one can see.
#0E1220 is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
Android resource cannot reference a XAML dictionary. It equals Canvas today and is named
separately in the palette for a reason worth keeping in mind here: this is ink on the accent, not
the window behind it, and it follows AccentInk if the two ever part.
The stroke widths are the one place this deliberately departs from the screen. In the app
the box is a 1px border on 44px; scaled honestly that would be 1.0 here, and a launcher
drawing this at 48dp would render it at half a pixel and show nothing. 2.2 and 2.8 are
what keep it reading as the same hairline mark at the size it is actually looked at.
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer 18 on each
edge is what the launcher eats for masking and parallax. The glyph spans 36.06..71.94, which is
half the width of that 72 and centred in it. That half is taken from the headers rather than
invented: the phone draws >_ at font size 10 on a 26px tile and the titlebar at 9 on 20px, both a
little under half the tile across, and a launcher icon is looked at from further away than either.
The stroke width is the one place this deliberately departs from the screen. In the app the glyph
is a bold mono face whose stems come out near a fifth of its height; 5.4 here is nearer a quarter,
which is what keeps it reading as the same mark at the size it is actually looked at.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
@@ -27,28 +31,21 @@
android:viewportWidth="108"
android:viewportHeight="108">
<!-- The box: 42 across, centred, square-cornered as the Border in the app is. -->
<path
android:pathData="M33,33 L75,33 L75,75 L33,75 Z"
android:fillColor="#00000000"
android:strokeColor="#5B8CFF"
android:strokeWidth="2.2" />
<!-- The chevron of >_ -->
<path
android:pathData="M44.8,48.75 L51.9,54 L44.8,59.25"
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
android:fillColor="#00000000"
android:strokeColor="#5B8CFF"
android:strokeWidth="2.8"
android:strokeColor="#0E1220"
android:strokeWidth="5.4"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<!-- The underscore, on the baseline the chevron bottoms out at. -->
<path
android:pathData="M54,59.7 L63.2,59.7"
android:pathData="M54,64.68 L71.94,64.68"
android:fillColor="#00000000"
android:strokeColor="#5B8CFF"
android:strokeWidth="2.8"
android:strokeColor="#0E1220"
android:strokeWidth="5.4"
android:strokeLineCap="round" />
</vector>
@@ -4,9 +4,14 @@
the wallpaper's colours. The system tints this by its alpha and discards the colour, so
the geometry is the foreground's and white is only a way of saying "opaque here".
Note what that means for the filled design: the accent tile is the background layer, and a
themed icon drops the background entirely. So the shape that survives here is the glyph, not
the tile — which is the right way round anyway. Filling this layer to the edges to stand in
for the tile would tint to a featureless square with nothing of the mark left in it.
Worth shipping rather than leaving out: a launcher with themed icons on and no monochrome
layer to use falls back to the full-colour icon, so the one app on the home screen still
drawn in green is this one.
drawn in blue is this one.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
@@ -15,24 +20,18 @@
android:viewportHeight="108">
<path
android:pathData="M33,33 L75,33 L75,75 L33,75 Z"
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.2" />
<path
android:pathData="M44.8,48.75 L51.9,54 L44.8,59.25"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.8"
android:strokeWidth="5.4"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:pathData="M54,59.7 L63.2,59.7"
android:pathData="M54,64.68 L71.94,64.68"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.8"
android:strokeWidth="5.4"
android:strokeLineCap="round" />
</vector>
@@ -5,12 +5,17 @@
adaptive icons landed in 26, so there is no device this ships to that would need the
bitmaps. Density buckets exist to pick a PNG; a vector has nothing to pick between.
The background is the same @color/dodo_window the window, status bar and navigation bar
use, so the mark sits on the app's own near-black rather than on a second dark that is
almost but not quite it.
The background is the accent, and that is the tile itself rather than a backdrop for one:
the v2 mark is >_ knocked out of a solid accent square, so the square is this layer and the
launcher's mask is what rounds it. See ic_launcher_foreground.xml for why the rounding is
left to the mask instead of drawn.
It was @color/dodo_window until the mark went from outlined to filled, which is worth
knowing if a home screen still shows the dark version: a launcher caches icons, and the
cache outlives the install that changed them.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/dodo_window" />
<background android:drawable="@color/dodo_accent" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -10,4 +10,11 @@
hidden.
-->
<color name="dodo_window">#0E1220</color>
<!--
AccentColor from the same palette, here because the launcher icon's background layer is a colour
and not a drawable. Same hand-kept duplication as above, and the same rule: if the palette moves,
this moves with it.
-->
<color name="dodo_accent">#5B8CFF</color>
</resources>
@@ -418,6 +418,10 @@
<!--
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
is an invitation to type a secret nothing will use.
The tick below it is the phone's whole answer to storing one, and on this head it is the only one:
the keychain lists credentials here but has no editor to create one in, so before this a password
typed on a phone could only ever be typed again. The host editor's picker could then bind it.
-->
<TextBox Classes="field secret" IsVisible="{Binding SelectedHostAsksForAPassword}"
Text="{Binding ConnectPassword}" PlaceholderText="password">
@@ -426,6 +430,12 @@
</TextBox.KeyBindings>
</TextBox>
<CheckBox IsChecked="{Binding RemembersConnectPassword}" MinHeight="44"
IsVisible="{Binding SelectedHostAsksForAPassword}">
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Text="Remember this password for this host" />
</CheckBox>
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !SelectedHostAsksForAPassword}"
Text="{Binding SelectedHostAuthenticationNote}" />
@@ -8,10 +8,13 @@
<!--
Design v2 — MORE: the hub for everything the bottom bar has no room for.
Four slots and nine destinations is the arithmetic the design solves by putting five of them one tap
Four slots and ten destinations is the arithmetic the design solves by putting six of them one tap
deeper. This screen is that tap. It takes the shell as its data context rather than the vault, because
every row on it is a navigation command and nothing here reads an item.
Teams is the tenth and the design never drew it — see the row itself. The count is the design's plus
one rather than a rearrangement of it: nothing moved out of the bottom bar to make room.
The rows are the design's list rows rather than cards: a card is one thing you act on, and a destination
is not a thing — it is a place. Each carries a sentence saying what is behind it, because a hub whose
entries are one word each is a menu you have to open to read.
@@ -89,6 +92,29 @@
</Grid>
</Button>
<!--
Teams, which the v2 design has no row for — it is a shipped screen the design had no slot for
rather than a drawn one with nothing behind it. It is on the phone because an invitation is
claimed by signing in, and somebody being invited is at least as likely to be holding a phone.
◎ rather than a glyph of its own. The desktop rail already draws teams with it, and two heads
giving one destination two marks is how a user learns the wrong one.
-->
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Team}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="◎" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Teams" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Who shares a keychain with you, and who holds its key." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
<Grid ColumnDefinitions="Auto,*,Auto">
+125 -74
View File
@@ -14,13 +14,24 @@
state machine. What differs is only what each one draws.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
The desktop's eight rail destinations become four in a bottom bar, and five more live one tap deeper
behind MORE: snippets, SFTP, S3, logs and preferences. 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 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. All three are bound on IsShowingPages, which is the same question asked once — the
surface is either a page or a terminal, and these are the chrome a page has.
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.
-->
<!--
@@ -48,46 +59,55 @@
Hidden behind MORE, 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 five hub screens draw their own and this one stands down rather than stacking a second row of
chrome above theirs. It stays on the three destinations that are the product's top level, where the
so the hub's screens draw their own and this one stands down rather than stacking a second row of
chrome above theirs. It stays on the two destinations that are the product's top level, 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
collapses it over a terminal, where the surface draws its own bar and the vault's name is not what
the user is looking at. That is one of three rows this Grid stands down while a shell is showing —
see the strip and the bottom bar below.
-->
<Border Grid.Row="0" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="14,0" Height="56" IsVisible="{Binding !IsMoreSurface}">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Panel Grid.Row="0" IsVisible="{Binding IsShowingPages}">
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="14,0" Height="56"
IsVisible="{Binding !IsMoreSurface}">
<Grid ColumnDefinitions="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>
<!--
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" />
<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 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>
<Button Grid.Column="3" 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>
<Button Grid.Column="3" 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">
@@ -116,7 +136,7 @@
============ under MORE ============
The hub itself takes the shell as its data context, because every row on it is a navigation
command; the five destinations behind it each take the view model they are about, so each one is
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.
@@ -131,6 +151,15 @@
<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 Teams is not nullable: the reason for the wrapper
is the data context, not the null. IsTeamShowing is the shell's and Teams is not the shell.
-->
<Panel IsVisible="{Binding IsTeamShowing}">
<views:TeamsScreen DataContext="{Binding Teams}" />
</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
@@ -180,41 +209,57 @@
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.
-->
<Border Grid.Row="2" 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 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 ============ -->
<Border Grid.Row="3" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
<!--
Gone while a terminal is showing, which is the whole of that surface's arrangement: the bar's four
destinations are replaced by a back arrow and a + that leads to three of them, both in the terminal's
own bar. See TerminalScreen.axaml.
This one is bound directly rather than wrapped — its visibility is a single question and it has no
second condition of its own to keep separate.
-->
<Border Grid.Row="3" IsVisible="{Binding IsShowingPages}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Height="64">
<Grid ColumnDefinitions="*,*,*,*">
@@ -225,8 +270,13 @@
<!--
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.
The only entry here that never lights, and deliberately no longer tries: this bar is collapsed
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway
would be a rule about a state this control cannot be in. What marks the terminal as current is
the surface filling the screen.
-->
<views:NavButton Grid.Column="1" Label="Terminal" Glyph="⌗" IsCurrent="{Binding IsTerminalSurface}"
<views:NavButton Grid.Column="1" Label="Terminal" Glyph="⌗"
Command="{Binding ShowTerminalCommand}" />
<!--
@@ -242,8 +292,9 @@
CommandParameter="{x:Static vm:ShellScreen.Vault}" />
<!--
IsMoreSurface rather than IsMoreShowing: this tab stands for six screens, and a bar that went
dark the moment you opened one of them would only ever light three of its four entries.
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 three of its
four entries.
-->
<views:NavButton Grid.Column="3" Label="More" Glyph="≣" IsCurrent="{Binding IsMoreSurface}"
Command="{Binding ShowScreenCommand}"
@@ -272,7 +272,7 @@ internal sealed partial class PhoneShell : UserControl
/// </summary>
/// <remarks>
/// <para>
/// v2 is the first arrangement here with a second level: five destinations sit behind MORE, each with
/// v2 is the first arrangement here with a second level: six destinations sit behind MORE, each with
/// its own back arrow. Android's back is the same gesture as that arrow and users reach for it first,
/// and left unhandled it does not go up — it finishes the activity. Ending the application from a log
/// screen is not a plausible reading of "back".
@@ -298,6 +298,13 @@ internal sealed partial class PhoneShell : UserControl
/// moving between screens at all. Closing an editor is not the same refusal as leaving a host-key
/// decision alone — an editor is abandonable by design, and the CANCEL button beside it says so.
/// </para>
/// <para>
/// <b>The connect menu is a second such guard, and it matters more than the first.</b> A terminal now
/// fills the screen — no header, no bottom bar — so while that menu is up this gesture is the only way
/// off it other than the scrim and CANCEL. It is checked before the terminal is dismissed for the
/// reason it is drawn over it: back takes the topmost thing, and dismissing the surface underneath a
/// menu would take two, neither of them the one being looked at.
/// </para>
/// </remarks>
private void OnBackRequested(object? sender, RoutedEventArgs e)
{
@@ -313,6 +320,17 @@ internal sealed partial class PhoneShell : UserControl
return;
}
// The connect menu, which is raised from the terminal's own bar and is the topmost thing the phone
// draws while it is up. Ahead of the editors below because it is nearer, and ahead of leaving the
// terminal because a gesture that dismissed the surface underneath a menu would close two things at
// once — and the one the user was looking at would not be either of them.
if (current.IsConnectSheetOpen)
{
current.CloseConnectSheetCommand.Execute(null);
e.Handled = true;
return;
}
if (TryCloseAnOpenEditor(current))
{
e.Handled = true;
@@ -329,7 +347,7 @@ internal sealed partial class PhoneShell : UserControl
switch (current.Screen)
{
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
or ShellScreen.Buckets or ShellScreen.Preferences:
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Team:
current.ShowScreenCommand.Execute(ShellScreen.More);
e.Handled = true;
break;
@@ -0,0 +1,367 @@
<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.TeamsScreen"
x:DataType="vm:TeamsViewModel"
Background="{StaticResource Canvas}">
<!--
TEAMS, under MORE — and the one screen behind that hub the v2 phone design never drew.
It is the reverse of every other entry in docs/design-import-gaps.md: a shipped screen the design had
no slot for, rather than a drawn screen with nothing behind it. It is on the phone because an
invitation is claimed by *signing in*, and the person being invited is at least as likely to be
holding a phone as sitting at a desktop — a team the server has just put somebody into, visible only
on a head they may never have installed, is a membership they cannot see.
That argument is also why the invited list is drawn here and not treated as an administrator's detail:
the people on it are the ones who cannot yet see the team, and the row says out loud that no mail was
sent.
So there is no mock-up to depart from. What this departs from instead is the desktop screen over the
same view model, and every difference below is a phone difference rather than a second opinion.
**The desktop's two columns are one.** A 268-pixel team list beside a members-and-vaults table does
not exist at 360dp, so the three lists stack in one scrolling column with the teams at the top. That
is the same thing HOSTS does with the desktop's sidebar and its connect column, and for the same
reason.
**Nothing scrolls inside anything.** The desktop caps its members and vaults lists at 240 and 200
pixels so the two can sit above each other in one pane. Here every list is sized to its content and
the screen's own ScrollViewer does all of the scrolling: a list that scrolls inside a page is a region
a thumb has to find the edges of, and three of them on one screen is three ways to get stuck.
◆ **SHARE KEY is drawn and nothing that takes something away is.** That is a decision rather than a
subset. Wrapping a vault key is the one act on this screen a server cannot perform at all — it needs a
machine that already holds the key, and this phone is one — so a teams screen that could only be read
would leave the product's central claim undemonstrated on the head most people carry. REMOVE MEMBER,
WITHDRAW KEY and REVOKE INVITATION are the other half of that, and each of them acts on the first
press: the view model's armed-confirmation state covers archiving a team and handing one over, and
those three are not armed by it. The desktop guards them with a tooltip instead, which is a control a
touch screen has no way to show. An irreversible revocation under a thumb with its explanation missing
is the wrong trade, so all three stay on the desktop — where the sentence beside them is visible.
Archiving and hand-over are not drawn either, for a plainer reason: they decide whether a team goes on
existing and who controls it, which is not a thing to do while walking.
**ADD MEMBER is not drawn either**, and it is the operation this screen least needs. It is an address
typed into a box, a directory lookup, a role picker, and a paragraph beside it saying what adding
somebody did *not* do — and since invitations arrived the ordinary way into a team is one the server
claims at sign-in, which is what put this screen on the phone at all. Creating a team is here, because
a team is where those invitations are sent from and it is two short fields.
**The key-holder list under a vault is not drawn.** It is a fourth list, it belongs to the selected
vault rather than to the team, and the view model publishes no flag saying whether it has anything in
it — so a heading for it would sit over nothing whenever nobody holds a key, which is exactly the
empty state this head insists comes from the view model rather than from markup. What the phone can
answer about a vault is on the vault's own row: whether *this* machine can open it.
**↻ and `+` both, because this screen has more reason to re-read than any other.** Nothing here is
cached — it is all read from the server on arrival and again at the end of every command — so the one
thing a member cannot otherwise see is a change somebody else just made: a vault key wrapped to them
from a colleague's desktop, or a team they have this moment been invited into. On the desktop the
re-read is leaving the rail and coming back, which is one click. Here it is a trip out to MORE and
back, so the button earns its place. It binds to a real command rather than to ShowScreen(Team),
which would set Screen to the value it already holds, raise nothing and reload nothing.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto" Height="56" Margin="8,0">
<Button Grid.Column="0" Classes="icon" Content="←"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Teams" Margin="4,0" />
<Button Grid.Column="2" Classes="icon" Content="↻" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="3" Classes="icon accent" Content="+" Command="{Binding NewTeamCommand}"
IsEnabled="{Binding !IsBusy}" />
</Grid>
<!-- ============ a new team ============ -->
<!--
Above the list rather than in place of it, which is the opposite of what the host and snippet
editors do — and the difference is what the form is about. Those two edit a row that is on screen,
so a card stacked over the list hides the thing being changed. This one is about a team that does
not exist yet, and the teams that do are exactly the useful thing to be able to see while naming it:
the slug has to be unique on this server, and the near misses are right underneath.
-->
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding IsCreatingTeam}">
<StackPanel Spacing="10">
<TextBlock Classes="label" Text="NEW TEAM" />
<TextBox Classes="field" Text="{Binding NewTeamName}" PlaceholderText="name" />
<TextBox Classes="field" Text="{Binding NewTeamSlug}" PlaceholderText="slug-for-urls" />
<TextBlock Classes="body"
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server. It is fixed once the team exists — a team can be renamed and its slug cannot." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="CREATE"
Command="{Binding CreateTeamCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelNewTeamCommand}" />
</Grid>
</StackPanel>
</Border>
<!--
Status, and it is the empty state as well: the view model writes "you are not in a team yet" into
the same property it writes an offline notice and every command's outcome into. A literal here would
be a second voice saying the same thing slightly differently.
-->
<TextBlock Grid.Row="2" Classes="detail" Margin="18,2,18,6" TextWrapping="Wrap"
Text="{Binding Status}"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- ============ the column ============ -->
<ScrollViewer Grid.Row="3">
<StackPanel Margin="0,0,0,18">
<TextBlock Classes="section" Text="TEAMS" Margin="18,4,18,4" />
<!--
Rows as cards, filled when chosen, which is what HOSTS settled on in v2 and what the radius
ladder calls a card: one item, one rule, one thing you act on. The fill is on the item rather
than on a Border inside it so the rounding the theme draws for selection is the row's own.
-->
<ListBox ItemsSource="{Binding Teams}" SelectedItem="{Binding SelectedTeam}"
IsVisible="{Binding HasTeams}" Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="54" Margin="14,11">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Detail}" />
</StackPanel>
<!-- The caller's own role in this team, which is what says why some of it is read-only. -->
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- ============ the chosen team ============ -->
<StackPanel IsVisible="{Binding HasSelection}">
<TextBlock Classes="section" Text="MEMBERS" Margin="18,18,18,4" />
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamMemberRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="54" Margin="14,11">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
<!--
◆ The one fact on this row that decides whether the button at the foot of the screen
can do anything: an account with no published identity key has nothing for a vault
key to be wrapped to. One sentence, from the view model, painted twice rather than
written twice — the warning colour is the whole of the difference, and a converter
for it would hide that the two are the same string.
The published case is quiet rather than green. Green on this head means a shell is
open right now, and a published key is a durable fact about an account — borrowing
the status colour for it would be the second meaning that makes the first
unreadable. Only the missing key is coloured, because only it needs answering.
-->
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding KeyState}"
IsVisible="{Binding Member.IsEnrolled}" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding KeyState}"
IsVisible="{Binding !Member.IsEnrolled}" />
<!--
A date to the day, or that they have never been here at all. The view model writes
both, and neither is a guess: the server records the account's last authenticated
request at most once an hour, which is what makes a day the honest unit.
-->
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextFaint}"
Text="{Binding LastActive}" />
</StackPanel>
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="body" Margin="18,10,18,0"
Text="Being in a team is what lets the server hand somebody this team's vaults. It is not what lets them read one: a vault key can only be wrapped by a machine that already holds it, which is what sharing below does." />
<!-- ============ ◆ who has been asked and has not arrived ============ -->
<!--
The section this screen exists for, and the one the desktop had nothing to draw until
invitations were built. Read-only here: withdrawing one is a control that acts on the first
press, which is the line drawn at the top of this file.
So these are cards rather than the flat rows above them, and the shape is the difference: a row
that fills when you touch it is one of several you are choosing between, and there is nothing
to choose here. An ItemsControl rather than a ListBox for the same reason — a list with a
selection nothing reads would be a control offering something it cannot do.
Gated on the view model's own count rather than left to stand over an empty list, because a
team with nobody outstanding is the ordinary case and a permanent empty heading would make it
look like a section that had failed to load.
The waiting row carries the whole mechanism in its own sentence — no mail was sent, and they
join when they first sign in here. That is the sentence somebody has to read, because every
other product's version of this word means an email is on its way.
-->
<StackPanel IsVisible="{Binding HasInvitations}">
<TextBlock Classes="section" Text="INVITED" Margin="18,18,18,4" />
<ItemsControl ItemsSource="{Binding Invitations}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TeamInvitationRowViewModel">
<Border Classes="card" Margin="12,3">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding State}"
IsVisible="{Binding !IsPending}" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding State}"
IsVisible="{Binding IsPending}" />
</StackPanel>
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<TextBlock Classes="section" Text="VAULTS" Margin="18,18,18,4" />
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamVaultRowViewModel">
<StackPanel Spacing="3" MinHeight="54" Margin="14,11" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<!--
Whether *this* phone can open it, which is a property of its keyring rather than
anything the server could answer. Painted the same two ways as the member's key
state above, because it is the same question asked from the other end.
-->
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding State}"
IsVisible="{Binding IsReadable}" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding State}"
IsVisible="{Binding !IsReadable}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="body" Margin="18,10,18,0"
Text="A vault listed here that this phone has no key to stays listed and stays shut. That is the ordinary case rather than a fault: somebody has been added to the team and nobody has wrapped the key to them yet." />
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- ============ ◆ giving somebody the key ============ -->
<!--
Raised over the column when both halves of the act have been chosen, as HOSTS raises its connect bar
and SNIPPETS its insert bar, and for the reason written there: there is no second column to put it
in, so it names what it will do rather than relying on a selection being visible beside the button.
Two wrappers rather than one condition. Sharing needs a member *and* a vault, and a binding cannot
say `SelectedMember is not null && SelectedVault is not null` without a converter that does not
exist — the log screen makes the same trade for the same reason. It also gets the halves in the
right order: choosing who comes first, and until a vault is chosen there is nothing to offer them.
-->
<Panel Grid.Row="4" IsVisible="{Binding SelectedMember, Converter={x:Static ObjectConverters.IsNotNull}}">
<Border IsVisible="{Binding SelectedVault, Converter={x:Static ObjectConverters.IsNotNull}}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Padding="14,12">
<StackPanel Spacing="9">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="THE KEY TO" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedVault.Name}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="FOR" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedMember.Name}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
<Button Classes="primary" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
IsEnabled="{Binding !IsBusy}" />
<!--
The sentence the desktop hangs off a tooltip, which a phone cannot show — so it is body text
under the button, where it is read before the tap rather than after it. It is not decoration:
the key-log check proves this server has been consistent with itself and nothing more.
-->
<TextBlock Classes="body"
Text="Their published key is checked against the server's append-only key log first, and nothing is wrapped if it does not appear there unchanged. That proves the server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
</StackPanel>
</Border>
</Panel>
</Grid>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>Teams, under MORE — who is in one, and which of its vaults this phone can open.</summary>
internal sealed partial class TeamsScreen : UserControl
{
public TeamsScreen() => AvaloniaXamlLoader.Load(this);
}
@@ -16,113 +16,142 @@
The tab strip is horizontal-scrolling rather than wrapping. Wrapping would reflow the terminal every
time a tab opened, which is the one thing a terminal must not do while output is arriving.
── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
A connected phone shows one bar and then the terminal. The vault header, the shells strip and the
four-entry bottom bar are all collapsed by PhoneShell while this surface is up, and what replaces them
is the row below: back, the sessions, and the way to open another one.
That is a trade, and the thing bought is the only one a terminal really wants. At 360dp the chrome this
screen used to sit inside came to 254 pixels of a roughly 780-pixel display — a third of it — and every
one of those rows was about somewhere the user was not. What is given up is the bottom bar's one-tap
reach to Hosts, Keychain and MORE; back and the + between them lead to all of it, and the system back
gesture does the same thing the arrow does.
-->
<Grid RowDefinitions="Auto,Auto,*,Auto">
<Panel>
<!-- ============ tabs ============ -->
<Grid RowDefinitions="Auto,*,Auto">
<!-- ============ the bar ============ -->
<!--
v2 draws these as pills rather than as a segmented strip, so the row is transparent and each session
carries its own outline. The close cross moved inside the pill with the name, which is what makes it
read as one object you can dismiss rather than as two adjacent targets.
-->
<Border Grid.Row="0" Height="52">
<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">
<!--
44 tall, where the session pills on the shell strip are 34. The difference is the close
cross: a pill you only select can be chip-sized, and a pill containing the control that ends
a shell cannot. This head's rule is 44 and this is the one control on the phone that is both
destructive and has no confirmation and no undo — see CloseTabAsync, which ends the session
the moment it is pressed.
-->
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="11" Height="44">
<StackPanel Orientation="Horizontal">
<Button Classes="row" MinHeight="42" Padding="13,0" CornerRadius="11"
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
Text="{Binding Label}" />
</StackPanel>
</Button>
<!--
The close cross is inside the tab, which the plan calls out: a strip-level close would
act on whichever tab happened to be selected, and on a phone that is a mis-tap away from
killing the wrong shell.
Everything the phone draws above a shell. It is a bar rather than a strip because it now carries the
two controls the collapsed chrome took with it, one at each end, with the sessions between them.
The hairline down its left edge is not decoration. The two targets are flush inside one
pill, so without a visible seam there is nothing telling a thumb where "switch to this
shell" stops and "end it" starts.
-->
<Button Classes="row" MinHeight="42" Width="44" Padding="0" CornerRadius="0,11,11,0"
HorizontalContentAlignment="Center"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}">
<TextBlock Text="×" Foreground="{StaticResource TextFaint}" FontSize="14" />
</Button>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
Both are outside the ScrollViewer deliberately. They are the way out of this surface and the way to
another host, and a tenth tab must not be able to push either of them off the right-hand edge.
<!-- ============ the connection line ============ -->
<!--
The text-size buttons live here rather than in the accessory row below, and the row is the reason:
that one scrolls, so a key can be off-screen, and these two must not be — a terminal that is too
small to read is exactly the state in which hunting for the control that fixes it is worst.
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head has
no keyboard to press it with, which is why the two heads differ here and nowhere else in this screen.
35 tall, a third off the 52 it opened at. Every height inside it came down with it — the icon squares
to 34, the pills to 30 — because a bar that shrank around controls that did not would only have moved
the clipping somewhere harder to see.
-->
<!--
The design's line here also carries a round-trip time and a forwarded port. Neither is drawn: the SSH
library offers no RTT measurement, and nothing in this application forwards anything. What is left is
the one fact that is real and is the one that matters — the account and endpoint actually dialled.
-->
<Border Grid.Row="1" Padding="16,5" Height="36"
IsVisible="{Binding SelectedTab, Converter={x:Static ObjectConverters.IsNotNull}}">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="detail" FontSize="10.5" TextTrimming="CharacterEllipsis"
Foreground="{StaticResource TextDim}"
VerticalAlignment="Center" Text="{Binding SelectedTab.Address}" />
<Border Grid.Row="0" Height="35" Background="{StaticResource Chrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<!--
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
nothing reads as the terminal having stopped responding, which is the one thing this screen must
never look like.
-->
<Button Grid.Column="1" Classes="row" MinHeight="34" MinWidth="38" Padding="0"
HorizontalContentAlignment="Center"
Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A" />
</Button>
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
system back gesture already does exactly that — see PhoneShell.axaml.cs — and an arrow that
landed somewhere else would be the second of two answers to one question.
<Button Grid.Column="2" Classes="row" MinHeight="34" MinWidth="38" Padding="0" Margin="4,0,0,0"
HorizontalContentAlignment="Center"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
Height overridden and width left alone. Button.icon is a 44 square, which is taller than this bar;
the 44 that matters is the horizontal one, since nothing in a row of this shape is hard to hit
above or below.
-->
<Button Grid.Column="0" Classes="icon" Content="←" Height="34" Margin="4,0,0,0"
Command="{Binding ShowScreenCommand}" CommandParameter="{Binding Screen}" />
<!--
v2 draws these as pills rather than as a segmented strip, so the row is transparent and each
session carries its own outline. The close cross moved inside the pill with the name, which is
what makes it read as one object you can dismiss rather than as two adjacent targets.
-->
<ScrollViewer Grid.Column="1" HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Tabs}" Margin="6,0" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<!--
30 tall, in a bar of 35. It was 44, and the argument for 44 was that a pill you only
select can be chip-sized while one containing the control that ends a shell cannot — the
close cross is the one control on this head that is both destructive and has neither
confirmation nor undo. That argument is now carried by width rather than by height: the
cross keeps its full 44-pixel column, and what it lost is 14 pixels of vertical slack in a
row where nothing sits above or below it to be hit by mistake.
-->
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="9" Height="30">
<StackPanel Orientation="Horizontal">
<Button Classes="row" MinHeight="28" Padding="11,0" CornerRadius="9"
VerticalContentAlignment="Center"
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
Text="{Binding Label}" />
</StackPanel>
</Button>
<!--
The close cross is inside the tab, which the plan calls out: a strip-level close would
act on whichever tab happened to be selected, and on a phone that is a mis-tap away from
killing the wrong shell.
The hairline down its left edge is not decoration. The two targets are flush inside one
pill, so without a visible seam there is nothing telling a thumb where "switch to this
shell" stops and "end it" starts.
Both alignments are stated, and the vertical one is not decoration either: Button.row
sets HorizontalContentAlignment and says nothing about the other axis, so the cross
was sitting against the top of its own column rather than in the middle of the pill.
It reads as a misprint, which for the control that ends a session is the wrong thing
to look like.
-->
<Button Classes="row" MinHeight="28" Width="44" Padding="0" CornerRadius="0,9,9,0"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}">
<TextBlock Text="×" Foreground="{StaticResource TextFaint}" FontSize="14"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" />
</Button>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!--
Another connection, and the three kinds this application can make. A menu rather than a straight
jump to Hosts because SFTP and S3 used to be two taps through the bottom bar's MORE, and the bar
is not on screen here — so the control that replaces it has to lead to all three or it has quietly
removed two of them.
The desktop's own + refuses a flyout on this reasoning, in TerminalTabs.axaml: a popup dropping
into the renderer's rectangle may or may not composite above a native child window, and that is
not a claim to make without a screenshot. It is answered here rather than dodged — this is a sheet
at the bottom of the screen and opening it collapses the renderer outright, exactly as the
palette does on the desktop. Nothing is drawn over the WebView.
Plain Button.icon, the same as the arrow across from it, rather than the accent variant. The two
are a matched pair at either end of one bar — one leaves this surface, one adds to it — and an
accented + would rank itself above the way out. The accent fill belongs to the floating + on
HOSTS, which is the only action on its screen; this one is not.
-->
<Button Grid.Column="2" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
Command="{Binding OpenConnectSheetCommand}" />
</Grid>
</Border>
<!-- ============ the renderer ============ -->
<Panel Grid.Row="2">
<Panel Grid.Row="1">
<!--
The empty state, and it says what the surface is for rather than that it is empty. A phone opens
@@ -131,7 +160,7 @@
<StackPanel IsVisible="{Binding !HasTabs}" VerticalAlignment="Center" Margin="24" Spacing="10">
<TextBlock Classes="title" FontSize="13" Text="NO SHELL OPEN" />
<TextBlock Classes="body"
Text="Choose a host and press CONNECT. A shell opened here keeps running while the app is in the background, and keeps running after the keychain is locked — a notification says so for as long as one is alive." />
Text="Press + above, or choose a host and press CONNECT. A shell opened here keeps running while the app is in the background, and keeps running after the keychain is locked — a notification says so for as long as one is alive." />
</StackPanel>
<!--
@@ -140,10 +169,17 @@
hold the application still any more, so there is a stretch in which a tab is selected and there is
nothing yet to render in it. A phone needs it more than a desktop does: mobile links are slower, and
the alternative is a black rectangle.
It carries the address, which is where that fact went when the connection line was folded into the
bar above. This is the moment it is worth reading — what is being dialled, before anything has
answered — and once a shell is open its own prompt says the same thing more accurately than a header
derived from the keychain ever did.
-->
<StackPanel IsVisible="{Binding IsConnectingShowing}" VerticalAlignment="Center" Margin="24"
Spacing="10">
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}" />
<Button Classes="row" MinHeight="44" Padding="14,0" HorizontalAlignment="Left"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
@@ -153,13 +189,14 @@
<!--
Collapsed rather than merely covered when there is no pane to show. On Windows this control is a
native child window that composites above everything Avalonia draws, which is why the desktop head
native child view that composites above everything Avalonia draws, which is why the desktop head
hides it explicitly; whether Android's WebView does the same is recorded as unverified in
docs/android-port.md. Hiding it either way costs nothing and is correct under both answers.
IsTerminalShowing rather than HasTabs, which are no longer the same question: a tab that is still
connecting has no pane, and showing the renderer for it would show the previous session's output
under the name of a machine nothing has connected to yet.
under the name of a machine nothing has connected to yet. It is also what the connect sheet turns
off — see MainWindowViewModel.IsTerminalShowing.
v2 insets this behind a 14-pixel radius. Not done, and not an oversight: this is a native child view
composited above everything Avalonia draws, so a rounded Border behind it clips nothing — the
@@ -178,13 +215,144 @@
then release, because holding a modifier while typing is not possible one-thumbed.
-->
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="50">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
VerticalAlignment="Center" />
</ScrollViewer>
<!--
33 tall rather than 50, a third off it like the bar at the top, with the keys inside coming down from
38 to 30 for the reason the pills did: a row that shrank around its own contents would clip them.
The five pixels above it are a gap and not a border. The renderer is a native child view, so nothing
Avalonia draws can sit on top of it — a hairline between the two would have to be a row of its own —
and a terminal whose last line of output is flush against a row of grey keys reads as one surface
that has gone wrong rather than as two that are different things.
-->
<Border Grid.Row="2" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
<Grid ColumnDefinitions="*,Auto">
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
VerticalAlignment="Center" />
</ScrollViewer>
<!--
The text-size buttons, pinned at this row's right-hand end rather than scrolling with the keys
beside them.
They used to have a row of their own above the terminal, on the argument that the accessory row
scrolls and these two must never be off-screen — a terminal too small to read is exactly the state
in which hunting for the control that fixes it is worst. That argument is answered rather than
abandoned: outside the ScrollViewer they cannot scroll away, and the row they had costs 36 pixels
on a surface this change exists to give back.
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
has no keyboard to press it with, which is why the two heads differ here and nowhere else in this
screen.
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
nothing reads as the terminal having stopped responding, which is the one thing this screen must
never look like.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5" Margin="8,0,12,0"
VerticalAlignment="Center">
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
VerticalAlignment="Center" />
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1"
Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A" />
</Button>
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
</StackPanel>
</Grid>
</Border>
</Grid>
<!-- ============ the connect sheet ============ -->
<!--
Follows the add sheet on HostsScreen, which follows HostKeySheet: a scrim, a bottom-aligned panel with
the top two corners rounded, and a grab handle that is decoration. Dismissible, like the add sheet and
unlike the host-key one — "which kind of connection" has no wrong answer and no answer at all is one
of them.
It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing
but this surface raises it. The scrim reaching only the screen area is not a compromise here the way
it was there — the bottom bar is collapsed while a terminal is showing, so the screen area is the
display.
Every row navigates away from the terminal. That is not a side effect of the menu, it is the menu:
each of the three destinations is a picker, and the shell they open lands back on this surface as a
new tab in the bar above.
-->
<Panel IsVisible="{Binding IsConnectSheetOpen}">
<!--
Declared before the sheet so the sheet draws over it: a Panel stacks its children in declaration
order. See the .scrim style for why a tap on it must not light anything up.
-->
<Button Classes="scrim" Command="{Binding CloseConnectSheetCommand}" />
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
CornerRadius="22,22,0,0" Padding="20,18,20,16">
<StackPanel Spacing="0">
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
HorizontalAlignment="Center" Margin="0,0,0,16" />
<TextBlock Classes="title" Text="CONNECT" FontSize="13" />
<Button Classes="row" Margin="0,10,0,0" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}">
<StackPanel Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect" />
<TextBlock Classes="detail" FontSize="10.5"
Text="Another shell, on this host or any other in the keychain." />
</StackPanel>
</Button>
<!--
These two go through ShowFiles rather than ShowScreen, as the MORE hub's own rows do: one screen
over one view model, and which kind of remote it offers is the thing being chosen. It can refuse
— there is a single transfer session behind both — and refusing lands on the screen the open one
belongs to with a sentence saying why, which is a better place to read it than a sheet that has
just closed.
-->
<Button Classes="row" Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}">
<StackPanel Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect via SFTP" />
<TextBlock Classes="detail" FontSize="10.5" Text="Browse a host's files." />
</StackPanel>
</Button>
<Button Classes="row" Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
<StackPanel Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect via S3" />
<TextBlock Classes="detail" FontSize="10.5"
Text="Objects in an S3-compatible bucket from the keychain." />
</StackPanel>
</Button>
<Button Classes="secondary" Content="CANCEL" Margin="0,12,0,0"
Command="{Binding CloseConnectSheetCommand}" />
</StackPanel>
</Border>
</Panel>
</Panel>
</UserControl>
@@ -126,10 +126,13 @@ internal sealed partial class TerminalScreen : UserControl
VerticalAlignment = VerticalAlignment.Center,
},
// 44 wide as well as tall. The design draws them flexed across the width, which at 360dp
// with ten keys is 32 pixels each — under every thumb-target guideline there is.
// 44 wide, and that is the number that matters: the design draws these flexed across the
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
// costs nothing a width does: the keys are a single row with the terminal above and the
// system's gesture bar below, so there is no neighbour a short press can land on instead.
MinWidth = 44,
Height = 38,
Height = 30,
Padding = new Thickness(10, 0),
CornerRadius = new CornerRadius(9),
Background = Palette("Panel"),