Give the phone the second design, and both heads the palette it arrives with

The Android v2 design is what this head draws now: four destinations in a bottom bar — Hosts,
Terminal, Keychain, More — with snippets, SFTP, S3, logs and preferences one tap deeper behind the
last. The first design's four had nothing behind them, which is what made a hub worth building.

The palette moved from green-black to blue-black, and it moved in the shared project because that is
where it lives and the desktop v2 specifies the same seventeen tokens. One colour changed meaning
rather than value, and it is the only semantic change in the file. Green used to *be* the accent, so
Ellipse.dot.live filled with Accent and "the thing to press" and "a shell is open on this host" were
the same colour by construction. v2 makes the accent blue and keeps a green for status alone, which
finally separates them: Live is that green and nothing merely interactive may use it. The accent is
also two colours now — Accent fills, AccentText writes — because a row of chips in the fill colour is
a row of things that all look like the primary action.

A palette is not one file, which is the part worth knowing before the next one. Nine hex literals
lived outside it: the nav bar's own label colours, the accessory keys and their Ctrl-latched state,
two scrims, the window background Android paints before Avalonia has a frame, and the launcher
vector. The two C# sites now resolve from the dictionary by name rather than restating it. The
renderer's page cannot — it is served to a WebView over a loopback socket — so terminal.css and
terminal.js keep hand-copied values and say so at both sites.

ShellScreen gained More and Buckets, appended rather than slotted in. SFTP and S3 are one screen over
one TransfersViewModel differing only in which picker they offer, and the kind is set by the button
that navigates rather than on arrival — doing it in OnScreenChanged made every arrival at Transfers
force the picker back to hosts, including the desktop's own rail arriving at a screen with a bucket
already open. It refuses to change kind while a session is live, because there is one session behind
both destinations and switching under it would title a screen S3 while it listed an SFTP host.

