Give the phone a selection instead of a card under the list

A long press on a host raised a connect card over the bottom of the list: a
password box, CONNECT, EDIT, MOVE and DELETE. It was the right idea in the wrong
place. It covered rows, it had room for five things and never a sixth, and every
one of them was about exactly one machine — so filing eleven imported hosts under
a group was eleven trips through a form, and there was nowhere to put a sixth
action if anybody wanted one.

A long press now chooses the host it landed on, and the actions move into a bar
across the top of the screen, in the vault header's place rather than beside it.
That is where Android has put them since contextual action bars existed, and it
is the one strip a list can never grow into — but the real reason for it is that
while it is up the screen is unambiguously about the ticked hosts and nothing
else, which is what lets the count in the middle of it mean something. Left to
right: the cross that leaves the mode, the count, the pencil, and a ⋯ holding
Connect, Connect via SFTP, Move to vault, Copy to vault, Change group, Duplicate
and Remove.

A tap still connects and still raises nothing. Once anything is ticked it ticks
and unticks instead, which is what every Android list does and is not merely a
convention worth following: a tap that connected while five machines sat ticked
would open a terminal on top of a selection somebody was halfway through
building. Unticking the last host leaves the mode, so there are two ways out of
it and the cross is only one of them.

Both gestures now read the row from the element under the finger rather than from
the list's selection, and that is a correctness change rather than tidying. A tap
on a group heading moves the selection and the view model bounces it straight back
to whichever host was chosen before — which answered "a host, or nothing" for free
while a tap only ever connected. It stops answering it the moment a tap can tick
one: the heading would tick a machine the user was not pointing at, into a set
they are about to delete.

Three of the seven entries are about one machine and are drawn only for one. A
terminal, a file-transfer session and a form each have no reading over six, so
they are collapsed rather than refused. The other four read better for a count
than without one — it is the reason the set exists — and each of them says
afterwards how many hosts it wrote and how many it left alone. Skipping beats
refusing the whole run: a selection of eleven with one read-only row would
otherwise do nothing at all and then report about the wrong ten.

Copy to vault and Duplicate are new, and the difference between them is what each
can safely carry. A copy crosses a key boundary, so it drops the group and the
tags exactly as a move does — both are items of the vault being left, and a host
arriving with either would point at something the destination does not contain,
resolvable on the machine that sent it and dangling for everybody else. A
duplicate stays in the same keychain, so everything it points at is still there
and it keeps both. Change group is the write dragging a card onto a group already
makes on the desktop, run over a selection; it refuses one spanning two keychains
rather than half-filing it, which is the refusal a drop across that boundary
already makes one host at a time.

Connect via SFTP is the one action that leaves the vault. Which machine is a
decrypted item and so is this object's business; the screen it leads to and the
transfers view model behind it are the shell's — so it is an event, on the same
division SessionOpened already draws for a shell. The host is re-found in that
screen's own copy of the list, because the picker binds to rows in that copy and
handing it the vault's object would select nothing.

What is left of the card is the password box, and only because it had nowhere
else to go: a host that authenticates with a typed password cannot be reached by
a tap alone. That tap now raises a sheet rather than the bar, and the difference
is that a sheet is up only while a question is on screen — the bar was raised by
a long press and stayed, so it was a password box sitting over the list whether or
not anything was being asked. Dismissing it empties the box, which is not tidiness
either: a secret left behind would satisfy the emptiness check that decides
whether to raise the sheet at all, so the next tap would dial with somebody else's
password.

The pencil moving into that bar takes the host editor with it. It was a card in
the list's own row, under the search box and the sync line — twenty controls
sharing a screen with two rows of chrome about the list it had replaced. It is a
page now, and PhoneShell stands all four of its rows down for it, which is what
"opens with all the options" means at 360dp. That needed a second subscription in
that control: two of its flags are questions about the vault rather than about the
shell, and the shell does not forward the vault's notifications.

