Public Access
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:
1 parent
174ef7c420
commit
c882fa0cd3
11 files changed
+2669
-722
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user