What the design draws and this does not, on the usual grounds. The FORWARDING screen: nothing here
forwards anything, so every toggle would be a control with no effect — it is a paragraph on the hub
naming the absence, for the reason the desktop keeps TEAMS in its rail. The terminal's `23 ms · fwd
5432`. An ED25519 badge and a SHA256 line on keychain cards, which need an algorithm field and a
fingerprint the item type does not have. An `agent` chip, for an agent that does not exist. Snippet
run history and exit codes. The Logs FOLLOW pill, which claims a live tail over records that are
written once at close and read when the screen opens, and the severity filter, which has nothing to
count — that chip row is spent on the real choice, which of the two logs. S3 bucket totals and
lifecycle. And the + on HOSTS, which would open a host editor this head has not got.

SFTP is browse, open and delete. Both transfer commands work, and what they work against is the local
pane: QueueDownloads writes to Path.Combine(LocalPath, name), and LocalPath starts at
SpecialFolder.UserProfile, which on Android is the application's own private directory. A download
would have reported success and left the file where the person who asked for it cannot open it, which
is worse than not offering it — a refusal is visible and a file in /data/user/0/ is not. The queue is
not drawn either, since nothing here can put anything in it. Both return with the document picker.
The foreground service still counts zero transfers, and the reason moved rather than went away.

Four defects worth naming, because three of them are the kind that compile. A Button as a ListBox
ItemTemplate swallows the pointer press before the list sees it, so the files listing selected
nothing and every command reading the selection did nothing — the row is a Border now and the
phone-only single-tap-to-open is a Tapped handler, which also keeps a desktop single click from
walking into directories. Avalonia type selectors are exact, so TextBlock.fingerprint never matched
SelectableTextBlock and every fingerprint on this head rendered proportional and unwrapped: that was
breaking the never-truncated rule on the host-key sheet already. The new two-level hierarchy had no
handler for the system back gesture, so back left the application from a log screen. And the tab's
close cross had shrunk to a 30x32 target flush against the select target, which is the one control
here that ends a shell with no confirmation and no undo.

Fingerprint unlock is raised on arriving at the lock screen rather than waiting for its button, which
is still there. Only at launch: a lock the user asked for is not answered with an immediate request
to unlock, which makes LOCK look inert and trains the reflex of authenticating at a prompt nobody
asked for. And once, because a declined gesture leaves the passphrase box exactly where it was and a
prompt that came back after being dismissed would be a modal you cannot get out of to type into it.

Two fixes fall on the desktop. Its file listing coloured directories with Info and executables with
Accent, which was blue against green and is now two steps of one blue; an executable is Live now.
And a bucket's folders were drawn with a 0001-01-01 timestamp, because a prefix has no modification
time — blank now, for the reason a directory's size is blank.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the layout
suite that stands up real Avalonia and parses every desktop screen. Both heads build. Not verified on
a device — nothing in this head ever has been; see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
This commit is contained in:
2026-08-02 18:23:53 +02:00
co-authored by Claude Opus 5
parent c00e5dbc5c
commit 5593f337b6
33 changed files with 1959 additions and 257 deletions
@@ -0,0 +1,283 @@
<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.FilesScreen"
x:DataType="vm:TransfersViewModel"
Background="{StaticResource Canvas}">
<!--
Designs 06 and 08 — SFTP and S3, which are one screen.
Below the picker the two are the same thing: a bucket and an SFTP host are both an IRemoteFileStore, and
the pane, the queue and every transfer were written once. What differs is which picker is offered and
what the button says — OPEN for a bucket, because S3 is request-per-operation and nothing stays
connected. Drawing them twice would be two copies of a screen obliged to stay identical.
**One pane, not two.** The design's LOCAL / REMOTE toggle is not drawn, and this is the decision
docs/android-port.md took before any of this was built: Android has scoped storage, so there is no
browsable local filesystem to put in the other half. TransfersViewModel's local pane — LocalPath,
LocalRoots, LocalEntries — is desktop-only and is left alone here rather than shown empty.
◆ **And that is why neither DOWNLOAD nor UPLOAD is on this screen.** Both commands exist and both work;
what they work *against* is the local pane. `QueueDownloads` writes to `Path.Combine(LocalPath, name)`,
and `LocalPath` starts at `LocalDirectory.Home` — `SpecialFolder.UserProfile`, which on Android is the
application's own private directory. A download would report success and put the file somewhere the
person who asked for it cannot open it, which is worse than not offering it: a refusal is visible and a
file in `/data/user/0/…` is not. The way in and out is the system document picker, which is the shape
docs/android-port.md decided on and is the next piece of work.
So what ships is browsing a remote, and the two remote-side operations that need nothing local —
opening a directory and deleting. The queue is drawn because a transfer can still be running when this
screen is opened; it is simply not something this head can start yet.
◆ **The host key prompts are here too.** File transfer is a second, separate authenticated connection
and it makes its own trust decision — the host records a second login. So this screen carries its own
unknown-key prompt and its own changed-key refusal, over TransfersViewModel's own PendingHostKey, and
the refusal has no continue button here either.
-->
<Grid RowDefinitions="Auto,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="SFTP" Margin="4,0"
IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Grid.Column="1" Classes="heading" Text="S3" Margin="4,0"
IsVisible="{Binding ShowsBucketPicker}" />
<!-- What is actually open, named. On a bucket this reads s3://name. -->
<Border Grid.Column="2" Classes="tag" Margin="8,0" HorizontalAlignment="Left"
IsVisible="{Binding IsConnected}">
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
</Border>
<Button Grid.Column="3" Classes="icon" Content="↻" Command="{Binding RefreshRemoteCommand}"
IsVisible="{Binding IsConnected}" />
</Grid>
<!-- ============ opening one ============ -->
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding !IsConnected}">
<StackPanel Spacing="10">
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
ItemsSource="{Binding Hosts}" SelectedItem="{Binding SelectedHost}"
PlaceholderText="choose a host">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsBucketPicker}"
ItemsSource="{Binding Buckets}" SelectedItem="{Binding SelectedBucket}"
PlaceholderText="choose a bucket">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
<StackPanel Spacing="1">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
<TextBlock Classes="detail" Text="{Binding Description}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Its own box, deliberately not the hosts screen's. This is a separate authentication, so a password
typed to open a terminal has not been offered here — and quietly reusing it would make a one-time
password appear to work twice.
-->
<TextBox Classes="field" IsVisible="{Binding SelectedHostAsksForAPassword}"
Text="{Binding TypedPassword}" PasswordChar="•" PlaceholderText="password" />
<Button Classes="primary" Content="{Binding ConnectLabel}" Command="{Binding ConnectCommand}"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
</Border>
<!-- ============ ◆ the host key decisions ============ -->
<!--
The unknown key: the fingerprint in full, wrapping rather than clipping, and two ways out.
-->
<Border Grid.Row="2" Margin="12,0,12,8" IsVisible="{Binding HasPendingHostKey}"
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" CornerRadius="12" Padding="14">
<StackPanel Spacing="9">
<TextBlock Classes="label" Foreground="{StaticResource Warn}" Text="UNKNOWN HOST KEY" />
<TextBlock Classes="body">
<Run Text="First contact with" />
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
<Run Text="·" />
<Run Text="{Binding PendingHostKey.Port}" />
<Run Text=". Nothing in this keychain has approved this key." />
</TextBlock>
<TextBlock Classes="label" Foreground="{StaticResource TextFaint}"
Text="{Binding PendingHostKey.Algorithm}" />
<!-- ◆ In full, and wrapping rather than clipping. See the .fingerprint style. -->
<SelectableTextBlock Classes="fingerprint" Text="{Binding PendingHostKey.Fingerprint}" />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="TRUST AND OPEN"
Command="{Binding TrustHostKeyCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding RejectHostKeyCommand}" />
</Grid>
</StackPanel>
</Border>
<!--
◆ The changed key, and it has no continue button. Presenting this as dismissible is the one design
mistake that matters here — the same rule the hosts screen's sheet follows.
-->
<Border Grid.Row="2" Margin="12,0,12,8" IsVisible="{Binding HasHostKeyMismatch}"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1" CornerRadius="12" Padding="14">
<StackPanel Spacing="9">
<TextBlock Classes="label" Foreground="{StaticResource Danger}" Text="HOST KEY CHANGED — REFUSED" />
<TextBlock Classes="body" Foreground="{StaticResource DangerText}" Text="{Binding HostKeyMismatch}" />
</StackPanel>
</Border>
<!-- ============ where we are ============ -->
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="9"
Command="{Binding RemoteUpCommand}">
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" />
</Button>
<ItemsControl ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="2" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CrumbViewModel">
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="9"
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
CommandParameter="{Binding Path}">
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<!-- ============ the listing ============ -->
<Panel Grid.Row="4">
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
VerticalAlignment="Top" Text="{Binding Status}" />
<!--
Tapped on the list rather than a Command on the row, and that is not a preference.
A Button as the ItemTemplate root swallows the press: the ListBoxItem never sees it, SelectedItem
stays null, and every command that reads SelectedRemoteEntry — opening a directory, deleting —
quietly does nothing. Measured, not assumed. So the row is a Border, selection works as it does on
every other list in this head, and the one phone-specific behaviour is here: a single tap on a
directory opens it, because a phone has no double-tap-to-open and no Enter.
Not driven from OnSelectedRemoteEntryChanged, which would have been the tidier-looking place: that
setter is in the shared view model, and the desktop pane deliberately opens on DoubleTapped — making
selection navigate would send a single desktop click walking into a directory.
-->
<ListBox ItemsSource="{Binding RemoteEntries}" SelectedItem="{Binding SelectedRemoteEntry}"
Background="Transparent" BorderThickness="0" IsVisible="{Binding IsConnected}"
Tapped="OnRemoteEntryTapped">
<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="10" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="10" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
<Border MinHeight="52" Padding="10,7">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
VerticalAlignment="Center" FontSize="12" Text="▸"
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
VerticalAlignment="Center" FontSize="12" Text="·"
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="detail" Text="{Binding Size}"
IsVisible="{Binding Size, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- Real POSIX bits from the server, not a plausible-looking mode. -->
<TextBlock Classes="detail" Text="{Binding Permissions}" />
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Modified}"
VerticalAlignment="Center" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Panel>
<!-- ============ what to do with the chosen entry ============ -->
<Border Grid.Row="5" IsVisible="{Binding IsConnected}" Background="{StaticResource Chrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
<StackPanel Spacing="9">
<!-- ◆ Counted, and it replaces the actions in place rather than stacking over the row it names. -->
<Border IsVisible="{Binding IsConfirmingRemoteDeletion}" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1" CornerRadius="12"
Padding="13,11">
<StackPanel Spacing="6">
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Question}" />
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
Text="{Binding PendingRemoteDeletion.Consequence}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE"
Command="{Binding ConfirmDeleteRemoteCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP"
Command="{Binding CancelDeleteRemoteCommand}" />
</Grid>
</StackPanel>
</Border>
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
Command="{Binding DisconnectCommand}" />
</Grid>
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
Text="Copying files to and from this phone needs the system document picker, which is not built yet — see the note at the top of this screen. Browsing, opening and deleting work." />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
Text="{Binding Status}" />
<!--
There is no queue on this screen, and that follows from the note at the top rather than being a
separate decision: nothing here can enqueue a transfer, so a queue would be a region that is
empty for every possible state of the application. It comes back with the document picker, along
with the two buttons that would fill it.
-->
</StackPanel>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,43 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.Android.Views;
/// <summary>Designs 06 and 08 — one remote pane, over SFTP or over a bucket.</summary>
internal sealed partial class FilesScreen : UserControl
{
public FilesScreen() => AvaloniaXamlLoader.Load(this);
/// <summary>
/// Opens a directory on a single tap.
/// </summary>
/// <remarks>
/// <para>
/// The desktop pane opens on a double tap and on Enter. A phone has neither, so the gesture has to be
/// the one gesture there is — and that is why this is here rather than in the shared view model: making
/// selection navigate would give the desktop a single click that walks into a directory.
/// </para>
/// <para>
/// <see cref="InputElement.Tapped"/> rather than a command on the row, because the row must stay a
/// plain <see cref="Border"/>: a button as the item template swallows the press before the list sees it
/// — it marks the pointer event handled, and the list's own handler is not registered for handled
/// events — leaving nothing selected and every command that reads the selection doing nothing. Tapped
/// fires after the list has moved its selection, which is what lets this read it.
/// </para>
/// <para>
/// A file is left selected rather than opened. There is nothing this head could do with it — see the
/// note about the document picker at the top of the screen — and the actions below act on the selection.
/// </para>
/// </remarks>
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
{
if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
{
transfers.OpenRemoteCommand.Execute(null);
}
}
}
@@ -24,8 +24,12 @@
<!-- ============ 05 UNKNOWN HOST KEY ============ -->
<Panel IsVisible="{Binding HasPendingHostKey}">
<!-- Scrim. Non-interactive by itself: tapping outside must not answer the question. -->
<Border Background="#9E040505" />
<!--
Scrim. Non-interactive by itself: tapping outside must not answer the question. 62% of Canvas,
written out because the palette holds no alpha variant of a surface — see QuickConnect on the
desktop, which carries the same note.
-->
<Border Background="#9E0E1220" />
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
@@ -6,7 +6,7 @@
Background="{StaticResource Canvas}">
<!--
Design 02 — HOSTS, the phone's home.
Design 01 — HOSTS, the phone's home.
The desktop's sidebar and its right-hand connect column cannot both be on screen at 360dp, so they are
the same column here: the list fills the screen, and choosing a host raises the connect bar over the
@@ -15,29 +15,35 @@
Groups are rows in the same list rather than a nesting container, which is what SidebarRows already
hands both heads. The desktop shows group headings only when groups exist and so does this, for free.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
The rows became cards: rounded, inset from the edge, and filled when chosen rather than marked with a
strip. What did not come across is the design's floating + button. It would open a host editor, and this
head has none — hosts are created on the desktop and sync down. A button that opened nothing would be
the one control on this screen that lied about what the phone can do; see docs/design-import-gaps.md.
-->
<Grid RowDefinitions="Auto,Auto,*,Auto">
<!-- ============ filter ============ -->
<!--
The design's "quick connect · search hosts" bar. On the desktop this is Ctrl+K over a palette; on a
phone the list is already the palette, so it filters in place rather than opening a second surface.
The design's "Search or connect…" well. On the desktop this is Ctrl+K over a palette; on a phone the
list is already the palette, so it filters in place rather than opening a second surface.
-->
<Border Grid.Row="0" Margin="16,12,16,4" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="6">
<Border Grid.Row="0" Margin="14,10,14,4" Background="{StaticResource Field}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="11" Height="44">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="&gt;" Classes="detail" Margin="12,0,0,0"
VerticalAlignment="Center" />
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="search hosts"
Height="44" Padding="9,0" VerticalContentAlignment="Center"
<TextBlock Grid.Column="0" Text="⌕" Foreground="{StaticResource TextFaint}" FontSize="13"
Margin="13,0,0,0" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts"
Padding="9,0" VerticalContentAlignment="Center"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource Text}" FontFamily="{StaticResource MonoFont}" FontSize="11.5" />
Foreground="{StaticResource Text}" FontFamily="{StaticResource MonoFont}" FontSize="12" />
</Grid>
</Border>
<!-- ============ sync + conflicts ============ -->
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="6">
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7">
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
@@ -46,11 +52,11 @@
banner the user has to dismiss rather than a screen they might never open.
-->
<Border IsVisible="{Binding HasConflicts}" Background="{StaticResource WarnWash}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="1" CornerRadius="6" Padding="12,10">
<StackPanel Spacing="8">
BorderBrush="{StaticResource WarnSoft}" BorderThickness="1" CornerRadius="12" Padding="13,11">
<StackPanel Spacing="9">
<ItemsControl ItemsSource="{Binding Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DataTemplate x:DataType="vm:ConflictRowViewModel">
<TextBlock Classes="detail" Foreground="{StaticResource WarnText}" TextWrapping="Wrap"
Text="{Binding Summary}" Margin="0,2" />
</DataTemplate>
@@ -62,22 +68,33 @@
</Border>
<Border IsVisible="{Binding HasUnreadableItems}" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1" CornerRadius="6" Padding="12,10">
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1" CornerRadius="12" Padding="13,11">
<TextBlock Classes="detail" Foreground="{StaticResource DangerText}" TextWrapping="Wrap"
Text="{Binding UnreadableSummary}" />
</Border>
</StackPanel>
<!-- ============ the list ============ -->
<ListBox Grid.Row="2" Margin="0,8,0,0"
<ListBox Grid.Row="2" Margin="0,6,0,0"
ItemsSource="{Binding SidebarRows}"
SelectedItem="{Binding SelectedSidebarRow}"
Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<!--
The chosen row is a filled card. The corner radius is on the item rather than on a Border inside
it so that the fill the theme draws for selection is the rounded one — a radius set inside would
leave a square highlight behind a rounded row.
-->
<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>
@@ -89,45 +106,52 @@
the filter, which is what a heading over a filtered list has to say.
-->
<DataTemplate DataType="vm:SidebarGroupHeader">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="16,12,16,6">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="8,12,8,5">
<TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}" Margin="7,0,0,0" />
<TextBlock Grid.Column="1" Classes="section" Text="{Binding Label}" Margin="7,0,0,0" />
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9" />
</Grid>
</DataTemplate>
<!-- A host. -->
<DataTemplate DataType="vm:HostRowViewModel">
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="54" Margin="16,6">
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="56" Margin="14,12">
<!-- Green means a terminal is open on this host right now. See the .dot style. -->
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}" Width="8" Height="8"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="13" FontWeight="Medium"
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="14" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<Border IsVisible="{Binding HasVaultBadge}" BorderBrush="{StaticResource BorderFaint}"
BorderThickness="1" CornerRadius="3" Padding="4,0" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="8.5" Text="{Binding VaultBadge}" />
<Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" />
</Border>
<Border IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="1" CornerRadius="3"
Padding="4,0" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="8" Foreground="{StaticResource Warn}"
Text="{Binding Badge}" />
<Border Classes="tag outline" BorderBrush="{StaticResource WarnSoft}"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Foreground="{StaticResource Warn}" Text="{Binding Badge}" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="detail" Text="{Binding Address}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" Text="·" />
<TextBlock Classes="detail" Text="{Binding Authentication}" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="·" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" />
</StackPanel>
</StackPanel>
<!--
The design's "live" tag, on exactly the hosts the dot is green for — and in the same green,
overriding the tag style's accent text. A status word in the accent colour beside a green dot
meaning the same thing would be one fact drawn in two colours.
-->
<Border Grid.Column="2" Classes="tag" IsVisible="{Binding IsConnected}"
Background="{StaticResource LiveWash}">
<TextBlock Text="live" Foreground="{StaticResource Live}" />
</Border>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
VerticalAlignment="Center" IsVisible="{Binding !IsConnected}" />
</Grid>
</DataTemplate>
@@ -141,7 +165,7 @@
-->
<Border Grid.Row="3" IsVisible="{Binding SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Padding="16,12">
BorderThickness="0,1,0,0" Padding="14,12">
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8">
@@ -153,7 +177,7 @@
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.
-->
<TextBox Classes="field" Height="44" IsVisible="{Binding SelectedHostAsksForAPassword}"
<TextBox Classes="field" IsVisible="{Binding SelectedHostAsksForAPassword}"
Text="{Binding ConnectPassword}" PasswordChar="•" PlaceholderText="password">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
@@ -14,9 +14,21 @@
And ◆ the delete confirmation replaces the row in place rather than stacking a dialogue over it — the
design is explicit about that, and on a phone it matters more than on the desktop: a modal over a list
hides which row it is talking about, which is exactly the information the confirmation exists to give.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
Rows became cards with a glyph tile, which is the design's own shape. Three things drawn on those cards
are not here, and all three for the same reason: there is nothing behind them. An `ED25519` badge needs
an algorithm field, and `SshKeySecret` has none — the type stores the armour verbatim rather than
parsing it. A `SHA256:…` line needs a fingerprint, computed by the same parsing. And an `agent` chip
needs an agent, of which this application has none at all — the connect path decrypts the key and hands
the bytes to the SSH library. What the card carries instead is `Detail`, which is what is genuinely
known *about* an item and never the secret. See docs/design-import-gaps.md.
Mode=OneWay on the chips is load-bearing: a two-way selection binding moves the chip before the command
beside it has had the chance to refuse, which would leave the row lit for a section the list is not on.
-->
<Grid RowDefinitions="Auto,Auto,*">
<Grid RowDefinitions="Auto,Auto,Auto,*">
<!-- ============ categories ============ -->
<ScrollViewer Grid.Row="0" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
@@ -60,15 +72,22 @@
</StackPanel>
</ScrollViewer>
<!--
The design's "4 keys · synced". SectionSummary counts the rows actually on screen and says how many of
them are still waiting to be pushed — which is the honest version of the design's unconditional
"synced".
-->
<TextBlock Grid.Row="1" Classes="detail" Margin="18,6,18,2" Text="{Binding SectionSummary}" />
<!-- ============ ◆ the counted confirmation ============ -->
<!--
In place, above the list it refers to. The text comes from the view model, which counts what breaks —
"three hosts authenticate with this key and will refuse to connect" — and the count is the whole
difference between a sentence somebody reads and one they click past.
-->
<Border Grid.Row="1" IsVisible="{Binding IsConfirmingDeletion}" Margin="12,4"
<Border Grid.Row="2" IsVisible="{Binding IsConfirmingDeletion}" Margin="12,4"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
BorderThickness="1" CornerRadius="12" Padding="14,12">
<StackPanel Spacing="5">
<TextBlock Classes="mono" FontSize="12" Text="{Binding PendingDeletion.Question}" TextWrapping="Wrap" />
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
@@ -87,7 +106,7 @@
</Border>
<!-- ============ the items ============ -->
<Panel Grid.Row="2">
<Panel Grid.Row="3">
<!-- Written per section by the view model, and it says what the section is for. -->
<TextBlock Classes="body" IsVisible="{Binding !HasVaultItems}" Margin="24,20"
@@ -103,27 +122,41 @@
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="56" Margin="16,6">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" FontSize="13" FontWeight="Medium" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<Border BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="3"
Padding="5,1" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="8" Text="{Binding Type}" />
</Border>
<Border IsVisible="{Binding HasBadge}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" CornerRadius="3" Padding="5,1" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="8" Foreground="{StaticResource Warn}"
Text="{Binding Badge}" />
</Border>
<Border Classes="card" Margin="12,4">
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="64">
<!--
The glyph tile. One mark for all three kinds rather than three: a key, a password and a
bucket are already named by the badge beside them, and three near-identical monochrome
glyphs at 14px would be decoration that reads as a distinction.
-->
<Border Grid.Column="0" Width="36" Height="36" CornerRadius="10"
Background="{StaticResource Active}" VerticalAlignment="Center">
<TextBlock Text="" Foreground="{StaticResource AccentText}" FontSize="14"
FontWeight="SemiBold" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Margin="12,0" Spacing="3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<Border Classes="tag outline">
<TextBlock Text="{Binding Type}" />
</Border>
<Border Classes="tag outline" BorderBrush="{StaticResource WarnSoft}"
IsVisible="{Binding HasBadge}">
<TextBlock Foreground="{StaticResource Warn}" Text="{Binding Badge}" />
</Border>
</StackPanel>
<!-- What is known *about* an item, never the secret. -->
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
</StackPanel>
<!-- What is known *about* an item, never the secret. -->
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="1" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
@@ -62,7 +62,7 @@
<Button Content="UNLOCK" Command="{Binding UnlockCommand}" IsEnabled="{Binding !IsBusy}"
Height="48" Margin="0,10,0,0" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Background="{StaticResource Accent}" Foreground="{StaticResource Canvas}"
Background="{StaticResource Accent}" Foreground="{StaticResource AccentInk}"
CornerRadius="6" FontFamily="{StaticResource MonoFont}" FontSize="12" FontWeight="SemiBold" />
<!--
@@ -99,7 +99,9 @@
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="8">
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
<!-- Live, not Accent: this marks shells that are still running, which is a fact and not a
thing to press. It read as green until v2 turned the accent blue. -->
<Ellipse Width="6" Height="6" Fill="{StaticResource Live}" VerticalAlignment="Center" />
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Warn}"
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="SemiBold"
TextWrapping="Wrap" />
@@ -0,0 +1,173 @@
<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.LogsScreen"
x:DataType="vm:LogsViewModel"
Background="{StaticResource Canvas}">
<!--
Design 07 — LOGS, under MORE.
Three things on the mock-up are not drawn, and each is the same kind of omission.
◆ **FOLLOW.** The design's header carries a blinking green "FOLLOW" pill and the screen is described as
a live tail over ssh. Nothing here tails anything: these are the keychain's own synced audit records,
written once at close, and they are read when the screen is opened. A pill that blinked would be
animation standing in for a feature. It is a REFRESH button, which is what actually happens.
**The severity filter** — ALL / INFO / WARN / ERROR with counts — has nothing to count. A connection
record is not a syslog line and carries no level. What this screen genuinely has two of is *logs*, so
the design's chip row is spent on the real choice: what was connected to, and what was changed.
**The host chips** are gone for the same reason: nothing filters either log by host.
What is kept is the design's row shape — a quiet timestamp, a coloured word, a source, and the fact
underneath — because that shape is why a log is readable at 10pt on a phone.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*">
<!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="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="Logs" Margin="4,0" />
<Button Grid.Column="2" Classes="icon" Content="↻" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}" />
</Grid>
<!-- ============ which log ============ -->
<!--
Mode=OneWay is load-bearing on both of these, not a tidiness: a two-way selection binding moves the
chip before the command it is paired with has had a chance to refuse, so the screen would show a
section it is not on. The keychain's categories carry the same note.
-->
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="6" Margin="14,0,14,4">
<RadioButton GroupName="log" Classes="chip" IsChecked="{Binding ShowsConnections, Mode=OneWay}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Connections}"
Content="CONNECTIONS" />
<RadioButton GroupName="log" Classes="chip" IsChecked="{Binding ShowsActivity, Mode=OneWay}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Activity}"
Content="KEYCHAIN" />
</StackPanel>
<TextBlock Grid.Row="2" Classes="detail" Margin="18,4,18,6" TextWrapping="Wrap"
Text="{Binding Status}" />
<!-- ============ the log ============ -->
<Border Grid.Row="3" Classes="output" Margin="12,0,12,12">
<Panel>
<!-- ============ what was connected to ============ -->
<!--
One wrapper per section, each holding its own empty state, because "this section is empty" is a
pair of facts — which section, and whether it has rows — and a binding cannot say `ShowsConnections
&& !HasConnections` without a converter. Two Panels are cheaper than a converter and they keep
EmptyMessage, which is already written per section, pointing at the section it describes.
-->
<Panel IsVisible="{Binding ShowsConnections}">
<TextBlock Classes="body" VerticalAlignment="Top" Text="{Binding EmptyMessage}"
IsVisible="{Binding !HasConnections}" />
<ScrollViewer IsVisible="{Binding HasConnections}">
<ItemsControl ItemsSource="{Binding Connections}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
<StackPanel Spacing="1" Margin="0,5">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="detail" FontSize="10" Text="{Binding Started}"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<!--
The kind sits where the design puts a severity, because it is the word that tells you
what the row is: a shell or a file transfer. Green while the session is still open —
the same green the host list uses, and the one thing on this screen that is about now
rather than about the past.
-->
<TextBlock Classes="detail" FontSize="10" FontWeight="SemiBold" Text="{Binding Kind}"
Foreground="{StaticResource Info}" VerticalAlignment="Center"
IsVisible="{Binding !IsLive}" />
<StackPanel Orientation="Horizontal" Spacing="5" IsVisible="{Binding IsLive}"
VerticalAlignment="Center">
<Ellipse Classes="dot live" Width="6" Height="6" VerticalAlignment="Center" />
<TextBlock Classes="detail" FontSize="10" FontWeight="SemiBold"
Text="{Binding Kind}" Foreground="{StaticResource Live}" />
</StackPanel>
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
Text="{Binding Address}" TextTrimming="CharacterEllipsis" />
<!--
TextDim rather than the `detail` class's own faint step. This block is drawn on
TerminalSurface, which is lighter than the Canvas that step was chosen against, so the
same colour that reads as quiet on a screen background reads as barely there here.
-->
<StackPanel Orientation="Horizontal" Spacing="7">
<!-- "still open" rather than a dash, which the plan calls out by name. -->
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
Text="{Binding Duration}" />
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
Text="·" />
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
Text="{Binding DeviceName}" TextTrimming="CharacterEllipsis" />
</StackPanel>
<!-- A refused host key is the one outcome worth a colour of its own. -->
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasOutcome}"
Foreground="{StaticResource DangerText}" Text="{Binding Outcome}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Panel>
<!-- ============ what was changed ============ -->
<Panel IsVisible="{Binding ShowsActivity}">
<TextBlock Classes="body" VerticalAlignment="Top" Text="{Binding EmptyMessage}"
IsVisible="{Binding !HasActivity}" />
<ScrollViewer IsVisible="{Binding HasActivity}">
<ItemsControl ItemsSource="{Binding Activity}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
<StackPanel Spacing="1" Margin="0,5">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="detail" FontSize="10" Text="{Binding At}"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Classes="detail" FontSize="10" FontWeight="SemiBold"
Text="{Binding Operation}" Foreground="{StaticResource Info}"
VerticalAlignment="Center" />
<TextBlock Classes="detail" FontSize="10" Text="{Binding ItemKind}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
Text="{Binding ItemLabel}" TextTrimming="CharacterEllipsis" />
<!-- The names of the fields that changed, never their values. -->
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasChangedFields}"
Foreground="{StaticResource TextDim}"
Text="{Binding ChangedFields}" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Panel>
</Panel>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design 07 — the two logs: what has been connected to, and what has been changed.</summary>
internal sealed partial class LogsScreen : UserControl
{
public LogsScreen() => AvaloniaXamlLoader.Load(this);
}
@@ -0,0 +1,160 @@
<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.MoreScreen"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource Canvas}">
<!--
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
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.
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.
-->
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Height="56" Padding="16,0">
<TextBlock Classes="heading" Text="More" />
</Border>
<ScrollViewer Grid.Row="1">
<StackPanel Margin="10,0,10,20" Spacing="2">
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}">
<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="Snippets" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="Saved commands, typed into an open shell." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<!--
These two go through ShowFiles rather than ShowScreen: they are one screen over one view model,
and which kind of remote it offers is the thing being chosen. See ShowFilesCommand for why that
is set by the button rather than on arrival.
-->
<Button Classes="row" Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}">
<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="SFTP" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Browse a host's files." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button Classes="row" Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
<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="S3" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Objects in an S3-compatible bucket from the keychain." />
</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.Logs}">
<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="Logs" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="What has been connected to, and what has changed." />
</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">
<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="Preferences" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="What this phone can and cannot do, and why." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<!--
Locking, here as well as in the header.
The header carries it on the three top-level destinations and stands down behind MORE, so without
this row the five screens under here would each be two taps from locking the keychain. That is the
wrong direction for the one action whose whole value is being quick, and it is why this is a row
on the hub rather than something you go back for.
-->
<Button Classes="row" Margin="0,10,0,0" Command="{Binding LockCommand}">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="⚿" Foreground="{StaticResource TextDim}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Lock the keychain" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Shells you have open keep running." />
</StackPanel>
</Grid>
</Button>
<!--
◆ Port forwarding, stated rather than drawn.
The v2 design gives this hub a sixth entry and a full screen behind it: local, remote and dynamic
rules with toggles and byte counters. Nothing in this application forwards anything — ISshConnection
offers OpenShellAsync and nothing else — so every switch on that screen would be a control with no
effect and every counter a number with no source.
It is a paragraph here rather than a missing row for the reason the desktop keeps TEAMS in its rail:
a destination that is quietly absent reads as a product that never intended it, and this one is
intended. SyncEntityType.PortForward = 9 is reserved on the wire and unused. See
docs/design-import-gaps.md.
-->
<Border Classes="card" Margin="6,16,6,0">
<StackPanel Spacing="7">
<StackPanel Orientation="Horizontal" Spacing="9">
<TextBlock Text="⇄" Foreground="{StaticResource TextFaint}" FontSize="14"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource TextDim}" Text="Port forwarding" />
<Border Classes="tag outline" VerticalAlignment="Center">
<TextBlock Text="NOT BUILT" />
</Border>
</StackPanel>
<TextBlock Classes="body"
Text="A connection here opens one channel — a shell — and nothing else, so there is no tunnel for a rule to run through. The wire protocol reserves a slot for forwarding rules and no client writes one. Nothing on this screen is hidden by a setting; the feature is absent." />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design v2 — MORE, the hub for the five destinations the bottom bar has no room for.</summary>
internal sealed partial class MoreScreen : UserControl
{
public MoreScreen() => AvaloniaXamlLoader.Load(this);
}
+77 -15
View File
@@ -17,13 +17,21 @@ namespace DodoSSH.Client.Android.Views;
/// </summary>
/// <remarks>
/// <para>
/// Written in C# rather than as a XAML control because it is a button with a label and a rule about the
/// accent bar, and a templated control for that would be more machinery than the thing it draws.
/// Written in C# rather than as a XAML control because it is a button with a glyph, a label and a rule about
/// the accent bar, and a templated control for that would be more machinery than the thing it draws.
/// </para>
/// <para>
/// <b>The accent bar is on top, not underneath.</b> The design puts it there in the nav rail and it is
/// worth keeping on a phone for a reason the desktop does not have: the bottom of a phone screen is under
/// the system's own gesture bar, and an indicator drawn there is one the user cannot see.
/// <b>The accent bar is on top, not underneath.</b> The v2 design distinguishes the current destination with
/// colour and weight alone and draws no bar at all; the bar is kept, and kept at the top, for a reason the
/// design does not have to think about. The bottom of a phone screen is under the system's own gesture bar,
/// so an indicator drawn there is one the user cannot see — and colour alone at 9pt on a dark background is
/// the single signal a user with a colour deficiency would be left with. Weight, colour and the bar are
/// three, which is the right number for the control that says where you are.
/// </para>
/// <para>
/// Every colour here is resolved from <c>Theme/Palette.axaml</c> rather than written out. A nav bar that
/// named its own blues is exactly how the fourth place a colour appears ends up one shade off — which is the
/// argument the desktop head's <c>Ellipse.dot</c> style makes for being a style rather than a converter.
/// </para>
/// </remarks>
internal sealed class NavButton : Button
@@ -31,12 +39,26 @@ internal sealed class NavButton : Button
public static readonly StyledProperty<string> LabelProperty =
AvaloniaProperty.Register<NavButton, string>(nameof(Label), string.Empty);
/// <summary>The glyph drawn above the label.</summary>
/// <remarks>
/// A character rather than an icon asset. The four the design uses — ▤ ⌗ ⚿ ≣ — are all in the fonts
/// Android already has, so shipping four vectors to draw them would buy nothing but four more files to
/// keep in step with the palette.
/// </remarks>
public static readonly StyledProperty<string> GlyphProperty =
AvaloniaProperty.Register<NavButton, string>(nameof(Glyph), string.Empty);
public static readonly StyledProperty<bool> IsCurrentProperty =
AvaloniaProperty.Register<NavButton, bool>(nameof(IsCurrent));
private readonly Border indicator;
private readonly TextBlock glyph;
private readonly TextBlock text;
// Resolved from the palette once the control is in the tree; see Repaint.
private IBrush? currentBrush;
private IBrush? idleBrush;
public NavButton()
{
Background = Brushes.Transparent;
@@ -55,16 +77,28 @@ internal sealed class NavButton : Button
Background = Brushes.Transparent,
};
glyph = new TextBlock
{
FontSize = 15,
HorizontalAlignment = HorizontalAlignment.Center,
};
text = new TextBlock
{
FontSize = 9,
FontWeight = FontWeight.SemiBold,
FontWeight = FontWeight.Medium,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Foreground = new SolidColorBrush(Color.Parse("#566059")),
};
Content = new Panel { Children = { indicator, text } };
var stack = new StackPanel
{
Spacing = 3,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Children = { glyph, text },
};
Content = new Panel { Children = { indicator, stack } };
}
public string Label
@@ -73,6 +107,13 @@ internal sealed class NavButton : Button
set => SetValue(LabelProperty, value);
}
/// <inheritdoc cref="GlyphProperty" />
public string Glyph
{
get => GetValue(GlyphProperty);
set => SetValue(GlyphProperty, value);
}
/// <summary>Whether this is the destination currently showing.</summary>
public bool IsCurrent
{
@@ -89,14 +130,13 @@ internal sealed class NavButton : Button
{
text.Text = Label;
}
else if (change.Property == GlyphProperty)
{
glyph.Text = Glyph;
}
else if (change.Property == IsCurrentProperty)
{
// Both the label and the bar, not just the bar. Colour alone at 9pt on a dark background is a
// weak signal, and it is the only one a user with a colour deficiency would have.
text.Foreground = new SolidColorBrush(Color.Parse(IsCurrent ? "#3CE88F" : "#566059"));
indicator.Background = IsCurrent
? new SolidColorBrush(Color.Parse("#3CE88F"))
: Brushes.Transparent;
Repaint();
}
else if (change.Property == FontFamilyProperty)
{
@@ -115,5 +155,27 @@ internal sealed class NavButton : Button
{
text.FontFamily = mono;
}
currentBrush = Application.Current?.FindResource("AccentText") as IBrush;
idleBrush = Application.Current?.FindResource("TextFaint") as IBrush;
// IsCurrent is normally bound and so has usually already been set by the time the template applies,
// which is why this runs again here rather than only on the property change.
Repaint();
}
private void Repaint()
{
// Both the label and the bar, not just the bar — and the weight as well. See the remark on the type.
var paint = IsCurrent ? currentBrush : idleBrush;
if (paint is not null)
{
glyph.Foreground = paint;
text.Foreground = paint;
}
text.FontWeight = IsCurrent ? FontWeight.SemiBold : FontWeight.Medium;
indicator.Background = IsCurrent && currentBrush is not null ? currentBrush : Brushes.Transparent;
}
}
+144 -63
View File
@@ -13,10 +13,14 @@
It switches on ShellState, which is the same six states the desktop has because both heads share one
state machine. What differs is only what each one draws.
The nav rail's eight destinations become four in a bottom bar. That is not a reflow: the plan says five
characters was a desktop constraint and the phone can use words, and it also says a phone cannot carry
eight. What is here is what the decided scope needs — the keychain and a terminal — and the destinations
behind the other four are reachable from those two rather than being hidden.
── 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 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.
-->
<Panel>
@@ -34,29 +38,47 @@
<!-- ============ unlocked ============ -->
<Grid IsVisible="{Binding IsUnlocked}" RowDefinitions="Auto,*,Auto,Auto">
<!-- The header: which vault, and whether it is synced. -->
<!--
The header: which vault, and whether it is synced.
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
vault's name and the sync light are the most useful thing a header could say.
-->
<Border Grid.Row="0" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="16,0" Height="52">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Width="20" Height="20" BorderBrush="{StaticResource Accent}"
BorderThickness="1" VerticalAlignment="Center">
<TextBlock Text="&gt;_" Foreground="{StaticResource Accent}"
FontFamily="{StaticResource MonoFont}" FontSize="9" FontWeight="SemiBold"
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>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center" Spacing="1">
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="5">
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="5" Height="5"
VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="8" Text="{Binding SyncLabel}" />
</StackPanel>
<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>
<Button Grid.Column="2" Classes="row" Width="44" MinHeight="44" Padding="0"
HorizontalContentAlignment="Center" Command="{Binding LockCommand}">
<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>
@@ -85,15 +107,60 @@
-->
<views:TerminalScreen IsVisible="{Binding IsTerminalSurface}" />
<views:PendingScreen IsVisible="{Binding IsPreferencesShowing}"
Heading="PREFERENCES"
Detail="Registering this phone's device key, and signing out, are on the unlock screen for now. Importing an ~/.ssh/config has no meaning here — scoped storage means there is no such file to read." />
<!--
============ 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
wrapped. SnippetsScreen and LogsScreen are nullable on the shell — they are rebuilt on every
unlock and nulled on lock — and it is the collapsed wrapper that keeps a template from binding
against nothing.
-->
<views:MoreScreen IsVisible="{Binding IsMoreShowing}" />
<Panel IsVisible="{Binding IsSnippetsShowing}">
<views:SnippetsScreen DataContext="{Binding SnippetsScreen}" />
</Panel>
<Panel IsVisible="{Binding IsLogsShowing}">
<views:LogsScreen DataContext="{Binding LogsScreen}" />
</Panel>
<!--
One screen for both file destinations. SFTP and S3 differ in which picker they offer and in
nothing else below it — the panes, the queue and the transfers are the same IRemoteFileStore
either way — so drawing them twice would be two copies of a screen that has to stay identical.
Which picker shows is TransfersViewModel.Remote, set on arrival by the shell.
-->
<Panel IsVisible="{Binding IsTransfersShowing}">
<views:FilesScreen DataContext="{Binding Transfers}" />
</Panel>
<Panel IsVisible="{Binding IsBucketsShowing}">
<views:FilesScreen DataContext="{Binding Transfers}" />
</Panel>
<!--
Preferences keeps PendingScreen for its body — it is still a screen that states what is absent
rather than one with settings on it — and gains the header its siblings under MORE have, here
rather than inside PendingScreen: that control is shared with three states of getting in, none of
which has anywhere to go back to.
-->
<Grid IsVisible="{Binding IsPreferencesShowing}" RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0">
<Button Grid.Column="0" Classes="icon" Content="←" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Preferences" Margin="4,0" />
</Grid>
<views:PendingScreen Grid.Row="1"
Heading="PREFERENCES"
Detail="Registering this phone's device key, and signing out, are on the unlock screen for now. Importing an ~/.ssh/config has no meaning here — scoped storage means there is no such file to read." />
</Grid>
</Panel>
<!--
◆ The two host-key decisions, over everything. Outside the screen Panel deliberately: file transfer
makes its own trust decision on the desktop, and when the phone grows that screen this must already
be somewhere both can reach rather than nested inside one of them.
makes its own trust decision on the desktop, and the phone now has that screen — so a sheet nested
inside the hosts screen would be one the files screen could not raise.
-->
<Panel Grid.Row="1">
<views:HostKeySheet DataContext="{Binding Vault}" />
@@ -104,64 +171,78 @@
desktop-shaped idea in the product — it is what makes a terminal a surface you switch to rather than
a screen you navigate away from. Kept, because losing it would make an open shell invisible from the
host list, which is where you are when you open the next one.
v2 draws the sessions as pills rather than as a labelled row, and drops the word SHELLS: with a
rounded chip carrying a live dot and a name, the label was spending nine characters of a 360dp row
saying what the row already looks like.
-->
<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">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="12,0" VerticalAlignment="Center">
<TextBlock Classes="label" FontSize="8" Text="SHELLS" VerticalAlignment="Center" />
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<Button Classes="row" MinHeight="32" Padding="10,6" CornerRadius="5"
Background="{StaticResource Field}"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="6">
<!--
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="5" Height="5"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="10" Text="{Binding Label}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<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>
<!-- ============ navigation ============ -->
<Border Grid.Row="3" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Height="58">
BorderThickness="0,1,0,0" Height="64">
<Grid ColumnDefinitions="*,*,*,*">
<views:NavButton Grid.Column="0" Label="HOSTS" IsCurrent="{Binding IsHostsShowing}"
<views:NavButton Grid.Column="0" Label="Hosts" Glyph="▤" IsCurrent="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}" />
<views:NavButton Grid.Column="1" Label="KEYCHAIN" IsCurrent="{Binding IsVaultShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vault}" />
<!--
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.
-->
<views:NavButton Grid.Column="2" Label="TERMINAL" IsCurrent="{Binding IsTerminalSurface}"
<views:NavButton Grid.Column="1" Label="Terminal" Glyph="⌗" IsCurrent="{Binding IsTerminalSurface}"
Command="{Binding ShowTerminalCommand}" />
<views:NavButton Grid.Column="3" Label="MORE" IsCurrent="{Binding IsPreferencesShowing}"
<!--
"Keychain", although the design's tab says Vault and the enum member is Vault. The split is a
standing rule rather than an inconsistency: every sentence a user reads calls the encrypted
store a keychain, and everything under the surface calls it a vault, because renaming the wire
route, the tables and the CLR types would put an AAD resource type inside the blast radius of a
find-and-replace. See docs/design-import-gaps.md. A tab reading Vault would be the one place the
product surface leaked the implementation's word.
-->
<views:NavButton Grid.Column="2" Label="Keychain" Glyph="⚿" IsCurrent="{Binding IsVaultShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}" />
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.
-->
<views:NavButton Grid.Column="3" Label="More" Glyph="≣" IsCurrent="{Binding IsMoreSurface}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
</Grid>
</Border>
@@ -1,6 +1,8 @@
using global::Android.Views;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using DodoSSH.Client.Android.Platform;
@@ -13,6 +15,19 @@ internal sealed partial class PhoneShell : UserControl
{
private MainWindowViewModel? shell;
/// <summary>
/// Whether the lock screen currently showing is the one the application launched into.
/// </summary>
/// <remarks>
/// Set once, when the shell arrives, because this control is built once for the process. Cleared the
/// moment the state leaves <see cref="ShellState.Locked"/>, and never set again — which is what stops a
/// deliberate lock from being answered with an immediate request to unlock. See
/// <see cref="TryOfferDeviceUnlock"/>.
/// </remarks>
private bool thisLockIsTheLaunch;
private bool offeredDeviceUnlock;
public PhoneShell()
{
AvaloniaXamlLoader.Load(this);
@@ -30,15 +45,167 @@ internal sealed partial class PhoneShell : UserControl
{
shell.PropertyChanged += OnShellChanged;
ApplyScreenshotPolicy(shell.State);
thisLockIsTheLaunch = true;
TryOfferDeviceUnlock();
}
};
}
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (shell is not null && e.PropertyName is nameof(MainWindowViewModel.State))
if (shell is null)
{
return;
}
if (e.PropertyName is nameof(MainWindowViewModel.State))
{
ApplyScreenshotPolicy(shell.State);
if (shell.State is not ShellState.Locked)
{
thisLockIsTheLaunch = false;
offeredDeviceUnlock = false;
}
}
// Three properties rather than one, because the condition is assembled out of order. Startup sets
// State to Locked and only then awaits the keystore to decide CanUnlockWithDevice, and it does all
// of it inside the busy wrapper — which refuses a second command outright. So whichever of the
// three settles last is the one that has to ask again.
if (e.PropertyName is nameof(MainWindowViewModel.State)
or nameof(MainWindowViewModel.CanUnlockWithDevice)
or nameof(MainWindowViewModel.IsBusy))
{
TryOfferDeviceUnlock();
}
}
/// <summary>
/// Raises the fingerprint prompt on arriving at the lock screen, rather than waiting to be asked.
/// </summary>
/// <remarks>
/// <para>
/// The button is still there and still says what it does; this only spends the tap for you. On a phone
/// that is the difference between opening the application in one gesture and in two, and the second of
/// the two was a button whose entire content was "yes, do the thing you already know I want".
/// </para>
/// <para>
/// <b>Only at launch.</b> A lock the user asked for is not answered with a request to unlock — that
/// turns LOCK into a control that appears to do nothing, and worse, trains the reflex of authenticating
/// at a prompt that appeared without being asked for. So the offer belongs to the locked screen the
/// process started on and to no other.
/// </para>
/// <para>
/// <b>Once.</b> A declined gesture leaves the passphrase box exactly where it was, which is the whole
/// fallback — and a prompt that reappeared after being dismissed would be a modal the user cannot get
/// out of to type into it.
/// </para>
/// <para>
/// Nothing here needs a failure path. <c>UnlockWithDeviceAsync</c> turns every refusal into a status
/// line, and a phone with no enrolled fingerprint never gets here at all, because
/// <c>CanUnlockWithDevice</c> already asked the keystore.
/// </para>
/// </remarks>
private void TryOfferDeviceUnlock()
{
if (!thisLockIsTheLaunch || offeredDeviceUnlock)
{
return;
}
if (shell is not { State: ShellState.Locked, CanUnlockWithDevice: true, IsBusy: false } current)
{
return;
}
offeredDeviceUnlock = true;
current.UnlockWithDeviceCommand.Execute(null);
}
/// <inheritdoc />
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
if (TopLevel.GetTopLevel(this) is { } top)
{
top.BackRequested += OnBackRequested;
}
}
/// <inheritdoc />
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
if (TopLevel.GetTopLevel(this) is { } top)
{
top.BackRequested -= OnBackRequested;
}
base.OnDetachedFromVisualTree(e);
}
/// <summary>
/// Takes the system back gesture up the hierarchy rather than out of the application.
/// </summary>
/// <remarks>
/// <para>
/// v2 is the first arrangement here with a second level: five 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".
/// </para>
/// <para>
/// Handled in the order the interface is stacked, not by screen alone: the terminal is a surface over a
/// page, so it is dismissed before the page under it is considered. Setting <c>Handled</c> is what stops
/// the event being re-dispatched to the activity's own default.
/// </para>
/// <para>
/// <b>Two things it deliberately does not do.</b> It does not answer a host-key prompt — those are
/// decisions with two named buttons, and a gesture that dismissed one would be the swipe-to-dismiss this
/// head refused when it made the changed-key refusal a full-screen panel rather than a sheet. And from
/// the host list it does nothing at all, so back still leaves the application from the screen the
/// application opens on, which is what every other Android app does.
/// </para>
/// </remarks>
private void OnBackRequested(object? sender, RoutedEventArgs e)
{
if (shell is not { State: ShellState.Unlocked } current)
{
return;
}
// A decision is on screen. Leave it alone — see the remark.
if (current.Vault is { HasPendingHostKey: true } or { HasHostKeyMismatch: true }
|| current.Transfers is { HasPendingHostKey: true } or { HasHostKeyMismatch: true })
{
return;
}
if (!current.IsShowingPages)
{
current.ShowScreenCommand.Execute(current.Screen);
e.Handled = true;
return;
}
switch (current.Screen)
{
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
or ShellScreen.Buckets or ShellScreen.Preferences:
current.ShowScreenCommand.Execute(ShellScreen.More);
e.Handled = true;
break;
case ShellScreen.More or ShellScreen.Vault:
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
e.Handled = true;
break;
default:
// Hosts, and anything the phone does not draw. Left unhandled, so back leaves the app.
break;
}
}
@@ -0,0 +1,167 @@
<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.SnippetsScreen"
x:DataType="vm:SnippetsViewModel"
Background="{StaticResource Canvas}">
<!--
Design 05 — SNIPPETS, under MORE.
Two departures from the mock-up, both because of what a snippet actually is here.
The design puts a ▶ Run button on every card. There are two operations rather than one — type the
command at the prompt, and type it *and* press Enter — and which of them a snippet allows is a property
of the snippet, set by a checkbox in its editor. ◆ That checkbox is the whole safety property: a
snippet that does not run on insert leaves the command sitting at the prompt for a human to read before
committing to it. Collapsing the two into one button called Run would throw that away, so the actions
are a bar over the bottom of the list, they name the terminal they will type into, and RUN only appears
for a snippet that is marked as running.
And the design's per-card footer — the hosts it ran on, the time, `exit 0` — is not drawn. Nothing here
records where a snippet was run, when, or what it returned; a snippet is a saved string, and the shell
it is typed into never reports back. See docs/design-import-gaps.md.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="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="Snippets" Margin="4,0" />
<Button Grid.Column="2" Classes="icon accent" Content="+" Command="{Binding NewCommand}" />
</Grid>
<!-- ============ filter ============ -->
<!-- Matches the command text as well as the name: the workflow is "the one that vacuums", not its label. -->
<TextBox Grid.Row="1" Classes="field" Margin="14,2,14,8" Text="{Binding Filter}"
PlaceholderText="search snippets" IsVisible="{Binding HasSnippets}" />
<TextBlock Grid.Row="2" Classes="detail" Margin="18,0,18,6" TextWrapping="Wrap"
Text="{Binding Status}" />
<!-- ============ the editor ============ -->
<!--
In place, above the list, rather than as a screen of its own — the same decision the keychain's
delete confirmation makes and for the same reason: a form stacked over the list hides what it is
about.
-->
<Border Grid.Row="3" Classes="card" Margin="12,0" IsVisible="{Binding IsEditing}"
VerticalAlignment="Top">
<ScrollViewer>
<StackPanel Spacing="10">
<TextBlock Classes="label" Text="NEW SNIPPET" IsVisible="{Binding IsCreating}" />
<TextBlock Classes="label" Text="EDIT SNIPPET" IsVisible="{Binding !IsCreating}" />
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
<TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
VerticalContentAlignment="Top" Padding="14,10" />
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
<!--
◆ The checkbox the whole screen is arranged around, with the paragraph that says why leaving it
off is the safe default. It is not a preference — it is the difference between a command a human
reads before committing to it and one that has already run.
-->
<CheckBox IsChecked="{Binding EditorRunsOnInsert}" MinHeight="44">
<TextBlock Classes="mono" FontSize="11.5" Text="Press Enter after inserting this"
TextWrapping="Wrap" />
</CheckBox>
<TextBlock Classes="body"
Text="Left off, inserting types the command at the prompt and stops, so it can be read and edited before it runs. Nothing here can tell whether the terminal is even at a prompt, which is why that is the default." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
Command="{Binding SaveCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelCommand}" />
</Grid>
</StackPanel>
</ScrollViewer>
</Border>
<!-- ============ the list ============ -->
<Panel Grid.Row="3" IsVisible="{Binding !IsEditing}">
<!-- Written by the view model, and it says what a snippet is for rather than that there are none. -->
<TextBlock Classes="body" IsVisible="{Binding !HasVisible}" Margin="24,20" VerticalAlignment="Top"
Text="{Binding EmptyMessage}" />
<ListBox ItemsSource="{Binding Visible}" SelectedItem="{Binding Selected}"
Background="Transparent" BorderThickness="0" IsVisible="{Binding HasVisible}">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<Border Classes="card" Margin="12,5">
<StackPanel Spacing="9">
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Label}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!-- ◆ The chip that says this one does not wait to be read. -->
<Border Classes="tag" IsVisible="{Binding RunsOnInsert}"
Background="{StaticResource WarnWash}">
<TextBlock Text="RUNS" Foreground="{StaticResource Warn}" />
</Border>
<Border Classes="tag outline"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" />
</Border>
</StackPanel>
<!-- The command, on the surface every block of monospace in this design is drawn on. -->
<Border Classes="output" CornerRadius="9" Padding="11,9">
<TextBlock Classes="mono" FontSize="11" Text="{Binding Preview}"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
</Border>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Panel>
<!-- ============ what to do with the chosen one ============ -->
<!--
Raised over the list, as the hosts screen raises its connect bar, and for the same reason: there is no
second column to put it in. Both buttons name the terminal — "TYPE INTO pg-primary" — because on a
phone the tab strip may be scrolled away and "whatever is in the terminal receives this" is not a
sentence anybody should have to guess the subject of.
-->
<Border Grid.Row="4" IsVisible="{Binding HasSelection}" Background="{StaticResource Chrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="14,12"
IsEnabled="{Binding !IsEditing}">
<StackPanel Spacing="9">
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
IsEnabled="{Binding CanInsert}" />
<!-- Only for a snippet marked as running. For every other one there is nothing this could mean. -->
<Button Classes="secondary" Height="44" Content="{Binding RunLabel}"
Command="{Binding RunCommand}" IsVisible="{Binding SelectionRuns}"
IsEnabled="{Binding CanInsert}" />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
Command="{Binding EditCommand}" />
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
Command="{Binding DeleteCommand}" />
</Grid>
</StackPanel>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design 05 — the saved commands, and the two ways of sending one to a shell.</summary>
internal sealed partial class SnippetsScreen : UserControl
{
public SnippetsScreen() => AvaloniaXamlLoader.Load(this);
}
@@ -21,43 +21,61 @@
<Grid RowDefinitions="Auto,Auto,*,Auto">
<!-- ============ tabs ============ -->
<Border Grid.Row="0" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1">
<!--
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">
<StackPanel Orientation="Horizontal" Height="44">
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<Grid ColumnDefinitions="Auto,Auto">
<Button Grid.Column="0" Classes="row" MinHeight="44" Padding="12,0"
<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="5" Height="5"
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
<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.
-->
<Button Grid.Column="1" Classes="row" MinHeight="44" Width="34" Padding="0"
<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>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
@@ -70,11 +88,16 @@
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.
-->
<Border Grid.Row="1" Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1" Padding="14,5"
<!--
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="9.5" TextTrimming="CharacterEllipsis"
<TextBlock Grid.Column="0" Classes="detail" FontSize="10.5" TextTrimming="CharacterEllipsis"
Foreground="{StaticResource TextDim}"
VerticalAlignment="Center" Text="{Binding SelectedTab.Address}" />
<!--
@@ -137,6 +160,12 @@
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.
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
corners would stay square over a frame pretending otherwise. What the design was really after is
that the terminal reads as the lit surface, and that comes from its own background, which now is the
design's #171A26. See WebAssets/terminal.css.
-->
<NativeWebView x:Name="Renderer" IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Panel>
@@ -149,11 +178,10 @@
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" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" IsVisible="{Binding IsTerminalShowing}">
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="50">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Height="44" Spacing="4"
Margin="6,5" />
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
VerticalAlignment="Center" />
</ScrollViewer>
</Border>
@@ -129,13 +129,13 @@ internal sealed partial class TerminalScreen : UserControl
// 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.
MinWidth = 44,
Height = 34,
Height = 38,
Padding = new Thickness(10, 0),
CornerRadius = new CornerRadius(5),
Background = new SolidColorBrush(Color.Parse("#161B19")),
BorderBrush = new SolidColorBrush(Color.Parse("#232927")),
CornerRadius = new CornerRadius(9),
Background = Palette("Panel"),
BorderBrush = Palette("BorderMid"),
BorderThickness = new Thickness(1),
Foreground = new SolidColorBrush(Color.Parse("#B7C0BB")),
Foreground = Palette("TextDim"),
HorizontalContentAlignment = HorizontalAlignment.Center,
};
@@ -164,13 +164,18 @@ internal sealed partial class TerminalScreen : UserControl
// Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
// ^L to a database prompt believing they typed an l.
controlKey.Background = new SolidColorBrush(
Color.Parse(controlLatched ? "#243A2F" : "#161B19"));
controlKey.Foreground = new SolidColorBrush(
Color.Parse(controlLatched ? "#3CE88F" : "#B7C0BB"));
controlKey.Background = Palette(controlLatched ? "Active" : "Panel");
controlKey.Foreground = Palette(controlLatched ? "AccentText" : "TextDim");
}
/// <summary>One brush from <c>Theme/Palette.axaml</c>, by key.</summary>
/// <remarks>
/// The accessory row is built in code because its keys come from a table, so its colours cannot be set in
/// XAML with the rest of the screen's. Resolving them by name is the next best thing: a row that named
/// its own blues is how the palette ends up with a fifth surface nobody meant to add.
/// </remarks>
private static IBrush? Palette(string key) => Application.Current?.FindResource(key) as IBrush;
/// <remarks>
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
/// 0x1A, which is letter &amp; 0x1F. Applied only to letters, because Ctrl with an arrow key is a