The ticks are held as entity ids rather than as rows, and written back onto the
rows after every reload. Every row object in the list is replaced on every filter
keystroke and every synchronisation pass, so a set of rows would empty itself once
a minute under somebody choosing what to do with eleven machines. Ids that no
longer resolve are dropped, so a colleague's deletion arriving mid-selection
leaves a count that matches what is on screen.

One caller had to change with it. ConnectToRecent opened the pane about a host,
which was the desktop's drawer and the phone's card; the phone's answer is now a
tick, and nothing on that list means "selected" any more — so arriving with the
host merely selected would be arriving at a screen with nothing to press. Both are
raised together, and the one the head in front of the user does not draw is inert.
This commit is contained in:
2026-08-06 09:15:37 +02:00
parent 174ef7c420
commit c882fa0cd3
11 changed files with 2669 additions and 722 deletions
@@ -0,0 +1,63 @@
<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.HostActionBar"
x:DataType="vm:VaultViewModel">
<!--
◆ THE CONTEXTUAL ACTION BAR, WHICH IS WHAT THE CONNECT CARD BECAME.
A long press on a host used to raise a card over the bottom of the list: a password box, CONNECT, EDIT,
MOVE and DELETE. It was a menu drawn as a form, in the one part of the screen a list grows into — so it
covered rows, it had room for five things and never a sixth, and everything on it was about exactly one
machine. What replaced it is a selection and this bar.
It takes the vault header's place rather than sitting under it, which is the arrangement Android has used
for this since contextual action bars existed and is the reason it can be unambiguous: while it is up, the
screen is about the ticked hosts and about nothing else. See PhoneShell.ShowsHostSelectionBar, which is
where the swap happens and why it is computed rather than bound.
Left to right: the cross that leaves selection mode, the count, then the two controls. The pencil is out
in front of the menu because editing is the thing people reach for most and is worth not opening a menu
for; the other six are behind the ⋯, which is a sheet rather than a flyout — see
VaultViewModel.IsHostActionSheetOpen.
── one host or several ──────────────────────────────────────────────────────────────────────────────────
The pencil is drawn only while exactly one host is ticked, and it is collapsed rather than greyed. Three
of the seven actions are like that — edit, connect, and browse — and the reason is the same for all
three: a form, a terminal and a file session are each about one machine, and there is no sensible reading
of "edit these six". The three that stay are the ones a count makes better rather than worse.
-->
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="6,0" Height="56">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<!--
The cross, and it is the first thing on the bar for the reason back arrows are: the way out of a
mode belongs at the edge the thumb reaches without crossing anything it might press by mistake.
-->
<Button Grid.Column="0" Classes="icon" Content="✕"
Command="{Binding ClearHostChoiceCommand}"
ToolTip.Tip="Stop choosing hosts" />
<!--
The count, and only the count. The bar it sits in is already saying what the number is about, and
"6 hosts selected" beside a pencil and a menu is the width those two icons need at 360dp.
-->
<TextBlock Grid.Column="1" Classes="heading" FontSize="17" Margin="8,0"
VerticalAlignment="Center" Text="{Binding ChosenHostsLabel}" />
<Button Grid.Column="2" Classes="icon" Content="✎" FontSize="15"
IsVisible="{Binding HasOneChosenHost}"
Command="{Binding EditChosenHostCommand}"
ToolTip.Tip="Edit this host" />
<Button Grid.Column="3" Classes="icon" Content="⋯" FontSize="18"
Command="{Binding OpenHostActionSheetCommand}"
ToolTip.Tip="More things to do with these hosts" />
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>The bar that takes the header's place while hosts are chosen.</summary>
internal sealed partial class HostActionBar : UserControl
{
public HostActionBar() => AvaloniaXamlLoader.Load(this);
}
File diff suppressed because it is too large Load Diff
@@ -1,23 +1,26 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design 02 — the host list, and the connect bar that replaces the desktop's right column.</summary>
/// <summary>Design 02 — the host list, and the two gestures that are the whole of this file.</summary>
/// <remarks>
/// <para>
/// ◆ <b>Two gestures on one list, and this file is the whole of the difference between them.</b> A tap on a
/// host connects to it; a long press asks about it, which is what raises the bar. Why they were split is on
/// the screen itself; what is here is the mechanics, and there are two of them worth knowing.
/// ◆ <b>A tap connects, a long press chooses, and once anything is chosen a tap ticks instead.</b> The
/// branch in the middle of that is why the tap is handled here rather than bound in the markup: which of
/// the two a tap means depends on whether the screen is in selection mode, and Avalonia's bindings cannot
/// ask.
/// </para>
/// <para>
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain <c>Grid</c>.
/// A button as the item template swallows the press before the list sees it — <c>FilesScreen</c> writes that
/// out at length — leaving nothing selected and every control that reads the selection doing nothing. Both
/// events fire after the list has moved its selection, which is what lets these read it.
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain
/// <c>Border</c>. A button as the item template swallows the press before the list sees it —
/// <c>FilesScreen</c> writes that out at length — so neither handler would ever run.
/// </para>
/// </remarks>
internal sealed partial class HostsScreen : UserControl
@@ -27,18 +30,18 @@ internal sealed partial class HostsScreen : UserControl
/// </summary>
/// <remarks>
/// Avalonia raises <c>Tapped</c> on release whatever the press lasted, so without this a long press
/// would open the bar and then connect — the two gestures firing one after the other on one touch, which
/// is the one outcome that would make both of them untrustworthy. Set when the hold starts and cleared
/// by the tap it suppresses, so it never survives the gesture that set it.
/// would tick the row and then the tap it becomes would untick it again — one touch doing a thing and
/// undoing it, which is the one outcome that would make both gestures untrustworthy. Set when the hold
/// starts and cleared by the tap it suppresses, so it never survives the gesture that set it.
/// </remarks>
private bool held;
/// <remarks>
/// <para>
/// The long press is attached here rather than in the markup so that it sits beside the property that
/// makes it fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than assumed:
/// it is the whole of the gesture, and a default that changed would take it away silently — every tap
/// would go on working and nothing would ever open the bar again.
/// Both gestures are attached here rather than in the markup so that they sit beside the property that
/// makes one of them fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than
/// assumed: it is the whole of the long press, and a default that changed would take it away silently —
/// every tap would go on working and nothing would ever choose a host again.
/// </para>
/// <para>
/// ◆ <b><c>FindControl</c> rather than the field the name generator declares for <c>x:Name</c></b>,
@@ -62,20 +65,20 @@ internal sealed partial class HostsScreen : UserControl
}
/// <summary>
/// Connects to the row that was tapped.
/// Connects to the row that was tapped, or ticks it where hosts are already ticked.
/// </summary>
/// <remarks>
/// <para>
/// Guarded on the selection being a host rather than on what was under the finger. A tap on a group
/// heading moves the list's selection and the view model bounces it straight back to whatever was chosen
/// before — see <c>VaultViewModel.SelectedSidebarRow</c> — so reading the selection here answers "a host,
/// or nothing" without this file needing to know that rule. The cost of getting it wrong is connecting to
/// a machine the user was not pointing at.
/// ◆ <b>The branch is the selection, not the row.</b> Once anything is chosen the screen is in selection
/// mode — the bar across the top says so — and in that mode a tap adds and removes rather than
/// connecting. That is what every Android list does, and the alternative is worse than a rule to
/// remember: a tap that connected while five machines sat ticked would open a terminal on top of a
/// selection somebody was halfway through building.
/// </para>
/// <para>
/// Fire-and-forget, as the desktop grid's activation is: the command reports its own failures onto the
/// status line — an unknown host key, a refused password — and awaiting it here would be an event handler
/// returning a task nothing observes.
/// Fire-and-forget where it connects, as the desktop grid's activation is: the command reports its own
/// failures onto the status line — an unknown host key, a refused password — and awaiting it here would
/// be an event handler returning a task nothing observes.
/// </para>
/// </remarks>
private void OnRowTapped(object? sender, TappedEventArgs e)
@@ -86,25 +89,25 @@ internal sealed partial class HostsScreen : UserControl
return;
}
if (DataContext is VaultViewModel { SelectedHost: { } row } vault)
if (DataContext is not VaultViewModel vault || RowUnder(e) is not { } row)
{
_ = vault.ConnectToRowCommand.ExecuteAsync(row);
return;
}
if (vault.IsChoosingHosts)
{
vault.ToggleHostChoiceCommand.Execute(row);
return;
}
_ = vault.ConnectToRowCommand.ExecuteAsync(row);
}
/// <summary>
/// Raises the bar about the row that was held.
/// </summary>
/// <summary>Puts a tick against the row that was held, entering selection mode with it.</summary>
/// <remarks>
/// <para>
/// On <see cref="HoldingState.Started"/> rather than on completion, so the bar is up while the finger is
/// still down. A long press that showed nothing until release would be a gesture with no way to tell it
/// had been recognised, and the only feedback available on this list is the thing it does.
/// </para>
/// <para>
/// It goes through <c>OpenHostPaneCommand</c>, which is the desktop's own "ask about this host" — the two
/// heads raise different furniture from one flag rather than keeping a selection rule each.
/// </para>
/// </remarks>
private void OnRowHeld(object? sender, HoldingRoutedEventArgs e)
{
@@ -115,9 +118,33 @@ internal sealed partial class HostsScreen : UserControl
held = true;
if (DataContext is VaultViewModel { SelectedHost: { } row } vault)
if (DataContext is VaultViewModel vault && RowUnder(e) is { } row)
{
vault.OpenHostPaneCommand.Execute(row);
vault.ChooseHostCommand.Execute(row);
}
}
/// <summary>
/// The host the gesture landed on, or null where it landed on something that is not one.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Read off the element under the finger rather than off the list's selection, which is what both
/// handlers used to do.</b> The selection was defensible while a tap only ever connected: a tap on a
/// group heading moves the selection and the view model bounces it straight back — see
/// <c>VaultViewModel.SelectedSidebarRow</c> — so reading it answered "a host, or nothing" for free.
/// It stops being defensible the moment a tap can tick one: a heading would then bounce the selection to
/// whichever host was last chosen and tick <em>that</em>, which is a machine the user was not pointing
/// at going into a set they are about to delete.
/// </para>
/// <para>
/// The ancestor rather than <c>e.Source</c> itself, because the source is whatever leaf the finger
/// landed on — a tag chip's <c>TextBlock</c> has a string for a data context, and the row's own
/// <c>Ellipse</c> has the row. The <see cref="ListBoxItem"/> is the one element in that chain whose data
/// context is always the list's item, whatever kind it is.
/// </para>
/// </remarks>
private static HostRowViewModel? RowUnder(RoutedEventArgs e) =>
(e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true)?.DataContext
as HostRowViewModel;
}
@@ -98,7 +98,7 @@
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultHeader}">
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="14,0" Height="56">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<!--
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
@@ -127,30 +127,18 @@
</StackPanel>
<!--
◆ THE PENCIL, and it is here rather than on the row for the reason the whole gesture split
happened: choosing a host on this head no longer raises a bar carrying EDIT, because that bar
was five controls over the bottom of the list in the way of a tap that means "connect". EDIT is
the one of the five common enough to be worth a control that is always in the same place, so it
is in the header — the phone's only piece of persistent chrome — and the long press still
reaches the other four. See HostsScreen.axaml.
◆ THE PENCIL WAS HERE, AND IT MOVED INTO THE ACTION BAR.
Two conditions, nested rather than combined, because Avalonia's bindings have no "and" and the
two belong to different view models: which screen is showing is the shell's question, and
whether there is a host to edit is the vault's. That is the same arrangement the header itself
is wrapped in one level up.
It was in the header because a long press raised a connect card carrying EDIT, and a card over
the bottom of the list was the wrong place for the one action people reach for most. Both
halves of that changed at once: a long press now chooses hosts rather than raising a card, and
the bar it raises takes this header's place — so the pencil is in that bar, beside the count of
what it would edit. See HostActionBar.axaml, which is drawn in the Panel below this one.
Collapsed rather than disabled when there is nothing chosen. A greyed pencil sitting beside the
vault's name on every screen would be a permanent reminder of a control that is only ever about
one row.
What is left in this row is the vault's name, the sync light and LOCK: three facts about the
keychain, and none of them about a row.
-->
<Panel Grid.Column="3" IsVisible="{Binding IsHostsShowing}">
<Button Classes="icon" Content="✎" FontSize="15" Margin="4,0,0,0"
IsVisible="{Binding Vault.CanEditSelectedHost}"
Command="{Binding Vault.EditSelectedHostCommand}"
ToolTip.Tip="Edit the selected host" />
</Panel>
<Button Grid.Column="4" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
<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}" />
@@ -159,6 +147,21 @@
</Border>
</Panel>
<!-- ============ ◆ the contextual action bar ============ -->
<!--
In the header's row and in its place, never beside it. A bar that stacked under the vault's name
would be two rows of chrome above a list at 360dp, and worse, it would leave the keychain's name and
LOCK on screen over a list that is no longer about the keychain — see PhoneShell.ShowsHostSelectionBar,
which is where the two are made exclusive.
Wrapped so the data context can be the vault, as every other screen in this file is: what the bar
counts and what its four controls do are one vault's business, and the shell's own binding would
resolve none of them.
-->
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsHostSelectionBar}">
<views:HostActionBar DataContext="{Binding Vault}" />
</Panel>
<!-- The screens. Only one draws; which one is the shell's business. -->
<Panel Grid.Row="1">
<!--
@@ -271,8 +274,13 @@
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
whether there are any tabs and this one is about which surface is up.
◆ And it stands down under the host editor, which is a page rather than a card since the pencil moved
into the action bar. A strip of open shells above a form is the same two-rows-of-chrome problem the
terminal has, on a screen where the form is the whole point of being there. See
PhoneShell.ShowsShellStrip, which is where that "and" is made, Avalonia's bindings having none.
-->
<Panel Grid.Row="2" IsVisible="{Binding IsShowingPages}">
<Panel Grid.Row="2" IsVisible="{Binding $parent[views:PhoneShell].ShowsShellStrip}">
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
@@ -17,6 +17,18 @@ internal sealed partial class PhoneShell : UserControl
{
private MainWindowViewModel? shell;
/// <summary>
/// The open vault, while there is one, so that this control hears about the hosts screen's own state.
/// </summary>
/// <remarks>
/// ◆ <b>A second subscription, and it is the price of the header being swappable.</b> Two of the flags
/// below are questions about the vault rather than about the shell — whether hosts are ticked, and
/// whether the host editor is filling the screen — and the shell does not forward the vault's
/// notifications. Kept in step from <see cref="OnShellChanged"/>, because <c>Vault</c> is replaced on
/// every unlock and nulled on every lock; a handler left on a disposed vault would keep it alive.
/// </remarks>
private VaultViewModel? vault;
/// <summary>
/// Everything the phone draws, which is the element the software keyboard is kept off.
/// </summary>
@@ -90,10 +102,40 @@ internal sealed partial class PhoneShell : UserControl
TryOfferDeviceUnlock();
}
FollowTheVault();
RefreshChrome();
};
}
/// <summary>Moves this control's second subscription onto whichever vault is open now.</summary>
/// <remarks>
/// Compared before being swapped, so that the ordinary case — a shell notification about something else
/// entirely — costs one reference comparison rather than an unsubscribe and a resubscribe per property
/// change on the shell.
/// </remarks>
private void FollowTheVault()
{
if (ReferenceEquals(vault, shell?.Vault))
{
return;
}
if (vault is not null)
{
vault.PropertyChanged -= OnVaultChanged;
}
vault = shell?.Vault;
if (vault is not null)
{
vault.PropertyChanged += OnVaultChanged;
}
}
private void OnVaultChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
=> RefreshChrome();
/// <summary>Whether this surface is wide enough to be laid out like the desktop.</summary>
/// <remarks>
/// A property of the control rather than of the view model, because it is a fact about the surface and
@@ -115,6 +157,14 @@ internal sealed partial class PhoneShell : UserControl
public static readonly StyledProperty<bool> ShowsVaultHeaderProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultHeader));
/// <summary>Whether the bar about the chosen hosts is drawn in the header's place.</summary>
public static readonly StyledProperty<bool> ShowsHostSelectionBarProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsHostSelectionBar));
/// <summary>Whether the strip of open shells above the bottom bar is drawn.</summary>
public static readonly StyledProperty<bool> ShowsShellStripProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsShellStrip));
/// <inheritdoc cref="IsWideProperty" />
public bool IsWide
{
@@ -143,6 +193,20 @@ internal sealed partial class PhoneShell : UserControl
private set => SetValue(ShowsVaultHeaderProperty, value);
}
/// <inheritdoc cref="ShowsHostSelectionBarProperty" />
public bool ShowsHostSelectionBar
{
get => GetValue(ShowsHostSelectionBarProperty);
private set => SetValue(ShowsHostSelectionBarProperty, value);
}
/// <inheritdoc cref="ShowsShellStripProperty" />
public bool ShowsShellStrip
{
get => GetValue(ShowsShellStripProperty);
private set => SetValue(ShowsShellStripProperty, value);
}
/// <summary>
/// Works out which chrome this surface should be wearing.
/// </summary>
@@ -161,7 +225,16 @@ internal sealed partial class PhoneShell : UserControl
/// Losing them on the keychain would be losing the only LOCK button on the surface.
/// </para>
/// <para>
/// Recomputed on every shell notification rather than on a named list of them. Three boolean
/// ◆ <b>The header is now a swap rather than a switch, and the editor takes the whole screen.</b> Two
/// more flags and two more inputs, both of them the vault's rather than the shell's — see
/// <see cref="vault"/>. While hosts are ticked the header stands down and
/// <see cref="ShowsHostSelectionBar"/> puts the action bar in its place, which is what makes that bar
/// unambiguous: the screen is about the ticked hosts and nothing else. While the host editor is open it
/// is a page rather than a card, so all four rows of chrome stand down and the form has the display —
/// which is what "opens in a separate page" means on a 360dp screen.
/// </para>
/// <para>
/// Recomputed on every notification from either object rather than on a named list of them. Five boolean
/// comparisons and no allocation is cheaper than being wrong: the properties this reads are computed
/// ones, and which of them raise a change is a fact about a file in another project that nothing here
/// would notice going stale.
@@ -172,6 +245,16 @@ internal sealed partial class PhoneShell : UserControl
var wide = body.Bounds.Width >= WideAt;
var pages = shell?.IsShowingPages == true;
// The editor is a page of its own now, so nothing else is drawn around it — not the vault header,
// not the shells strip, and not the way off the screen. Its own header carries the back arrow, which
// is the one control it needs and the one the system gesture already maps to.
var editing = vault?.IsEditing == true;
// Only on the hosts screen. The ticks survive a trip to the keychain — the set is not cleared by
// navigating — and a bar counting hosts over the transfers screen would be chrome about a list that
// is not on the display.
var choosing = vault?.IsChoosingHosts == true && shell?.IsHostsShowing == true;
// Before the flags, because it changes what one of them reads. Nothing else on this head navigates
// in response to a resize, and this is not navigation for its own sake: the hub is a list of the
// destinations the rail now carries, so an unfolded device would otherwise sit on a menu of things
@@ -183,9 +266,11 @@ internal sealed partial class PhoneShell : UserControl
}
IsWide = wide;
ShowsRail = wide && pages;
ShowsBottomBar = !wide && pages;
ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
ShowsRail = wide && pages && !editing;
ShowsBottomBar = !wide && pages && !editing;
ShowsShellStrip = pages && !editing;
ShowsHostSelectionBar = pages && choosing && !editing;
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
}
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -195,6 +280,7 @@ internal sealed partial class PhoneShell : UserControl
return;
}
FollowTheVault();
RefreshChrome();
if (e.PropertyName is nameof(MainWindowViewModel.State))
@@ -500,16 +586,23 @@ internal sealed partial class PhoneShell : UserControl
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
/// <remarks>
/// <para>
/// Order is the whole of it. The two sheets sit over the list and the two editors sit in place of it, so
/// a sheet has to go first — closing an editor while a sheet was open would leave the sheet floating
/// over a list nobody asked to see, and the second back would then close the sheet rather than the
/// editor the user was looking at.
/// Order is the whole of it. The sheets sit over the list, the panels sit above it and the editors sit
/// in place of it, so a sheet has to go first — closing an editor while a sheet was open would leave the
/// sheet floating over a list nobody asked to see, and the second back would then close the sheet rather
/// than the editor the user was looking at.
/// </para>
/// <para>
/// The editors are cancelled rather than merely hidden. Cancelling is what clears the boxes, and the
/// host editor's boxes are the ones worth clearing: leaving a half-typed hostname behind would have the
/// next NEW HOST open on somebody else's abandoned draft.
/// </para>
/// <para>
/// ◆ <b>Selection mode is last and is still a thing back has to spend itself on.</b> It is a mode rather
/// than a surface — the list underneath is fully drawn and the only sign of it is the bar across the top
/// — and a gesture that left the application from it would take somebody out of the app because they had
/// held a row down. Its panels go before it, in the order they are stacked: the picker or the question is
/// what the user is looking at, and the ticks underneath are what it is about.
/// </para>
/// </remarks>
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
{
@@ -518,22 +611,71 @@ internal sealed partial class PhoneShell : UserControl
return false;
}
return TryLowerASheet(vault) || TryCloseSomethingBehindTheSheets(vault);
}
/// <summary>Lowers the nearest of the four sheets, which are what sits over everything else.</summary>
/// <remarks>
/// The four cannot be open at once — each is raised from a control the others hide — so their order
/// between themselves decides nothing. What matters is that all of them come before the panels and the
/// editors underneath: closing an editor while a sheet was open would leave the sheet floating over a
/// list nobody asked to see.
/// </remarks>
private static bool TryLowerASheet(VaultViewModel vault)
{
// ◆ The action bar's own menu, first of the four because it is raised from chrome that is already
// over everything else.
if (vault.IsHostActionSheetOpen)
{
vault.CloseHostActionSheetCommand.Execute(null);
return true;
}
// The password sheet, which is what a tap on a machine that wants one raises. Cancelled rather than
// hidden, because cancelling is what empties the box — see VaultViewModel.CancelConnectPassword.
if (vault.IsAskingForConnectPassword)
{
vault.CancelConnectPasswordCommand.Execute(null);
return true;
}
if (vault.IsAddSheetOpen)
{
vault.CloseAddSheetCommand.Execute(null);
return true;
}
// The other sheet, and it is checked beside the first rather than after the editors for the same
// reason: it is raised over the list, so it is the nearest thing on screen. The two cannot be open
// at once — one is raised by the +, the other by a heading, and each hides the list the other's
// control is on — so their order between themselves decides nothing.
if (vault.GroupSheet is not null)
{
vault.CloseGroupSheetCommand.Execute(null);
return true;
}
return false;
}
/// <summary>Closes the nearest of the panels, the editors and selection mode itself.</summary>
/// <inheritdoc cref="TryCloseAnOpenEditor" path="/remarks" />
private static bool TryCloseSomethingBehindTheSheets(VaultViewModel vault)
{
if (vault.IsSendingChosenHostsToAVault)
{
vault.CancelSendChosenHostsToAVaultCommand.Execute(null);
return true;
}
if (vault.IsRegroupingChosenHosts)
{
vault.CancelRegroupChosenHostsCommand.Execute(null);
return true;
}
if (vault.IsConfirmingChosenHostDeletion)
{
vault.CancelDeleteCommand.Execute(null);
return true;
}
if (vault.IsEditing)
{
vault.CancelEditCommand.Execute(null);
@@ -546,6 +688,12 @@ internal sealed partial class PhoneShell : UserControl
return true;
}
if (vault.IsChoosingHosts)
{
vault.ClearHostChoiceCommand.Execute(null);
return true;
}
return false;
}